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

### Added

- **`basou orient` says when the workspace keeps a progress board, and how
to update it.** When the manifest declares the workspace's own repo
private and `board/board.yaml` is there, the position's "where you are
now" ends with one line: when the last record was written (from its name,
the day on this host's clock) and how long ago, or that there is none yet;
the axis's version the declaration gives; and to follow `basou board
guide`. `basou refresh` writes the same position, and the SessionStart
hooks hand it to a Claude Code or Codex session, so an agent learns of the
board, and where its steps are, with no skill of its own. Only the
records' names and the declaration's `axis.version` are read, as `basou
board` reads YAML (a board.yaml larger than 128 KiB is not read): a board
that cannot be read is said to be so (the records cannot be read, the axis
version is unknown), and never fails the position, changes an exit code or
the hooks' output beyond that line. The line is basou's own words around a
date and a version, so the check for other workspaces' names passes it
over. A workspace with no board gets no line.
- **`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,
Expand Down
136 changes: 134 additions & 2 deletions packages/cli/src/commands/orient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { devNull, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import {
basouPaths,
Expand All @@ -13,7 +13,14 @@ import {
} from "@basou/core";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { doRunOrient, registerOrientCommand, runOrient } from "./orient.js";
import { regenerateOrientation } from "../lib/provenance-actions.js";
import { renderRegisteredWorkspacePosition } from "./hook.js";
import {
doRunOrient,
registerOrientCommand,
renderOrientationForRoot,
runOrient,
} from "./orient.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -364,3 +371,128 @@ describe("basou orient", () => {
expect(orient).toBeDefined();
});
});

describe("basou orient: the workspace's progress board", () => {
async function withBoard(
visibility: "private" | "public",
yaml: string,
records: string[] = [],
): Promise<string> {
const repo = await setupInitedRepo();
const paths = basouPaths(repo);
const manifest = createManifest({
workspaceName: "fixture-ws",
now: FIXED_DATE,
workspaceId: FIXED_WS_ID,
});
await writeManifest(
paths,
{ ...manifest, repos: [{ path: ".", visibility }] },
{ force: true },
);
await mkdir(join(repo, "board", "records"), { recursive: true });
await writeFile(join(repo, "board", "board.yaml"), yaml);
for (const id of records) await writeFile(join(repo, "board", "records", `${id}.json`), "{}");
return repo;
}

it("says when the default board was last recorded, and how to update it", async () => {
// Three days before the fixed clock.
const repo = await withBoard("private", "axis: { version: 2 }\n", [
ulidAt(FIXED_DATE.getTime() - 3 * 86_400_000),
]);
const out = captureStdout();
const err = captureStderr();
await doRunOrient({}, ctxFor(repo));
expect(joinCalls(err)).toBe("");
const line = joinCalls(out)
.split("\n")
.find((l) => l.startsWith("- Progress board: "));
expect(line).toMatch(
/^- Progress board: last record \d{4}-\d{2}-\d{2} \(3d ago\), axis v2\. To update it, follow `basou board guide`\.$/,
);
// The hook's position says the same, writing nothing.
const rendered = await renderOrientationForRoot(repo, {}, ctxFor(repo), { write: false });
expect(rendered.body).toContain(line);
});

it("says nothing of a board the manifest does not declare private", async () => {
const repo = await withBoard("public", "axis: { version: 2 }\n");
const out = captureStdout();
await doRunOrient({}, ctxFor(repo));
expect(joinCalls(out)).not.toContain("Progress board");
});

it("says nothing of a board where there is none", async () => {
const repo = await setupInitedRepo();
const out = captureStdout();
await doRunOrient({}, ctxFor(repo));
expect(joinCalls(out)).not.toContain("Progress board");
});

it("still gives the position when the board cannot be read", async () => {
const repo = await withBoard("private", "axis: [\n");
await rm(join(repo, "board", "records"), { recursive: true });
await writeFile(join(repo, "board", "records"), "");
const out = captureStdout();
await runOrient({}, ctxFor(repo));
expect(joinCalls(out)).toContain(
"- Progress board: its records cannot be read, axis version unknown. To update it, follow `basou board guide`.",
);
expect(process.exitCode ?? 0).toBe(0);
});
});

// A ULID of the time given, its random part all zeros.
function ulidAt(ms: number): string {
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let time = "";
for (let rest = ms, i = 0; i < 10; i++, rest = Math.floor(rest / 32)) {
time = alphabet[rest % 32] + time;
}
return `${time}${"0".repeat(16)}`;
}

