diff --git a/CHANGELOG.md b/CHANGELOG.md index 34034459..bf7c6f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,23 @@ All notable changes to **basou** are recorded here. The project follows reads, never under `.basou/`, and that what it writes is not part of the trail. +- **`basou view` shows the experimental board at `/board`, in single mode.** + The page draws one record of `board/board.yaml` (read only when the + manifest declares the workspace's own repo private) from the record + alone, by the declaration it was judged by: its title and judge, a + summary with six counts (lanes in operation, blocked and unverified + cells, open tracks, sessions, the operator's turns) and the observations + from outside (one not made shows the previous record's value), the period + and effort, the reach matrix with the cells that moved since the previous + record marked and the stages left behind above it, where each lane is, + the ratios, the operator's turns, and footnotes. What the judge reported + is marked as such. Its fixed strings are in the language the anchor + declares. A link in the header leads to it, and earlier records open from + it. It draws from the records, never from the board.yaml as it is now + (the manifest says whether there is a board and in which language), takes + no new flag, and is not served in portfolio mode. Like every page of `basou view`, it is a local + UI and not an API. + ### Changed - **`@basou/core` now depends on simple-git 4, for four advisories against diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index dc366f70..88423fbf 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -17,15 +17,26 @@ import { basename, join } from "node:path"; import { promisify } from "node:util"; import { basouPaths, + boardPageStrings, createManifest, displayPath, ensureBasouDirectory, + type RepoEntry, writeManifest, } from "@basou/core"; +import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BOARD_HTML } from "../lib/board-ui.js"; import type { ViewServerHandle } from "../lib/view-server.js"; import { VIEW_HTML } from "../lib/view-ui.js"; -import { doRunView, runView, type ViewContext, type ViewOptions } from "./view.js"; +import { doRunBoardMeasure, doRunBoardRecord } from "./board.js"; +import { + doRunView, + registerViewCommand, + runView, + type ViewContext, + type ViewOptions, +} from "./view.js"; const execFileAsync = promisify(execFile); @@ -948,3 +959,414 @@ describe("the view page shows a recorded file name with its control characters e ); }); }); + +describe("basou view: the board page", () => { + const [A_REC, B_REC, C_REC] = [ + "00000000010000000000000000", + "00000000020000000000000000", + "00000000030000000000000000", + ]; + const STAGES = Object.fromEntries( + ["01", "02", "03", "04", "05", "06"].map((id) => [id, { meaning: `stage ${id}` }]), + ); + + async function workspaceWith(repos?: RepoEntry[]): Promise { + const repo = await realpath(tmpRepo as string); + const paths = await ensureBasouDirectory(repo); + const manifest = createManifest({ + workspaceName: "view-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + }); + await writeManifest(paths, repos === undefined ? manifest : { ...manifest, repos }); + return repo; + } + + // Declare a board in the workspace, measure it and record a judgement of it. + async function recorded(repo: string): Promise { + await mkdir(join(repo, "board"), { recursive: true }); + await writeFile( + join(repo, "board", "board.yaml"), + JSON.stringify({ + board_version: 1, + title: "Board", + stages: STAGES, + lanes: [{ id: "core", name: "Core" }], + axis: { version: 1, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "UTC" }, + }), + ); + const ctx = { + cwd: repo, + nowProvider: () => FIXED_DATE, + portfolioConfigPath: join(repo, ".portfolio.yaml"), + claudeProjectsDir: getClaudeRoot(), + codexSessionsDir: getCodexRoot(), + }; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const measured = await doRunBoardMeasure({ json: true }, ctx); + const result = await doRunBoardRecord( + {}, + { + ...ctx, + readInput: async () => + JSON.stringify({ + measure_digest: measured.digest, + observed: { site: { value: null, observed_at: "2026-05-09", source: "s", error: "x" } }, + cells: ["01", "02", "03", "04", "05", "06"].map((stage) => ({ + lane: "core", + stage, + state: stage === "01" ? "done" : "none", + })), + prose: { summary: "Fine `here`." }, + judged_by: { model: "Claude Opus 5.5", self_reported: true }, + }), + }, + ); + vi.restoreAllMocks(); + return ( + (result.record ?? "") + .split("/") + .at(-1) + ?.replace(/\.json$/, "") ?? "" + ); + } + + async function withView( + repo: string, + options: Partial, + body: (handle: ViewServerHandle) => Promise, + ): Promise { + const controller = new AbortController(); + let handle: ViewServerHandle | undefined; + let markReady: () => void = () => {}; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + const running = doRunView( + { port: 0, ...options }, + { + cwd: repo, + signal: controller.signal, + openBrowser: () => {}, + codexSessionsDir: getCodexRoot(), + onListening: (h) => { + handle = h; + markReady(); + }, + }, + ); + await ready; + try { + if (handle === undefined) throw new Error("server never listened"); + await body(handle); + } finally { + controller.abort(); + await running; + } + } + + it("serves the page, and the records with the strings of the anchor's language", async () => { + const repo = await workspaceWith([{ path: ".", visibility: "private", language: "ja" }]); + const id = await recorded(repo); + await withView(repo, {}, async (h) => { + const page = await fetch(`${h.url}/board`); + expect(page.status).toBe(200); + expect(await page.text()).toBe(BOARD_HTML); + const latest = await getJson(h, "/api/board"); + expect(latest.status).toBe(200); + // The table of the anchor's language, not the English one. + expect((latest.data as { strings: unknown }).strings).not.toEqual(boardPageStrings("en")); + expect(latest.data).toMatchObject({ + language: "ja", + strings: boardPageStrings("ja"), + page: { + status: "ok", + id, + older: null, + newer: null, + board: { + heading: { title: "Board", model: "Claude Opus 5.5" }, + summary: { + text: "Fine `here`.", + observed: [{ name: "site", value: null, previous: { status: "none" } }], + }, + }, + }, + }); + const scoped = await getJson(h, `/api/ws/${FIXED_WS_ID}/board/${id}`); + expect(scoped.data).toMatchObject({ page: { status: "ok", id } }); + expect((await getJson(h, "/api/board/00000000010000000000000000")).data).toMatchObject({ + page: { status: "unavailable", why: "not_found" }, + }); + expect((await getJson(h, "/api/board/..%2F..%2Fx")).status).toBe(404); + }); + }); + + it("says why there is no board to show", async () => { + const repo = await workspaceWith(); + await withView(repo, {}, async (h) => { + expect((await getJson(h, "/api/board")).data).toMatchObject({ + language: "en", + strings: boardPageStrings("en"), + page: { status: "unavailable", why: "no_board" }, + }); + }); + }); + + it("has no record to draw before the first one is written", async () => { + const repo = await workspaceWith([{ path: ".", visibility: "private" }]); + await withView(repo, {}, async (h) => { + expect((await getJson(h, "/api/board")).data).toMatchObject({ + page: { status: "unavailable", why: "no_records", records: [] }, + }); + }); + }); + + it("is not served in portfolio mode", async () => { + const repo = await initWorkspaceAt(tmpRepo as string, WS_ID_A, "a"); + await withPortfolioServer([repo], {}, async (h) => { + expect((await fetch(`${h.url}/board`)).status).toBe(404); + expect((await getJson(h, `/api/ws/${WS_ID_A}/board`)).status).toBe(404); + }); + }); + + it("takes no flag to point at a board: the command is guaranteed and the board is not", () => { + const program = new Command(); + registerViewCommand(program); + const view = program.commands.find((c) => c.name() === "view"); + expect(view?.options.map((o) => o.long)).toContain("--port"); + expect(view?.options.map((o) => o.long)).not.toContain("--board"); + }); + + it("links to the page from the single-mode header, and only there", () => { + expect(VIEW_HTML).toContain(''); + const shows = "$('board-link').style.display = '';"; + expect(liftFrom(VIEW_HTML, "enterSingle")).toContain(shows); + expect(liftFrom(VIEW_HTML, "openWorkspace")).not.toContain("board-link"); + expect(VIEW_HTML.split(shows)).toHaveLength(2); + }); + + /** Lift a named function out of a page. */ + function liftFrom(html: string, name: string): string { + const marker = `function ${name}(`; + const start = html.indexOf(marker); + expect(start).toBeGreaterThan(-1); + let depth = 0; + for (let i = html.indexOf("{", start); i < html.length; i++) { + const ch = html[i]; + if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) return html.slice(start, i + 1); + } + throw new Error(`no end of ${name}`); + } + const lift = (name: string) => liftFrom(BOARD_HTML, name); + + // Just enough of a DOM for the page's drawing functions. + class Node { + children: Node[] = []; + attrs: Record = {}; + className = ""; + textContent = ""; + selected = false; + disabled = false; + style: Record = {}; + constructor(readonly tag: string) {} + appendChild(child: Node): Node { + this.children.push(child); + return child; + } + get firstChild(): Node | null { + return this.children[0] ?? null; + } + removeChild(child: Node): void { + this.children = this.children.filter((c) => c !== child); + } + setAttribute(key: string, value: unknown): void { + this.attrs[key] = String(value); + } + addEventListener(): void {} + text(): string { + return this.textContent + this.children.map((c) => c.text()).join(""); + } + find(pick: (node: Node) => boolean): Node[] { + return [...(pick(this) ? [this] : []), ...this.children.flatMap((c) => c.find(pick))]; + } + } + function drawing(names: string[]) { + const byId: Record = {}; + const document = { + createElement: (tag: string) => new Node(tag), + createTextNode: (text: string) => Object.assign(new Node("#text"), { textContent: text }), + getElementById: (id: string) => { + byId[id] ??= new Node("div"); + return byId[id]; + }, + }; + const helpers = [ + "$", + "clear", + "el", + "fill", + "codeSpans", + "prose", + "reported", + "num", + "hm", + "when", + "shown", + "stateName", + "section", + ]; + const source = [...helpers, ...names].map(lift).join("\n"); + const fns = new Function("document", "S", "lang", `${source}; return { ${names.join(", ")} };`)( + document, + boardPageStrings("en"), + "en", + ) as Record Node>; + const call = (name: string, arg: unknown): Node => { + const fn = fns[name]; + if (fn === undefined) throw new Error(`no ${name}`); + return fn(arg); + }; + return { call, byId }; + } + + it("draws a page it cannot draw around the record asked for", () => { + const { call, byId } = drawing(["records"]); + const records = [A_REC, B_REC, C_REC].map((id, i) => ({ + id, + at: `2026-10-0${i + 1}T00:00:00.000Z`, + })); + call("records", { + page: { + status: "unavailable", + why: "not_json", + records, + id: B_REC, + older: A_REC, + newer: C_REC, + }, + }); + const nav = byId.records as Node; + expect(nav.find((n) => n.tag === "a").map((a) => a.attrs.href)).toEqual([ + `/board?record=${A_REC}`, + `/board?record=${C_REC}`, + "/board", + ]); + const chosen = nav.find((n) => n.tag === "option" && n.selected); + expect(chosen.map((o) => o.attrs.value)).toEqual([B_REC]); + call("records", { + page: { + status: "unavailable", + why: "not_found", + records, + id: "NOPE", + older: null, + newer: null, + }, + }); + const none = nav.find((n) => n.tag === "option" && n.selected); + expect(none.map((o) => [o.attrs.value, o.disabled])).toEqual([["", true]]); + }); + + it("marks a lane's marks as reported, and leaves out of the period what was not measured", () => { + const { call } = drawing(["lanes", "effort", "footnotes"]); + const lane = (flags: Record) => ({ + id: "core", + name: "Core", + now: null, + attention: [], + prose: null, + measures: [], + flags: { live: false, blocked: false, unverified: false, ...flags }, + }); + const heads = (flags: Record) => + call("lanes", { lanes: [lane(flags)] }).find((n) => n.tag === "h3"); + expect(heads({ blocked: true })[0]?.find((n) => n.className === "reported")).toHaveLength(1); + expect(heads({})[0]?.find((n) => n.className === "reported")).toHaveLength(0); + const effort = call("effort", { + effort: { + start: "2026-04-28", + time_zone: null, + elapsed_days: null, + active_ms: { union: null, claude: null, codex: null }, + output_tokens: null, + sessions_without_tokens: null, + commits: null, + milestones: [], + }, + }) as Node; + const rows = effort.find((n) => n.tag === "tr").map((r) => r.children.map((c) => c.text())); + expect(rows.slice(0, 2)).toEqual([ + ["Start", "2026-04-28"], + ["Elapsed", "not measured"], + ]); + const foot = call("footnotes", { + footnotes: { + notes: [], + axis: { version: 1, review_needed: null, last_review: null, last_review_known: false }, + model: "m", + not_found: [], + measured_with: { basou: "0", build: null }, + }, + }) as Node; + expect(foot.text()).toContain(boardPageStrings("en").footnotes.reviewUnknown); + expect(foot.text()).not.toContain(boardPageStrings("en").footnotes.noReview); + }); + + it("builds prose of text nodes, with only a span between backticks as a code element", () => { + const document = { + createElement: (tag: string) => new Node(tag), + createTextNode: (text: string) => Object.assign(new Node("#text"), { textContent: text }), + }; + const prose = new Function( + "document", + `${lift("el")}; ${lift("codeSpans")}; ${lift("prose")}; return prose;`, + )(document) as (text: string) => Node; + const node = prose("a `x` c ``"); + expect(node.children.map((c) => [c.tag, c.textContent])).toEqual([ + ["#text", "a "], + ["code", "x"], + ["#text", " c ``"], + ]); + }); + + it("puts text in without markup, a span between backticks as code and nothing else", () => { + expect(BOARD_HTML).not.toContain("innerHTML"); + const codeSpans = new Function(`${lift("codeSpans")}; return codeSpans;`)() as ( + text: string, + ) => { code: boolean; text: string }[]; + expect(codeSpans("run `basou board` now")).toEqual([ + { code: false, text: "run " }, + { code: true, text: "basou board" }, + { code: false, text: " now" }, + ]); + expect(codeSpans("a `b` c `d")).toEqual([ + { code: false, text: "a " }, + { code: true, text: "b" }, + { code: false, text: " c `d" }, + ]); + expect(codeSpans("`x`")).toEqual([{ code: true, text: "x" }]); + expect(codeSpans("`")).toEqual([{ code: false, text: "`" }]); + const fill = new Function(`${lift("fill")}; return fill;`)() as ( + template: string, + values: Record, + ) => string; + expect(fill("{n} of {n} and {m}", { n: 2 })).toBe("2 of 2 and {m}"); + // What a value holds is never filled in itself. + expect(fill("{a} then {b}", { a: "{b}", b: "x" })).toBe("{b} then x"); + expect(codeSpans("x `` y")).toEqual([{ code: false, text: "x `` y" }]); + const num = new Function("S", `${lift("num")}; return num;`)(boardPageStrings("en")) as ( + n: unknown, + ) => string; + expect([num(0.333333), num(1234567), num(null), num("12")]).toEqual([ + "0.3333", + "1,234,567", + "not measured", + "12", + ]); + }); +}); diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index 68712da9..f55d0d07 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { basename, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { assertBasouRootSafe, basouPaths, @@ -17,10 +17,12 @@ import { checkPortfolioSafety, formatSafetyReport } from "../lib/portfolio-safet import { type RemoteUrlResolver, startViewServer, + type ViewBoard, type ViewServerDeps, type ViewServerHandle, type WorkspaceEntry, } from "../lib/view-server.js"; +import { DEFAULT_BOARD_PATH } from "./board.js"; import type { ImportContext } from "./import.js"; const DEFAULT_PORT = 4319; @@ -225,9 +227,26 @@ async function buildSingleDeps(ctx: ViewContext, cwd: string): Promise): Promise { + let own: { visibility?: string | undefined } | undefined; + try { + own = (await readManifest(paths)).repos?.find((repo) => repo.path === "."); + } catch { + own = undefined; + } + if (own?.visibility !== "private") return { why: "no_board" }; + return { recordsDir: join(root, dirname(DEFAULT_BOARD_PATH), "records") }; +} + /** * Portfolio mode: serve several workspaces. Sources are the explicit * `--workspace` flags (resolved against the cwd) or, absent those, diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts new file mode 100644 index 00000000..7de79bff --- /dev/null +++ b/packages/cli/src/lib/board-ui.ts @@ -0,0 +1,447 @@ +/** + * The board page of `basou view`, served verbatim at `GET /board` in single + * mode. It draws one record of the board (`GET /api/board`, or + * `/api/board/` with `?record=`) in eight sections, from the record + * alone, with the fixed strings the server sends in the workspace's language. + * Everything is put in with createElement / textContent (never innerHTML): a + * record's prose is the judge's text, and a script running on this origin + * could call the view's POST routes. Only a span between backticks in prose + * becomes a element, built as a node. The embedded script uses no + * template literals, no backslashes and no backticks (this file is itself a + * template literal). + */ +export const BOARD_HTML = ` + + + + +basou board + + + +
+ +
+
+
+ + + +`; diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index acef15f3..f3f09d16 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -3,6 +3,9 @@ import type { AddressInfo } from "node:net"; import { basename, join, resolve } from "node:path"; import { type BasouPaths, + boardPage, + boardPageStrings, + boardPageUnavailable, computeWorkStats, enumerateApprovals, findErrorCode, @@ -19,11 +22,13 @@ import { readTaskFile, renderDecisions, renderHandoff, + resolveViewLanguageFromPaths, SessionIdSchema, summarizeOrientation, tryRemoteUrl, } from "@basou/core"; import type { ImportContext } from "../commands/import.js"; +import { BOARD_HTML } from "./board-ui.js"; import { warnIfPositionNamesOtherWorkspaces } from "./foreign-workspace-warn.js"; import { importClaudeCode, @@ -59,6 +64,12 @@ export type WorkspaceEntry = { manifestError?: string; }; +/** + * Where the board page reads a board's records from (the `records/` beside + * its board.yaml), or why there is no board to show. + */ +export type ViewBoard = { recordsDir: string } | { why: "no_board" }; + /** Resolve a repo's remote URL live from its local git config; `undefined` when unset. */ export type RemoteUrlResolver = (repoRoot: string) => Promise; @@ -75,6 +86,8 @@ export type ViewServerDeps = { * {@link tryRemoteUrl}; injectable so tests need no real git remotes. */ remoteUrlOf?: RemoteUrlResolver; + /** The board the board page shows: single mode only (the page is not served otherwise). */ + board?: ViewBoard; }; /** A running view server, with the means to stop it. */ @@ -198,6 +211,12 @@ async function handleGet( sendHtml(res, VIEW_HTML); return; } + // Single mode only: a board carries the effort figures the portfolio + // cards never show. + if (pathname === "/board" && deps.mode === "single") { + sendHtml(res, BOARD_HTML); + return; + } if (pathname === "/api/portfolio") { sendJson(res, 200, await portfolio(deps)); return; @@ -209,7 +228,16 @@ async function handleGet( sendError(res, 404, "Unknown workspace"); return; } - if (!(await handleWorkspaceGet(res, scoped.sub, ws, deps.nowProvider, remoteUrlOf(deps)))) { + if ( + !(await handleWorkspaceGet( + res, + scoped.sub, + ws, + deps.nowProvider, + remoteUrlOf(deps), + boardOf(deps), + )) + ) { sendError(res, 404, "Not found"); } return; @@ -223,6 +251,7 @@ async function handleGet( primaryWorkspace(deps), deps.nowProvider, remoteUrlOf(deps), + boardOf(deps), )) ) { sendError(res, 404, "Not found"); @@ -232,6 +261,11 @@ async function handleGet( sendError(res, 404, "Not found"); } +/** The board the board routes serve: none outside single mode. */ +function boardOf(deps: ViewServerDeps): ViewBoard | undefined { + return deps.mode === "single" ? deps.board : undefined; +} + /** The configured live remote-URL resolver, or core's default. */ function remoteUrlOf(deps: ViewServerDeps): RemoteUrlResolver { return deps.remoteUrlOf ?? tryRemoteUrl; @@ -273,7 +307,19 @@ async function handleWorkspaceGet( ws: WorkspaceEntry, nowProvider: () => Date, resolveRemoteUrl: RemoteUrlResolver, + board: ViewBoard | undefined, ): Promise { + if (board !== undefined) { + if (sub === "board") { + sendJson(res, 200, await boardView(ws, board, undefined)); + return true; + } + const recordId = matchId(sub, "board/"); + if (recordId !== null) { + sendJson(res, 200, await boardView(ws, board, recordId)); + return true; + } + } if (sub === "overview") { sendJson(res, 200, await overview(ws, nowProvider, resolveRemoteUrl)); return true; @@ -393,6 +439,22 @@ function matchWsRoute(pathname: string): { key: string; sub: string } | null { // --- handlers ------------------------------------------------------------- +/** + * What the board page draws, with its fixed strings in the language the + * workspace's anchor declares. Read-only: the page is drawn from the records, + * never from the board.yaml as it is now (the manifest gives the language). + */ +async function boardView( + ws: WorkspaceEntry, + board: ViewBoard, + recordId: string | undefined, +): Promise> { + const language = await resolveViewLanguageFromPaths(ws.paths); + const page = + "why" in board ? boardPageUnavailable(board.why) : await boardPage(board.recordsDir, recordId); + return { language, strings: boardPageStrings(language), page }; +} + /** * Aggregate the per-workspace "current position" for the portfolio landing. * Read-only: it runs NO import, so a stale capture is shown as stale (run a diff --git a/packages/cli/src/lib/view-ui.ts b/packages/cli/src/lib/view-ui.ts index 64bea881..9b914279 100644 --- a/packages/cli/src/lib/view-ui.ts +++ b/packages/cli/src/lib/view-ui.ts @@ -74,6 +74,7 @@ export const VIEW_HTML = `

basou view

+ @@ -235,6 +236,8 @@ export const VIEW_HTML = ` state.canAct = true; document.body.classList.remove('landing'); $('btn-back').style.display = 'none'; + // The board page is served in single mode only. + $('board-link').style.display = ''; updateActionBar(); buildTabs(); loadTab('overview'); diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index cdc30d99..731ee48a 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -54,6 +54,13 @@ export type { MeasureBoardInput, } from "./measure.js"; export { boardDigest, measureBoard } from "./measure.js"; +export type { + BoardPage, + BoardPageBody, + BoardPageRecordRef, + BoardPageUnavailable, +} from "./page.js"; +export { boardPage, boardPageUnavailable } from "./page.js"; export type { BoardPortfolio } from "./portfolio.js"; export { BOARD_PORTFOLIO_METHOD } from "./portfolio.js"; export type { diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts new file mode 100644 index 00000000..63bede69 --- /dev/null +++ b/packages/core/src/board/page.test.ts @@ -0,0 +1,407 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { boardPage } from "./page.js"; + +// Names in the order a ULID sorts in, written at 1, 2 and 3 ms after the epoch. +const [A, B, C] = [ + "00000000010000000000000000", + "00000000020000000000000000", + "00000000030000000000000000", +]; + +const STAGES = Object.fromEntries( + ["01", "02", "03", "04", "05", "06"].map((id) => [id, { meaning: `stage ${id}` }]), +); + +function cells(states: Record) { + return Object.entries(states).flatMap(([lane, list]) => + list.map((state, i) => ({ + lane, + stage: `0${i + 1}`, + state, + ...(state === "blocked" || state === "shelved" || state === "unverified" + ? { reason: `why ${lane} 0${i + 1}` } + : {}), + })), + ); +} + +// A record as basou board record writes it, cut to what the page reads and a +// little more. +function record(over: Record = {}) { + return { + record_version: 1, + recorded_at: "2026-10-07T00:00:00.000Z", + recorded_with: { basou: "0.65.0", build: null }, + declaration: { + title: "Test board", + // Out of order, as an object may hold them. + stages: { "06": STAGES["06"], ...STAGES }, + lanes: [ + { id: "core", name: "Core", about: "the core" }, + { id: "docs", name: "Docs", notes: ["a note"] }, + ], + measures: [ + { id: "src", kind: "line_count", unit: "lines", lane: "core" }, + { id: "test", kind: "line_count", unit: "lines" }, + ], + ratios: [{ id: "r", label: "Tests to source", numerator: "test", denominator: "src" }], + components: {}, + axis: { version: 2, review_due_days: 60 }, + effort: { + start: "2026-04-28", + milestones: [{ date: "2026-05-04", label: "Started", ref: "abc1234" }], + }, + }, + measure: { + board_version: 1, + measured_with: { basou: "0.65.0", build: "abcdef0" }, + complete: false, + not_found: [{ at: "measures.test", reason: "no such file" }], + digest: `sha256:${"a".repeat(64)}`, + methods: { repos: 1, trail: 1, integrity: 1, components: 2, axis: 2 }, + measures: { + src: { value: 100, unit: "lines", lane: "core" }, + test: { value: null, unit: "lines" }, + }, + ratios: { r: { value: null, numerator: "test", denominator: "src" } }, + trail: { + decisions_all: 9, + decisions_live: 8, + tracks_open: [{ id: "decision_X", title: "X" }], + }, + integrity: { by_status: { verified: 10, unchained: 2 }, not_verified: 2 }, + effort: { + start: "2026-04-28", + time_zone: "Asia/Tokyo", + elapsed_days: 162, + active_ms: { union: 7_200_000, claude: 7_000_000, codex: null }, + output_tokens: 1000, + sessions_without_tokens: 1, + commits: { ".": 12, "../basou": 34 }, + daily: [], + }, + components: { found: {}, unacknowledged: [], gone: [], kind_changed: null }, + axis: { + version: 2, + review_needed: false, + reasons: [], + unjudged: [], + last_review: { date: "2026-09-28", model: "Claude Opus 5.5", from: "seed" }, + }, + }, + observed: { + npm: { value: "0.65.0", observed_at: "2026-10-07", source: "registry" }, + site: { value: null, observed_at: "2026-10-07", source: "site", error: "timeout" }, + }, + cells: cells({ + core: ["done", "done", "part", "blocked", "none", "none"], + docs: ["done", "done", "done", "done", "done", "done"], + }), + prose: { + summary: "All is `fine`.", + lanes: { core: "Core is moving." }, + operator_turns: [{ text: "Decide X", source: "decision_X" }], + footnotes: ["Measured with care."], + }, + judged_by: { model: "Claude Opus 5.5", self_reported: true }, + axis_review: null, + order_anomalies: [], + ...over, + }; +} + +describe("boardPage", () => { + let dir: string; + let records: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "basou-board-page-")); + records = join(dir, "records"); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + const place = (id: string, value: unknown) => + writeFile( + join(records, `${id}.json`), + typeof value === "string" ? value : JSON.stringify(value), + ); + + it("has nothing to draw with no records, and lists the records it has by when they were written", async () => { + expect(await boardPage(records)).toEqual({ + status: "unavailable", + why: "no_records", + records: [], + id: null, + older: null, + newer: null, + }); + await mkdir(records); + await writeFile(join(records, ".record-1.tmp"), "{"); + expect((await boardPage(records)).status).toBe("unavailable"); + await place(A, record()); + await place(B, record()); + const page = await boardPage(records); + expect(page.records).toEqual([ + { id: A, at: "1970-01-01T00:00:00.001Z" }, + { id: B, at: "1970-01-01T00:00:00.002Z" }, + ]); + expect(page).toMatchObject({ status: "ok", id: B, older: A, newer: null }); + expect(await boardPage(records, A)).toMatchObject({ + status: "ok", + id: A, + older: null, + newer: B, + }); + expect(await boardPage(records, C)).toMatchObject({ + status: "unavailable", + why: "not_found", + id: C, + older: null, + newer: null, + }); + }); + + it("draws the heading, the tiles and the observations from the record", async () => { + await mkdir(records); + await place(A, record()); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const b = page.board; + expect(b.heading).toEqual({ + title: "Test board", + recorded_at: "2026-10-07T00:00:00.000Z", + model: "Claude Opus 5.5", + complete: false, + not_found: 1, + }); + expect(b.summary.text).toBe("All is `fine`."); + expect(b.summary.tiles).toEqual([ + { key: "live_lanes", value: 1, detail: 2, reported: true }, + { key: "blocked", value: 1, reported: true }, + { key: "unverified", value: 0, reported: true }, + { key: "open_tracks", value: 1, reported: false }, + { key: "sessions", value: 12, detail: 2, reported: false }, + { key: "turns", value: 1, reported: true }, + ]); + // A lane in operation is one whose last stage is done, not begun or blocked. + await place( + B, + record({ + cells: cells({ + core: ["done", "done", "done", "done", "done", "part"], + docs: ["done", "done", "done", "done", "done", "blocked"], + }), + }), + ); + const latest = await boardPage(records); + if (latest.status !== "ok") throw new Error(latest.why); + expect(latest.board.summary.tiles[0]).toEqual({ + key: "live_lanes", + value: 0, + detail: 2, + reported: true, + }); + // The first record has no previous one to take a value from. + expect(b.summary.observed).toEqual([ + { name: "npm", value: "0.65.0", observed_at: "2026-10-07", source: "registry" }, + { + name: "site", + value: null, + observed_at: "2026-10-07", + source: "site", + error: "timeout", + previous: { status: "none" }, + }, + ]); + }); + + it("takes an observation not made from the previous record, and marks the cells that moved", async () => { + await mkdir(records); + await place( + A, + record({ + observed: { site: { value: "0.64.0", observed_at: "2026-10-01", source: "site" } }, + cells: cells({ + core: ["done", "part", "none", "none", "none", "none"], + docs: ["done", "done", "done", "done", "done", "done"], + }), + }), + ); + await place(B, record()); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.summary.observed[1]?.previous).toEqual({ status: "value", value: "0.64.0" }); + const core = page.board.matrix.lanes[0]; + expect(core?.cells.map((c) => c.moved_from ?? "")).toEqual([ + "", + "part", + "none", + "none", + "", + "", + ]); + expect(core?.cells[3]).toEqual({ + stage: "04", + state: "blocked", + reason: "why core 04", + moved_from: "none", + }); + // A cell the previous record did not have has not moved, and a value it + // did not observe either is no previous value. + await place( + A, + record({ + observed: { site: { value: null, observed_at: "2026-10-01", source: "site", error: "x" } }, + cells: cells({ core: ["done", "part", "none", "none", "none", "none"] }), + }), + ); + const fresh = await boardPage(records); + if (fresh.status !== "ok") throw new Error(fresh.why); + expect(fresh.board.summary.observed[1]?.previous).toEqual({ status: "none" }); + expect(fresh.board.matrix.lanes[1]?.cells.some((c) => "moved_from" in c)).toBe(false); + expect(fresh.board.matrix.lanes[0]?.cells[1]?.moved_from).toBe("part"); + // With the previous record unreadable, nothing is marked and the value is not known. + await place(A, "{ not json"); + const unread = await boardPage(records); + if (unread.status !== "ok") throw new Error(unread.why); + expect(unread.board.summary.observed[1]?.previous).toEqual({ status: "unreadable" }); + expect(unread.board.matrix.lanes[0]?.cells.some((c) => c.moved_from !== undefined)).toBe(false); + }); + + it("draws the matrix by the stages the record holds, and each lane where it is", async () => { + await mkdir(records); + await place( + A, + record({ order_anomalies: [{ lane: "core", stage: "05", state: "none", before: "06" }] }), + ); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const b = page.board; + expect(b.matrix.stages.map((s) => s.id)).toEqual(["01", "02", "03", "04", "05", "06"]); + expect(b.matrix.anomalies).toEqual([ + { lane: "core", stage: "05", state: "none", before: "06" }, + ]); + expect(b.lanes).toEqual([ + { + id: "core", + name: "Core", + about: "the core", + now: { stage: "03", meaning: "stage 03", state: "part" }, + attention: [{ stage: "04", state: "blocked", reason: "why core 04" }], + prose: "Core is moving.", + measures: [{ id: "src", value: 100, unit: "lines" }], + flags: { live: false, blocked: true, unverified: false }, + }, + { + id: "docs", + name: "Docs", + notes: ["a note"], + now: { stage: "06", meaning: "stage 06", state: "done" }, + attention: [], + prose: null, + measures: [], + flags: { live: true, blocked: false, unverified: false }, + }, + ]); + }); + + it("draws the composition, the effort, the turns and the footnotes from the record", async () => { + await mkdir(records); + await place(A, record()); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const b = page.board; + expect(b.composition).toEqual([ + { + id: "r", + label: "Tests to source", + value: null, + numerator: { id: "test", value: null, unit: "lines" }, + denominator: { id: "src", value: 100, unit: "lines" }, + }, + ]); + expect(b.effort).toEqual({ + start: "2026-04-28", + time_zone: "Asia/Tokyo", + elapsed_days: 162, + active_ms: { union: 7_200_000, claude: 7_000_000, codex: null }, + output_tokens: 1000, + sessions_without_tokens: 1, + commits: [ + { repo: ".", count: 12 }, + { repo: "../basou", count: 34 }, + ], + milestones: [{ date: "2026-05-04", label: "Started", ref: "abc1234" }], + }); + expect(b.turns).toEqual([{ text: "Decide X", source: "decision_X" }]); + expect(b.footnotes).toEqual({ + notes: ["Measured with care."], + axis: { + version: 2, + review_needed: false, + last_review: { date: "2026-09-28", model: "Claude Opus 5.5", from: "seed" }, + last_review_known: true, + }, + model: "Claude Opus 5.5", + not_found: [{ at: "measures.test", reason: "no such file" }], + measured_with: { basou: "0.65.0", build: "abcdef0" }, + }); + expect(b.footnotes.axis.last_review_known).toBe(true); + // A last review a record that could not be read may hold is not known, not none. + const unknown = record(); + unknown.measure.axis.last_review = null as unknown as typeof unknown.measure.axis.last_review; + unknown.measure.not_found = [ + { at: "axis.last_review", reason: "the last review is not known" }, + ]; + await place(A, unknown); + const notKnown = await boardPage(records); + if (notKnown.status !== "ok") throw new Error(notKnown.why); + expect(notKnown.board.footnotes.axis).toMatchObject({ + last_review: null, + last_review_known: false, + }); + }); + + it("does not draw a record it cannot read, of a version it does not know, or not in the shape of one", async () => { + await mkdir(records); + await place(A, "{ not json"); + await place(B, record()); + // The records around the one asked for are still there to go to. + expect(await boardPage(records, A)).toEqual({ + status: "unavailable", + why: "not_json", + records: [ + { id: A, at: "1970-01-01T00:00:00.001Z" }, + { id: B, at: "1970-01-01T00:00:00.002Z" }, + ], + id: A, + older: null, + newer: B, + }); + await place(A, record({ record_version: 2 })); + expect(await boardPage(records, A)).toMatchObject({ + why: "unknown_version", + id: A, + version: 2, + }); + await place(A, record({ prose: { summary: 1 } })); + expect(await boardPage(records, A)).toMatchObject({ why: "not_a_record", id: A }); + await rm(join(records, `${B}.json`)); + // A key the page does not draw is let through. + await place(A, record({ later: { added: true } })); + expect((await boardPage(records)).status).toBe("ok"); + }); + + it("does not read a records/ that is a link", async () => { + const elsewhere = join(dir, "elsewhere"); + await mkdir(elsewhere); + await symlink(elsewhere, records); + expect(await boardPage(records)).toMatchObject({ + status: "unavailable", + why: "records_not_directory", + }); + }); +}); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts new file mode 100644 index 00000000..80b384a6 --- /dev/null +++ b/packages/core/src/board/page.ts @@ -0,0 +1,521 @@ +import { lstat, readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { decodeTime } from "ulid"; +import { z } from "zod"; +import { findErrorCode } from "../lib/error-codes.js"; +import { diffCells } from "./diff.js"; +import { readBoardRecordFile, recordIds } from "./previous.js"; + +// The record versions the page draws. A version is added here, never removed. +const DRAWN_VERSIONS = new Set([1]); + +const text = z.string(); +const count = z.number().nullable(); + +// What the page draws from a record, kept loose: a key it does not draw is +// let through. +const pageRecordSchema = z.looseObject({ + record_version: z.number(), + recorded_at: text, + declaration: z.looseObject({ + title: text, + stages: z.record(text, z.looseObject({ meaning: text })), + lanes: z.array( + z.looseObject({ + id: text, + name: text, + about: text.optional(), + notes: z.array(text).optional(), + }), + ), + measures: z.array(z.looseObject({ id: text, unit: text, lane: text.optional() })), + ratios: z.array(z.looseObject({ id: text, label: text, numerator: text, denominator: text })), + axis: z.looseObject({ version: z.number() }), + effort: z.looseObject({ + milestones: z.array(z.looseObject({ date: text, label: text, ref: text })).optional(), + }), + }), + measure: z.looseObject({ + measured_with: z.looseObject({ basou: text, build: text.nullable() }), + complete: z.boolean(), + not_found: z.array(z.looseObject({ at: text, reason: text })), + measures: z.record( + text, + z.looseObject({ value: z.union([z.number(), text]).nullable(), unit: text }), + ), + ratios: z.record(text, z.looseObject({ value: z.number().nullable() })), + trail: z.looseObject({ + tracks_open: z.array(z.looseObject({ id: text, title: text })).nullable(), + }), + integrity: z.looseObject({ + by_status: z.record(text, z.number()).nullable(), + not_verified: count, + }), + effort: z.looseObject({ + start: text, + time_zone: text.nullable(), + elapsed_days: count, + active_ms: z.looseObject({ union: count, claude: count, codex: count }), + output_tokens: count, + sessions_without_tokens: count, + commits: z.record(text, count).nullable(), + }), + axis: z.looseObject({ + review_needed: z.boolean().nullable(), + last_review: z + .looseObject({ date: text, model: text, from: text, record: text.optional() }) + .nullable(), + }), + }), + observed: z.record( + text, + z.looseObject({ value: z.unknown(), observed_at: text, source: text, error: text.optional() }), + ), + cells: z.array(z.looseObject({ lane: text, stage: text, state: text, reason: text.optional() })), + prose: z.looseObject({ + summary: text, + lanes: z.record(text, text), + operator_turns: z.array(z.looseObject({ text, source: text })), + footnotes: z.array(text), + }), + judged_by: z.looseObject({ model: text }), + order_anomalies: z.array(z.looseObject({ lane: text, stage: text, state: text, before: text })), +}); + +type PageRecord = z.output; + +/** A record the page can open: its ULID, and when it was written (from the ULID). */ +export type BoardPageRecordRef = { id: string; at: string }; + +/** Why there is no board to draw. */ +export type BoardPageUnavailable = + | "no_board" + | "no_records" + | "records_not_directory" + | "records_unreadable" + | "not_found" + | "not_json" + | "unknown_version" + | "not_a_record"; + +/** A value the page shows, and whether the judge reported it rather than basou measured it. */ +export type BoardPageTile = { + key: "live_lanes" | "blocked" | "unverified" | "open_tracks" | "sessions" | "turns"; + value: number | null; + /** The lanes for live_lanes, the sessions not verified for sessions. */ + detail?: number | null; + reported: boolean; +}; + +/** An observation from outside, with the previous record's value when this one has none. */ +export type BoardPageObservation = { + name: string; + value: unknown; + observed_at: string; + source: string; + error?: string; + /** Present when the value is null: the previous record's value of the same name. */ + previous?: BoardPagePrevious; +}; + +/** The previous record's value of an observation: there, none, or not known. */ +export type BoardPagePrevious = + | { status: "value"; value: unknown } + | { status: "none" } + | { status: "unreadable" }; + +export type BoardPageCell = { + stage: string; + state: string; + reason?: string; + /** The state in the previous record, when it was another. */ + moved_from?: string; +}; + +export type BoardPageLane = { + id: string; + name: string; + about?: string; + notes?: string[]; + /** The furthest stage done or begun, null when there is none. */ + now: { stage: string; meaning: string; state: string } | null; + attention: { stage: string; state: string; reason?: string }[]; + prose: string | null; + measures: { id: string; value: number | string | null; unit: string }[]; + flags: { live: boolean; blocked: boolean; unverified: boolean }; +}; + +/** What the board page draws from one record: the eight sections, in order. */ +export type BoardPageBody = { + heading: { + title: string; + recorded_at: string; + model: string; + complete: boolean; + not_found: number; + }; + summary: { text: string; tiles: BoardPageTile[]; observed: BoardPageObservation[] }; + effort: { + start: string; + time_zone: string | null; + elapsed_days: number | null; + active_ms: { union: number | null; claude: number | null; codex: number | null }; + output_tokens: number | null; + sessions_without_tokens: number | null; + commits: { repo: string; count: number | null }[] | null; + milestones: { date: string; label: string; ref: string }[]; + }; + matrix: { + stages: { id: string; meaning: string }[]; + lanes: { id: string; name: string; cells: BoardPageCell[] }[]; + anomalies: { lane: string; stage: string; state: string; before: string }[]; + }; + lanes: BoardPageLane[]; + composition: { + id: string; + label: string; + value: number | null; + numerator: { id: string; value: number | string | null; unit: string | null }; + denominator: { id: string; value: number | string | null; unit: string | null }; + }[]; + turns: { text: string; source: string }[]; + footnotes: { + notes: string[]; + axis: { + version: number; + review_needed: boolean | null; + /** + * Null when there is none on record, or, with `last_review_known` + * false, when a record that may hold it could not be read. + */ + last_review: { date: string; model: string; from: string; record?: string } | null; + last_review_known: boolean; + }; + model: string; + not_found: { at: string; reason: string }[]; + measured_with: { basou: string; build: string | null }; + }; +}; + +/** The board page's data for one record, or why there is none to draw. */ +export type BoardPage = + | { + status: "unavailable"; + why: BoardPageUnavailable; + records: BoardPageRecordRef[]; + /** The record asked for, or the last one, when there was one to ask for. */ + id: string | null; + older: string | null; + newer: string | null; + /** The record_version it does not draw, for unknown_version. */ + version?: number; + } + | { + status: "ok"; + records: BoardPageRecordRef[]; + id: string; + older: string | null; + newer: string | null; + board: BoardPageBody; + }; + +/** Say why there is no board to draw. */ +export function boardPageUnavailable( + why: BoardPageUnavailable, + around: { + records?: BoardPageRecordRef[]; + id?: string | null; + older?: string | null; + newer?: string | null; + version?: number; + } = {}, +): BoardPage { + return { + status: "unavailable", + why, + records: around.records ?? [], + id: around.id ?? null, + older: around.older ?? null, + newer: around.newer ?? null, + ...(around.version === undefined ? {} : { version: around.version }), + }; +} + +/** + * Read what the board page draws for the record named `id` in a records + * directory, or the last one when `id` is not given: only the records, + * never the board.yaml as it is now, so a past record is drawn by the axis it + * was judged by. The record before it marks the cells that moved and gives + * the previous value of an observation not made. + */ +export async function boardPage(recordsDir: string, id?: string): Promise { + let names: string[]; + try { + const entry = await lstat(recordsDir); + if (!entry.isDirectory()) return boardPageUnavailable("records_not_directory"); + names = await readdir(recordsDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) return boardPageUnavailable("no_records"); + return boardPageUnavailable("records_unreadable"); + } + const ids = recordIds(names); + const records = ids.map((name) => ({ id: name, at: new Date(decodeTime(name)).toISOString() })); + if (ids.length === 0) return boardPageUnavailable("no_records", { records }); + const shown = id ?? (ids[ids.length - 1] as string); + const index = ids.indexOf(shown); + if (index === -1) return boardPageUnavailable("not_found", { records, id: shown }); + + const older = index > 0 ? (ids[index - 1] as string) : null; + const newer = ids[index + 1] ?? null; + const read = await readPageRecord(recordsDir, shown); + if (!read.ok) { + return boardPageUnavailable(read.why, { + records, + id: shown, + older, + newer, + ...(read.version === undefined ? {} : { version: read.version }), + }); + } + const previous = older === null ? null : await readBoardRecordFile(recordsDir, older); + return { + status: "ok", + records, + id: shown, + older, + newer, + board: bodyOf( + read.record, + previous === null + ? { status: "none" } + : previous.ok + ? { status: "found", cells: previous.record.cells, observed: previous.record.observed } + : { status: "unreadable" }, + ), + }; +} + +async function readPageRecord( + recordsDir: string, + id: string, +): Promise< + | { ok: true; record: PageRecord } + | { ok: false; why: "not_json" | "unknown_version" | "not_a_record"; version?: number } +> { + let value: unknown; + try { + value = JSON.parse(await readFile(join(recordsDir, `${id}.json`), "utf8")); + } catch { + return { ok: false, why: "not_json" }; + } + const version = (value as { record_version?: unknown } | null)?.record_version; + if (typeof version === "number" && !DRAWN_VERSIONS.has(version)) { + return { ok: false, why: "unknown_version", version }; + } + const parsed = pageRecordSchema.safeParse(value); + if (!parsed.success || !DRAWN_VERSIONS.has(parsed.data.record_version)) { + return { ok: false, why: "not_a_record" }; + } + return { ok: true, record: parsed.data }; +} + +type Previous = + | { status: "none" } + | { status: "unreadable" } + | { + status: "found"; + cells: readonly { lane: string; stage: string; state: string }[]; + observed: Readonly>; + }; + +// The states a cell has when it is done or begun, and when it needs attention. +const REACHED = new Set(["done", "part"]); +const ATTENTION = new Set(["blocked", "shelved", "unverified"]); + +function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { + const d = r.declaration; + const m = r.measure; + const stages = Object.entries(d.stages) + .map(([id, stage]) => ({ id, meaning: stage.meaning })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const cellOf = (lane: string, stage: string) => + r.cells.find((c) => c.lane === lane && c.stage === stage); + const moved = new Map(); + if (previous.status === "found") { + for (const change of diffCells(previous.cells, r.cells)) { + if (change.before !== null && change.after !== null) { + moved.set(`${change.lane}\0${change.stage}`, change.before); + } + } + } + const sessions = + m.integrity.by_status === null + ? null + : Object.values(m.integrity.by_status).reduce((sum, n) => sum + n, 0); + const stateCount = (state: string) => r.cells.filter((c) => c.state === state).length; + const measureOf = (id: string) => ({ + id, + value: m.measures[id]?.value ?? null, + unit: m.measures[id]?.unit ?? null, + }); + + return { + heading: { + title: d.title, + recorded_at: r.recorded_at, + model: r.judged_by.model, + complete: m.complete, + not_found: m.not_found.length, + }, + summary: { + text: r.prose.summary, + tiles: [ + { + key: "live_lanes", + value: d.lanes.filter((lane) => cellOf(lane.id, "06")?.state === "done").length, + detail: d.lanes.length, + reported: true, + }, + { key: "blocked", value: stateCount("blocked"), reported: true }, + { key: "unverified", value: stateCount("unverified"), reported: true }, + { key: "open_tracks", value: m.trail.tracks_open?.length ?? null, reported: false }, + { + key: "sessions", + value: sessions, + detail: m.integrity.not_verified, + reported: false, + }, + { key: "turns", value: r.prose.operator_turns.length, reported: true }, + ], + observed: Object.entries(r.observed).map(([name, o]) => ({ + name, + value: o.value, + observed_at: o.observed_at, + source: o.source, + ...(o.error === undefined ? {} : { error: o.error }), + ...(o.value === null ? { previous: previousValue(previous, name) } : {}), + })), + }, + effort: { + start: m.effort.start, + time_zone: m.effort.time_zone, + elapsed_days: m.effort.elapsed_days, + active_ms: { + union: m.effort.active_ms.union, + claude: m.effort.active_ms.claude, + codex: m.effort.active_ms.codex, + }, + output_tokens: m.effort.output_tokens, + sessions_without_tokens: m.effort.sessions_without_tokens, + commits: + m.effort.commits === null + ? null + : Object.entries(m.effort.commits).map(([repo, n]) => ({ repo, count: n })), + milestones: (d.effort.milestones ?? []).map(({ date, label, ref }) => ({ date, label, ref })), + }, + matrix: { + stages, + lanes: d.lanes.map((lane) => ({ + id: lane.id, + name: lane.name, + cells: stages.map(({ id: stage }) => { + const cell = cellOf(lane.id, stage); + const from = moved.get(`${lane.id}\0${stage}`); + return { + stage, + state: cell?.state ?? "none", + ...(cell?.reason === undefined ? {} : { reason: cell.reason }), + ...(from === undefined ? {} : { moved_from: from }), + }; + }), + })), + anomalies: r.order_anomalies.map(({ lane, stage, state, before }) => ({ + lane, + stage, + state, + before, + })), + }, + lanes: d.lanes.map((lane) => { + const cells = stages.map(({ id }) => cellOf(lane.id, id)); + let now: BoardPageLane["now"] = null; + stages.forEach((stage, i) => { + const state = cells[i]?.state; + if (state !== undefined && REACHED.has(state)) { + now = { stage: stage.id, meaning: stage.meaning, state }; + } + }); + return { + id: lane.id, + name: lane.name, + ...(lane.about === undefined ? {} : { about: lane.about }), + ...(lane.notes === undefined ? {} : { notes: lane.notes }), + now, + attention: cells.flatMap((cell) => + cell === undefined || !ATTENTION.has(cell.state) + ? [] + : [ + { + stage: cell.stage, + state: cell.state, + ...(cell.reason === undefined ? {} : { reason: cell.reason }), + }, + ], + ), + prose: Object.hasOwn(r.prose.lanes, lane.id) ? (r.prose.lanes[lane.id] as string) : null, + measures: d.measures + .filter((measure) => measure.lane === lane.id) + .map((measure) => ({ + id: measure.id, + value: m.measures[measure.id]?.value ?? null, + unit: measure.unit, + })), + flags: { + live: cellOf(lane.id, "06")?.state === "done", + blocked: cells.some((cell) => cell?.state === "blocked"), + unverified: cells.some((cell) => cell?.state === "unverified"), + }, + }; + }), + composition: d.ratios.map((ratio) => ({ + id: ratio.id, + label: ratio.label, + value: m.ratios[ratio.id]?.value ?? null, + numerator: measureOf(ratio.numerator), + denominator: measureOf(ratio.denominator), + })), + turns: r.prose.operator_turns.map(({ text: t, source }) => ({ text: t, source })), + footnotes: { + notes: [...r.prose.footnotes], + axis: { + version: d.axis.version, + review_needed: m.axis.review_needed, + last_review: + m.axis.last_review === null + ? null + : { + date: m.axis.last_review.date, + model: m.axis.last_review.model, + from: m.axis.last_review.from, + ...(m.axis.last_review.record === undefined + ? {} + : { record: m.axis.last_review.record }), + }, + last_review_known: !m.not_found.some((n) => n.at === "axis.last_review"), + }, + model: r.judged_by.model, + not_found: m.not_found.map(({ at, reason }) => ({ at, reason })), + measured_with: { basou: m.measured_with.basou, build: m.measured_with.build }, + }, + }; +} + +// The previous record's value of an observation this record could not make. +function previousValue(previous: Previous, name: string): BoardPagePrevious { + if (previous.status === "unreadable") return { status: "unreadable" }; + if (previous.status === "none" || !Object.hasOwn(previous.observed, name)) { + return { status: "none" }; + } + const value = previous.observed[name]?.value; + return value === null || value === undefined ? { status: "none" } : { status: "value", value }; +} diff --git a/packages/core/src/board/previous.ts b/packages/core/src/board/previous.ts index 76ca891a..fbb68ece 100644 --- a/packages/core/src/board/previous.ts +++ b/packages/core/src/board/previous.ts @@ -58,6 +58,14 @@ export type BoardPreviousRecords = { lastReview: PreviousOutcome<{ id: string; record: ReadBoardRecord }>; }; +/** The names that are records, as their ULIDs, oldest first. */ +export function recordIds(names: readonly string[]): string[] { + return names + .filter((name) => name.endsWith(".json") && isUlidBody(name.slice(0, -".json".length))) + .map((name) => name.slice(0, -".json".length)) + .sort(); +} + /** No record to compare with. */ export const NO_PREVIOUS_RECORDS: BoardPreviousRecords = { last: { status: "none" }, @@ -87,15 +95,12 @@ export async function readPreviousRecords(recordsDir: string): Promise name.endsWith(".json") && isUlidBody(name.slice(0, -".json".length))) - .map((name) => name.slice(0, -".json".length)) - .sort(); + const ids = recordIds(names); let last: BoardPreviousRecords["last"] = { status: "none" }; for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i] as string; - const read = await readRecord(recordsDir, id); + const read = await readBoardRecordFile(recordsDir, id); if (!read.ok) { const unreadable = { status: "unreadable" as const, reason: read.reason }; return { last: last.status === "none" ? unreadable : last, lastReview: unreadable }; @@ -108,7 +113,8 @@ export async function readPreviousRecords(recordsDir: string): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2595d63d..956f0262 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,10 @@ export type { BoardObservation, BoardObservedChange, BoardOrderAnomaly, + BoardPage, + BoardPageBody, + BoardPageRecordRef, + BoardPageUnavailable, BoardPortfolio, BoardPreviousRecords, BoardRatioValue, @@ -148,6 +152,8 @@ export { BOARD_TRAIL_METHOD, BOARD_VERSION, boardDigest, + boardPage, + boardPageUnavailable, buildRecord, byCodePoint, diffCells, @@ -228,8 +234,14 @@ export { } from "./lib/path-sanitizer.js"; export type { SourceRootScope } from "./lib/source-root-scope.js"; export { AGENT_INFRA_DIRS, classifyFilesBySourceRoot } from "./lib/source-root-scope.js"; -export type { PresetStrings, ViewLanguage, ViewStrings } from "./lib/view-strings.js"; +export type { + BoardPageStrings, + PresetStrings, + ViewLanguage, + ViewStrings, +} from "./lib/view-strings.js"; export { + boardPageStrings, presetStrings, resolveAnchorContentLanguage, resolveRepoContentLanguage, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 7ba5e5bd..febae7a2 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -826,3 +826,357 @@ const PRESET_JA: PresetStrings = { ], }, }; + +/** + * The fixed strings of the board page of `basou view`, as plain text so that + * the server can hand them to the page as JSON. A `{name}` in one is filled in + * by the page. What a board declares or a record holds (lane names, the + * meanings of stages, prose) is the user's data and is never translated. + */ +export type BoardPageStrings = { + pageTitle: string; + latest: string; + older: string; + newer: string; + loadFailed: string; + /** Marks what the judge reported, as against what basou measured. */ + reported: string; + heading: { + recordedAt: string; + judgedBy: string; + complete: string; + incomplete: string; + }; + sections: { + summary: string; + effort: string; + matrix: string; + lanes: string; + composition: string; + turns: string; + footnotes: string; + }; + tiles: { + liveLanes: string; + liveLanesOf: string; + blocked: string; + unverified: string; + openTracks: string; + sessions: string; + sessionsNotVerified: string; + turns: string; + notMeasured: string; + }; + observed: { + heading: string; + name: string; + value: string; + observedAt: string; + source: string; + none: string; + missing: string; + missingNoPrevious: string; + missingPreviousUnreadable: string; + }; + effort: { + start: string; + elapsedLabel: string; + elapsed: string; + active: string; + claude: string; + codex: string; + noCodex: string; + outputTokens: string; + withoutTokens: string; + commits: string; + milestones: string; + noMilestones: string; + notMeasured: string; + }; + states: { + done: string; + part: string; + blocked: string; + shelved: string; + none: string; + unverified: string; + }; + matrix: { + lane: string; + moved: string; + anomalies: string; + anomaly: string; + }; + lane: { + now: string; + notStarted: string; + attention: string; + measures: string; + live: string; + blocked: string; + unverified: string; + }; + composition: { none: string }; + turns: { none: string; source: string }; + footnotes: { + axis: string; + lastReview: string; + lastReviewFromRecord: string; + noReview: string; + reviewUnknown: string; + reviewNeeded: string; + reviewNeededYes: string; + reviewNeededNo: string; + reviewNeededUnknown: string; + judgedBy: string; + reportedNote: string; + notMeasured: string; + measuredWith: string; + }; + unavailable: { + noBoard: string; + noRecords: string; + recordsNotDirectory: string; + recordsUnreadable: string; + notFound: string; + notJson: string; + unknownVersion: string; + notARecord: string; + }; +}; + +const BOARD_PAGE_EN: BoardPageStrings = { + pageTitle: "Progress board", + latest: "Latest", + older: "Older record", + newer: "Newer record", + loadFailed: "The board could not be loaded: {message}", + reported: "reported", + heading: { + recordedAt: "Recorded {at}", + judgedBy: "judged by {model}", + complete: "everything was measured", + incomplete: "{n} could not be measured", + }, + sections: { + summary: "Summary", + effort: "Period and effort", + matrix: "Reach matrix", + lanes: "Where each lane is", + composition: "Composition", + turns: "The operator's turns", + footnotes: "Footnotes", + }, + tiles: { + liveLanes: "Lanes in operation", + liveLanesOf: "of {n} lanes", + blocked: "Blocked cells", + unverified: "Unverified cells", + openTracks: "Open tracks", + sessions: "Sessions in the trail", + sessionsNotVerified: "{n} not verified", + turns: "The operator's turns", + notMeasured: "not measured", + }, + observed: { + heading: "Observed outside", + name: "Name", + value: "Value", + observedAt: "Observed", + source: "Source", + none: "Nothing was observed outside.", + missing: "not observed (previous: {value})", + missingNoPrevious: "not observed (no previous value)", + missingPreviousUnreadable: "not observed (the previous record could not be read)", + }, + effort: { + start: "Start", + elapsedLabel: "Elapsed", + elapsed: "{days} days", + active: "Active", + claude: "Claude", + codex: "Codex", + noCodex: "no Codex session", + outputTokens: "Output tokens", + withoutTokens: "imports that recorded no tokens: {n}", + commits: "Commits", + milestones: "Milestones", + noMilestones: "No milestones declared.", + notMeasured: "not measured", + }, + states: { + done: "done", + part: "partly", + blocked: "blocked", + shelved: "shelved", + none: "not started", + unverified: "unverified", + }, + matrix: { + lane: "Lane", + moved: "was {state} in the previous record", + anomalies: "Out of order", + anomaly: "{lane} {stage} is {state} before {before}, which is done or begun", + }, + lane: { + now: "Now at {stage} {meaning} ({state})", + notStarted: "No stage done or begun", + attention: "Blocked, shelved or unverified", + measures: "Measured", + live: "in operation", + blocked: "blocked", + unverified: "has unverified cells", + }, + composition: { none: "No ratios are declared." }, + turns: { none: "Nothing is waiting on the operator.", source: "source: {source}" }, + footnotes: { + axis: "Axis v{version}", + lastReview: "last reviewed {date} by {model} (declared)", + lastReviewFromRecord: "last reviewed {date} by {model} (record {record})", + noReview: "no review on record", + reviewUnknown: "the last review is not known (a record that may hold it could not be read)", + reviewNeeded: "Review needed: {answer}", + reviewNeededYes: "yes", + reviewNeededNo: "no", + reviewNeededUnknown: "not known", + judgedBy: "The cells and prose were written by {model}, as it reported itself.", + reportedNote: + "Marked reported: what the judge wrote (cells, prose and observations outside). Everything else was measured by basou.", + notMeasured: "Not measured", + measuredWith: "Measured with basou {basou}{build}.", + }, + unavailable: { + noBoard: + "The board page shows the records beside board/board.yaml in the workspace, which is read only when the manifest declares this workspace's own repo (path: .) private.", + noRecords: + "The board has no record yet. Declare the board in board/board.yaml and write a record with basou board record; it is drawn here.", + recordsNotDirectory: + "The records/ beside board/board.yaml is not a directory (a symlink or a file).", + recordsUnreadable: "The records/ beside board/board.yaml could not be read.", + notFound: "There is no record {record}.", + notJson: "The record {record} could not be read as JSON.", + unknownVersion: + "The record {record} is of record_version {version}, which this basou does not draw.", + notARecord: "The record {record} is not in the shape of a record.", + }, +}; + +const BOARD_PAGE_JA: BoardPageStrings = { + pageTitle: "進捗盤", + latest: "最新", + older: "前の記録", + newer: "次の記録", + loadFailed: "盤を読み込めませんでした: {message}", + reported: "申告", + heading: { + recordedAt: "{at} に記録", + judgedBy: "判定 {model}", + complete: "すべて測れた", + incomplete: "{n} 件が測れなかった", + }, + sections: { + summary: "サマリー", + effort: "期間と労力", + matrix: "到達マトリクス", + lanes: "レーンごとの現在地", + composition: "構成比", + turns: "operator の手番", + footnotes: "脚注", + }, + tiles: { + liveLanes: "稼働に届いたレーン", + liveLanesOf: "{n} レーン中", + blocked: "止まっているセル", + unverified: "未確認のセル", + openTracks: "未完トラック", + sessions: "証跡の session", + sessionsNotVerified: "verified でないもの {n}", + turns: "operator の手番", + notMeasured: "測れなかった", + }, + observed: { + heading: "外の観測", + name: "項目", + value: "値", + observedAt: "観測", + source: "出所", + none: "外の観測はない。", + missing: "未確認(前回値: {value})", + missingNoPrevious: "未確認(前回値なし)", + missingPreviousUnreadable: "未確認(前回の記録が読めない)", + }, + effort: { + start: "開始", + elapsedLabel: "経過", + elapsed: "{days} 日", + active: "実働", + claude: "Claude", + codex: "Codex", + noCodex: "Codex の session なし", + outputTokens: "出力トークン", + withoutTokens: "トークンを記録しなかった取り込み {n}", + commits: "commit", + milestones: "節目", + noMilestones: "節目の宣言はない。", + notMeasured: "測れなかった", + }, + states: { + done: "到達", + part: "部分到達", + blocked: "止まっている", + shelved: "棚上げ", + none: "未着手", + unverified: "未確認", + }, + matrix: { + lane: "レーン", + moved: "前回は {state}", + anomalies: "順序の破れ", + anomaly: "{lane} の {stage} は {state} なのに、後の {before} は到達か部分到達", + }, + lane: { + now: "いまの段 {stage} {meaning}({state})", + notStarted: "到達・部分到達の段はない", + attention: "止まり・棚上げ・未確認", + measures: "測った値", + live: "稼働", + blocked: "止まっている", + unverified: "未確認あり", + }, + composition: { none: "構成比の宣言はない。" }, + turns: { none: "operator の手番はない。", source: "出所: {source}" }, + footnotes: { + axis: "軸 v{version}", + lastReview: "最終見直し {date}・{model}(宣言)", + lastReviewFromRecord: "最終見直し {date}・{model}(記録 {record})", + noReview: "見直しの記録なし", + reviewUnknown: "前回の見直しは分からない(それを含みうる記録が読めない)", + reviewNeeded: "見直しの要否: {answer}", + reviewNeededYes: "要", + reviewNeededNo: "不要", + reviewNeededUnknown: "分からない", + judgedBy: "セルと文章を書いたのは {model}(自己申告)。", + reportedNote: + "「申告」の印は、判定したモデルが書いたもの(セル・文章・外の観測)。それ以外は basou が測った値。", + notMeasured: "測れなかったもの", + measuredWith: "測った basou {basou}{build}。", + }, + unavailable: { + noBoard: + "盤のページは workspace の board/board.yaml の隣の記録を描く。board/board.yaml を読むのは、manifest がこの workspace の repo(path: .)を private と宣言しているときだけ。", + noRecords: + "盤の記録はまだない。board/board.yaml に盤を宣言し、basou board record で記録を書くと、ここに描かれる。", + recordsNotDirectory: + "board/board.yaml の隣の records/ がディレクトリでない(symlink かファイル)。", + recordsUnreadable: "board/board.yaml の隣の records/ を読めない。", + notFound: "記録 {record} はない。", + notJson: "記録 {record} を JSON として読めない。", + unknownVersion: "記録 {record} は record_version {version} で、この basou はこの版を描けない。", + notARecord: "記録 {record} は記録の形をしていない。", + }, +}; + +/** The fixed strings of the board page for a resolved view language. */ +export function boardPageStrings(language: ViewLanguage): BoardPageStrings { + return language === "ja" ? BOARD_PAGE_JA : BOARD_PAGE_EN; +}