diff --git a/CHANGELOG.md b/CHANGELOG.md index 2240a51a..484fd704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ All notable changes to **basou** are recorded here. The project follows ### Added +- **`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 + (`board/board.yaml`, read only when the manifest declares the workspace's + own repo private): where it is, its last record and when, the axis's + version and last review, the language the prose is written in, the port + its page opens on (chosen from the workspace's path, never `basou view`'s + default) and a working directory of its own; the commands that take the + other repos' state before and after (each one's HEAD and a hash of what + its working tree holds beyond it), measure, record and open the board, + runnable as printed once the judging model's name is put in (they stop + without it); how to make each observation the board declares + (the read-only command, the value to take from it, its source, and how to + write one that could not be made), the manual ones listed as the + operator's; the principles of judging, with each stage's meaning, `look` + and `notes` and each lane's notes; the record's input to fill in, with + every cell's state left empty and the previous record's state and reason + beside it (record refuses the template as printed, so it cannot be passed + on unjudged); the gates before a commit; and what not to do. With no + board declared, it says how to declare one. It writes nothing and sends + nothing, and has no `--json`: the text is for an agent to read and its + shape is not promised. It exits 1, saying why on stderr, when the + workspace's own repo is not declared private or its `board.yaml` does + not read. - **A board can declare what to observe outside basou, and what to look at when judging each stage (`board_version: 2`, experimental).** `observe` lists observations, each under the `key` its record holds it by, of seven diff --git a/docs/spec/cli-commands.md b/docs/spec/cli-commands.md index 99023d75..45e8c37c 100644 --- a/docs/spec/cli-commands.md +++ b/docs/spec/cli-commands.md @@ -55,6 +55,7 @@ basou hook stop | session-start # the handlers themselves — the tool invo # Progress board (experimental: outside every guarantee, see compatibility.md) 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) # 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 8dd71649..f045ea41 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -27,9 +27,11 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeStaleness } from "../lib/provenance-actions.js"; import { + doRunBoardGuide, doRunBoardMeasure, doRunBoardRecord, measureLiveBoard, + runBoardGuide, runBoardMeasure, runBoardRecord, } from "./board.js"; @@ -1360,6 +1362,116 @@ describe("basou board measure and record against the previous record", () => { }); }); +describe("basou board guide", () => { + const guideCtx = (repo: string) => ({ ...ctx(repo), basouCommand: "basou-under-test" }); + + // Every path under a directory with, for a file, its size, modification + // time and content. + async function tree(dir: string): Promise> { + const out: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out[path] = "dir"; + Object.assign(out, await tree(path)); + } else { + const s = await stat(path, { bigint: true }); + out[path] = `${s.size}:${s.mtimeNs}:${createHash("sha256") + .update(await readFile(path)) + .digest("hex")}`; + } + } + return out; + } + + it("prints the steps for the board declared, with its last record, and writes nothing", async () => { + const repo = await workspace([ + { path: ".", visibility: "private", language: "ja" }, + { path: "../elsewhere" }, + ]); + const board = JSON.parse(boardYaml([MD])); + board.board_version = 2; + board.observe = [{ key: "npm_cli", kind: "npm_version", package: "@scope/cli" }]; + await placeBoard(repo, JSON.stringify(board)); + await mkdir(join(repo, "board", "records")); + const id = "01M4A00000000000000000000A"; + await writeFile( + join(repo, "board", "records", `${id}.json`), + JSON.stringify({ + record_version: 1, + recorded_at: "2026-10-07T00:00:00.000Z", + measure: { methods: {}, components: { found: null } }, + observed: {}, + cells: [{ lane: "core", stage: "01", state: "done" }], + judged_by: { model: "Claude Opus 5.5", self_reported: true }, + axis_review: null, + }), + ); + const before = await tree(repo); + const { out } = capture(); + const guide = await doRunBoardGuide({}, guideCtx(repo)); + expect(out.join("\n")).toBe(guide.trimEnd()); + expect(guide).toContain(`- Workspace (its own repo): ${repo}`); + expect(guide).toContain(`board/records/, 1 record; the last is ${id}`); + expect(guide).toContain("in Japanese (ja)"); + expect(guide).toContain("`basou-under-test` printed this"); + expect(guide).toContain("npm view '@scope/cli' version"); + expect(guide).toContain("for d in '../elsewhere'; do"); + expect(guide).toContain('{"lane":"core","stage":"01","state":"","previous":{"state":"done"}}'); + expect(await tree(repo)).toEqual(before); + expect(process.exitCode ?? 0).toBe(0); + }); + + it("says how to declare a board when there is none", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + const { out } = capture(); + await runBoardGuide({}, guideCtx(repo)); + expect(out.join("\n")).toContain("## No board is declared yet"); + expect(process.exitCode ?? 0).toBe(0); + await expect(readdir(join(repo, "board"))).rejects.toThrow(); + }); + + it("refuses a workspace whose own repo is not declared private", async () => { + const repo = await workspace([{ path: ".", visibility: "public" }]); + await placeBoard(repo, boardYaml([])); + const { out, err } = capture(); + await runBoardGuide({}, guideCtx(repo)); + expect(out).toEqual([]); + 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); + }); + + it("refuses a repo path holding a control character, which its commands would carry", async () => { + const repo = await workspace([ + { path: ".", visibility: "private" }, + { path: `../o${String.fromCharCode(27)}[31mther` }, + ]); + await placeBoard(repo, boardYaml([])); + const { out, err } = capture(); + await runBoardGuide({}, guideCtx(repo)); + expect(out).toEqual([]); + expect(err.join("\n")).toContain( + "The workspace's path, or a repo path the manifest declares, holds a control character", + ); + 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: "" })); + const { out, err } = capture(); + await runBoardGuide({}, guideCtx(repo)); + expect(out).toEqual([]); + expect(err.join("\n")).toContain( + "board/board.yaml is not a valid board declaration, so there are no steps to print until it reads", + ); + expect(err.join("\n")).toContain(" - title: must be a non-empty string"); + 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 05aa0ecf..e9052743 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1,4 +1,4 @@ -import { lstat, readFile, realpath } from "node:fs/promises"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { assertBasouRootSafe, @@ -15,6 +15,7 @@ import { type BoardPreviousRecords, type BoardRepo, basouPaths, + boardGuide, buildRecord, byCodePoint, diffCells, @@ -29,6 +30,9 @@ import { parseRecordInput, readManifest, readPreviousRecords, + recordIds, + resolveViewLanguage, + shellWord, writeRecord, } from "@basou/core"; import type { Command } from "commander"; @@ -62,6 +66,8 @@ export type BoardRecordOptions = { verbose?: boolean; }; +export type BoardGuideOptions = { verbose?: boolean }; + export type BoardContext = { /** Defaults to `process.cwd()`. Injectable for tests. */ cwd?: string; @@ -82,6 +88,11 @@ export type BoardContext = { * not depend on a real stdin stream. Ignored when `--file` names a file. */ readInput?: () => Promise; + /** + * How the guide says to call basou where `basou` is not on PATH. Defaults + * to the node and the script running now. Injectable for tests. + */ + basouCommand?: string; }; /** Where a board's declaration is read from when `--board` is not given. */ @@ -158,6 +169,127 @@ written; the reasons are on stderr).`, .action(async (options: BoardRecordOptions) => { await runBoardRecord(options); }); + board + .command("guide") + .description( + "Print the steps an agent follows to judge the board and record it (writes nothing, sends nothing)", + ) + .option("-v, --verbose", "Show error causes") + .addHelpText( + "after", + ` +The steps are filled in with this workspace's board (${DEFAULT_BOARD_PATH}, +read only when the manifest declares the workspace's own repo private): where +it is and its last record, the commands that measure, observe, record and show +it, what each stage is judged by, and the record's input to fill in, with the +previous record's state of each cell beside it. With no board declared yet, it +says how to declare one. The text is for an agent to read; its shape may change +at any release. Exit codes: 0 when the steps were printed; 1 when the workspace, +its manifest or its board.yaml cannot be read, or its own repo is not declared +private (the reason is on stderr).`, + ) + .action(async (options: BoardGuideOptions) => { + await runBoardGuide(options); + }); +} + +/** 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 cwd = ctx.cwd ?? process.cwd(); + const root = await resolveBasouRootForCommand( + cwd, + "board guide", + ctx.portfolioConfigPath === undefined ? {} : { portfolioConfigPath: ctx.portfolioConfigPath }, + ); + const paths = basouPaths(root); + await assertWorkspaceInitialized(paths.root); + const manifest = await readManifest(paths); + const own = (manifest.repos ?? []).find((repo) => repo.path === "."); + if (own?.visibility !== "private") { + throw new Error( + `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.`, + ); + } + const board: BoardLocation = { + path: join(root, DEFAULT_BOARD_PATH), + shown: DEFAULT_BOARD_PATH, + given: false, + }; + let text: string | null; + try { + text = await readDeclaration(board); + } catch (error: unknown) { + if (!findErrorCode(error, "ENOENT")) throw error; + text = null; + } + const otherRepos = (manifest.repos ?? []).map((repo) => repo.path).filter((p) => p !== "."); + // The commands carry these paths as they are, and a control character in + // one would reach the terminal of whoever reads the guide. + if ([root, ...otherRepos].some((path) => displayPath(path) !== path)) { + throw new Error( + "The workspace's path, or a repo path the manifest declares, holds a control character, which the guide's commands cannot carry safely. Rename it, or follow the steps by hand.", + ); + } + const common = { + // As basou resolves it, the root measure resolves the repos' paths against. + anchor: root, + otherRepos, + language: resolveViewLanguage(manifest), + basouCommand: + ctx.basouCommand ?? + [process.execPath, process.argv[1] ?? ""] + .filter((part) => part !== "") + .map(shellWord) + .join(" "), + basouVersion: BASOU_CLI_VERSION, + now: ctx.nowProvider?.() ?? new Date(), + }; + let guide: string; + if (text === null) { + guide = boardGuide({ ...common, board: { status: "undeclared" } }); + } else { + const parsed = parseBoardDeclaration(text, { + manifestRepoPaths: (manifest.repos ?? []).map((repo) => repo.path), + }); + if (!parsed.ok) { + throw new Error( + `${DEFAULT_BOARD_PATH} is not a valid board declaration, so there are no steps to print until it reads (fix it through an axis review when it changes the lanes or the stages):\n${parsed.errors + .map((e) => ` - ${displayPath(e)}`) + .join("\n")}`, + ); + } + const recordsDir = join(dirname(board.path), "records"); + guide = boardGuide({ + ...common, + board: { + status: "declared", + declaration: parsed.declaration, + recordCount: recordIds(await readdir(recordsDir).catch(() => [])).length, + previous: await readPreviousRecords(recordsDir), + }, + }); + } + console.log(guide.trimEnd()); + return guide; } /** Programmatic entry that owns `process.exitCode`. Tests prefer {@link doRunBoardMeasure}. */ diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts new file mode 100644 index 00000000..2bf9cc4f --- /dev/null +++ b/packages/core/src/board/guide.test.ts @@ -0,0 +1,614 @@ +import { execFileSync, spawn } from "node:child_process"; +import { chmod, mkdir, mkdtemp, 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"; +import { stringify } from "yaml"; +import { type BoardDeclaration, parseBoardDeclaration } from "./declaration.js"; +import { + BOARD_GUIDE_PORTS, + type BoardGuideInput, + boardGuide, + boardGuidePort, + boardGuideWorkDir, + shellWord, +} from "./guide.js"; +import { + type BoardPreviousRecords, + NO_PREVIOUS_RECORDS, + type ReadBoardRecord, +} from "./previous.js"; +import { parseRecordInput } from "./record.js"; + +const STAGES = ["01", "02", "03", "04", "05", "06"]; +const ANCHOR = "/work/a b's board"; +const NOW = new Date("2026-10-09T03:00:00.000Z"); +// The shell word the working directory starts with. +const TMPDIR_WORD = ["$", "{TMPDIR:-/tmp}"].join(""); + +function declaration(over: Record = {}): BoardDeclaration { + const result = parseBoardDeclaration( + stringify({ + board_version: 2, + title: "Test board", + stages: Object.fromEntries( + STAGES.map((id) => [ + id, + id === "05" + ? { meaning: "open", look: ["the npm version"], notes: ["merged is not open"] } + : { meaning: `stage ${id}` }, + ]), + ), + lanes: [ + { id: "core", name: "Core", about: "the engine", notes: ["06 needs a real run"] }, + { id: "docs", name: "Docs" }, + ], + observe: [ + { key: "npm_cli", kind: "npm_version", package: "@scope/cli" }, + { key: "release", kind: "github_release", repo: "owner/name" }, + { key: "issues", kind: "github_open_issues", repo: "owner/name" }, + { key: "prs", kind: "github_open_prs", repo: "owner/name" }, + { key: "ci", kind: "github_ci", repo: "owner/name", workflow: "quality.yml" }, + { key: "site", kind: "page_version", url: "https://example.com/ja/?a=1&b=2" }, + { key: "users", kind: "manual", how: "count the users in production, read only" }, + ], + axis: { version: 3, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "UTC" }, + ...over, + }), + { manifestRepoPaths: [".", "../app"] }, + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + return result.declaration; +} + +function guideInput(over: Partial = {}): BoardGuideInput { + return { + anchor: ANCHOR, + otherRepos: ["../app"], + language: "ja", + basouCommand: "'/usr/bin/node' '/opt/basou/index.js'", + basouVersion: "0.0.0-test", + now: NOW, + board: { + status: "declared", + declaration: declaration(), + recordCount: 0, + previous: NO_PREVIOUS_RECORDS, + }, + ...over, + }; +} + +// A record as read back, with the cells given. +function readRecord( + cells: { lane: string; stage: string; state: string; reason?: string }[], + over: Record = {}, +): ReadBoardRecord { + return { + record_version: 2, + recorded_at: "2026-10-07T00:00:00.000Z", + measure: { methods: {}, components: { found: null } }, + observed: {}, + cells, + judged_by: { model: "Claude Opus 5.5" }, + axis_review: null, + ...over, + } as ReadBoardRecord; +} + +// The record's input the guide prints, as JSON. +function templateIn(guide: string): Record { + const start = guide.indexOf("```json\n"); + const end = guide.indexOf("\n```", start + 1); + if (start === -1 || end === -1) throw new Error("no template"); + return JSON.parse(guide.slice(start + "```json\n".length, end)); +} + +// The blocks of shell commands the guide prints. +function blocksIn(guide: string): string[] { + return [...guide.matchAll(/```sh\n([\s\S]*?)\n```/g)].map((m) => m[1] as string); +} + +describe("boardGuidePort and boardGuideWorkDir", () => { + it("chooses the same port for the same path, in its range, and not basou view's default", () => { + const port = boardGuidePort(ANCHOR); + expect(boardGuidePort(ANCHOR)).toBe(port); + const ports = Array.from({ length: 200 }, (_, i) => boardGuidePort(`/work/ws-${i}`)); + for (const p of [port, ...ports]) { + expect(p).toBeGreaterThanOrEqual(BOARD_GUIDE_PORTS.first); + expect(p).toBeLessThan(BOARD_GUIDE_PORTS.first + BOARD_GUIDE_PORTS.count); + expect(p).not.toBe(4319); + } + // Spread over the range, not piled on a few ports. + expect(new Set(ports).size).toBeGreaterThan(150); + }); + + it("names a working directory by the path's hash, not by its name", () => { + expect(boardGuideWorkDir(ANCHOR)).toMatch(/^\$\{TMPDIR:-\/tmp\}\/basou-board-[0-9a-f]{12}$/); + expect(boardGuideWorkDir(ANCHOR)).not.toBe(boardGuideWorkDir("/work/other")); + }); +}); + +describe("shellWord", () => { + it.each([["plain"], ["it's"], ["$(rm -rf ~) `x` $HOME"], ["a\nb"], ["'"], [""]])( + "is read back by sh as %j", + (text) => { + expect(execFileSync("sh", ["-c", `printf %s ${shellWord(text)}`], { encoding: "utf8" })).toBe( + text, + ); + }, + ); +}); + +describe("boardGuide: a board declared", () => { + it("says where the board is, its last record, its axis, the prose language and the port", () => { + const guide = boardGuide( + guideInput({ + board: { + status: "declared", + declaration: declaration(), + recordCount: 2, + previous: { + last: { + status: "found", + id: "01M4E6PAN7J2BJ5YK1NFPVAQAR", + record: readRecord([]), + }, + lastReview: { + status: "found", + id: "01M4A00000000000000000000A", + record: readRecord([], { + recorded_at: "2026-09-30T23:30:00.000Z", + judged_by: { model: "Old Model" }, + }), + }, + }, + }, + }), + ); + expect(guide).toContain(`- Workspace (its own repo): ${ANCHOR}`); + expect(guide).toContain( + '- Declaration: board/board.yaml, "Test board", board_version 2, 2 lanes, 7 observations outside basou', + ); + expect(guide).toContain( + "- Records: board/records/, 2 records; the last is 01M4E6PAN7J2BJ5YK1NFPVAQAR, recorded 2026-10-08T16:49:07.751Z, 1 day ago", + ); + expect(guide).toContain( + "- Axis: version 3, due a review every 60 days; last reviewed 2026-09-30 by Old Model (record 01M4A00000000000000000000A)", + ); + expect(guide).toContain("write every prose string and reason in Japanese (ja)"); + expect(guide).toContain(`- Board page port: ${boardGuidePort(ANCHOR)}`); + expect(guide).toContain(`- Working files: ${boardGuideWorkDir(ANCHOR)}`); + expect(guide).toContain("'/usr/bin/node' '/opt/basou/index.js'"); + expect(guide).toContain("basou 0.0.0-test"); + }); + + it("says when there is no record yet, and when the last cannot be read", () => { + expect(boardGuide(guideInput())).toContain( + "- Records: board/records/, none yet; this will be the first", + ); + expect(boardGuide(guideInput())).toContain( + "- Axis: version 3, due a review every 60 days; no review on record (the first record is the first review", + ); + const unreadable: BoardPreviousRecords = { + last: { status: "unreadable", reason: "the record X could not be read as JSON" }, + lastReview: { status: "unreadable", reason: "the record X could not be read as JSON" }, + }; + const guide = boardGuide( + guideInput({ + board: { + status: "declared", + declaration: declaration(), + recordCount: 1, + previous: unreadable, + }, + }), + ); + expect(guide).toContain( + "- Records: board/records/, 1 record; the last cannot be read (the record X could not be read as JSON)", + ); + expect(guide).toContain("the last review is not known (a record cannot be read)"); + expect(guide).toContain( + "The previous record cannot be read (the record X could not be read as JSON)", + ); + }); + + it("takes the seed review when no record has one, and says when records hold none", () => { + const seeded = declaration({ + axis: { version: 1, review_due_days: 30, seed_review: { date: "2026-09-28", model: "M" } }, + }); + expect( + boardGuide( + guideInput({ + board: { + status: "declared", + declaration: seeded, + recordCount: 0, + previous: NO_PREVIOUS_RECORDS, + }, + }), + ), + ).toContain("last reviewed 2026-09-28 by M (seed_review in board.yaml)"); + const found: BoardPreviousRecords = { + last: { status: "found", id: "01M4A00000000000000000000A", record: readRecord([]) }, + lastReview: { status: "none" }, + }; + expect( + boardGuide( + guideInput({ + board: { + status: "declared", + declaration: declaration(), + recordCount: 1, + previous: found, + }, + }), + ), + ).toContain("no review on record (measure's axis section says whether one is due)"); + }); + + it("gives each observation's command, its value and its source, quoted for the shell", () => { + const guide = boardGuide(guideInput()); + const blocks = blocksIn(guide).join("\n"); + expect(blocks).toContain(`npm view '@scope/cli' version; echo "exit=$?"`); + expect(blocks).toContain("gh release view --repo 'owner/name' --json tagName -q .tagName"); + expect(blocks).toContain( + "gh issue list --repo 'owner/name' --state open --limit 1000 --json number -q length", + ); + expect(blocks).toContain( + "gh pr list --repo 'owner/name' --state open --limit 1000 --json number -q length", + ); + expect(blocks).toContain( + "gh run list --repo 'owner/name' --workflow 'quality.yml' --branch 'main' --limit 1", + ); + expect(blocks).toContain( + `curl -fsS --max-time 20 'https://example.com/ja/?a=1&b=2' > "$W/page-site.html"`, + ); + expect(guide).toContain( + "Only the operator can make this one: count the users in production, read only", + ); + expect(guide).toContain("- Observing `users`: count the users in production, read only"); + expect(guide).toContain( + "Source: `gh run list --repo owner/name --workflow quality.yml --branch main`.", + ); + }); + + it("leaves the model to be named, in the commands and the template, so neither runs as printed", () => { + const guide = boardGuide(guideInput()); + const blocks = blocksIn(guide).join("\n"); + expect(blocks).not.toContain(""); + expect(blocks).not.toContain(").map(Object.keys)).toEqual( + Array.from({ length: 7 }, () => ["value", "observed_at", "source"]), + ); + expect(guide).toContain("the lane ids are `core`, `docs`."); + expect(guide).toContain( + "write every prose string and reason in Japanese (ja), the language this board's page is drawn in", + ); + }); + + it("says to declare what to observe when nothing is declared", () => { + const guide = boardGuide( + guideInput({ + board: { + status: "declared", + declaration: declaration({ observe: [] }), + recordCount: 0, + previous: NO_PREVIOUS_RECORDS, + }, + }), + ); + expect(guide).toContain("This board declares nothing to observe outside basou."); + expect(templateIn(guide).observed).toEqual({}); + }); + + it("lists each stage with what to look at and mind, and each lane with its notes", () => { + const guide = boardGuide(guideInput()); + expect(guide).toContain( + "- 05: open\n - look at: the npm version\n - mind: merged is not open\n- 06: stage 06", + ); + expect(guide).toContain( + "- `core` Core: the engine\n - mind: 06 needs a real run\n- `docs` Docs\n", + ); + }); + + it("escapes what a terminal would act on in the board's own words", () => { + const d = declaration({ + title: "T\u001b[31m", + lanes: [{ id: "core", name: "Co\u0085re", notes: [`a${String.fromCodePoint(0x202e)}b`] }], + }); + const guide = boardGuide( + guideInput({ + board: { + status: "declared", + declaration: d, + recordCount: 0, + previous: NO_PREVIOUS_RECORDS, + }, + }), + ); + expect(guide).toContain('"T\\x1b[31m"'); + expect(guide).toContain("- `core` Co\\x85re\n - mind: a\\u202eb"); + const acting = [0x1b, 0x85, 0x202e]; + expect([...guide].some((c) => acting.includes(c.codePointAt(0) ?? 0))).toBe(false); + }); +}); + +describe("boardGuide: the record's input", () => { + const previous: BoardPreviousRecords = { + last: { + status: "found", + id: "01M4A00000000000000000000A", + record: readRecord([ + { lane: "core", stage: "01", state: "done" }, + { lane: "core", stage: "06", state: "unverified", reason: "needs a run elsewhere" }, + { lane: "gone", stage: "01", state: "done" }, + ]), + }, + lastReview: { status: "none" }, + }; + + it("leaves every state empty, with the previous record's state and reason beside it", () => { + const template = templateIn( + boardGuide( + guideInput({ + board: { status: "declared", declaration: declaration(), recordCount: 1, previous }, + }), + ), + ); + const cells = template.cells as Record[]; + expect(cells).toHaveLength(12); + expect(cells[0]).toEqual({ lane: "core", stage: "01", state: "", previous: { state: "done" } }); + expect(cells[1]).toEqual({ lane: "core", stage: "02", state: "", previous: null }); + expect(cells[5]).toEqual({ + lane: "core", + stage: "06", + state: "", + previous: { state: "unverified", reason: "needs a run elsewhere" }, + }); + expect(cells.every((c) => c.state === "")).toBe(true); + }); + + it("has no previous beside the cells when there is no previous record", () => { + const cells = templateIn(boardGuide(guideInput())).cells as Record[]; + expect(cells.map((c) => Object.keys(c))).toEqual( + Array.from({ length: 12 }, () => ["lane", "stage", "state"]), + ); + }); + + it("is refused by record as printed, and taken once each cell is judged and each observation made", () => { + const d = declaration(); + const template = templateIn( + boardGuide( + guideInput({ board: { status: "declared", declaration: d, recordCount: 1, previous } }), + ), + ); + const refused = parseRecordInput(template, d); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.errors).toContain( + "cells[0].state: must be one of done, part, blocked, shelved, none, unverified", + ); + expect(refused.errors).toContain("cells[0]: unknown key 'previous'"); + expect(refused.errors.some((e) => e.startsWith("observed.npm_cli.observed_at:"))).toBe(true); + expect(refused.errors.some((e) => e.startsWith("measure_digest:"))).toBe(true); + } + const observed = template.observed as Record>; + expect(Object.keys(observed)).toEqual(d.observe.map((o) => o.key)); + expect(observed.site?.source).toBe("https://example.com/ja/?a=1&b=2"); + expect(observed.users?.source).toBe("the operator"); + for (const o of Object.values(observed)) { + o.observed_at = "2026-10-09T03:00:00Z"; + o.error = "not observed in this test"; + } + template.measure_digest = `sha256:${"a".repeat(64)}`; + template.cells = (template.cells as Record[]).map( + ({ previous: _p, ...cell }) => ({ ...cell, state: "none" }), + ); + (template.judged_by as Record).model = "Claude Opus 5.5"; + const taken = parseRecordInput(template, d); + expect(taken.ok ? [] : taken.errors).toEqual([]); + }); +}); + +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("Run `basou board guide` again"); + expect(guide).not.toContain("## Steps"); + expect(guide).not.toContain("```json"); + }); +}); + +describe("boardGuide: the commands run", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "basou-board-guide-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const git = (cwd: string, ...args: string[]) => + execFileSync("git", ["-c", "user.email=t@example.com", "-c", "user.name=t", ...args], { + cwd, + encoding: "utf8", + }); + + it("takes the before of the other repos, and gate (d) tells a change apart", async () => { + const anchor = join(dir, "it's here"); + const other = join(dir, "other repo"); + for (const repo of [anchor, other]) { + await mkdir(repo); + git(repo, "init", "-q", "-b", "main"); + await writeFile(join(repo, "a.txt"), "a\n"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "init"); + } + const guide = boardGuide(guideInput({ anchor, otherRepos: ["../other repo"], language: "en" })); + const blocks = blocksIn(guide); + const before = blocks.find((b) => b.includes('> "$W/before.txt"')) as string; + const gate = blocks.find((b) => b.includes('echo "(d) same"')) as string; + const env = { ...process.env, TMPDIR: dir }; + const sh = (script: string) => { + try { + return { out: execFileSync("sh", ["-c", script], { env, encoding: "utf8" }), code: 0 }; + } catch (error: unknown) { + return { out: String((error as { stdout?: unknown }).stdout ?? ""), code: 1 }; + } + }; + expect(sh(before).out).toContain("exit=0"); + const work = boardGuideWorkDir(anchor).replace(TMPDIR_WORD, dir); + expect(await readFile(join(work, "before.txt"), "utf8")).toMatch( + /^\.\.\/other repo [0-9a-f]{40} [0-9a-f]{40}\n$/, + ); + expect(sh(gate)).toEqual({ out: "(d) same\n", code: 0 }); + await writeFile(join(other, "b.txt"), "b\n"); + expect(sh(gate).code).toBe(1); + // Edits to a file already changed, and to one not tracked, are changes too. + await writeFile(join(other, "a.txt"), "a changed\n"); + expect(sh(before).out).toContain("exit=0"); + expect(sh(gate).code).toBe(0); + await writeFile(join(other, "a.txt"), "a changed again\n"); + expect(sh(gate).code).toBe(1); + expect(sh(before).out).toContain("exit=0"); + await writeFile(join(other, "b.txt"), "b changed\n"); + expect(sh(gate).code).toBe(1); + // A repo that cannot be read stops the before instead of leaving it out. + const missing = boardGuide(guideInput({ anchor, otherRepos: ["../nowhere"], language: "en" })); + const stop = blocksIn(missing).find((b) => b.includes('> "$W/before.txt"')) as string; + expect(sh(stop).out).toContain("exit=1"); + }); + + it("uses a working directory only when it is the user's own and no link, and closes it to others", async () => { + const guide = boardGuide(guideInput({ anchor: dir, otherRepos: [] })); + const measure = blocksIn(guide).find((b) => b.includes('> "$W/measure.json"')) as string; + const prefix = measure.slice(0, measure.indexOf(" && basou ")); + const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); + const env = { ...process.env, TMPDIR: dir }; + const sh = (script: string) => { + try { + execFileSync("sh", ["-c", script], { env, encoding: "utf8" }); + return 0; + } catch { + return 1; + } + }; + expect(sh(prefix)).toBe(0); + expect((await stat(work)).mode & 0o777).toBe(0o700); + await chmod(work, 0o755); + expect(sh(prefix)).toBe(0); + expect((await stat(work)).mode & 0o777).toBe(0o700); + await rm(work, { recursive: true }); + const elsewhere = join(dir, "elsewhere"); + await mkdir(elsewhere); + await symlink(elsewhere, work); + expect(sh(prefix)).toBe(1); + }); + + 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"); + await mkdir(anchor); + await mkdir(fresh); + git(fresh, "init", "-q", "-b", "main"); + await writeFile(join(fresh, "a.txt"), "a\n"); + const guide = boardGuide(guideInput({ anchor, otherRepos: ["../fresh"], language: "en" })); + const blocks = blocksIn(guide); + const before = blocks.find((b) => b.includes('> "$W/before.txt"')) as string; + const gate = blocks.find((b) => b.includes('echo "(d) same"')) as string; + const env = { ...process.env, TMPDIR: dir }; + const sh = (script: string) => { + try { + return execFileSync("sh", ["-c", script], { env, encoding: "utf8" }); + } catch { + return "failed"; + } + }; + expect(sh(before)).toContain("exit=0"); + const work = boardGuideWorkDir(anchor).replace(TMPDIR_WORD, dir); + expect(await readFile(join(work, "before.txt"), "utf8")).toMatch( + /^\.\.\/fresh no-commit [0-9a-f]{40}\n$/, + ); + expect(sh(gate)).toBe("(d) same\n"); + await writeFile(join(fresh, "a.txt"), "b\n"); + expect(sh(gate)).toBe("failed"); + }); + + it("says why it does not use a working directory that is a link", async () => { + const guide = boardGuide(guideInput({ anchor: dir, otherRepos: [] })); + const measure = blocksIn(guide).find((b) => b.includes('> "$W/measure.json"')) as string; + const prefix = measure.slice(0, measure.indexOf(" && basou ")); + const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); + await mkdir(join(dir, "elsewhere")); + await symlink(join(dir, "elsewhere"), work); + const run = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + const child = spawn("sh", ["-c", prefix], { env: { ...process.env, TMPDIR: dir } }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += String(d); + }); + child.on("close", (code) => resolve({ code, stderr })); + }); + expect(run.code).not.toBe(0); + expect(run.stderr).toBe(`not a directory of your own, so not used: ${work}\n`); + }); + + it("passes gate (e) with exit 0 when only board/ is staged, and fails it otherwise", async () => { + const anchor = join(dir, "ws"); + await mkdir(join(anchor, "board"), { recursive: true }); + git(anchor, "init", "-q", "-b", "main"); + await writeFile(join(anchor, "board", "board.yaml"), "x\n"); + const guide = boardGuide(guideInput({ anchor, otherRepos: [] })); + const gate = blocksIn(guide).find((b) => b.includes("git add -- board")) as string; + const sh = () => { + try { + return { out: execFileSync("sh", ["-c", gate], { encoding: "utf8" }), code: 0 }; + } catch (error: unknown) { + return { out: String((error as { stdout?: unknown }).stdout ?? ""), code: 1 }; + } + }; + expect(sh()).toEqual({ out: "(e) only board/\n", code: 0 }); + await writeFile(join(anchor, "other.txt"), "x\n"); + git(anchor, "add", "other.txt"); + expect(sh()).toEqual({ out: "other.txt\n(e) more than board/ is staged\n", code: 1 }); + }); + + it("reads the version off a page, past the generator meta tag and a bare number", async () => { + const guide = boardGuide(guideInput({ anchor: dir })); + const read = (blocksIn(guide).find((b) => b.includes('> "$W/page-site.html"')) ?? "") + .split("\n") + .find((l) => l.includes("node -e")) as string; + const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); + await mkdir(work, { recursive: true }); + const run = async (html: string) => { + await writeFile(join(work, "page-site.html"), html); + return execFileSync("sh", ["-c", read], { + env: { ...process.env, TMPDIR: dir }, + encoding: "utf8", + }); + }; + expect( + await run( + '