describe("the progress-board line, wherever the position is written", () => {
async function privateBoard(): Promise<string> {
const repo = await setupInitedRepo();
const paths = basouPaths(repo);
const manifest = createManifest({
workspaceName: "fixture-ws",
now: FIXED_DATE,
workspaceId: FIXED_WS_ID,
});
await writeManifest(
paths,
{ ...manifest, repos: [{ path: ".", visibility: "private" }] },
{
force: true,
},
);
await mkdir(join(repo, "board", "records"), { recursive: true });
await writeFile(join(repo, "board", "board.yaml"), "axis: { version: 4 }\n");
return repo;
}

it("is in the position basou refresh writes too", async () => {
const repo = await privateBoard();
await regenerateOrientation(basouPaths(repo), FIXED_DATE.toISOString());
expect(await readFile(basouPaths(repo).files.orientation, "utf8")).toContain(
"- Progress board: no record yet, axis v4. To update it, follow `basou board guide`.",
);
});

it("does not silence the hook's position when another workspace is named like one of its words", async () => {
const repo = await privateBoard();
const guide = join(dirname(repo), `${basename(repo)}-x`, "guide");
await mkdir(guide, { recursive: true });
const portfolio = join(repo, "portfolio.yaml");
await writeFile(portfolio, `workspaces:\n - path: ${repo}\n - path: ${guide}\n`);
const { body } = await renderRegisteredWorkspacePosition(repo, portfolio);
expect(body).toContain("- Progress board: no record yet, axis v4.");
const err = captureStderr();
captureStdout();
await doRunOrient({}, { ...ctxFor(repo), portfolioConfigPath: portfolio });
expect(joinCalls(err)).toBe("");
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/commands/orient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
writeMarkdownFile,
} from "@basou/core";
import type { Command } from "commander";
import { glanceDefaultBoard } from "../lib/board-glance.js";
import {
isVerbose,
printReplayWarning,
Expand Down Expand Up @@ -199,6 +200,7 @@ export async function renderOrientationForRoot(
paths,
nowIso,
staleness,
board: await glanceDefaultBoard(repositoryRoot, paths),
verbose: options.verbose === true,
federatedRoots,
onWarning: (w, sid) => printReplayWarning(w, sid),
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/lib/board-glance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { join } from "node:path";
import { type BasouPaths, type BoardGlance, glanceBoard, readManifest } from "@basou/core";
import { DEFAULT_BOARD_PATH } from "../commands/board.js";

/**
* The workspace's default board, glanced at for one line of its position: the
* board.yaml `basou board` reads by default, which it reads only when the
* manifest declares the workspace's own repo private. Null when there is
* none, or the manifest cannot be read: a position never fails on a board,
* which an experimental command wrote. Every writer of the position calls
* this, so the line is there whichever wrote it last.
*/
export async function glanceDefaultBoard(
root: string,
paths: BasouPaths,
): Promise<BoardGlance | null> {
try {
const own = (await readManifest(paths)).repos?.find((repo) => repo.path === ".");
if (own?.visibility !== "private") return null;
} catch {
return null;
}
return glanceBoard(join(root, DEFAULT_BOARD_PATH));
}
19 changes: 13 additions & 6 deletions packages/cli/src/lib/foreign-workspace-warn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { realpath } from "node:fs/promises";
import { dirname } from "node:path";
import { type BasouPaths, loadPortfolioConfig, readMarkdownFile } from "@basou/core";
import {
type BasouPaths,
isPositionBoardLine,
loadPortfolioConfig,
readMarkdownFile,
} from "@basou/core";
import { scanForeignWorkspaceNames } from "./foreign-workspace-scan.js";

/**
Expand Down Expand Up @@ -73,11 +78,13 @@ export async function findForeignWorkspaceNames(args: {
return null;
}

const hits = scanForeignWorkspaceNames({
text: args.text,
workspacePaths,
selfPath,
});
// The progress-board line is basou's own words around a date and a
// version; blanked, not dropped, so the line numbers stay the text's.
const text = args.text
.split("\n")
.map((line) => (isPositionBoardLine(line) ? "" : line))
.join("\n");
const hits = scanForeignWorkspaceNames({ text, workspacePaths, selfPath });
if (hits.length === 0) return null;

const lines = [...new Set(hits.flatMap((h) => h.lines))].sort((a, b) => a - b);
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/lib/provenance-actions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { dirname } from "node:path";
import {
assertApprovalStoreSafe,
assertLockStoreSafe,
Expand All @@ -16,6 +17,7 @@ import {
type ImportContext,
type ImportOptions,
} from "../commands/import.js";
import { glanceDefaultBoard } from "./board-glance.js";

/**
* Shared provenance actions reused by both `basou refresh` (one-shot CLI) and
Expand Down Expand Up @@ -267,7 +269,12 @@ export async function regenerateOrientation(
nowIso: string,
callbacks?: Omit<Parameters<typeof renderOrientation>[0], "paths" | "nowIso">,
): Promise<OrientationCounts> {
const result = await renderOrientation({ paths, nowIso, ...callbacks });
const result = await renderOrientation({
paths,
nowIso,
board: await glanceDefaultBoard(dirname(paths.root), paths),
...callbacks,
});
await writeMarkdownFile(paths.files.orientation, `${result.body}\n`);
return {
sessionCount: result.sessionCount,
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/board/glance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { decodeTime } from "ulid";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BOARD_GLANCE_MAX_BYTES, glanceBoard } from "./glance.js";

describe("glanceBoard", () => {
let dir: string;
let yaml: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "basou-board-glance-"));
await mkdir(join(dir, "board"));
yaml = join(dir, "board", "board.yaml");
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it("finds no board where there is no board.yaml, or a directory of that name", async () => {
expect(await glanceBoard(yaml)).toBeNull();
await mkdir(yaml);
expect(await glanceBoard(yaml)).toBeNull();
});

it("reads the axis version and when the last record was written, from its name", async () => {
await writeFile(yaml, "board_version: 2\naxis: { version: 3, review_due_days: 60 }\n");
expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: 3 });
const records = join(dir, "board", "records");
await mkdir(records);
expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: 3 });
await writeFile(join(records, "01M4A00000000000000000000A.json"), "{}");
await writeFile(join(records, "01M4E6PAN7J2BJ5YK1NFPVAQAR.json"), "not even JSON");
await writeFile(join(records, ".record-x.tmp"), "");
await writeFile(join(records, "ZZZZ.json"), "");
expect(await glanceBoard(yaml)).toEqual({
lastRecordAt: "2026-10-08T16:49:07.751Z",
axisVersion: 3,
});
});

it("does not know the axis of a board.yaml it cannot read, and still finds its records", async () => {
await mkdir(join(dir, "board", "records"));
await writeFile(join(dir, "board", "records", "01M4A00000000000000000000A.json"), "{}");
for (const text of [
"axis: [",
"axis: { version: 0 }",
'axis: { version: "2" }',
"axis: { version: 1.5 }",
"- a list\n",
"",
]) {
await writeFile(yaml, text);
const glance = await glanceBoard(yaml);
expect(glance?.axisVersion).toBeNull();
expect(glance?.lastRecordAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
});

it("does not know the axis of a board.yaml basou board would not read as YAML", async () => {
for (const text of [
"axis: { version: 2 }\nlanes: [\n",
"axis: { version: 2 }\naxis: { version: 3 }\n",
"%YAML 1.1\n---\naxis: { version: 010 }\n",
"axis: !odd { version: 2 }\n",
]) {
await writeFile(yaml, text);
expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: null, axisVersion: null });
}
});

it("does not read a board.yaml too large to be written by hand", async () => {
const head = "axis: { version: 2 }\n";
await writeFile(yaml, head + "#".repeat(BOARD_GLANCE_MAX_BYTES - head.length));
expect((await glanceBoard(yaml))?.axisVersion).toBe(2);
await writeFile(yaml, head + "#".repeat(BOARD_GLANCE_MAX_BYTES - head.length + 1));
expect((await glanceBoard(yaml))?.axisVersion).toBeNull();
});

it("does not count a directory that bears a record's name", async () => {
await writeFile(yaml, "axis: { version: 1 }\n");
const records = join(dir, "board", "records");
await mkdir(join(records, "01M4E6PAN7J2BJ5YK1NFPVAQAR.json"), { recursive: true });
await writeFile(join(records, "01M4A00000000000000000000A.json"), "{}");
expect((await glanceBoard(yaml))?.lastRecordAt).toBe(
new Date(decodeTime("01M4A00000000000000000000A")).toISOString(),
);
});

it("cannot read records that are a file or a link", async () => {
await writeFile(yaml, "axis: { version: 1 }\n");
const records = join(dir, "board", "records");
await writeFile(records, "");
expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: undefined, axisVersion: 1 });
await rm(records);
const elsewhere = join(dir, "elsewhere");
await mkdir(elsewhere);
await symlink(elsewhere, records);
expect(await glanceBoard(yaml)).toEqual({ lastRecordAt: undefined, axisVersion: 1 });
});
});
Loading
Loading