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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ All notable changes to **basou** are recorded here. The project follows

### Added

- **`basou board init` (experimental) prints a board.yaml to start a board
from.** It is of the newest `board_version`, in the language the manifest
declares for the workspace's own repo: a title from the workspace's name,
the six stages with a meaning each (concept, spec, built, merged, open,
live), one sample lane to cut again (a board needs one), nothing to
observe, no component registered (the first measurement names every one it
finds, which fires the axis review that registers them), an axis of
version 1 reviewed every 60 days, and the effort from the day of the first
session in this host's time zone, as the board page counts it with no
board (today when there is none). Where the host's zone has no name a
board can declare (none, or an offset such as `+00:00`), the time zone is
left out and a comment and stderr say to write it, and with no name at all
the start is today in UTC, said so. It writes nothing, and runs only from
the top of the workspace's own repo: its help and `basou board guide` say
to save it through a file of its own inside `board/`, linked into place
only where no `board.yaml` is (a link never replaces one) and removed
either way, never by printing straight into `board/board.yaml`, which the
shell empties before init runs. It exits 1, printing nothing, where a
board is already declared (a link to nothing included), the workspace's
own repo is not declared private, or it is run from elsewhere.
- **`basou board guide` (experimental) prints the steps an agent follows to
judge the board and record it, so a workspace needs no skill of its own
for that.** The steps are filled in with the workspace's default board
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 @@ -56,6 +56,7 @@ basou hook stop | session-start # the handlers themselves — the tool invo
basou board measure # measure what a board.yaml declares (writes nothing, sends nothing)
basou board record # measure again and record a judgement of the board, beside its board.yaml
basou board guide # print the steps an agent follows to judge and record the board (writes nothing)
basou board init # print a board.yaml to start a board from (writes nothing)

# User-global context faces (files every project's AI tool auto-loads)
basou channel clear codex # remove an orientation block an older basou left in ~/.codex/AGENTS.md
Expand Down
184 changes: 184 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,26 @@ import { basename, join } from "node:path";
import { promisify } from "node:util";
import {
basouPaths,
boardInitStrings,
chainEvents,
createManifest,
type Event,
ensureBasouDirectory,
parseBoardDeclaration,
type RepoEntry,
writeManifest,
} from "@basou/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeStaleness } from "../lib/provenance-actions.js";
import {
doRunBoardGuide,
doRunBoardInit,
doRunBoardMeasure,
doRunBoardRecord,
INIT_SAVE,
measureLiveBoard,
runBoardGuide,
runBoardInit,
runBoardMeasure,
runBoardRecord,
} from "./board.js";
Expand Down Expand Up @@ -1458,6 +1463,19 @@ describe("basou board guide", () => {
expect(process.exitCode).toBe(1);
});

it("refuses a board.yaml that is a link to nothing, as init would not replace it", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await mkdir(join(repo, "board"));
await symlink(join(repo, "nowhere.yaml"), join(repo, "board", "board.yaml"));
const { out, err } = capture();
await runBoardGuide({}, guideCtx(repo));
expect(out).toEqual([]);
expect(err.join("\n")).toContain(
"board/board.yaml is there but cannot be read (a link to nothing?)",
);
expect(process.exitCode).toBe(1);
});

it("refuses a declaration that does not read, listing why", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await placeBoard(repo, JSON.stringify({ ...JSON.parse(boardYaml([])), title: "" }));
Expand All @@ -1472,6 +1490,172 @@ describe("basou board guide", () => {
});
});

describe("basou board init", () => {
it("prints a board that reads under the workspace's manifest, and writes nothing", async () => {
const repo = await workspace([
{ path: ".", visibility: "private", language: "en" },
{ path: "../elsewhere" },
]);
const before = await readdir(repo);
const out: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
});
const text = await doRunBoardInit({}, ctx(repo));
expect(out.join("")).toBe(text);
const parsed = parseBoardDeclaration(text, { manifestRepoPaths: [".", "../elsewhere"] });
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
expect(parsed.declaration.title).toBe("board-ws progress board");
expect(parsed.declaration.board_version).toBe(2);
// No session yet: the effort starts today.
expect(parsed.declaration.effort.start).toBe(
new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(NOW),
);
expect(await readdir(repo)).toEqual(before);
expect(process.exitCode ?? 0).toBe(0);
});

it("writes its words in the language the manifest declares for the workspace's own repo", async () => {
const repo = await workspace([{ path: ".", visibility: "private", language: "ja" }]);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const text = await doRunBoardInit({}, ctx(repo));
const parsed = parseBoardDeclaration(text, { manifestRepoPaths: ["."] });
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
expect(parsed.declaration.title).toBe(
boardInitStrings("ja").title.replace("{name}", "board-ws"),
);
});

