From 450fe635f13c258ca5acf480e74649f75e8f9471 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:27:23 +0900 Subject: [PATCH 1/5] basou: Print a board.yaml to start a board from basou board init prints a declaration of the newest board_version, in the anchor's language: a title from the workspace's name, the six stages with a meaning each, one sample lane to cut again, nothing to observe, no component registered, an axis of version 1, and the effort from the day of the first session in this host's time zone (the same default the board page measures with). It writes nothing; it stops, printing nothing, where a board is already declared or the workspace's own repo is not declared private. Its help and the guide say to save it through a file of its own, moved into place only where no board is: printing straight into board/board.yaml would let the shell empty an existing board before init could refuse. The guide's steps for a workspace with no board now start from init. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++ docs/spec/cli-commands.md | 1 + packages/cli/src/commands/board.test.ts | 54 +++++++++++ packages/cli/src/commands/board.ts | 117 ++++++++++++++++++++++-- packages/core/src/board/effort.test.ts | 30 +++++- packages/core/src/board/effort.ts | 23 +++++ packages/core/src/board/guide.test.ts | 19 +++- packages/core/src/board/guide.ts | 9 +- packages/core/src/board/index.ts | 4 +- packages/core/src/board/init.test.ts | 72 +++++++++++++++ packages/core/src/board/init.ts | 83 +++++++++++++++++ packages/core/src/index.ts | 7 ++ packages/core/src/lib/view-strings.ts | 90 ++++++++++++++++++ 13 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/board/init.test.ts create mode 100644 packages/core/src/board/init.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 484fd704..10c85d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ All notable changes to **basou** are recorded here. The project follows ### Added +- **`basou board init` (experimental) prints a board.yaml to start a board + from.** It is of the newest `board_version`, in the language the manifest + declares for the workspace's own repo: a title from the workspace's name, + the six stages with a meaning each (concept, spec, built, merged, open, + live), one sample lane to cut again (a board needs one), nothing to + observe, no component registered (the first measurement names every one + it finds, which fires the axis review that registers them), an axis of + version 1 reviewed every 60 days, and the effort from the day of the first + session in this host's time zone (today when there is none; with no name + for the zone, a comment says to write one). It writes nothing: its help + and `basou board guide` say to save it through a file of its own, moved + into place only where no board is, never by printing straight into + `board/board.yaml`, which the shell empties before init runs. It exits 1, + printing nothing, where a board is already declared or the workspace's + own repo is not declared private. - **`basou board guide` (experimental) prints the steps an agent follows to judge the board and record it, so a workspace needs no skill of its own for that.** The steps are filled in with the workspace's default board diff --git a/docs/spec/cli-commands.md b/docs/spec/cli-commands.md index 45e8c37c..88668581 100644 --- a/docs/spec/cli-commands.md +++ b/docs/spec/cli-commands.md @@ -56,6 +56,7 @@ basou hook stop | session-start # the handlers themselves — the tool invo basou board measure # measure what a board.yaml declares (writes nothing, sends nothing) basou board record # measure again and record a judgement of the board, beside its board.yaml basou board guide # print the steps an agent follows to judge and record the board (writes nothing) +basou board init # print a board.yaml to start a board from (writes nothing) # User-global context faces (files every project's AI tool auto-loads) basou channel clear codex # remove an orientation block an older basou left in ~/.codex/AGENTS.md diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index f045ea41..1361f267 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -21,6 +21,7 @@ import { createManifest, type Event, ensureBasouDirectory, + parseBoardDeclaration, type RepoEntry, writeManifest, } from "@basou/core"; @@ -28,10 +29,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeStaleness } from "../lib/provenance-actions.js"; import { doRunBoardGuide, + doRunBoardInit, doRunBoardMeasure, doRunBoardRecord, measureLiveBoard, runBoardGuide, + runBoardInit, runBoardMeasure, runBoardRecord, } from "./board.js"; @@ -1472,6 +1475,57 @@ describe("basou board guide", () => { }); }); +describe("basou board init", () => { + it("prints a board that reads under the workspace's manifest, and writes nothing", async () => { + const repo = await workspace([ + { path: ".", visibility: "private", language: "en" }, + { path: "../elsewhere" }, + ]); + const before = await readdir(repo); + const out: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }); + const text = await doRunBoardInit({}, ctx(repo)); + expect(out.join("")).toBe(text); + const parsed = parseBoardDeclaration(text, { manifestRepoPaths: [".", "../elsewhere"] }); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + expect(parsed.declaration.title).toBe("board-ws progress board"); + expect(parsed.declaration.board_version).toBe(2); + // No session yet: the effort starts today. + expect(parsed.declaration.effort.start).toBe(NOW.toISOString().slice(0, 10)); + expect(await readdir(repo)).toEqual(before); + expect(process.exitCode ?? 0).toBe(0); + }); + + it("refuses, printing nothing, where a board is already declared", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await placeBoard(repo, boardYaml([])); + const write = vi.spyOn(process.stdout, "write"); + const { err } = capture(); + await runBoardInit({}, ctx(repo)); + expect(write).not.toHaveBeenCalled(); + expect(err.join("\n")).toContain( + "board/board.yaml is already there, so there is no board to start", + ); + expect(process.exitCode).toBe(1); + expect(await readFile(join(repo, "board", "board.yaml"), "utf8")).toBe(boardYaml([])); + }); + + it("refuses a workspace whose own repo is not declared private", async () => { + const repo = await workspace([{ path: "." }]); + const write = vi.spyOn(process.stdout, "write"); + const { err } = capture(); + await runBoardInit({}, ctx(repo)); + expect(write).not.toHaveBeenCalled(); + expect(err.join("\n")).toContain( + "The board is kept at board/board.yaml only in a workspace whose manifest declares its own repo (path: .) private", + ); + expect(process.exitCode).toBe(1); + }); +}); + describe("measureLiveBoard: the board page's measurement with no board", () => { // A Claude Code transcript of the workspace that was never imported, beside // it where ctx() points the dry run of an import. diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index e9052743..4c4e6b7f 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -16,11 +16,13 @@ import { type BoardRepo, basouPaths, boardGuide, + boardInitText, buildRecord, byCodePoint, diffCells, diffObserved, displayPath, + effortStartOf, findErrorCode, type Manifest, measureBoard, @@ -68,6 +70,8 @@ export type BoardRecordOptions = { export type BoardGuideOptions = { verbose?: boolean }; +export type BoardInitOptions = { verbose?: boolean }; + export type BoardContext = { /** Defaults to `process.cwd()`. Injectable for tests. */ cwd?: string; @@ -191,15 +195,50 @@ private (the reason is on stderr).`, .action(async (options: BoardGuideOptions) => { await runBoardGuide(options); }); + board + .command("init") + .description("Print a board.yaml to start a board from (writes nothing)") + .option("-v, --verbose", "Show error causes") + .addHelpText( + "after", + ` +It prints a declaration of the newest board_version, in the language the +manifest declares for the workspace's own repo: a title from the workspace's +name, the six stages with a meaning each, one lane to cut again, nothing to +observe, no component registered, an axis of version 1, and the effort from +the day of the first session, in this host's time zone. It writes nothing: +save it, from the workspace's own repo, only where no board is, through a file +of its own: + + ${INIT_SAVE} + +Never print it straight into ${DEFAULT_BOARD_PATH}: the shell empties that file +before init runs, so a board already there would be lost. + +Exit codes: 0 when it was printed; 1 when the manifest does not declare the +workspace's own repo private (a board's records hold what the trail holds), +when ${DEFAULT_BOARD_PATH} is already there, or when the workspace cannot be +read (the reason is on stderr, and nothing is printed on stdout).`, + ) + .action(async (options: BoardInitOptions) => { + await runBoardInit(options); + }); } -/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardGuide}. */ -export async function runBoardGuide( - options: BoardGuideOptions, +/** + * How a printed board is saved: to a file of its own first, moved into place + * only where no board is. Printing straight into board.yaml would empty an + * existing one before init could refuse. + */ +export const INIT_SAVE = `basou board init > "\${TMPDIR:-/tmp}/board.yaml" && [ ! -e ${DEFAULT_BOARD_PATH} ] && mkdir -p board && mv "\${TMPDIR:-/tmp}/board.yaml" ${DEFAULT_BOARD_PATH}`; + +/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardInit}. */ +export async function runBoardInit( + options: BoardInitOptions, ctx: BoardContext = {}, ): Promise { try { - await doRunBoardGuide(options, ctx); + await doRunBoardInit(options, ctx); } catch (error: unknown) { renderCliError(error, { verbose: isVerbose(options) }); process.exitCode = 1; @@ -207,17 +246,52 @@ export async function runBoardGuide( } /** - * Print the steps that judge and record the workspace's default board, read - * from its board.yaml and the records beside it. Reads, never writes. + * Print a board.yaml to start from, for a workspace whose own repo the + * manifest declares private and that has no board yet. Reads, never writes. */ -export async function doRunBoardGuide( - _options: BoardGuideOptions, +export async function doRunBoardInit( + _options: BoardInitOptions, ctx: BoardContext, ): Promise { + const { root, paths, manifest } = await privateWorkspace(ctx, "board init"); + const board = join(root, DEFAULT_BOARD_PATH); + try { + await lstat(board); + throw new Error( + `${DEFAULT_BOARD_PATH} is already there, so there is no board to start: change that one (through an axis review when it changes the lanes or the stages).`, + ); + } catch (error: unknown) { + if (!findErrorCode(error, "ENOENT")) throw error; + } + const { start, timeZone, sessionsRead } = await effortStartOf( + paths, + ctx.nowProvider?.() ?? new Date(), + ); + if (!sessionsRead && timeZone !== undefined) { + console.error( + "basou: the sessions could not be read, so effort.start is today; set it to the day the work began.", + ); + } + const text = boardInitText({ + name: manifest.workspace.name, + language: resolveViewLanguage(manifest), + start, + timeZone, + }); + process.stdout.write(text); + return text; +} + +// The workspace a board command works in, when the manifest declares its own +// repo private; otherwise why there is no board for it. +async function privateWorkspace( + ctx: BoardContext, + command: string, +): Promise<{ root: string; paths: BasouPaths; manifest: Manifest }> { const cwd = ctx.cwd ?? process.cwd(); const root = await resolveBasouRootForCommand( cwd, - "board guide", + command, ctx.portfolioConfigPath === undefined ? {} : { portfolioConfigPath: ctx.portfolioConfigPath }, ); const paths = basouPaths(root); @@ -229,6 +303,31 @@ export async function doRunBoardGuide( `The board is kept at ${DEFAULT_BOARD_PATH} only in a workspace whose manifest declares its own repo (path: .) private, since a record holds what the trail holds (open tracks, time worked, model names). This one does not, so it has no judged board; basou view's /board page shows what is measured without one.`, ); } + return { root, paths, manifest }; +} + +/** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardGuide}. */ +export async function runBoardGuide( + options: BoardGuideOptions, + ctx: BoardContext = {}, +): Promise { + try { + await doRunBoardGuide(options, ctx); + } catch (error: unknown) { + renderCliError(error, { verbose: isVerbose(options) }); + process.exitCode = 1; + } +} + +/** + * Print the steps that judge and record the workspace's default board, read + * from its board.yaml and the records beside it. Reads, never writes. + */ +export async function doRunBoardGuide( + _options: BoardGuideOptions, + ctx: BoardContext, +): Promise { + const { root, manifest } = await privateWorkspace(ctx, "board guide"); const board: BoardLocation = { path: join(root, DEFAULT_BOARD_PATH), shown: DEFAULT_BOARD_PATH, diff --git a/packages/core/src/board/effort.test.ts b/packages/core/src/board/effort.test.ts index 0bb72dc4..70508ddd 100644 --- a/packages/core/src/board/effort.test.ts +++ b/packages/core/src/board/effort.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; -import { measureEffort } from "./effort.js"; +import { effortStartOf, measureEffort } from "./effort.js"; let root: string; let paths: BasouPaths; @@ -168,4 +168,32 @@ describe("measureEffort with no declared start", () => { await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); expect((await measureEffort(input("Asia/Tokyo"))).effort.start).toBe("2026-10-01"); }); + it("gives a board's start the same day, in this host's zone, and the zone", async () => { + hostZoneIs("Asia/Tokyo"); + const now = new Date("2026-10-05T03:00:00.000Z"); + expect(await effortStartOf(paths, now)).toEqual({ + start: "2026-10-05", + timeZone: "Asia/Tokyo", + sessionsRead: true, + }); + await placeStarted(SES("S02"), "2026-09-25T00:00:00Z"); + await placeStarted(SES("S01"), "2026-09-20T23:30:00Z"); + expect(await effortStartOf(paths, now)).toEqual({ + start: "2026-09-21", + timeZone: "Asia/Tokyo", + sessionsRead: true, + }); + await writeFile(join(paths.sessions, SES("S01"), "session.yaml"), "session: [broken]\n"); + expect(await effortStartOf(paths, now)).toEqual({ + start: "2026-10-05", + timeZone: "Asia/Tokyo", + sessionsRead: false, + }); + hostZoneIs(undefined); + expect(await effortStartOf(paths, new Date("2026-10-05T23:00:00.000Z"))).toEqual({ + start: "2026-10-05", + timeZone: undefined, + sessionsRead: false, + }); + }); }); diff --git a/packages/core/src/board/effort.ts b/packages/core/src/board/effort.ts index 6bf22b08..c3e711c9 100644 --- a/packages/core/src/board/effort.ts +++ b/packages/core/src/board/effort.ts @@ -265,6 +265,29 @@ export async function measureEffort(input: EffortInput): Promise<{ return { effort, notFound }; } +/** + * Where the effort starts when a board declares no start, as this section + * counts it then: the day of the first session in this host's time zone, or + * today when there is none or the sessions cannot be read (`sessionsRead` + * false). With no name for this host's zone, `timeZone` is undefined and the + * day is today in UTC. + */ +export async function effortStartOf( + paths: BasouPaths, + now: Date, +): Promise<{ start: string; timeZone: string | undefined; sessionsRead: boolean }> { + const zone = namedZone(undefined); + if (zone === undefined) { + return { start: now.toISOString().slice(0, 10), timeZone: undefined, sessionsRead: false }; + } + const calendar = new Calendar(zone); + const today = calendar.dateOf(now.getTime()); + const sessions = await readSessions(paths, now, zone); + return sessions.ok + ? { start: firstDay(sessions.sessions, calendar, today), timeZone: zone, sessionsRead: true } + : { start: today, timeZone: zone, sessionsRead: false }; +} + /** * Today's date in the zone the effort section counts days in (the declared * one, or this host's), or undefined when this host's cannot be named. diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 2bf9cc4f..2b41b669 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -421,7 +421,7 @@ describe("boardGuide: no board declared", () => { it("says how to declare one instead of the steps", () => { const guide = boardGuide(guideInput({ board: { status: "undeclared" } })); expect(guide).toContain("## No board is declared yet"); - expect(guide).toContain("Write board/board.yaml (board_version 2)"); + expect(guide).toContain('basou board init > "$W/board.yaml" && [ ! -e board/board.yaml ]'); expect(guide).toContain("Run `basou board guide` again"); expect(guide).not.toContain("## Steps"); expect(guide).not.toContain("```json"); @@ -512,6 +512,23 @@ describe("boardGuide: the commands run", () => { await mkdir(elsewhere); await symlink(elsewhere, work); expect(sh(prefix)).toBe(1); + + it("saves the board init prints only where no board is", async () => { + const anchor = join(dir, "ws"); + const bin = join(dir, "bin"); + await mkdir(anchor); + await mkdir(bin); + // A basou that prints a board, as init does. + await writeFile(join(bin, "basou"), "#!/bin/sh\necho 'title: printed'\n", { mode: 0o755 }); + const guide = boardGuide(guideInput({ anchor, board: { status: "undeclared" } })); + const save = blocksIn(guide).find((b) => b.includes("basou board init")) as string; + const env = { ...process.env, TMPDIR: dir, PATH: `${bin}:${process.env.PATH ?? ""}` }; + const run = () => execFileSync("sh", ["-c", save], { env, encoding: "utf8" }); + expect(run()).toBe("exit=0\n"); + expect(await readFile(join(anchor, "board", "board.yaml"), "utf8")).toBe("title: printed\n"); + await writeFile(join(anchor, "board", "board.yaml"), "title: mine\n"); + expect(run()).toBe("exit=1\n"); + expect(await readFile(join(anchor, "board", "board.yaml"), "utf8")).toBe("title: mine\n"); }); it("takes the before of a repo with no commit yet, and still tells a change apart", async () => { diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index deae563d..b18f1056 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -632,8 +632,13 @@ function undeclared( `This workspace (${displayPath(input.anchor)}) declares no board at board/board.yaml. \`basou view\`'s /board page already draws what can be measured without one (the period and effort, the repos, the trail, the components). A judged board (the reach matrix, the lanes, the operator's turns) needs a declaration first.`, "", "1. Check that no one outside can read this repo: records hold what the trail holds (open tracks, time worked, model names). Step 9 (f) of this guide, once a board is declared, says how; on GitHub it is visibility PRIVATE, no collaborator but the owner and no invitation. Never keep a board in a repo shared with a client.", - "2. Cut the axis. The board asks what can be used now and what is stuck, not what was built. Cut lanes by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one.", - '3. Write board/board.yaml (board_version 2): `title`, the six `stages` (`"01"` to `"06"`, quoted, each with a `meaning`, and `look` and `notes` if you like), at least one lane (`id`, `name`, `about`, `notes`), `observe` for what is reached outside the repos, `axis` (`version: 1`, `review_due_days`) and `effort` (`start`, `time_zone`). Measures, ratios and components may stay empty at first.', + "2. Print a board to start from, and save it only where no board is. Never print it straight into board/board.yaml: the shell empties that file before init runs.", + ); + block( + `${go} && basou board init > "$W/board.yaml" && [ ! -e board/board.yaml ] && mkdir -p board && mv "$W/board.yaml" board/board.yaml; echo "exit=$?"`, + ); + line( + "3. Cut the axis, with the operator. The board asks what can be used now and what is stuck, not what was built. Replace the sample lane with lanes cut by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one. Declare under `observe` what is reached outside the repos (a registry, a release, a site). Measures, ratios and components may stay empty at first.", "4. Check it until it reads (every problem with it is listed at once):", ); block(`${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`); diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 5fd320b2..9d62f916 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -44,7 +44,7 @@ export type { } from "./diff.js"; export { diffCells, diffMeasurements, diffObserved } from "./diff.js"; export type { BoardActiveMs, BoardEffort, BoardEffortDay } from "./effort.js"; -export { BOARD_EFFORT_METHOD } from "./effort.js"; +export { BOARD_EFFORT_METHOD, effortStartOf } from "./effort.js"; export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; export { BOARD_FRESHNESS_METHOD } from "./freshness.js"; export type { GlobMatcher } from "./glob.js"; @@ -57,6 +57,8 @@ export { boardGuideWorkDir, shellWord, } from "./guide.js"; +export type { BoardInitInput } from "./init.js"; +export { BOARD_INIT_LANE_ID, BOARD_INIT_REVIEW_DUE_DAYS, boardInitText } from "./init.js"; export type { BoardIntegrity } from "./integrity.js"; export { BOARD_INTEGRITY_METHOD } from "./integrity.js"; export type { BoardLiveMeasurement, BoardLivePage, MeasureBoardLiveInput } from "./live.js"; diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts new file mode 100644 index 00000000..d9c3c862 --- /dev/null +++ b/packages/core/src/board/init.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { boardInitStrings } from "../lib/view-strings.js"; +import { parseBoardDeclaration } from "./declaration.js"; +import { BOARD_INIT_LANE_ID, boardInitText } from "./init.js"; + +const en = boardInitStrings("en"); +const ja = boardInitStrings("ja"); +const read = (text: string) => parseBoardDeclaration(text, { manifestRepoPaths: ["."] }); + +describe("boardInitText", () => { + it("prints a declaration of the newest version that reads as it is", () => { + const text = boardInitText({ + name: "shop", + language: "en", + start: "2026-09-21", + timeZone: "Asia/Tokyo", + }); + const result = read(text); + if (!result.ok) throw new Error(result.errors.join("\n")); + const d = result.declaration; + expect(d.board_version).toBe(2); + expect(d.title).toBe("shop progress board"); + expect(Object.keys(d.stages)).toEqual(["01", "02", "03", "04", "05", "06"]); + expect(d.stages["04"].meaning).toBe(en.stages["04"]); + expect(d.stages["05"].meaning).toBe(en.stages["05"]); + expect(d.lanes.map((l) => l.id)).toEqual([BOARD_INIT_LANE_ID]); + expect(d.observe).toEqual([]); + expect(d.components).toEqual({}); + expect(d.measures).toEqual([]); + expect(d.axis).toEqual({ version: 1, review_due_days: 60 }); + expect(d.effort).toEqual({ start: "2026-09-21", time_zone: "Asia/Tokyo" }); + expect(text.split("\n")[0]).toMatch(/^# A progress board, printed by `basou board init`\./); + }); + + it("writes the words in Japanese for a Japanese anchor", () => { + const result = read( + boardInitText({ name: "shop", language: "ja", start: "2026-09-21", timeZone: "UTC" }), + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.title).toBe(ja.title.replace("{name}", "shop")); + expect(result.declaration.stages["05"].meaning).toBe(ja.stages["05"]); + expect(ja.stages["05"]).not.toBe(en.stages["05"]); + }); + + it("leaves the time zone out, and says to write it, when this host's has no name", () => { + const text = boardInitText({ + name: "shop", + language: "en", + start: "2026-10-05", + timeZone: undefined, + }); + expect(text).toContain(" # This host's time zone has no name: write effort.time_zone"); + const result = read(text); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.effort).toEqual({ start: "2026-10-05" }); + }); + + it("keeps a name as written, whatever it holds", () => { + const bidi = String.fromCodePoint(0x202e); + const lines = (name: string) => + boardInitText({ name, language: "en", start: "2026-10-05", timeZone: "UTC" }).split("\n"); + for (const name of ['a "quoted" $& name', "it's #1: yes", "x\ny", `${bidi}x`]) { + const text = lines(name).join("\n"); + const result = read(text); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.title).toBe(`${name} progress board`); + // One line for the title, and nothing a terminal would act on. + expect(lines(name)).toHaveLength(lines("plain").length); + expect(text).not.toContain(bidi); + } + }); +}); diff --git a/packages/core/src/board/init.ts b/packages/core/src/board/init.ts new file mode 100644 index 00000000..082cff13 --- /dev/null +++ b/packages/core/src/board/init.ts @@ -0,0 +1,83 @@ +import { boardInitStrings, type ViewLanguage } from "../lib/view-strings.js"; +import { BOARD_STAGE_IDS, BOARD_VERSION } from "./declaration.js"; + +/** The id of the lane a printed board starts with, to be cut again. */ +export const BOARD_INIT_LANE_ID = "replace-me"; + +/** How many days an axis is reviewed after, in a printed board. */ +export const BOARD_INIT_REVIEW_DUE_DAYS = 60; + +export type BoardInitInput = { + /** The workspace's name, which the title starts with. */ + name: string; + /** The language of the words the board starts with: the anchor's. */ + language: ViewLanguage; + /** The day the effort starts, `YYYY-MM-DD`. */ + start: string; + /** This host's time zone, or undefined when it has no name. */ + timeZone: string | undefined; +}; + +// A YAML 1.2 scalar that reads back as the string given: JSON's quoting is +// YAML's double-quoted style, with what a terminal would act on escaped. +function quoted(text: string): string { + return JSON.stringify(text).replace( + /[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, + (c) => `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`, + ); +} + +// A comment of one line, whatever its text. +function comment(text: string): string { + return `# ${text.replace(/\s+/g, " ")}`; +} + +/** + * The text of a board.yaml to start from, of the newest board_version: the + * title from the workspace's name, the six stages with a meaning each, one + * sample lane (a board needs one), nothing to observe, no component + * registered (the first measurement names every one it finds, which fires + * the axis review that registers them), an axis of version 1 with no review + * on record (the first record is its first review), and the effort from the + * day given, in this host's time zone when it has a name. It reads as a + * declaration as it is. + */ +export function boardInitText(input: BoardInitInput): string { + const t = boardInitStrings(input.language); + const lines = [ + comment(t.comments.head), + `board_version: ${BOARD_VERSION}`, + `title: ${quoted(t.title.replace("{name}", () => input.name))}`, + "", + comment(t.comments.stages), + "stages:", + ...BOARD_STAGE_IDS.map((id) => ` "${id}": { meaning: ${quoted(t.stages[id])} }`), + "", + comment(t.comments.lanes), + "lanes:", + ` - id: ${BOARD_INIT_LANE_ID}`, + ` name: ${quoted(t.lane.name)}`, + ` about: ${quoted(t.lane.about)}`, + " notes:", + ` - ${quoted(t.lane.note)}`, + "", + comment(t.comments.observe), + "observe: []", + "", + comment(t.comments.components), + "components: {}", + "", + comment(t.comments.axis), + "axis:", + " version: 1", + ` review_due_days: ${BOARD_INIT_REVIEW_DUE_DAYS}`, + "", + comment(t.comments.effort), + "effort:", + ` start: ${quoted(input.start)}`, + input.timeZone === undefined + ? ` ${comment(t.comments.timeZone)}` + : ` time_zone: ${quoted(input.timeZone)}`, + ]; + return `${lines.join("\n")}\n`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f789ddb7..a1a25ba1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,6 +102,7 @@ export type { BoardFreshness, BoardGuideInput, BoardImportProbe, + BoardInitInput, BoardIntegrity, BoardLastReview, BoardLiveMeasurement, @@ -149,6 +150,8 @@ export { BOARD_EFFORT_METHOD, BOARD_FRESHNESS_METHOD, BOARD_GUIDE_PORTS, + BOARD_INIT_LANE_ID, + BOARD_INIT_REVIEW_DUE_DAYS, BOARD_INTEGRITY_METHOD, BOARD_OBSERVE_KINDS, BOARD_PORTFOLIO_METHOD, @@ -166,6 +169,7 @@ export { boardGuide, boardGuidePort, boardGuideWorkDir, + boardInitText, boardLivePage, boardPage, boardPageUnavailable, @@ -174,6 +178,7 @@ export { diffCells, diffMeasurements, diffObserved, + effortStartOf, measureBoard, measureBoardLive, modelKey, @@ -253,12 +258,14 @@ export { export type { SourceRootScope } from "./lib/source-root-scope.js"; export { AGENT_INFRA_DIRS, classifyFilesBySourceRoot } from "./lib/source-root-scope.js"; export type { + BoardInitStrings, BoardPageStrings, PresetStrings, ViewLanguage, ViewStrings, } from "./lib/view-strings.js"; export { + boardInitStrings, boardPageStrings, presetStrings, resolveAnchorContentLanguage, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 67095498..f7d629fe 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1321,3 +1321,93 @@ const BOARD_PAGE_JA: BoardPageStrings = { export function boardPageStrings(language: ViewLanguage): BoardPageStrings { return language === "ja" ? BOARD_PAGE_JA : BOARD_PAGE_EN; } + +/** + * The words `basou board init` writes into a board.yaml it prints, in the + * anchor's language: the board's title, what each stage means by default, + * the sample lane a board starts with and the comments that say what to + * change. They are the user's data once printed: a board may change them all. + */ +export type BoardInitStrings = { + /** `{name}` is the name of the workspace's directory. */ + title: string; + stages: Record<"01" | "02" | "03" | "04" | "05" | "06", string>; + lane: { name: string; about: string; note: string }; + comments: { + head: string; + stages: string; + lanes: string; + observe: string; + components: string; + axis: string; + effort: string; + timeZone: string; + }; +}; + +const BOARD_INIT_EN: BoardInitStrings = { + title: "{name} progress board", + stages: { + "01": "Concept — a document says what the lane is for", + "02": "Spec — what to build and how is decided", + "03": "Built — working code is in a repo and its tests pass", + "04": "Merged — it is on main", + "05": "Open — its users can get it", + "06": "Live — it is in use", + }, + lane: { + name: "A lane: who uses what", + about: "Replace this lane with the board's own, cut by who uses what, not by repo", + note: "What to mind when judging this lane", + }, + comments: { + head: "A progress board, printed by `basou board init`. Change it to fit this workspace, then check it with `basou board measure`; `basou board guide` prints the steps that judge and record it.", + stages: + "The six stages are fixed; only what each means is the board's. Keep 04 (merged) apart from 05 (in users' hands). `look` and `notes` say what to look at and mind when judging a stage.", + lanes: "At least one lane. Its id is lowercase ASCII and stays: records join on it.", + observe: + "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, page_version, manual). basou never makes these; the guide says how.", + components: + "Left empty: the first measure names every component it finds, and the axis review registers each with its lanes or '-' and a note.", + axis: "Raise version when a lane is added or removed or a stage's meaning changes.", + effort: "start is the day of the first session (today when there is none).", + timeZone: + "This host's time zone has no name: write effort.time_zone (such as Asia/Tokyo), or the effort is not measured.", + }, +}; + +const BOARD_INIT_JA: BoardInitStrings = { + title: "{name} 進捗盤", + stages: { + "01": "構想 — そのレーンが何をするものかを書いた文書がある", + "02": "仕様 — 何をどう作るかが決まっている", + "03": "実装 — 動くコードが repo にあり、テストが通る", + "04": "本番 — main に入っている", + "05": "開通 — 利用者が手に取れる", + "06": "稼働 — 実際に使われている", + }, + lane: { + name: "レーンの名前(誰が使う何か)", + about: "このレーンを、この盤のレーンに切り直す。repo ではなく「誰が使う何か」で切る", + note: "このレーンを判定するときの注意", + }, + comments: { + head: "進捗盤の宣言(basou board init が出した雛形)。この workspace に合わせて直し、basou board measure で確かめる。判定と記録の手順は basou board guide が出す。", + stages: + "6 段の id は固定で、盤が決めるのは意味だけ。04 本番(main に入った)と 05 開通(利用者が手に取れる)を分ける。look と notes は、その段を判定するときに見るものと注意。", + lanes: "レーンは 1 本以上。id は小文字の ASCII で、記録の照合に使うので変えない。", + observe: + "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・page_version・manual)。basou は取りに行かない。取り方は guide が出す。", + components: + "空のままでよい。最初の measure が見つけた構成要素を全部名指しし、軸の見直しで各要素をレーンに(数えないなら '-' と note で)登録する。", + axis: "レーンの増減や段の意味を変えたら version を上げる。", + effort: "start は最初の session の日(session が無ければ今日)。", + timeZone: + "このホストの time zone に名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は測られない。", + }, +}; + +/** The words `basou board init` writes, for a resolved view language. */ +export function boardInitStrings(language: ViewLanguage): BoardInitStrings { + return language === "ja" ? BOARD_INIT_JA : BOARD_INIT_EN; +} From b46f22c21251bf128de0697b7d32a7279b3e3142 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:29:23 +0900 Subject: [PATCH 2/5] basou: Test that init writes in the anchor's language Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/board.test.ts | 12 ++++++++++++ packages/core/src/board/guide.test.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 1361f267..81518b03 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -17,6 +17,7 @@ import { basename, join } from "node:path"; import { promisify } from "node:util"; import { basouPaths, + boardInitStrings, chainEvents, createManifest, type Event, @@ -1499,6 +1500,17 @@ describe("basou board init", () => { expect(process.exitCode ?? 0).toBe(0); }); + it("writes its words in the language the manifest declares for the workspace's own repo", async () => { + const repo = await workspace([{ path: ".", visibility: "private", language: "ja" }]); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const text = await doRunBoardInit({}, ctx(repo)); + const parsed = parseBoardDeclaration(text, { manifestRepoPaths: ["."] }); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + expect(parsed.declaration.title).toBe( + boardInitStrings("ja").title.replace("{name}", "board-ws"), + ); + }); + it("refuses, printing nothing, where a board is already declared", async () => { const repo = await workspace([{ path: ".", visibility: "private" }]); await placeBoard(repo, boardYaml([])); diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 2b41b669..2d16939e 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -512,6 +512,7 @@ describe("boardGuide: the commands run", () => { await mkdir(elsewhere); await symlink(elsewhere, work); expect(sh(prefix)).toBe(1); + }); it("saves the board init prints only where no board is", async () => { const anchor = join(dir, "ws"); From 424f85340c78302b9b1943398f7e721db360044e Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:30:49 +0900 Subject: [PATCH 3/5] basou: Save a printed board through a file mktemp makes The help said to print the board to a fixed name under the temporary directory, which on a /tmp other users share is a path another user can place first. mktemp makes a file of the user's own. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/board.test.ts | 22 ++++++++++++++++++++++ packages/cli/src/commands/board.ts | 9 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 81518b03..aff0a5bb 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -33,6 +33,7 @@ import { doRunBoardInit, doRunBoardMeasure, doRunBoardRecord, + INIT_SAVE, measureLiveBoard, runBoardGuide, runBoardInit, @@ -1511,6 +1512,27 @@ describe("basou board init", () => { ); }); + it("says to save it through a file mktemp makes, and only where no board is", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "basou-board-init-save-"))); + try { + const bin = join(dir, "bin"); + await mkdir(bin); + await writeFile(join(bin, "basou"), "#!/bin/sh\necho 'title: printed'\n", { mode: 0o755 }); + const run = () => + promisify(execFile)("sh", ["-c", `${INIT_SAVE}; echo "exit=$?"`], { + cwd: dir, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + expect((await run()).stdout).toBe("exit=0\n"); + expect(await readFile(join(dir, "board", "board.yaml"), "utf8")).toBe("title: printed\n"); + await writeFile(join(dir, "board", "board.yaml"), "title: mine\n"); + expect((await run()).stdout).toBe("exit=1\n"); + expect(await readFile(join(dir, "board", "board.yaml"), "utf8")).toBe("title: mine\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("refuses, printing nothing, where a board is already declared", async () => { const repo = await workspace([{ path: ".", visibility: "private" }]); await placeBoard(repo, boardYaml([])); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 4c4e6b7f..a5856a94 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -226,11 +226,12 @@ read (the reason is on stderr, and nothing is printed on stdout).`, } /** - * How a printed board is saved: to a file of its own first, moved into place - * only where no board is. Printing straight into board.yaml would empty an - * existing one before init could refuse. + * How a printed board is saved: to a file of its own first (mktemp makes one + * no other user can have placed or read), moved into place only where no + * board is. Printing straight into board.yaml would empty an existing one + * before init could refuse. */ -export const INIT_SAVE = `basou board init > "\${TMPDIR:-/tmp}/board.yaml" && [ ! -e ${DEFAULT_BOARD_PATH} ] && mkdir -p board && mv "\${TMPDIR:-/tmp}/board.yaml" ${DEFAULT_BOARD_PATH}`; +export const INIT_SAVE = `f=$(mktemp) && basou board init > "$f" && [ ! -e ${DEFAULT_BOARD_PATH} ] && mkdir -p board && mv "$f" ${DEFAULT_BOARD_PATH}`; /** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardInit}. */ export async function runBoardInit( From eb3926967812680b12dd63fcf02b2c0a22c3611d Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 08:32:01 +0900 Subject: [PATCH 4/5] basou: Settle what the review found in board init - Leave out a time zone a board cannot declare (an offset such as +00:00, which some hosts name their zone), and say on stderr and in a comment when the start is today in UTC because the zone has no name. - Run only from the top of the workspace's own repo, where the save writes: from a member repo, a view or a subdirectory the board would land elsewhere, a public repo included. - Save through a file of its own inside board/, linked into place only where no board.yaml is and removed either way, with board/ when nothing else is in it: nothing is left in the temporary directory, and a board that appears meanwhile is never replaced. The guide shares the command. - The guide refuses a board.yaml that is a link to nothing, which init would not replace, and names the basou that printed it with no board declared too. - Escape the remaining bidirectional and invisible characters in what init and the guide print, and fix a comment and a test that hung on the host's time zone. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++++--- packages/cli/src/commands/board.test.ts | 84 ++++++++++++++++++++++++- packages/cli/src/commands/board.ts | 47 +++++++++++--- packages/core/src/board/declaration.ts | 12 ++-- packages/core/src/board/guide.test.ts | 30 +++++++-- packages/core/src/board/guide.ts | 16 ++--- packages/core/src/board/index.ts | 8 ++- packages/core/src/board/init.test.ts | 22 ++++++- packages/core/src/board/init.ts | 22 ++++++- packages/core/src/index.ts | 2 + packages/core/src/lib/view-strings.ts | 12 +++- 11 files changed, 231 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c85d27..1d303d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,21 @@ All notable changes to **basou** are recorded here. The project follows declares for the workspace's own repo: a title from the workspace's name, the six stages with a meaning each (concept, spec, built, merged, open, live), one sample lane to cut again (a board needs one), nothing to - observe, no component registered (the first measurement names every one - it finds, which fires the axis review that registers them), an axis of + observe, no component registered (the first measurement names every one it + finds, which fires the axis review that registers them), an axis of version 1 reviewed every 60 days, and the effort from the day of the first - session in this host's time zone (today when there is none; with no name - for the zone, a comment says to write one). It writes nothing: its help - and `basou board guide` say to save it through a file of its own, moved - into place only where no board is, never by printing straight into - `board/board.yaml`, which the shell empties before init runs. It exits 1, - printing nothing, where a board is already declared or the workspace's - own repo is not declared private. + session in this host's time zone, as the board page counts it with no + board (today when there is none). Where the host's zone has no name a + board can declare (none, or an offset such as `+00:00`), the time zone is + left out and a comment and stderr say to write it, and with no name at all + the start is today in UTC, said so. It writes nothing, and runs only from + the top of the workspace's own repo: its help and `basou board guide` say + to save it through a file of its own inside `board/`, linked into place + only where no `board.yaml` is (a link never replaces one) and removed + either way, never by printing straight into `board/board.yaml`, which the + shell empties before init runs. It exits 1, printing nothing, where a + board is already declared (a link to nothing included), the workspace's + own repo is not declared private, or it is run from elsewhere. - **`basou board guide` (experimental) prints the steps an agent follows to judge the board and record it, so a workspace needs no skill of its own for that.** The steps are filled in with the workspace's default board diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index aff0a5bb..e375702f 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1463,6 +1463,19 @@ describe("basou board guide", () => { expect(process.exitCode).toBe(1); }); + it("refuses a board.yaml that is a link to nothing, as init would not replace it", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await mkdir(join(repo, "board")); + await symlink(join(repo, "nowhere.yaml"), join(repo, "board", "board.yaml")); + const { out, err } = capture(); + await runBoardGuide({}, guideCtx(repo)); + expect(out).toEqual([]); + expect(err.join("\n")).toContain( + "board/board.yaml is there but cannot be read (a link to nothing?)", + ); + expect(process.exitCode).toBe(1); + }); + it("refuses a declaration that does not read, listing why", async () => { const repo = await workspace([{ path: ".", visibility: "private" }]); await placeBoard(repo, JSON.stringify({ ...JSON.parse(boardYaml([])), title: "" })); @@ -1496,7 +1509,13 @@ describe("basou board init", () => { expect(parsed.declaration.title).toBe("board-ws progress board"); expect(parsed.declaration.board_version).toBe(2); // No session yet: the effort starts today. - expect(parsed.declaration.effort.start).toBe(NOW.toISOString().slice(0, 10)); + expect(parsed.declaration.effort.start).toBe( + new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(NOW), + ); expect(await readdir(repo)).toEqual(before); expect(process.exitCode ?? 0).toBe(0); }); @@ -1512,7 +1531,7 @@ describe("basou board init", () => { ); }); - it("says to save it through a file mktemp makes, and only where no board is", async () => { + it("says to save it through a file of its own in board/, and only where no board is", async () => { const dir = await realpath(await mkdtemp(join(tmpdir(), "basou-board-init-save-"))); try { const bin = join(dir, "bin"); @@ -1528,11 +1547,72 @@ describe("basou board init", () => { await writeFile(join(dir, "board", "board.yaml"), "title: mine\n"); expect((await run()).stdout).toBe("exit=1\n"); expect(await readFile(join(dir, "board", "board.yaml"), "utf8")).toBe("title: mine\n"); + expect(await readdir(join(dir, "board"))).toEqual(["board.yaml"]); } finally { await rm(dir, { recursive: true, force: true }); } }); + it("runs only from the top of the workspace's own repo, where the save writes", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await mkdir(join(repo, "docs")); + const write = vi.spyOn(process.stdout, "write"); + const { err } = capture(); + await runBoardInit({}, { ...ctx(repo), cwd: join(repo, "docs") }); + expect(write).not.toHaveBeenCalled(); + expect(err.join("\n")).toContain( + "Run basou board init from the top of the workspace's own repo, where its .basou/ is", + ); + expect(process.exitCode).toBe(1); + }); + + it("leaves out a time zone a board cannot declare, and says when the start is not the first session's", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; + const zoneIs = (name: string | undefined) => + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(function ( + this: Intl.DateTimeFormat, + ) { + return { ...resolved.call(this), timeZone: name as string }; + }); + for (const [zone, said] of [ + ["+00:00", "no name a board can declare, so effort.time_zone is left out"], + [undefined, "no name, so effort.start is today in UTC and effort.time_zone is left out"], + ] as const) { + const spy = zoneIs(zone); + const out: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }); + const { err } = capture(); + try { + const text = await doRunBoardInit({}, ctx(repo)); + expect(err.join("\n")).toContain(said); + const parsed = parseBoardDeclaration(text, { manifestRepoPaths: ["."] }); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + expect(parsed.declaration.effort.time_zone).toBeUndefined(); + expect(text.includes(boardInitStrings("en").comments.effortToday)).toBe(zone === undefined); + } finally { + spy.mockRestore(); + vi.restoreAllMocks(); + } + } + }); + + it("says when the sessions could not be read, so the start is today", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + const sessions = join(repo, ".basou", "sessions", "ses_01HXABCDEF1234567890ABCS01"); + await mkdir(sessions, { recursive: true }); + await writeFile(join(sessions, "session.yaml"), "session: [broken]\n"); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { err } = capture(); + await doRunBoardInit({}, ctx(repo)); + expect(err.join("\n")).toContain( + "basou: the sessions could not be read, so effort.start is today; set it to the day the work began.", + ); + }); + it("refuses, printing nothing, where a board is already declared", async () => { const repo = await workspace([{ path: ".", visibility: "private" }]); await placeBoard(repo, boardYaml([])); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index a5856a94..e6114856 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -3,6 +3,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { assertBasouRootSafe, type BasouPaths, + BOARD_INIT_SAVE, type BoardCellChange, type BoardDeclaration, type BoardDiff, @@ -24,6 +25,7 @@ import { displayPath, effortStartOf, findErrorCode, + isBoardTimeZone, type Manifest, measureBoard, measureBoardLive, @@ -225,13 +227,8 @@ read (the reason is on stderr, and nothing is printed on stdout).`, }); } -/** - * How a printed board is saved: to a file of its own first (mktemp makes one - * no other user can have placed or read), moved into place only where no - * board is. Printing straight into board.yaml would empty an existing one - * before init could refuse. - */ -export const INIT_SAVE = `f=$(mktemp) && basou board init > "$f" && [ ! -e ${DEFAULT_BOARD_PATH} ] && mkdir -p board && mv "$f" ${DEFAULT_BOARD_PATH}`; +/** How a printed board is saved (see BOARD_INIT_SAVE in @basou/core). */ +export const INIT_SAVE = BOARD_INIT_SAVE; /** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardInit}. */ export async function runBoardInit( @@ -255,6 +252,15 @@ export async function doRunBoardInit( ctx: BoardContext, ): Promise { const { root, paths, manifest } = await privateWorkspace(ctx, "board init"); + // The way to save it writes board/board.yaml in the current directory, so + // init runs only where that is the workspace's own: from a member repo, a + // workspace view or a subdirectory the board would land elsewhere. + const cwd = ctx.cwd ?? process.cwd(); + if ((await realpath(cwd).catch(() => cwd)) !== (await realpath(root).catch(() => root))) { + throw new Error( + `Run basou board init from the top of the workspace's own repo, where its .basou/ is: the way to save what it prints writes ${DEFAULT_BOARD_PATH} in the current directory.`, + ); + } const board = join(root, DEFAULT_BOARD_PATH); try { await lstat(board); @@ -268,16 +274,27 @@ export async function doRunBoardInit( paths, ctx.nowProvider?.() ?? new Date(), ); - if (!sessionsRead && timeZone !== undefined) { + if (timeZone === undefined) { + console.error( + "basou: this host's time zone has no name, so effort.start is today in UTC and effort.time_zone is left out; write both.", + ); + } else if (!sessionsRead) { console.error( "basou: the sessions could not be read, so effort.start is today; set it to the day the work began.", ); + } else if (!isBoardTimeZone(timeZone)) { + console.error( + "basou: this host's time zone has no name a board can declare, so effort.time_zone is left out; write one.", + ); } const text = boardInitText({ name: manifest.workspace.name, language: resolveViewLanguage(manifest), start, - timeZone, + startIsFirstSession: timeZone !== undefined, + // An offset such as +00:00, which some hosts name their zone, is not + // one a board can declare. + timeZone: timeZone !== undefined && isBoardTimeZone(timeZone) ? timeZone : undefined, }); process.stdout.write(text); return text; @@ -339,6 +356,18 @@ export async function doRunBoardGuide( text = await readDeclaration(board); } catch (error: unknown) { if (!findErrorCode(error, "ENOENT")) throw error; + // A link to nothing is there, though it cannot be read: init would not + // replace it, so it is not a board to declare. + if ( + await lstat(board.path).then( + () => true, + () => false, + ) + ) { + throw new Error( + `${DEFAULT_BOARD_PATH} is there but cannot be read (a link to nothing?). Fix it before the board is updated.`, + ); + } text = null; } const otherRepos = (manifest.repos ?? []).map((repo) => repo.path).filter((p) => p !== "."); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 069c54cf..18114bd6 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -70,10 +70,12 @@ const dateText = z .string() .refine(isCalendarDate, { error: "must be a calendar date written as YYYY-MM-DD" }); -// A name from the time zone database. An offset such as +09:00 is refused -// although some Node versions accept one, so a declaration reads the same on -// every version. -function isTimeZone(s: string): boolean { +/** + * Whether a board can declare a time zone: a name from the time zone + * database. An offset such as +09:00 is refused although some Node versions + * accept one, so a declaration reads the same on every version. + */ +export function isBoardTimeZone(s: string): boolean { if (/^[+-]\d/.test(s)) return false; try { new Intl.DateTimeFormat("en-US", { timeZone: s }); @@ -83,7 +85,7 @@ function isTimeZone(s: string): boolean { } } -const timeZoneText = z.string().refine(isTimeZone, { +const timeZoneText = z.string().refine(isBoardTimeZone, { error: "must be a time zone name such as Asia/Tokyo, not an offset such as +09:00", }); diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 2d16939e..62bfd8d1 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -1,5 +1,15 @@ import { execFileSync, spawn } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -13,6 +23,7 @@ import { boardGuideWorkDir, shellWord, } from "./guide.js"; +import { BOARD_INIT_SAVE } from "./init.js"; import { type BoardPreviousRecords, NO_PREVIOUS_RECORDS, @@ -421,7 +432,8 @@ describe("boardGuide: no board declared", () => { it("says how to declare one instead of the steps", () => { const guide = boardGuide(guideInput({ board: { status: "undeclared" } })); expect(guide).toContain("## No board is declared yet"); - expect(guide).toContain('basou board init > "$W/board.yaml" && [ ! -e board/board.yaml ]'); + expect(guide).toContain(BOARD_INIT_SAVE); + expect(guide).toContain("basou: `'/usr/bin/node' '/opt/basou/index.js'` printed this."); expect(guide).toContain("Run `basou board guide` again"); expect(guide).not.toContain("## Steps"); expect(guide).not.toContain("```json"); @@ -514,21 +526,29 @@ describe("boardGuide: the commands run", () => { expect(sh(prefix)).toBe(1); }); - it("saves the board init prints only where no board is", async () => { + it("saves the board init prints only where no board is, leaving nothing else behind", async () => { const anchor = join(dir, "ws"); const bin = join(dir, "bin"); await mkdir(anchor); await mkdir(bin); - // A basou that prints a board, as init does. - await writeFile(join(bin, "basou"), "#!/bin/sh\necho 'title: printed'\n", { mode: 0o755 }); + // A basou that prints a board, as init does, or fails as init does. + const basou = join(bin, "basou"); + const prints = "#!/bin/sh\necho 'title: printed'\n"; + await writeFile(basou, prints, { mode: 0o755 }); const guide = boardGuide(guideInput({ anchor, board: { status: "undeclared" } })); const save = blocksIn(guide).find((b) => b.includes("basou board init")) as string; const env = { ...process.env, TMPDIR: dir, PATH: `${bin}:${process.env.PATH ?? ""}` }; const run = () => execFileSync("sh", ["-c", save], { env, encoding: "utf8" }); + await writeFile(basou, "#!/bin/sh\necho 'refused' >&2; exit 1\n", { mode: 0o755 }); + expect(run()).toBe("exit=1\n"); + await expect(readdir(anchor)).resolves.toEqual([]); + await writeFile(basou, prints, { mode: 0o755 }); expect(run()).toBe("exit=0\n"); + expect(await readdir(join(anchor, "board"))).toEqual(["board.yaml"]); expect(await readFile(join(anchor, "board", "board.yaml"), "utf8")).toBe("title: printed\n"); await writeFile(join(anchor, "board", "board.yaml"), "title: mine\n"); expect(run()).toBe("exit=1\n"); + expect(await readdir(join(anchor, "board"))).toEqual(["board.yaml"]); expect(await readFile(join(anchor, "board", "board.yaml"), "utf8")).toBe("title: mine\n"); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index b18f1056..d386a18e 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -8,6 +8,7 @@ import { isCalendarDate, } from "./declaration.js"; import { daysBetween, todayIn } from "./effort.js"; +import { BOARD_INIT_SAVE } from "./init.js"; import type { BoardPreviousRecords } from "./previous.js"; /** @@ -73,7 +74,7 @@ export function shellWord(text: string): string { // bidirectional controls as they are. function jsonText(value: unknown, indent?: number): string { return JSON.stringify(value, null, indent).replace( - /[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, + /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, (c) => `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`, ); } @@ -104,6 +105,7 @@ export function boardGuide(input: BoardGuideInput): string { ); if (input.board.status === "undeclared") { + line(basouLine(input), ""); undeclared(input, go, line, block); return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; } @@ -123,9 +125,7 @@ export function boardGuide(input: BoardGuideInput): string { ); line(`- Board page port: ${port}`); line(`- Working files: ${work} (W in the commands below)`); - line( - `- basou: \`${displayPath(input.basouCommand)}\` printed this. Where \`basou\` is not on PATH (an alias of an interactive shell), call that instead, and the same one at every step: a measurement records which basou measured it.`, - ); + line(`- ${basouLine(input)}`); line(""); line( @@ -357,6 +357,10 @@ export function boardGuide(input: BoardGuideInput): string { return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; } +function basouLine(input: BoardGuideInput): string { + return `basou: \`${displayPath(input.basouCommand)}\` printed this. Where \`basou\` is not on PATH (an alias of an interactive shell) or is another version, call that instead, and the same one at every step: a measurement records which basou measured it.`; +} + function count(n: number, noun: string): string { return `${n} ${noun}${n === 1 ? "" : "s"}`; } @@ -634,9 +638,7 @@ function undeclared( "1. Check that no one outside can read this repo: records hold what the trail holds (open tracks, time worked, model names). Step 9 (f) of this guide, once a board is declared, says how; on GitHub it is visibility PRIVATE, no collaborator but the owner and no invitation. Never keep a board in a repo shared with a client.", "2. Print a board to start from, and save it only where no board is. Never print it straight into board/board.yaml: the shell empties that file before init runs.", ); - block( - `${go} && basou board init > "$W/board.yaml" && [ ! -e board/board.yaml ] && mkdir -p board && mv "$W/board.yaml" board/board.yaml; echo "exit=$?"`, - ); + block(`${go} && ${BOARD_INIT_SAVE}; echo "exit=$?"`); line( "3. Cut the axis, with the operator. The board asks what can be used now and what is stuck, not what was built. Replace the sample lane with lanes cut by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one. Declare under `observe` what is reached outside the repos (a registry, a release, a site). Measures, ratios and components may stay empty at first.", "4. Check it until it reads (every problem with it is listed at once):", diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 9d62f916..c4efeb26 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -31,6 +31,7 @@ export { BOARD_TRAIL_COUNTS, BOARD_VERSION, BOARD_VERSIONS, + isBoardTimeZone, parseBoardDeclaration, } from "./declaration.js"; export type { @@ -58,7 +59,12 @@ export { shellWord, } from "./guide.js"; export type { BoardInitInput } from "./init.js"; -export { BOARD_INIT_LANE_ID, BOARD_INIT_REVIEW_DUE_DAYS, boardInitText } from "./init.js"; +export { + BOARD_INIT_LANE_ID, + BOARD_INIT_REVIEW_DUE_DAYS, + BOARD_INIT_SAVE, + boardInitText, +} from "./init.js"; export type { BoardIntegrity } from "./integrity.js"; export { BOARD_INTEGRITY_METHOD } from "./integrity.js"; export type { BoardLiveMeasurement, BoardLivePage, MeasureBoardLiveInput } from "./live.js"; diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index d9c3c862..5d3a4a17 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -13,6 +13,7 @@ describe("boardInitText", () => { name: "shop", language: "en", start: "2026-09-21", + startIsFirstSession: true, timeZone: "Asia/Tokyo", }); const result = read(text); @@ -34,7 +35,13 @@ describe("boardInitText", () => { it("writes the words in Japanese for a Japanese anchor", () => { const result = read( - boardInitText({ name: "shop", language: "ja", start: "2026-09-21", timeZone: "UTC" }), + boardInitText({ + name: "shop", + language: "ja", + start: "2026-09-21", + startIsFirstSession: true, + timeZone: "UTC", + }), ); if (!result.ok) throw new Error(result.errors.join("\n")); expect(result.declaration.title).toBe(ja.title.replace("{name}", "shop")); @@ -47,9 +54,12 @@ describe("boardInitText", () => { name: "shop", language: "en", start: "2026-10-05", + startIsFirstSession: false, timeZone: undefined, }); - expect(text).toContain(" # This host's time zone has no name: write effort.time_zone"); + expect(text).toContain(` # ${en.comments.timeZone}`); + expect(text).toContain(`# ${en.comments.effortToday}\neffort:`); + expect(text).not.toContain(en.comments.effort); const result = read(text); if (!result.ok) throw new Error(result.errors.join("\n")); expect(result.declaration.effort).toEqual({ start: "2026-10-05" }); @@ -58,7 +68,13 @@ describe("boardInitText", () => { it("keeps a name as written, whatever it holds", () => { const bidi = String.fromCodePoint(0x202e); const lines = (name: string) => - boardInitText({ name, language: "en", start: "2026-10-05", timeZone: "UTC" }).split("\n"); + boardInitText({ + name, + language: "en", + start: "2026-10-05", + startIsFirstSession: true, + timeZone: "UTC", + }).split("\n"); for (const name of ['a "quoted" $& name', "it's #1: yes", "x\ny", `${bidi}x`]) { const text = lines(name).join("\n"); const result = read(text); diff --git a/packages/core/src/board/init.ts b/packages/core/src/board/init.ts index 082cff13..014934d6 100644 --- a/packages/core/src/board/init.ts +++ b/packages/core/src/board/init.ts @@ -14,15 +14,31 @@ export type BoardInitInput = { language: ViewLanguage; /** The day the effort starts, `YYYY-MM-DD`. */ start: string; - /** This host's time zone, or undefined when it has no name. */ + /** + * Whether `start` is the day of the first session (or today, with none), + * rather than today in UTC because this host's time zone has no name. + */ + startIsFirstSession: boolean; + /** + * This host's time zone, when a board can declare it (a name, not an + * offset); otherwise undefined, and a comment says to write one. + */ timeZone: string | undefined; }; +/** + * How to save the board init prints, from the top of the workspace's own + * repo: to a file of its own inside board/, linked into place only where no + * board.yaml is (a link never replaces one), then removed, with board/ too + * when nothing else is in it. + */ +export const BOARD_INIT_SAVE = `mkdir -p board && f=$(mktemp board/.board.yaml.XXXXXX) && { basou board init > "$f" && ln "$f" board/board.yaml; s=$?; rm -f "$f"; rmdir board 2>/dev/null; [ "$s" -eq 0 ]; }`; + // A YAML 1.2 scalar that reads back as the string given: JSON's quoting is // YAML's double-quoted style, with what a terminal would act on escaped. function quoted(text: string): string { return JSON.stringify(text).replace( - /[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, + /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, (c) => `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`, ); } @@ -72,7 +88,7 @@ export function boardInitText(input: BoardInitInput): string { " version: 1", ` review_due_days: ${BOARD_INIT_REVIEW_DUE_DAYS}`, "", - comment(t.comments.effort), + comment(input.startIsFirstSession ? t.comments.effort : t.comments.effortToday), "effort:", ` start: ${quoted(input.start)}`, input.timeZone === undefined diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a1a25ba1..89f4e7dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -152,6 +152,7 @@ export { BOARD_GUIDE_PORTS, BOARD_INIT_LANE_ID, BOARD_INIT_REVIEW_DUE_DAYS, + BOARD_INIT_SAVE, BOARD_INTEGRITY_METHOD, BOARD_OBSERVE_KINDS, BOARD_PORTFOLIO_METHOD, @@ -179,6 +180,7 @@ export { diffMeasurements, diffObserved, effortStartOf, + isBoardTimeZone, measureBoard, measureBoardLive, modelKey, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index f7d629fe..94258e00 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1329,7 +1329,7 @@ export function boardPageStrings(language: ViewLanguage): BoardPageStrings { * change. They are the user's data once printed: a board may change them all. */ export type BoardInitStrings = { - /** `{name}` is the name of the workspace's directory. */ + /** `{name}` is the workspace's name in the manifest. */ title: string; stages: Record<"01" | "02" | "03" | "04" | "05" | "06", string>; lane: { name: string; about: string; note: string }; @@ -1341,6 +1341,8 @@ export type BoardInitStrings = { components: string; axis: string; effort: string; + /** The start is today in UTC: this host's zone has no name. */ + effortToday: string; timeZone: string; }; }; @@ -1371,8 +1373,10 @@ const BOARD_INIT_EN: BoardInitStrings = { "Left empty: the first measure names every component it finds, and the axis review registers each with its lanes or '-' and a note.", axis: "Raise version when a lane is added or removed or a stage's meaning changes.", effort: "start is the day of the first session (today when there is none).", + effortToday: + "start is today in UTC: this host's time zone has no name, so the day of the first session is not known. Set start to the day the work began.", timeZone: - "This host's time zone has no name: write effort.time_zone (such as Asia/Tokyo), or the effort is not measured.", + "This host's time zone has no name a board can declare: write effort.time_zone (such as Asia/Tokyo), or the days are counted in an unnamed zone or not at all.", }, }; @@ -1402,8 +1406,10 @@ const BOARD_INIT_JA: BoardInitStrings = { "空のままでよい。最初の measure が見つけた構成要素を全部名指しし、軸の見直しで各要素をレーンに(数えないなら '-' と note で)登録する。", axis: "レーンの増減や段の意味を変えたら version を上げる。", effort: "start は最初の session の日(session が無ければ今日)。", + effortToday: + "start は UTC の今日。このホストの time zone に名前が無く、最初の session の日が分からないため。仕事を始めた日に直す。", timeZone: - "このホストの time zone に名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は測られない。", + "このホストの time zone に、盤に書ける名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は名前の無い zone で数えられるか、測られない。", }, }; From f6ab4e6e39c67b906e63a56f2bf7a7551e7d6444 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 18:02:02 +0900 Subject: [PATCH 5/5] basou: Expect an offset time zone as Node takes it in the init test Node 20, which CI runs, refuses an offset such as +00:00 as a time zone, so a host named so has no zone at all there, while Node 24 takes it. The test now expects what the running Node does. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/board.test.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index e375702f..628f61c2 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1575,9 +1575,23 @@ describe("basou board init", () => { ) { return { ...resolved.call(this), timeZone: name as string }; }); + // Some Node versions take an offset as a zone, and name the host's zone so + // under TZ=GMT; older ones refuse it, and the host's zone then has no name. + const takesOffset = (() => { + try { + new Intl.DateTimeFormat("en-US", { timeZone: "+00:00" }); + return true; + } catch { + return false; + } + })(); + const unnamed = "no name, so effort.start is today in UTC and effort.time_zone is left out"; for (const [zone, said] of [ - ["+00:00", "no name a board can declare, so effort.time_zone is left out"], - [undefined, "no name, so effort.start is today in UTC and effort.time_zone is left out"], + [ + "+00:00", + takesOffset ? "no name a board can declare, so effort.time_zone is left out" : unnamed, + ], + [undefined, unnamed], ] as const) { const spy = zoneIs(zone); const out: string[] = []; @@ -1592,7 +1606,9 @@ describe("basou board init", () => { const parsed = parseBoardDeclaration(text, { manifestRepoPaths: ["."] }); if (!parsed.ok) throw new Error(parsed.errors.join("\n")); expect(parsed.declaration.effort.time_zone).toBeUndefined(); - expect(text.includes(boardInitStrings("en").comments.effortToday)).toBe(zone === undefined); + expect(text.includes(boardInitStrings("en").comments.effortToday)).toBe( + zone === undefined || !takesOffset, + ); } finally { spy.mockRestore(); vi.restoreAllMocks();