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
50 changes: 34 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,32 @@ All notable changes to **basou** are recorded here. The project follows
migrations, SQL) find in the working tree of each repository, each keyed
by the name of the repository's directory and the component's directory,
and held against the declaration's `components`: those it does not
register, and those it registers that were not found (a change in a
component's kinds needs a previous record, so until there is one,
`kind_changed` is a null that means so); and whether the board's axis is
due a review, by the triggers it can judge: the components found
unacknowledged or gone, `review_due_days` passed since the last review
(for now the declaration's `seed_review`) by the effort's time zone, and,
with `--model`, a model other than the one that reviewed last (names
compared lower case, each run of anything but an ASCII letter or digit as
one `-`, or as written when one has no such letter or digit; an empty
`--model` is refused), each with what it rests on, and the triggers it did
not judge. With no review on record, the time trigger fires, and the
model trigger too when `--model` is given.
register, those it registers or the previous record found that were not
found now, and those whose kinds are not the previous record's (with no
previous record, `kind_changed` is a null that means so); and whether the
board's axis is due a review, by the triggers it can judge: the components
found unacknowledged, gone or with changed kinds, `review_due_days` passed
since the last review (that of the last record with an axis review, its
day in the effort's time zone, or else the declaration's `seed_review`) by
the effort's time zone, with `--model`, a model other than the one that
reviewed last (names compared lower case, each run of anything but an
ASCII letter or digit as one `-`, or as written when one has no such
letter or digit; an empty `--model` is refused), and a built-in section's
method that is not the previous record's, each with what it rests on, and
the triggers it did not judge. With no review on record, the time trigger
fires, and the model trigger too when `--model` is given.
`review_needed` is null when nothing fired and the components or a time
zone to count days in were not measured. It
zone to count days in were not measured, or a record could not be read.
The previous record is, for a declaration named `board.yaml`, the last by
name in the `records/` beside it of the files named as a ULID with
`.json`. Against it the result also says what moved (`diff`): each value
that moved, a number with its change, marked when its section's method
changed; the methods that changed; and the values there only now or only
then (the clock, the freshness, the axis and its not_found entries,
`measured_with` and the daily rows of the effort are not compared). The text summary puts it first. A
record that cannot be read is not taken as no record: the diff, what the
components it alone found and the triggers it bears on are then null or
not judged, with a reason under `not_found`. It
writes nothing (not even the task index or a stale git index), sends
nothing (a partial clone is not fetched from), starts no file system
monitor, and runs no import. Counting the uncommitted paths runs `git
Expand All @@ -79,7 +91,8 @@ All notable changes to **basou** are recorded here. The project follows
cannot be read, for the review gaps and the effort's time and tokens an
`events.jsonl` that cannot be read, a
`~/.basou/portfolio.yaml` that `basou portfolio` refuses, a dry run of an
import that cannot run, commit dates git cannot give, no declared time zone
import that cannot run, a previous record that cannot be read, commit
dates git cannot give, no declared time zone
for the effort where this host's cannot be named, for the components two
repositories in directories of the same name or a directory whose name is
not valid UTF-8, and for the trail, the
Expand All @@ -90,7 +103,8 @@ All notable changes to **basou** are recorded here. The project follows
is `null`, with a reason under `not_found`, and is never counted as zero. The result carries a digest of its values but the
freshness, which moves as work goes on, the measuring session's own included,
the axis, which rests on the day, the model and the records rather than on
what was measured, and the days elapsed and today's row of the effort,
what was measured, the diff, derived from the previous record, and the
days elapsed and today's row of the effort,
which move with the clock. The
declaration is read from `board/board.yaml` in the workspace only when the
manifest declares the workspace's own repo private; otherwise `--board` is
Expand Down Expand Up @@ -124,7 +138,11 @@ All notable changes to **basou** are recorded here. The project follows
`records/` holds one board's records, never writes under a `.basou/`
directory, and, since a record holds what the trail holds, refuses a
`records/` that no repo the manifest declares private holds unless
`--not-private` is given. `--dry-run` checks where the record would go
`--not-private` is given. It says what moved since the previous record,
in the measurement, the cells and the observations, in its text and under
`diff` with `--json`, and what the measurement recorded could not measure
(`not_found`); the record does not hold the diff, which the previous
record derives. `--dry-run` checks where the record would go
and the input, and measures, but writes nothing. Exit codes: `0` when the
record was written (or, with `--dry-run`, everything checked out), `1`
when it was refused, saying why and that nothing was written. The README
Expand Down
199 changes: 195 additions & 4 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ describe("basou board record", () => {
const { out } = capture();
const result = await doRunBoardRecord({}, fed(repo, input));
expect(result.record).toMatch(/^board\/records\/[0-7][0-9A-HJKMNP-TV-Z]{25}\.json$/);
expect(out.join("\n")).toBe(`Recorded ${result.record}`);
expect(out.join("\n")).toBe(`Recorded ${result.record}\nNot compared with a previous record.`);
const name = (result.record ?? "").split("/").at(-1) ?? "";
expect(await readdir(join(repo, "board", "records"))).toEqual([name]);
const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8"));
Expand All @@ -934,11 +934,20 @@ describe("basou board record", () => {
const { repo, input } = await judged();
const { out } = capture();
const result = await doRunBoardRecord({ dryRun: true, json: true }, fed(repo, input));
expect(result).toEqual({ record: null, dry_run: true, complete: true, order_anomalies: [] });
expect(result).toEqual({
record: null,
dry_run: true,
complete: true,
not_found: [],
order_anomalies: [],
diff: null,
});
expect(JSON.parse(out.join("\n"))).toEqual(result);
out.length = 0;
await doRunBoardRecord({ dryRun: true }, fed(repo, input));
expect(out.join("\n")).toBe("Dry run: the record checked out (nothing was written).");
expect(out.join("\n")).toBe(
"Dry run: the record checked out (nothing was written).\nNot compared with a previous record.",
);
await expect(readdir(join(repo, "board"))).resolves.toEqual(["board.yaml"]);
});

Expand All @@ -964,7 +973,7 @@ describe("basou board record", () => {
expect(result.record).toMatch(
/^\.\.\/\.\.\/board\/records\/[0-7][0-9A-HJKMNP-TV-Z]{25}\.json$/,
);
expect(out.join("\n")).toBe(`Recorded ${result.record}`);
expect(out.join("\n")).toBe(`Recorded ${result.record}\nNot compared with a previous record.`);
await expect(readFile(join(deeper, result.record ?? ""), "utf8")).resolves.toContain(
'"record_version": 1',
);
Expand Down Expand Up @@ -1130,3 +1139,185 @@ describe("basou board record", () => {
);
});
});

describe("basou board measure and record against the previous record", () => {
async function recorded(): Promise<{ repo: string; first: string }> {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await placeBoard(repo, boardYaml([MD]));
capture();
const measured = await doRunBoardMeasure({ json: true }, ctx(repo));
const result = await doRunBoardRecord(
{},
{
...ctx(repo),
readInput: async () =>
JSON.stringify(
judgement(measured.digest, ["done", "part"], {
npm: { value: "0.64.0", observed_at: "2026-10-05", source: "registry" },
site: { value: "0.64.0", observed_at: "2026-10-05", source: "site" },
}),
),
},
);
vi.restoreAllMocks();
const first =
(result.record ?? "")
.split("/")
.at(-1)
?.replace(/\.json$/, "") ?? "";
return { repo, first };
}

function judgement(digest: string, states: string[], observed: Record<string, unknown>) {
return {
measure_digest: digest,
observed,
cells: ["01", "02", "03", "04", "05", "06"].map((stage, i) => ({
lane: "core",
stage,
state: states[i] ?? "none",
})),
prose: { summary: "fine" },
judged_by: { model: "Claude Opus 5.5", self_reported: true },
};
}

it("measures against the last record, and says first what moved", async () => {
const { repo, first } = await recorded();
await writeFile(join(repo, "LATER.md"), "# later\n");
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
expect(m.diff?.against).toBe(first);
expect(m.diff?.values).toContainEqual({
at: "measures.md.value",
before: 2,
after: 3,
delta: 1,
});
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
const lines = out.join("\n").split("\n");
expect(lines.slice(2, 5)).toEqual(["", `Since the previous record ${first}:`, lines[4]]);
expect(out.join("\n")).toContain(" measures.md.value: 2 -> 3 (+1)\n");
});

it("reads no records for a declaration not named board.yaml", async () => {
const { repo } = await recorded();
await placeBoard(repo, boardYaml([MD]), "board/other.yaml");
const { out } = capture();
const m = await doRunBoardMeasure({ board: "board/other.yaml", json: true }, ctx(repo));
expect(m.diff).toBeNull();
expect(m.components.kind_changed).toBeNull();
out.length = 0;
await doRunBoardMeasure({ board: "board/other.yaml" }, ctx(repo));
expect(out.join("\n")).toContain(
"\n\nNo records are read for a declaration not named board.yaml.\n",
);
});

it("records again, saying what moved in the cells, the observations and the measurement", async () => {
const { repo, first } = await recorded();
capture();
const measured = await doRunBoardMeasure({ json: true }, ctx(repo));
vi.restoreAllMocks();
const { out } = capture();
const result = await doRunBoardRecord(
{},
{
...ctx(repo),
readInput: async () =>
JSON.stringify(
judgement(measured.digest, ["done", "done", "part"], {
npm: { value: "0.65.0", observed_at: "2026-10-06", source: "registry" },
site: { value: null, observed_at: "2026-10-06", source: "site", error: "timeout" },
}),
),
},
);
expect(result.diff).toMatchObject({
against: first,
cells: [
{ lane: "core", stage: "02", before: "part", after: "done" },
{ lane: "core", stage: "03", before: "none", after: "part" },
],
observed: [
{ name: "npm", before: { value: "0.64.0" }, after: { value: "0.65.0" } },
{ name: "site", before: { value: "0.64.0" }, after: { value: null, error: "timeout" } },
],
});
expect(result.diff?.measure.values).toEqual(measured.diff?.values);
const text = out.join("\n");
expect(text).toContain(
`Since the previous record ${first}:\n core 02: part -> done\n core 03: none -> part\n observed npm: "0.64.0" -> "0.65.0"\n observed site: "0.64.0" -> null (timeout)\n`,
);
});

it("shows what a previous record holds with its control characters escaped", async () => {
const { repo, first } = await recorded();
const file = join(repo, "board", "records", `${first}.json`);
const written = JSON.parse(await readFile(file, "utf8"));
written.cells[1].state = `part${ESC}[31m`;
written.measure.methods[`evil${ESC}[2J`] = 1;
await writeFile(file, JSON.stringify(written));
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
await doRunBoardRecord(
{ dryRun: true },
{
...ctx(repo),
readInput: async () => JSON.stringify(judgement(m.digest, ["done", "done"], {})),
},
);
const text = out.join("\n");
expect(text).not.toContain(ESC);
expect(text).toContain(" core 02: part\\x1b[31m -> done\n");
expect(text).toContain("methods changed: evil\\x1b[2J 1 -> none");
});

it("takes a record it cannot read as a gap, not as no record", async () => {
const { repo, first } = await recorded();
await writeFile(join(repo, "board", "records", `${first}.json`), "{ not json");
// The record says why, in its text and as JSON, before anything else.
capture();
const measuredNow = await doRunBoardMeasure({ json: true }, ctx(repo));
vi.restoreAllMocks();
const said = capture();
const result = await doRunBoardRecord(
{ dryRun: true },
{
...ctx(repo),
readInput: async () => JSON.stringify(judgement(measuredNow.digest, ["done"], {})),
},
);
expect(result.diff).toBeNull();
expect(result.not_found).toContainEqual({
at: "diff",
reason: `the record ${first} could not be read as JSON`,
});
const recordText = said.out.join("\n").split("\n");
expect(recordText[1]).toBe(
"The previous record could not be read, so nothing is compared with it.",
);
expect(recordText[2]).toMatch(/^The measurement recorded is not complete \(\d+\):$/);
expect(recordText).toContain(` diff: the record ${first} could not be read as JSON`);
expect(said.out.join("\n")).not.toContain("not measured");
vi.restoreAllMocks();
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
expect(m.diff).toBeNull();
expect(m.complete).toBe(false);
expect(m.not_found).toContainEqual({
at: "diff",
reason: `the record ${first} could not be read as JSON`,
});
expect(process.exitCode).toBe(1);
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
"\n\nThe previous record could not be read (see Not measured).\n",
);
expect(out.join("\n")).toContain("Components:\n 0 found, 0 unacknowledged, gone not known\n");
});
});
Loading
Loading