it("says to save it through a file of its own in board/, and only where no board is", async () => {
const dir = await realpath(await mkdtemp(join(tmpdir(), "basou-board-init-save-")));
try {
const bin = join(dir, "bin");
await mkdir(bin);
await writeFile(join(bin, "basou"), "#!/bin/sh\necho 'title: printed'\n", { mode: 0o755 });
const run = () =>
promisify(execFile)("sh", ["-c", `${INIT_SAVE}; echo "exit=$?"`], {
cwd: dir,
env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
});
expect((await run()).stdout).toBe("exit=0\n");
expect(await readFile(join(dir, "board", "board.yaml"), "utf8")).toBe("title: printed\n");
await writeFile(join(dir, "board", "board.yaml"), "title: mine\n");
expect((await run()).stdout).toBe("exit=1\n");
expect(await readFile(join(dir, "board", "board.yaml"), "utf8")).toBe("title: mine\n");
expect(await readdir(join(dir, "board"))).toEqual(["board.yaml"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it("runs only from the top of the workspace's own repo, where the save writes", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await mkdir(join(repo, "docs"));
const write = vi.spyOn(process.stdout, "write");
const { err } = capture();
await runBoardInit({}, { ...ctx(repo), cwd: join(repo, "docs") });
expect(write).not.toHaveBeenCalled();
expect(err.join("\n")).toContain(
"Run basou board init from the top of the workspace's own repo, where its .basou/ is",
);
expect(process.exitCode).toBe(1);
});

it("leaves out a time zone a board cannot declare, and says when the start is not the first session's", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
const resolved = Intl.DateTimeFormat.prototype.resolvedOptions;
const zoneIs = (name: string | undefined) =>
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(function (
this: Intl.DateTimeFormat,
) {
return { ...resolved.call(this), timeZone: name as string };
});
// Some Node versions take an offset as a zone, and name the host's zone so
// under TZ=GMT; older ones refuse it, and the host's zone then has no name.
const takesOffset = (() => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: "+00:00" });
return true;
} catch {
return false;
}
})();
const unnamed = "no name, so effort.start is today in UTC and effort.time_zone is left out";
for (const [zone, said] of [
[
"+00:00",
takesOffset ? "no name a board can declare, so effort.time_zone is left out" : unnamed,
],
[undefined, unnamed],
] as const) {
const spy = zoneIs(zone);
const out: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
out.push(String(chunk));
return true;
});
const { err } = capture();
try {
const text = await doRunBoardInit({}, ctx(repo));
expect(err.join("\n")).toContain(said);
const parsed = parseBoardDeclaration(text, { manifestRepoPaths: ["."] });
if (!parsed.ok) throw new Error(parsed.errors.join("\n"));
expect(parsed.declaration.effort.time_zone).toBeUndefined();
expect(text.includes(boardInitStrings("en").comments.effortToday)).toBe(
zone === undefined || !takesOffset,
);
} finally {
spy.mockRestore();
vi.restoreAllMocks();
}
}
});

it("says when the sessions could not be read, so the start is today", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
const sessions = join(repo, ".basou", "sessions", "ses_01HXABCDEF1234567890ABCS01");
await mkdir(sessions, { recursive: true });
await writeFile(join(sessions, "session.yaml"), "session: [broken]\n");
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const { err } = capture();
await doRunBoardInit({}, ctx(repo));
expect(err.join("\n")).toContain(
"basou: the sessions could not be read, so effort.start is today; set it to the day the work began.",
);
});

it("refuses, printing nothing, where a board is already declared", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await placeBoard(repo, boardYaml([]));
const write = vi.spyOn(process.stdout, "write");
const { err } = capture();
await runBoardInit({}, ctx(repo));
expect(write).not.toHaveBeenCalled();
expect(err.join("\n")).toContain(
"board/board.yaml is already there, so there is no board to start",
);
expect(process.exitCode).toBe(1);
expect(await readFile(join(repo, "board", "board.yaml"), "utf8")).toBe(boardYaml([]));
});

it("refuses a workspace whose own repo is not declared private", async () => {
const repo = await workspace([{ path: "." }]);
const write = vi.spyOn(process.stdout, "write");
const { err } = capture();
await runBoardInit({}, ctx(repo));
expect(write).not.toHaveBeenCalled();
expect(err.join("\n")).toContain(
"The board is kept at board/board.yaml only in a workspace whose manifest declares its own repo (path: .) private",
);
expect(process.exitCode).toBe(1);
});
});

describe("measureLiveBoard: the board page's measurement with no board", () => {
// A Claude Code transcript of the workspace that was never imported, beside
// it where ctx() points the dry run of an import.
Expand Down
Loading
Loading