Node.js 20.10.0, tool-dev2.0.0, 1.v3.0.0, latest v1.2.3-rc.1

v9.9.9

', + ), + ).toBe("v1.2.3-rc.1\nexit=0\n"); + expect(await run("

no version, 1.2.3 alone

")).toBe("exit=1\n"); + for (const meta of [ + '', + "", + '', + '', + ]) { + expect(await run(`${meta}

release_v9.9.9 then v1.2.3

`)).toBe("v1.2.3\nexit=0\n"); + } + }); +}); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts new file mode 100644 index 00000000..deae563d --- /dev/null +++ b/packages/core/src/board/guide.ts @@ -0,0 +1,645 @@ +import { createHash } from "node:crypto"; +import { decodeTime } from "ulid"; +import { displayPath } from "../lib/display-path.js"; +import { + BOARD_STAGE_IDS, + type BoardDeclaration, + type BoardObserve, + isCalendarDate, +} from "./declaration.js"; +import { daysBetween, todayIn } from "./effort.js"; +import type { BoardPreviousRecords } from "./previous.js"; + +/** + * The steps an agent follows to judge a board and record it, filled in with + * one workspace's values: where the board is, the commands that measure, + * observe, record and show it, what each stage is judged by, and the input a + * record takes, with the previous record's cells beside each one. It is + * prose for an agent to read, not input for a program, so its shape is not + * promised. Building it reads nothing and writes nothing; the caller hands + * in what it read. + */ +export type BoardGuideInput = { + /** The real absolute path of the workspace's own repo, where the board is kept. */ + anchor: string; + /** The paths of the manifest's other repos, as the manifest writes them. */ + otherRepos: readonly string[]; + /** The language the prose and reasons are written in: the anchor's. */ + language: "en" | "ja"; + /** The command that printed the guide, to call when `basou` is not on PATH. */ + basouCommand: string; + /** The version of basou printing the guide. */ + basouVersion: string; + now: Date; + board: + | { status: "undeclared" } + | { + status: "declared"; + declaration: BoardDeclaration; + /** How many records `records/` holds. */ + recordCount: number; + previous: BoardPreviousRecords; + }; +}; + +/** The ports the board page is opened on: none of them is basou view's default (4319). */ +export const BOARD_GUIDE_PORTS = { first: 4400, count: 600 } as const; + +function anchorHash(anchor: string): Buffer { + return createHash("sha256").update(anchor).digest(); +} + +/** + * The port a workspace's board page is opened on: the same for the same + * path, chosen from {@link BOARD_GUIDE_PORTS} by the path's hash, so two + * workspaces rarely meet and one that does moves to the next port. + */ +export function boardGuidePort(anchor: string): number { + return BOARD_GUIDE_PORTS.first + (anchorHash(anchor).readUInt32BE(0) % BOARD_GUIDE_PORTS.count); +} + +/** The directory, under the temporary one, that one workspace's working files go in. */ +export function boardGuideWorkDir(anchor: string): string { + return `\${TMPDIR:-/tmp}/basou-board-${anchorHash(anchor).toString("hex").slice(0, 12)}`; +} + +/** A word for a POSIX shell: single quotes, with each quote in it closed and reopened. */ +export function shellWord(text: string): string { + return `'${text.replaceAll("'", `'\\''`)}'`; +} + +// JSON with what a terminal would act on escaped as well: JSON.stringify +// leaves the C1 controls, the line and paragraph separators and the +// 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, + (c) => `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`, + ); +} + +const LANGUAGE_NAMES = { en: "English (en)", ja: "Japanese (ja)" } as const; + +/** Print the guide. */ +export function boardGuide(input: BoardGuideInput): string { + const anchor = shellWord(input.anchor); + const work = boardGuideWorkDir(input.anchor); + // The working directory may sit in a /tmp other users share: it is used + // only when it is no link and this user's own, and only this user can + // read it (it holds what the trail holds). + const go = `cd ${anchor} && W="${work}" && mkdir -p "$W" && { { [ ! -L "$W" ] && [ -O "$W" ]; } || { echo "not a directory of your own, so not used: $W" >&2; false; }; } && chmod 700 "$W"`; + const out: string[] = []; + const line = (...lines: string[]) => out.push(...lines); + const fenced = (lang: string, ...lines: string[]) => + out.push("", `\`\`\`${lang}`, ...lines, "```", ""); + const block = (...lines: string[]) => fenced("sh", ...lines); + + line( + "# How to update this workspace's progress board", + "", + `Printed by \`basou board guide\` (basou ${input.basouVersion}). basou board is experimental: its commands, files and shapes may change at any release. This command wrote nothing and sent nothing.`, + "", + "The board answers one question: what can be used now, and what is stuck; not what was built. basou measures, records and draws it. You, the agent, judge each lane at each stage, write the prose and make the observations outside basou. basou never judges and never goes on the network.", + "", + ); + + if (input.board.status === "undeclared") { + undeclared(input, go, line, block); + return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; + } + const { declaration: d, previous, recordCount } = input.board; + const port = boardGuidePort(input.anchor); + const lastId = previous.last.status === "found" ? previous.last.id : null; + + line("## This board", ""); + line(`- Workspace (its own repo): ${displayPath(input.anchor)}`); + line( + `- Declaration: board/board.yaml, "${displayPath(d.title)}", board_version ${d.board_version}, ${count(d.lanes.length, "lane")}, ${count(d.observe.length, "observation")} outside basou`, + ); + line(`- Records: board/records/, ${recordsLine(input, recordCount)}`); + line(`- Axis: ${axisLine(input)}`); + line( + `- Language: write every prose string and reason in ${LANGUAGE_NAMES[input.language]}, the language this board's page is drawn in (Japanese when the manifest declares ja for this repo, English otherwise)`, + ); + 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(""); + + line( + "## Rules for every step", + "", + "- Run each block on its own: a shell variable does not carry over to the next call. Each block that uses W goes to the workspace and names W first.", + "- Keep working files in W, never in a repo. A block stops before using W unless it is a directory of your own and not a link (it says so on stderr), and makes it readable by you alone: it holds what the trail holds.", + "- From the measure of step 2 to the record of step 7, write nothing to basou (`decision capture`, `note`, `task`, `review record`, `refresh`), put no file in and change no file of any repo the manifest declares, do not rebuild or upgrade basou, and do not change `~/.basou/portfolio.yaml`. Each of these can move what the measurement's digest covers (the trail, the repos' files and uncommitted paths, the basou that measured, the portfolio's counts), and record refuses a digest that moved; not every change moves it, so do not count on record to catch one.", + '- The model name is your own report of yourself. Use the same name every time, as measure\'s `--model` and as `judged_by.model`. In the commands, `$MODEL` stands for it: put the name in its place, or set `MODEL=""` at the start of each block. A block run with it unset stops: measure refuses an empty `--model`.', + "- Isolation, both ways: read and write only this workspace. Do not open another project's repos, planning, `.basou/` or transcripts (other directories under `~/.claude/projects`, `~/.codex/sessions`), not even to read. Write the board only under board/ of this repo; never write it, its numbers or this work into a public repo or one whose contents are published (no file, `.gitignore` comment, commit message, issue or pull request). Do not bring other projects' names, paths, numbers or ids into the board, nor take this one's out. Asked about another project, decline and suggest a session of its own.", + "", + ); + + line("## Steps", ""); + + line("### 0. Take the before of the other repos", ""); + if (input.otherRepos.length === 0) { + line( + "The manifest declares no repo but this one: nothing to take. Gate (d) of step 9 is not needed.", + "", + ); + } else { + line( + "Gate (d) of step 9 compares the other repos with this. If a repo cannot be read, stop: a repo left out of the comparison would change unseen.", + ); + block(`${go} && ( ${snapshotLoop(input.otherRepos)} ) > "$W/before.txt"; echo "exit=$?"`); + line( + "It keeps each repo's HEAD (or that it has no commit yet) and a hash of what its working tree holds beyond HEAD: the changes to tracked files and the content of untracked ones that are not ignored.", + "", + ); + } + + line( + "### 1. Check how fresh the trail is (the operator's turn)", + "", + "Sessions not yet imported leave the active time and the decisions as of the last import.", + ); + block(`${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`); + line( + "Under `Freshness:` in `$W/measure.txt`, `not imported N new, N updated, N unverifiable`: when any is above 0, show the operator this line to run, and do not run it yourself (it writes the trail):", + ); + block(`cd ${anchor} && basou refresh`); + line( + "Never suggest `--force` without a reason you can state. This session counts among the new or updated ones, so 0 is not to be expected. If the operator does not run it, say so in a footnote.", + "", + ); + + line("### 2. Measure"); + block( + `${go} && basou board measure --json --model "$MODEL" > "$W/measure.json"; echo "exit=$?"`, + `${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`, + ); + line( + "- Exit 0: everything was measured. Exit 1 with JSON on stdout: measured with gaps (`complete: false`, a null for each, its reason under `not_found`). Exit 1 with nothing on stdout: read stderr and stop. The declaration or the manifest cannot be read (fix board.yaml through step 2b), `--model` was empty, or W was not used.", + "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", + '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', + "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", + "- `repos[].behind_main` counts from the last fetch; measure does not fetch.", + "", + ); + + line( + "### 2b. Review the axis (only when `axis.review_needed` is true; apply nothing yourself)", + "", + "The axis (the lanes, what each stage means, the measures, the observations and the registry of components) was cut once by a model at a time. The components change and models move on, so a fixed axis pours right numbers into old boxes. `axis.reasons` says what fired:", + "", + "- (a) the components changed (unacknowledged, gone or of other kinds): required, before judging", + "- (b) `review_due_days` passed since the last review: advised", + "- (c) the model judging is not the one that reviewed last: advised", + "- (d) the operator asked: by hand", + "- (e) a built-in section measures another way than at the previous record: advised", + "", + "Read board.yaml, the commits that changed it (`git log --format='%ad %h %s' --date=short -- board/board.yaml`), the last review, the components in measure.json and the decisions recorded since (`.basou/decisions.md`, which is as of the last `basou refresh` or `basou decisions generate`: when step 1 found sessions not imported and the operator did not refresh, the latest decisions may be missing from it, and the proposal says so). Then write one proposal to `$W/axis-proposal.md`: (A) what stays, with why; (B) what changes, as a diff of board.yaml; (C) what to ask the operator. Answer:", + "", + "1. Each lane: does it still stand as who uses what? Do its stages' conditions fit what is built now? Merge, split, retire or keep, with one sentence why even for keep.", + "2. Each unacknowledged component: does it change a lane's conditions, make a new lane, or count in no lane (and why)? For the first two, what to measure or observe for it.", + "3. Each gone component: done with, or a repo in another state? If done with, take it off the registry and fix the lane's conditions.", + "4. Do the decisions recorded hold anything that should move the axis (a new surface, a new base, something decided not to build)?", + "5. If the last review was by another model: does anything in how the lanes are cut or worded need fixing now? Change nothing only to make it look new; change only what is worth losing the comparison with past records.", + "", + 'Apply nothing until the operator approves. Then edit board.yaml, measure again from step 2 (the digest moves with the declaration), and give the record an `axis_review`: `{ "triggers": [every letter that led to it], "summary": "what was reviewed and decided" }`. Raise `axis.version` when a lane is added or removed or a stage\'s condition changes, and name the commit `axis review v`. Give `axis_review` even when nothing changed: the next (b) counts from it. Registering a component only to silence (a) is not a review.', + "", + ); + + line("### 3. Observe outside basou", ""); + observeSteps(d.observe, go, line, block); + + line("### 4. Judge every lane at every stage", ""); + judging(d, line); + + line("### 5. Compare with the previous record", ""); + if (lastId === null) { + line( + previous.last.status === "unreadable" + ? `The previous record cannot be read (${displayPath(previous.last.reason)}). Judge with no record to compare with, and say so in a footnote.` + : "There is no record yet: this is the first. Nothing is compared, and an observation not made is drawn as not confirmed, with no previous value.", + "", + ); + } else { + line( + `The previous record is ${lastId}. Its state and reason are beside each cell of the template in step 6, for reference only: judge each cell again. A cell that moved needs a reason the prose can state; if you cannot state it, the judgement is early. \`record --dry-run\` (step 7) lists the cells that moved.`, + "", + ); + } + + line( + "### 6. Write the record's input", + "", + "Write it to `$W/input.json` from a file you edit, not a heredoc (a shell expands backquotes in one and drops words), starting from the template below:", + "", + "- `measure_digest`: the `digest` of `$W/measure.json`.", + '- `observed`: when the board declares observations, one entry per declared observation, under its key, each as step 3 says; nothing else and nothing left out, or record refuses it. An observation made has no `error`; one not made has `"value": null` and an `error`. With none declared, `observed` may stay empty.', + "- `cells`: every lane at every stage. Set each `state` and delete each `previous`: record refuses a cell with an empty state or a `previous`. `blocked`, `shelved` and `unverified` need a `reason`.", + `- \`prose\`: \`summary\`, \`lanes\`, \`operator_turns\` as \`{ "text", "source" }\` and \`footnotes\`, all plain strings (HTML is shown as text; only \`\` \`code\` \`\` is drawn as code). Do not repeat what basou draws (the heading, the tiles, the period and effort, the matrix, the ratios, the axis version and the model). Do not start a lane's prose with its \`about\`: the page draws the about right before it. Give \`lanes\` an entry only for a lane you have something to say about (an empty string is drawn as an empty paragraph); the lane ids are ${d.lanes.map((l) => `\`${l.id}\``).join(", ")}.`, + '- `axis_review`: null, or `{ "triggers": [...], "summary": "..." }` as step 2b says.', + "", + ); + fenced("json", templateOf(d, previous)); + + line( + "### 7. Record", + "", + "First check without writing (where it goes, the input's shape, every cell, the reasons, the measurement again and what moved):", + ); + block(`${go} && basou board record --file "$W/input.json" --dry-run; echo "exit=$?"`); + line( + "A refusal lists every reason on stderr. A digest that differs means something measured moved since step 2, or basou was rebuilt or upgraded: measure again from step 2 (the judgement can be kept where nothing it rests on moved). When it checks out, write it:", + ); + block(`${go} && basou board record --file "$W/input.json"; echo "exit=$?"`); + line( + "It prints where the record is and what moved since the previous one. A record cannot be taken back (only a later one added), and nothing goes to the trail: what moved is recorded in step 10.", + "", + ); + + line( + "### 8. Open the board", + "", + `Start the view in the background (run_in_background, or append \`&\`) and open http://127.0.0.1:${port}/board (\`?record=\` opens a past record):`, + ); + block(`cd ${anchor} && basou view --port ${port} --no-open`); + line( + `If it stops with \`Port ${port} is already in use.\`, do not query that port (another board may be there): start it on ${port + 1}, and so on. Once it runs, check that it is this board (it prints the title, or why there is none to draw):`, + ); + block( + `curl -s http://127.0.0.1:${port}/api/board | node -e 'let t="";process.stdin.on("data",(d)=>{t+=d}).on("end",()=>{const p=JSON.parse(t).page;console.log(JSON.stringify(p.status==="ok"?p.board.heading.title:"unavailable: "+p.why))})'`, + ); + line(`(On the port it did start on, if not ${port}.)`); + line( + "Look at the page before committing: a record cannot be taken back. Stop the view when done.", + "", + ); + + line( + "### 9. Commit", + "", + "Commit only when every gate passes; if one fails, stop and ask the operator.", + "", + ); + if (input.otherRepos.length > 0) { + line("(d) The other repos are as they were at step 0 (it prints `(d) same`):"); + block( + `${go} && ( ${snapshotLoop(input.otherRepos)} ) > "$W/after.txt" && diff "$W/before.txt" "$W/after.txt" && echo "(d) same"`, + ); + } + line( + "(e) Only board/ is staged: add it alone; it prints `(e) only board/`, or what else is staged.", + ); + block( + `cd ${anchor} && git add -- board && if git diff --cached --name-only | grep -v '^board/'; then echo "(e) more than board/ is staged"; false; else echo "(e) only board/"; fi`, + ); + line( + "(f) No one outside can read this repo. On GitHub: visibility PRIVATE, no collaborator but the owner, no invitation. Look at the numbers only, never the names:", + ); + block( + `cd ${anchor} && SLUG=$(git remote get-url origin | sed -E 's#.*github.com[:/]##; s#\\.git$##') && gh repo view "$SLUG" --json visibility -q .visibility && gh api "repos/$SLUG/collaborators?affiliation=all" --jq "[.[] | select(.login != \\"\${SLUG%%/*}\\")] | length" && gh api "repos/$SLUG/invitations" --jq length`, + ); + line( + "It should print PRIVATE, 0 and 0 (for a repo an organization owns, ask the operator who can read it). Not on GitHub: ask the operator. Then commit:", + ); + block(`cd ${anchor} && git commit -m "board: record of $(date +%Y-%m-%d)"`); + line( + "Name it `axis review v` after an axis review. Push it the way this repo is kept. A commit keeps the board's history: three weeks later, the board of then can still be opened.", + "", + ); + + line( + "### 10. Record what moved (after step 7)", + "", + '- A cell moved (blocked to done, and so on): `basou decision capture` with `"kind": "decision"`.', + '- Something newly stuck that only a person can settle: the same with `"kind": "track"`.', + "- What to do next: `basou note`, with the text on stdin through a quoted heredoc.", + "", + "Nothing moved: write nothing. Pass JSON and long text through a file; `alternatives` and `linked_files` are lists, not strings.", + "", + ); + + line( + "## What the board page draws, and what you write", + "", + "1. Heading: basou draws it (title, when recorded, the model, whether everything was measured).", + "2. Summary: `prose.summary`, what matters this time and what moved since the last record. basou draws the tiles and the table of observations.", + "3. Period and effort: basou draws it. Write nothing that gives the numbers a meaning.", + "4. Reach matrix: your `cells`. basou draws the legend, the order anomalies and the cells that moved.", + "5. Each lane: `prose.lanes`. For a stuck lane, one sentence on what would move it, with the measured values it rests on.", + "6. Ratios: basou draws them.", + "7. Operator's turns: `prose.operator_turns`, the items waiting for a ruling (not for implementation), the ones that move most first, each with its source (a decision or task id, a pull request) in `source`. This is what is read most: what the operator can do to move the board.", + "8. Footnotes: `prose.footnotes`, what was not confirmed, what is estimated, whether the trail was refreshed, a build that is not the repo's main. basou adds the axis, the model, what was not measured and which basou measured.", + "", + "## Do not", + "", + "- Write the trail: `basou refresh` is the operator's.", + "- Write to basou between measure and record.", + "- Argue priorities: the board lists facts, and the operator's turns list what waits for a ruling.", + "- Write an estimate as a measurement, or let a number you could not get pass as blank or 0.", + "- Put money on the board, or read effort as progress: reach is shown by the matrix alone.", + "- Change the axis silently, or register a component only to silence (a).", + "- Write the board's HTML: basou draws it.", + "- Touch another project's repos.", + "", + "## The operator's turns in this work", + "", + "- Running `basou refresh` when step 1 finds sessions not imported.", + "- Approving an axis review (step 2b) before anything in board.yaml changes.", + ); + for (const o of d.observe) { + if (o.kind === "manual") line(`- Observing \`${o.key}\`: ${displayPath(o.how)}`); + } + line("- A gate of step 9 that does not pass.", ""); + return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; +} + +function count(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + +// Each other repo's HEAD (or no-commit) and a hash of what its working tree +// holds beyond it: the diff of tracked files against HEAD (or, with no commit +// yet, against the index and the index against nothing), the names of the +// untracked files that are not ignored and their contents. Hashed, never +// written: git hash-object without -w stores nothing. A repo that is not a +// git repo, or cannot be read, stops it. +function snapshotLoop(repos: readonly string[]): string { + return `for d in ${repos.map(shellWord).join(" ")}; do git -C "$d" rev-parse --git-dir >/dev/null 2>&1 || { echo "cannot read $d" >&2; exit 1; }; h=$(git -C "$d" rev-parse -q --verify HEAD || echo no-commit); s=$(cd "$d" && { if [ "$h" = no-commit ]; then git diff --binary --cached && git diff --binary; else git diff --binary HEAD; fi && git ls-files -o --exclude-standard && git ls-files -o --exclude-standard | git hash-object --stdin-paths; } | git hash-object --stdin) || { echo "cannot read $d" >&2; exit 1; }; echo "$d $h $s"; done`; +} + +function ago(from: string, to: string): string { + const days = daysBetween(from, to); + return days === 0 ? "today" : `${count(days, "day")} ago`; +} + +function recordsLine(input: BoardGuideInput, recordCount: number): string { + if (input.board.status !== "declared") return ""; + const { last } = input.board.previous; + if (recordCount === 0 && last.status === "none") return "none yet; this will be the first"; + const today = todayIn(input.board.declaration.effort.time_zone, input.now); + if (last.status === "found") { + const at = new Date(decodeTime(last.id)); + const day = todayIn(input.board.declaration.effort.time_zone, at); + const when = day !== undefined && today !== undefined ? `, ${ago(day, today)}` : ""; + return `${count(recordCount, "record")}; the last is ${last.id}, recorded ${at.toISOString()}${when}`; + } + if (last.status === "unreadable") { + return `${count(recordCount, "record")}; the last cannot be read (${displayPath(last.reason)})`; + } + return count(recordCount, "record"); +} + +function axisLine(input: BoardGuideInput): string { + if (input.board.status !== "declared") return ""; + const { declaration: d, previous } = input.board; + const head = `version ${d.axis.version}, due a review every ${count(d.axis.review_due_days, "day")}`; + const { lastReview } = previous; + if (lastReview.status === "found") { + const day = + todayIn(d.effort.time_zone, new Date(lastReview.record.recorded_at)) ?? + new Date(lastReview.record.recorded_at).toISOString().slice(0, 10); + return `${head}; last reviewed ${day} by ${displayPath(lastReview.record.judged_by.model)} (record ${lastReview.id})`; + } + if (lastReview.status === "unreadable") { + return `${head}; the last review is not known (a record cannot be read)`; + } + const seed = d.axis.seed_review; + if (seed !== undefined && isCalendarDate(seed.date)) { + return `${head}; last reviewed ${seed.date} by ${displayPath(seed.model)} (seed_review in board.yaml)`; + } + return input.board.recordCount === 0 && previous.last.status === "none" + ? `${head}; no review on record (the first record is the first review: give it an axis_review, step 2b)` + : `${head}; no review on record (measure's axis section says whether one is due)`; +} + +// How each declared observation is made, and how it is written down. +function observeSteps( + observe: readonly BoardObserve[], + go: string, + line: (...lines: string[]) => void, + block: (...lines: string[]) => void, +): void { + if (observe.length === 0) { + line( + "This board declares nothing to observe outside basou. If a lane reaches its users through something outside the repos (a package registry, a release, a site, a production database), declare it under `observe` (board_version 2) through an axis review (step 2b).", + "", + ); + return; + } + line( + 'Read only: these commands write nothing and change nothing. Make each one, and write it under its key in `observed` as `{ "value": , "observed_at": , "source": }`. `observed_at` is the time with its offset, such as `date -u +%Y-%m-%dT%H:%M:%SZ` prints. When it cannot be made (a command failed, nothing to read, no answer), write `"value": null` and an `"error"` saying why: the board then shows it as not confirmed, with the previous record\'s value. Never carry an old value forward as today\'s. Keep the keys: they join each value to the previous record\'s.', + "", + ); + for (const o of observe) { + line(`#### \`${o.key}\` (${o.kind})`, ""); + switch (o.kind) { + case "npm_version": + line(`The version of ${o.package} published on npm (its \`latest\` tag):`); + block(`npm view ${shellWord(o.package)} version; echo "exit=$?"`); + line( + `Value: the last line of the output, as a string (such as "1.2.3"). Source: \`${sourceOf(o)}\`.`, + "", + ); + break; + case "github_release": + line(`The latest release of ${o.repo} on GitHub:`); + block( + `gh release view --repo ${shellWord(o.repo)} --json tagName -q .tagName; echo "exit=$?"`, + ); + line( + `Value: the tag, as a string. No release yet is a null with that as the error. Source: \`${sourceOf(o)}\`.`, + "", + ); + break; + case "github_open_issues": + case "github_open_prs": { + const what = o.kind === "github_open_issues" ? "issue" : "pr"; + line(`How many ${what === "issue" ? "issues" : "pull requests"} of ${o.repo} are open:`); + block( + `gh ${what} list --repo ${shellWord(o.repo)} --state open --limit 1000 --json number -q length; echo "exit=$?"`, + ); + line( + `Value: the number, as a number. At 1000 there may be more: say so in a footnote. Source: \`${sourceOf(o)}\`.`, + "", + ); + break; + } + case "github_ci": + line(`How the latest run of the workflow ${o.workflow} on ${o.branch} of ${o.repo} ended:`); + block( + `gh run list --repo ${shellWord(o.repo)} --workflow ${shellWord(o.workflow)} --branch ${shellWord(o.branch)} --limit 1 --json status,conclusion -q '.[0] | if .status == "completed" then .conclusion else .status end'; echo "exit=$?"`, + ); + line( + `Value: what it prints, as a string (\`success\`, \`failure\`, or the status of a run not finished, such as \`in_progress\`). Nothing printed (no run) is a null with that as the error. Source: \`${sourceOf(o)}\`.`, + "", + ); + break; + case "page_version": + line( + `The version ${o.url} shows: the first \`v..\` (with what follows a \`-\`) not right after a letter, digit, underscore or dot, once the page's generator meta tags are taken out, however they are quoted or cased (they name the site builder's version, not the product's).`, + ); + block( + `${go} && curl -fsS --max-time 20 ${shellWord(o.url)} > "$W/page-${o.key}.html"; echo "curl exit=$?"`, + `${go} && node -e 'let t="";process.stdin.on("data",(d)=>{t+=d}).on("end",()=>{const m=t.replace(/]*\\bname\\s*=\\s*["\\x27]?generator\\b[^>]*>/gi,"").match(/(? void): void { + line( + "The six stages come in order: a stage is not reached before the one before it, so the board shows where each lane stops. What each means on this board, and what to look at:", + "", + ); + for (const id of BOARD_STAGE_IDS) { + const stage = d.stages[id]; + line(`- ${id}: ${displayPath(stage.meaning)}`); + for (const look of stage.look ?? []) line(` - look at: ${displayPath(look)}`); + for (const note of stage.notes ?? []) line(` - mind: ${displayPath(note)}`); + } + line( + "", + "04 (merged, in production) and 05 (open, in users' hands) are apart on purpose: what is merged but not published is used by no one. There is more than one way out, and one closed is enough to make 05 `part` or `blocked`.", + "", + "The states of a cell:", + "", + "- `done` (drawn \u25cf): the condition is met", + "- `part` (\u25d0): met for some of its surfaces or features", + "- `blocked` (\u25a0): stuck here; what the next step needs is missing. Needs a reason", + "- `shelved` (\u2298): stopped on purpose, by a settled decision. Needs a reason naming that decision. Not a failure", + "- `none` (\u25cb): not started", + "- `unverified` (?): this board cannot confirm it (a run only another workspace can make). Needs a reason. Not when this board could check it and you did not: measure it, or say in a footnote why not", + "", + "Principles:", + "", + '1. Do not judge a stage generously. "Mostly works" is `part`, not `done`: the board is worth what it says about what has not arrived.', + "2. What is not committed is not 04. Work left in a working tree stops at 03: written is not merged.", + "3. Give a stuck cell its reason in one sentence. A reason you cannot write means the judgement is early.", + "4. Do not paint a shelved cell red. Shelving is a decision; blaming it makes stopping harder and bends judgement.", + "5. Do not mix estimates and measurements. Plans and estimates are not results: name them in a footnote.", + "6. When a number falls, find out why. Counts of files or commands rarely fall: the measuring may have broken, or a repo is in another state.", + "7. Do not bend a stage to get a record through. A stage left behind before a later one done is recorded as an order anomaly and shown, not refused: if that is the fact, write it.", + "", + "Where judging goes wrong most: 04 taken for 05; uncommitted work counted as 04; `blocked` and `shelved` mixed up (stuck, or stopped on purpose); what cannot be confirmed pushed into `part` or `blocked` instead of `unverified`; a stage given generously.", + "", + "The lanes, in the board's order, with what to mind for each:", + "", + ); + for (const lane of d.lanes) { + const about = lane.about === undefined ? "" : `: ${displayPath(lane.about)}`; + line(`- \`${lane.id}\` ${displayPath(lane.name)}${about}`); + for (const note of lane.notes ?? []) line(` - mind: ${displayPath(note)}`); + } + line(""); +} + +// What an observation's source says: the command, the page, or the operator. +function sourceOf(o: BoardObserve): string { + switch (o.kind) { + case "npm_version": + return `npm view ${o.package} version`; + case "github_release": + return `gh release view --repo ${o.repo}`; + case "github_open_issues": + return `gh issue list --repo ${o.repo} --state open`; + case "github_open_prs": + return `gh pr list --repo ${o.repo} --state open`; + case "github_ci": + return `gh run list --repo ${o.repo} --workflow ${o.workflow} --branch ${o.branch}`; + case "page_version": + return o.url; + case "manual": + return "the operator"; + } +} + +// The record's input to fill in: the previous record's cells beside each +// (null where it has none), or no `previous` with no previous record. +function templateOf(d: BoardDeclaration, previous: BoardPreviousRecords): string { + const before = new Map(); + if (previous.last.status === "found") { + for (const cell of previous.last.record.cells) { + const reason = (cell as { reason?: unknown }).reason; + before.set(`${cell.lane}\0${cell.stage}`, { + state: cell.state, + ...(typeof reason === "string" ? { reason } : {}), + }); + } + } + const observed = d.observe.map( + (o) => + ` ${jsonText(o.key)}: ${jsonText({ value: null, observed_at: "", source: sourceOf(o) })}`, + ); + const cells = d.lanes.flatMap((lane) => + BOARD_STAGE_IDS.map((stage) => { + const cell = + previous.last.status === "found" + ? { + lane: lane.id, + stage, + state: "", + previous: before.get(`${lane.id}\0${stage}`) ?? null, + } + : { lane: lane.id, stage, state: "" }; + return ` ${jsonText(cell)}`; + }), + ); + return [ + "{", + ' "measure_digest": "",', + ` "observed": {${observed.length === 0 ? "}," : `\n${observed.join(",\n")}\n },`}`, + ` "cells": [\n${cells.join(",\n")}\n ],`, + ' "prose": {', + ' "summary": "",', + ' "lanes": {},', + ' "operator_turns": [],', + ' "footnotes": []', + " },", + ' "judged_by": { "model": "", "self_reported": true },', + ' "axis_review": null', + "}", + ].join("\n"); +} + +// What the guide says when no board is declared yet. +function undeclared( + input: BoardGuideInput, + go: string, + line: (...lines: string[]) => void, + block: (...lines: string[]) => void, +): void { + line( + "## No board is declared yet", + "", + `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.', + "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=$?"`); + line( + '5. Register the components: at first every component the markers find is unacknowledged, and the axis review (a) fires. Give each the lanes it counts in, or `"-"` with a note saying why it counts in none.', + "6. Run `basou board guide` again: with a board declared, it prints the steps that judge and record it. The first record is the first review of the axis: give it an `axis_review`.", + "", + ); +} diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index cf4407a2..5fd320b2 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -49,6 +49,14 @@ export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; export { BOARD_FRESHNESS_METHOD } from "./freshness.js"; export type { GlobMatcher } from "./glob.js"; export { compileGlob, compileGlobs } from "./glob.js"; +export type { BoardGuideInput } from "./guide.js"; +export { + BOARD_GUIDE_PORTS, + boardGuide, + boardGuidePort, + boardGuideWorkDir, + shellWord, +} from "./guide.js"; export type { BoardIntegrity } from "./integrity.js"; export { BOARD_INTEGRITY_METHOD } from "./integrity.js"; export type { BoardLiveMeasurement, BoardLivePage, MeasureBoardLiveInput } from "./live.js"; @@ -77,7 +85,7 @@ export type { PreviousOutcome, ReadBoardRecord, } from "./previous.js"; -export { NO_PREVIOUS_RECORDS, readPreviousRecords } from "./previous.js"; +export { NO_PREVIOUS_RECORDS, readPreviousRecords, recordIds } from "./previous.js"; export type { BoardOrderAnomaly, BoardRecord, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e72c1d0..f789ddb7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,6 +100,7 @@ export type { BoardEffort, BoardEffortDay, BoardFreshness, + BoardGuideInput, BoardImportProbe, BoardIntegrity, BoardLastReview, @@ -147,6 +148,7 @@ export { BOARD_DEFAULT_CI_BRANCH, BOARD_EFFORT_METHOD, BOARD_FRESHNESS_METHOD, + BOARD_GUIDE_PORTS, BOARD_INTEGRITY_METHOD, BOARD_OBSERVE_KINDS, BOARD_PORTFOLIO_METHOD, @@ -161,6 +163,9 @@ export { BOARD_VERSION, BOARD_VERSIONS, boardDigest, + boardGuide, + boardGuidePort, + boardGuideWorkDir, boardLivePage, boardPage, boardPageUnavailable, @@ -177,6 +182,8 @@ export { parseBoardDeclaration, parseRecordInput, readPreviousRecords, + recordIds, + shellWord, writeRecord, } from "./board/index.js"; export type {