diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d303d12..32584e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/packages/cli/src/commands/orient.test.ts b/packages/cli/src/commands/orient.test.ts index 925d2bc5..959001f8 100644 --- a/packages/cli/src/commands/orient.test.ts +++ b/packages/cli/src/commands/orient.test.ts @@ -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, @@ -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); @@ -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 { + 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 { + 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(""); + }); +}); diff --git a/packages/cli/src/commands/orient.ts b/packages/cli/src/commands/orient.ts index 24b37cd3..0fe6f086 100644 --- a/packages/cli/src/commands/orient.ts +++ b/packages/cli/src/commands/orient.ts @@ -7,6 +7,7 @@ import { writeMarkdownFile, } from "@basou/core"; import type { Command } from "commander"; +import { glanceDefaultBoard } from "../lib/board-glance.js"; import { isVerbose, printReplayWarning, @@ -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), diff --git a/packages/cli/src/lib/board-glance.ts b/packages/cli/src/lib/board-glance.ts new file mode 100644 index 00000000..9f997049 --- /dev/null +++ b/packages/cli/src/lib/board-glance.ts @@ -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 { + 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)); +} diff --git a/packages/cli/src/lib/foreign-workspace-warn.ts b/packages/cli/src/lib/foreign-workspace-warn.ts index 9753037b..33567559 100644 --- a/packages/cli/src/lib/foreign-workspace-warn.ts +++ b/packages/cli/src/lib/foreign-workspace-warn.ts @@ -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"; /** @@ -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); diff --git a/packages/cli/src/lib/provenance-actions.ts b/packages/cli/src/lib/provenance-actions.ts index 7675dd9a..c2d89081 100644 --- a/packages/cli/src/lib/provenance-actions.ts +++ b/packages/cli/src/lib/provenance-actions.ts @@ -1,3 +1,4 @@ +import { dirname } from "node:path"; import { assertApprovalStoreSafe, assertLockStoreSafe, @@ -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 @@ -267,7 +269,12 @@ export async function regenerateOrientation( nowIso: string, callbacks?: Omit[0], "paths" | "nowIso">, ): Promise { - 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, diff --git a/packages/core/src/board/glance.test.ts b/packages/core/src/board/glance.test.ts new file mode 100644 index 00000000..18a9b4e1 --- /dev/null +++ b/packages/core/src/board/glance.test.ts @@ -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 }); + }); +}); diff --git a/packages/core/src/board/glance.ts b/packages/core/src/board/glance.ts new file mode 100644 index 00000000..1143fed5 --- /dev/null +++ b/packages/core/src/board/glance.ts @@ -0,0 +1,78 @@ +import { lstat, readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { decodeTime } from "ulid"; +import { parseDocument } from "yaml"; +import { findErrorCode } from "../lib/error-codes.js"; +import { recordIds } from "./previous.js"; + +/** + * What a workspace's position says of its board, read loosely: the board is + * not checked, only looked at, so a broken one never fails the position. + */ +export type BoardGlance = { + /** + * When the last record was written, read from its ULID: null when there + * is none, undefined when the records cannot be read. + */ + lastRecordAt: string | null | undefined; + /** The declaration's `axis.version`, null when it cannot be read. */ + axisVersion: number | null; +}; + +/** + * The largest board.yaml glanced at for its axis version. A position is + * rendered at every session start, under a time limit, and reading YAML + * grows faster than the file; a board of this size is far larger than any + * written by hand. + */ +export const BOARD_GLANCE_MAX_BYTES = 128 * 1024; + +/** + * Glance at the board declared at `boardYaml`: the names of the records + * beside it and the axis version it declares, nothing else. Null when there + * is no board there. Never throws. + */ +export async function glanceBoard(boardYaml: string): Promise { + let size: number; + try { + const entry = await stat(boardYaml); + if (!entry.isFile()) return null; + size = entry.size; + } catch { + return null; + } + let axisVersion: number | null = null; + try { + if (size <= BOARD_GLANCE_MAX_BYTES) + axisVersion = axisVersionOf(await readFile(boardYaml, "utf8")); + } catch { + axisVersion = null; + } + let lastRecordAt: string | null | undefined; + try { + const records = join(dirname(boardYaml), "records"); + // A link or a file is not read, as the board page does not read one. + if (!(await lstat(records)).isDirectory()) throw new Error("not a directory"); + // A directory that bears a record's name is not a record. + const files = (await readdir(records, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + const ids = recordIds(files); + const last = ids.at(-1); + lastRecordAt = last === undefined ? null : new Date(decodeTime(last)).toISOString(); + } catch (error: unknown) { + lastRecordAt = findErrorCode(error, "ENOENT") ? null : undefined; + } + return { lastRecordAt, axisVersion }; +} + +// The axis version a board.yaml declares, read as basou board reads YAML (1.2 +// only, no key twice, nothing the parser could only guess at), or null. +function axisVersionOf(text: string): number | null { + const doc = parseDocument(text, { version: "1.2", uniqueKeys: true, logLevel: "silent" }); + if (doc.errors.length > 0 || doc.warnings.length > 0) return null; + if (doc.directives?.yaml.version !== "1.2") return null; + const raw: unknown = doc.toJS({ maxAliasCount: 100 }); + const version = (raw as { axis?: { version?: unknown } } | null)?.axis?.version; + return typeof version === "number" && Number.isInteger(version) && version >= 1 ? version : null; +} diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index c4efeb26..257118b6 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -48,6 +48,8 @@ export type { BoardActiveMs, BoardEffort, BoardEffortDay } from "./effort.js"; export { BOARD_EFFORT_METHOD, effortStartOf } from "./effort.js"; export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; export { BOARD_FRESHNESS_METHOD } from "./freshness.js"; +export type { BoardGlance } from "./glance.js"; +export { glanceBoard } from "./glance.js"; export type { GlobMatcher } from "./glob.js"; export { compileGlob, compileGlobs } from "./glob.js"; export type { BoardGuideInput } from "./guide.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 89f4e7dd..ac76c4ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,6 +100,7 @@ export type { BoardEffort, BoardEffortDay, BoardFreshness, + BoardGlance, BoardGuideInput, BoardImportProbe, BoardInitInput, @@ -180,6 +181,7 @@ export { diffMeasurements, diffObserved, effortStartOf, + glanceBoard, isBoardTimeZone, measureBoard, measureBoardLive, @@ -269,6 +271,7 @@ export type { export { boardInitStrings, boardPageStrings, + isPositionBoardLine, presetStrings, resolveAnchorContentLanguage, resolveRepoContentLanguage, diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts index 06412474..0d15b115 100644 --- a/packages/core/src/lib/view-strings.test.ts +++ b/packages/core/src/lib/view-strings.test.ts @@ -6,6 +6,7 @@ import type { Manifest } from "../schemas/manifest.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { createManifest, writeManifest } from "../storage/manifest.js"; import { + isPositionBoardLine, presetStrings, resolveAnchorContentLanguage, resolveRepoContentLanguage, @@ -161,6 +162,9 @@ describe("viewStrings", () => { t.orientation.verdictCurrent("1h ago", "terminal", false), t.orientation.verdictCurrent("1h ago", "terminal", true), t.orientation.verdictSuspectsCaveat(1), + t.orientation.boardLine(undefined, null), + t.orientation.boardLine(null, 1), + t.orientation.boardLine({ date: "2026-05-08", age: "1d ago" }, 2), ]; for (const line of rendered) { expect(typeof line).toBe("string"); @@ -246,3 +250,29 @@ describe("presetStrings", () => { } }); }); + +describe("isPositionBoardLine", () => { + it("knows the progress-board line in either language, as the position bullets it", () => { + for (const lang of ["en", "ja"] as const) { + const t = viewStrings(lang); + for (const line of [ + t.orientation.boardLine(undefined, null), + t.orientation.boardLine(null, 1), + t.orientation.boardLine({ date: "2026-05-08", age: "3d ago" }, 12), + ]) { + expect(isPositionBoardLine(`- ${line}`)).toBe(true); + expect(isPositionBoardLine(line)).toBe(false); + expect(isPositionBoardLine(` - ${line}`)).toBe(false); + } + } + }); + + it("does not take a line that only begins or ends like it, or holds more code", () => { + const line = viewStrings("en").orientation.boardLine(null, 1); + expect(isPositionBoardLine(`- ${line} and more`)).toBe(false); + expect(isPositionBoardLine(`- Latest decision: ${line}`)).toBe(false); + expect( + isPositionBoardLine("- Progress board: `x`. To update it, follow `basou board guide`."), + ).toBe(false); + }); +}); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 94258e00..b39fb413 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -165,6 +165,16 @@ export type ViewStrings = { toolHuman: string; toolImport: string; toolUnknown: string; + /** + * The line saying the workspace keeps a progress board: when its last + * record was written (null when there is none, undefined when the + * records cannot be read), the axis's version (null when it cannot be + * read), and how to update it. + */ + boardLine: ( + last: { date: string; age: string } | null | undefined, + axisVersion: number | null, + ) => string; }; handoff: { headingCurrentState: string; @@ -235,6 +245,28 @@ function relativeAgeJa(startedAt: string | null, now: Date): string { return `${mins}分前`; } +// How the position's progress-board line begins and ends in each language. +// Between them stand only a date, an age and a version, which basou writes. +const BOARD_LINE = { + en: { head: "Progress board: ", tail: ". To update it, follow `basou board guide`." }, + ja: { head: "進捗盤: ", tail: "。更新は `basou board guide` の手順で。" }, +} as const; + +/** + * Whether a line of a position is the progress-board line, as a bullet: + * fixed words of basou's around a date, an age and a version, which carry no + * name of the operator's. A scan for other workspaces' names passes it over, + * so a workspace named like one of its words does not silence a position. + */ +export function isPositionBoardLine(line: string): boolean { + return Object.values(BOARD_LINE).some( + ({ head, tail }) => + line.startsWith(`- ${head}`) && + line.endsWith(tail) && + !line.slice(2 + head.length, line.length - tail.length).includes("`"), + ); +} + const EN: ViewStrings = { relativeAge: relativeAgeEn, common: { @@ -326,6 +358,16 @@ const EN: ViewStrings = { toolHuman: "manual note", toolImport: "another workspace", toolUnknown: "unknown", + boardLine: (last, axisVersion) => { + const record = + last === undefined + ? "its records cannot be read" + : last === null + ? "no record yet" + : `last record ${last.date} (${last.age})`; + const axis = axisVersion === null ? "axis version unknown" : `axis v${axisVersion}`; + return `${BOARD_LINE.en.head}${record}, ${axis}${BOARD_LINE.en.tail}`; + }, }, handoff: { headingCurrentState: "## Current state", @@ -449,6 +491,16 @@ const JA: ViewStrings = { toolHuman: "手動メモ", toolImport: "他ワークスペース", toolUnknown: "不明", + boardLine: (last, axisVersion) => { + const record = + last === undefined + ? "記録が読めない" + : last === null + ? "記録なし" + : `最後の記録 ${last.date}(${last.age})`; + const axis = axisVersion === null ? "軸の版は不明" : `軸 v${axisVersion}`; + return `${BOARD_LINE.ja.head}${record}・${axis}${BOARD_LINE.ja.tail}`; + }, }, handoff: { headingCurrentState: "## 現在の状態", diff --git a/packages/core/src/orientation/orientation-renderer.test.ts b/packages/core/src/orientation/orientation-renderer.test.ts index b9087bba..f4b646fa 100644 --- a/packages/core/src/orientation/orientation-renderer.test.ts +++ b/packages/core/src/orientation/orientation-renderer.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; import { LOCAL_CLI_EVENT_SOURCE } from "../schemas/shared.schema.js"; import type { TaskStatus } from "../schemas/task.schema.js"; @@ -2556,3 +2556,84 @@ describe("orientation: an out-of-root file name that carries line breaks", () => expect(body).not.toContain("\u001b"); }); }); + +describe("orientation: the workspace's progress board", () => { + const LINE = /^- Progress board: /m; + + it("says nothing of a board when there is none", async () => { + const paths = await setupPaths(); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const omitted = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO }); + expect(omitted.body).not.toMatch(LINE); + const none = await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, board: null }); + expect(none.body).toBe(omitted.body); + }); + + it("says when the board was last recorded, its axis, and to follow the guide, in where you are now", async () => { + const paths = await setupPaths(); + await placeSession(paths, { id: SES("S01"), status: "completed" }); + const at = new Date(Date.parse(FIXED_NOW_ISO) - 3 * 86_400_000).toISOString(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + board: { lastRecordAt: at, axisVersion: 2 }, + }); + const where = result.body.slice( + result.body.indexOf("## Where you are now"), + result.body.indexOf("## Recent direction"), + ); + const d = new Date(at); + const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + expect(where).toContain( + `- Progress board: last record ${day} (3d ago), axis v2. To update it, follow \`basou board guide\`.\n`, + ); + }); + + it("gives the record's day on this host's clock, even in a zone with no name", async () => { + const saved = process.env.TZ; + try { + for (const tz of ["Asia/Tokyo", "JST-9"]) { + process.env.TZ = tz; + const paths = await setupPaths(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + // 05:00 on 2026-05-06 in Tokyo, though still 2026-05-05 in UTC. + board: { lastRecordAt: "2026-05-05T20:00:00.000Z", axisVersion: 1 }, + }); + expect(result.body).toContain("- Progress board: last record 2026-05-06 ("); + } + } finally { + if (saved === undefined) delete process.env.TZ; + else process.env.TZ = saved; + } + }); + + it("says when there is no record yet, when the records cannot be read, and when the axis is not known", async () => { + const paths = await setupPaths(); + const body = async (board: { + lastRecordAt: string | null | undefined; + axisVersion: number | null; + }) => (await renderOrientation({ paths, nowIso: FIXED_NOW_ISO, board })).body; + expect(await body({ lastRecordAt: null, axisVersion: 1 })).toContain( + "- Progress board: no record yet, axis v1. To update it, follow `basou board guide`.", + ); + expect(await body({ lastRecordAt: undefined, axisVersion: null })).toContain( + "- Progress board: its records cannot be read, axis version unknown. To update it, follow `basou board guide`.", + ); + }); + + it("writes the line in the anchor's language", async () => { + const paths = await setupPaths(); + const result = await renderOrientation({ + paths, + nowIso: FIXED_NOW_ISO, + language: "ja", + board: { lastRecordAt: null, axisVersion: 3 }, + }); + const line = result.body.split("\n").find((l) => l.includes("basou board guide")) ?? ""; + expect(line.startsWith("- ")).toBe(true); + expect(line).not.toContain("Progress board"); + expect(line).toContain("v3"); + }); +}); diff --git a/packages/core/src/orientation/orientation-renderer.ts b/packages/core/src/orientation/orientation-renderer.ts index a75c4b8d..dea977c7 100644 --- a/packages/core/src/orientation/orientation-renderer.ts +++ b/packages/core/src/orientation/orientation-renderer.ts @@ -1,5 +1,6 @@ import { dirname, join } from "node:path"; import { enumerateApprovals, isLazyExpired, loadApproval } from "../approval/approval-store.js"; +import type { BoardGlance } from "../board/glance.js"; import { countOpenDecisionGaps, type DecisionForGapCount } from "../decision-gaps/index.js"; import { type ReplayWarning, replayEvents } from "../events/event-replay.js"; import { displayPath } from "../lib/display-path.js"; @@ -71,6 +72,12 @@ export type OrientationRendererInput = { * store and other hosts still render. An absent root path is silently empty. */ onHostUnavailable?: (host: string, error: unknown) => void; + /** + * The workspace's progress board, as the CLI glanced at it: a line in "where + * am I now" says when it was last recorded and how to update it. Omitted or + * null = no board, and no line. + */ + board?: BoardGlance | null; }; export type OrientationRendererResult = { @@ -772,6 +779,7 @@ export async function renderOrientation( staleness: input.staleness ?? null, verbose: input.verbose === true, language, + board: input.board ?? null, }), sessionCount: summary.sessionCount, pendingApprovalsCount: summary.pendingApprovals.length, @@ -792,6 +800,7 @@ function formatOrientationBody( } | null; verbose: boolean; language: ViewLanguage; + board: BoardGlance | null; }, ): string { const t = viewStrings(opts.language); @@ -901,6 +910,17 @@ function formatOrientationBody( } else { lines.push(`- ${t.common.recentFilesLabel}: (none recorded)`); } + if (opts.board !== null) { + const at = opts.board.lastRecordAt; + const last = + at === null || at === undefined + ? at + : { + date: localDay(new Date(at)), + age: t.relativeAge(at, now), + }; + lines.push(`- ${t.orientation.boardLine(last, opts.board.axisVersion)}`); + } lines.push(""); // "recent direction" — the arc of the last N sessions, the read-side safety @@ -1340,3 +1360,10 @@ function shortId(id: string): string { if (sep === -1) return id.slice(0, 10); return id.slice(0, sep + 1) + id.slice(sep + 1, sep + 1 + 10); } + +// The day of an instant on this host's clock, as YYYY-MM-DD: the Date's own +// local fields, which follow TZ even where the zone has no name. +function localDay(at: Date): string { + const pad = (n: number, width: number) => String(n).padStart(width, "0"); + return `${pad(at.getFullYear(), 4)}-${pad(at.getMonth() + 1, 2)}-${pad(at.getDate(), 2)}`; +}