Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/spec/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, string>> {
const out: Record<string, string> = {};
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.
Expand Down
134 changes: 133 additions & 1 deletion packages/cli/src/commands/board.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,6 +15,7 @@ import {
type BoardPreviousRecords,
type BoardRepo,
basouPaths,
boardGuide,
buildRecord,
byCodePoint,
diffCells,
Expand All @@ -29,6 +30,9 @@ import {
parseRecordInput,
readManifest,
readPreviousRecords,
recordIds,
resolveViewLanguage,
shellWord,
writeRecord,
} from "@basou/core";
import type { Command } from "commander";
Expand Down Expand Up @@ -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;
Expand All @@ -82,6 +88,11 @@ export type BoardContext = {
* not depend on a real stdin stream. Ignored when `--file` names a file.
*/
readInput?: () => Promise<string>;
/**
* 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. */
Expand Down Expand Up @@ -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<void> {
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<string> {
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}. */
Expand Down
Loading
Loading