diff --git a/CHANGELOG.md b/CHANGELOG.md index 484fd704..1d303d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ 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, 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/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..628f61c2 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -17,10 +17,12 @@ import { basename, join } from "node:path"; import { promisify } from "node:util"; import { basouPaths, + boardInitStrings, chainEvents, createManifest, type Event, ensureBasouDirectory, + parseBoardDeclaration, type RepoEntry, writeManifest, } from "@basou/core"; @@ -28,10 +30,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeStaleness } from "../lib/provenance-actions.js"; import { doRunBoardGuide, + doRunBoardInit, doRunBoardMeasure, doRunBoardRecord, + INIT_SAVE, measureLiveBoard, runBoardGuide, + runBoardInit, runBoardMeasure, runBoardRecord, } from "./board.js"; @@ -1458,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: "" })); @@ -1472,6 +1490,172 @@ 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( + 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); + }); + + 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("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"); + 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"); + 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 }; + }); + // 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", + 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[] = []; + 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 || !takesOffset, + ); + } 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([])); + 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..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, @@ -16,12 +17,15 @@ import { type BoardRepo, basouPaths, boardGuide, + boardInitText, buildRecord, byCodePoint, diffCells, diffObserved, displayPath, + effortStartOf, findErrorCode, + isBoardTimeZone, type Manifest, measureBoard, measureBoardLive, @@ -68,6 +72,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 +197,46 @@ 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 (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( + 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 +244,72 @@ 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"); + // 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); + 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 (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, + 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; +} + +// 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 +321,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, @@ -239,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/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..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("Write board/board.yaml (board_version 2)"); + 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,6 +526,32 @@ describe("boardGuide: the commands run", () => { expect(sh(prefix)).toBe(1); }); + 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, 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"); + }); + it("takes the before of a repo with no commit yet, and still tells a change apart", async () => { const anchor = join(dir, "ws"); const fresh = join(dir, "fresh"); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index deae563d..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"}`; } @@ -632,8 +636,11 @@ 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} && ${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):", ); 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..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 { @@ -44,7 +45,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 +58,13 @@ export { boardGuideWorkDir, shellWord, } from "./guide.js"; +export type { BoardInitInput } 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 new file mode 100644 index 00000000..5d3a4a17 --- /dev/null +++ b/packages/core/src/board/init.test.ts @@ -0,0 +1,88 @@ +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", + startIsFirstSession: true, + 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", + startIsFirstSession: true, + 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", + startIsFirstSession: false, + timeZone: undefined, + }); + 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" }); + }); + + 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", + 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); + 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..014934d6 --- /dev/null +++ b/packages/core/src/board/init.ts @@ -0,0 +1,99 @@ +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; + /** + * 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\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/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(input.startIsFirstSession ? t.comments.effort : t.comments.effortToday), + "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..89f4e7dd 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,9 @@ export { BOARD_EFFORT_METHOD, BOARD_FRESHNESS_METHOD, BOARD_GUIDE_PORTS, + BOARD_INIT_LANE_ID, + BOARD_INIT_REVIEW_DUE_DAYS, + BOARD_INIT_SAVE, BOARD_INTEGRITY_METHOD, BOARD_OBSERVE_KINDS, BOARD_PORTFOLIO_METHOD, @@ -166,6 +170,7 @@ export { boardGuide, boardGuidePort, boardGuideWorkDir, + boardInitText, boardLivePage, boardPage, boardPageUnavailable, @@ -174,6 +179,8 @@ export { diffCells, diffMeasurements, diffObserved, + effortStartOf, + isBoardTimeZone, measureBoard, measureBoardLive, modelKey, @@ -253,12 +260,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..94258e00 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1321,3 +1321,99 @@ 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 workspace's name in the manifest. */ + 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; + /** The start is today in UTC: this host's zone has no name. */ + effortToday: 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).", + 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 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.", + }, +}; + +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 が無ければ今日)。", + effortToday: + "start は UTC の今日。このホストの time zone に名前が無く、最初の session の日が分からないため。仕事を始めた日に直す。", + timeZone: + "このホストの time zone に、盤に書ける名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は名前の無い zone で数えられるか、測られない。", + }, +}; + +/** 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; +}