From 0e6dd19a591aefc9c46657c8cc9c327e127a5140 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 18:22:00 +0900 Subject: [PATCH 1/4] basou: show the experimental board at /board in basou view In single mode, basou view now serves a board page at /board and its data at /api/board (and /api/ws//board), the latest record or, with /board/, another. 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, in eight sections: the heading, a summary with six counts 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, where each lane is, the ratios, the operator's turns, and footnotes. What the judge reported is marked as such, and the fixed strings come in the language the anchor declares, from a table beside the other view strings. Everything is put in with textContent; only a span between backticks in prose becomes a code element. The page reads the records and nothing else, takes no new flag (the command is guaranteed and the board is experimental), and is not served in portfolio mode. A link in the single-mode header leads to it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 + packages/cli/src/commands/view.test.ts | 231 +++++++++++- packages/cli/src/commands/view.ts | 20 +- packages/cli/src/lib/board-ui.ts | 422 +++++++++++++++++++++ packages/cli/src/lib/view-server.ts | 65 +++- packages/cli/src/lib/view-ui.ts | 3 + packages/core/src/board/index.ts | 7 + packages/core/src/board/page.test.ts | 346 ++++++++++++++++++ packages/core/src/board/page.ts | 485 +++++++++++++++++++++++++ packages/core/src/board/previous.ts | 18 +- packages/core/src/index.ts | 14 +- packages/core/src/lib/view-strings.ts | 334 +++++++++++++++++ 12 files changed, 1951 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/lib/board-ui.ts create mode 100644 packages/core/src/board/page.test.ts create mode 100644 packages/core/src/board/page.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34034459..14a27014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,22 @@ 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 reads the records and nothing else, 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..f9d00520 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,221 @@ describe("the view page shows a recorded file name with its control characters e ); }); }); + +describe("basou view: the board page", () => { + 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); + 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", () => { + expect(VIEW_HTML).toContain(''); + expect(VIEW_HTML).toContain("$('board-link').style.display = '';"); + }); + + /** Lift a named function out of the board page. */ + function lift(name: string): string { + const marker = `function ${name}(`; + const start = BOARD_HTML.indexOf(marker); + expect(start).toBeGreaterThan(-1); + let depth = 0; + for (let i = BOARD_HTML.indexOf("{", start); i < BOARD_HTML.length; i++) { + const ch = BOARD_HTML[i]; + if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) return BOARD_HTML.slice(start, i + 1); + } + throw new Error(`no end of ${name}`); + } + + 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}"); + }); +}); diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index 68712da9..6ecb4993 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,25 @@ 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..fd9f6910 --- /dev/null +++ b/packages/cli/src/lib/board-ui.ts @@ -0,0 +1,422 @@ +/** + * 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..afc9622f 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,23 @@ 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: only the records are read. + */ +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..2be28684 --- /dev/null +++ b/packages/core/src/board/page.test.ts @@ -0,0 +1,346 @@ +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", + detail: "", + records: [], + }); + 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", + detail: C, + }); + }); + + 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.detail); + 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 }, + ]); + // 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.detail); + 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", + }); + // 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.detail); + 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.detail); + 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.detail); + 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" }, + }, + model: "Claude Opus 5.5", + not_found: [{ at: "measures.test", reason: "no such file" }], + measured_with: { basou: "0.65.0", build: "abcdef0" }, + }); + }); + + 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"); + expect(await boardPage(records)).toMatchObject({ + status: "unavailable", + why: "unreadable", + detail: `the record ${A} could not be read as JSON`, + }); + await place(A, record({ record_version: 2 })); + expect(await boardPage(records)).toMatchObject({ + why: "unreadable", + detail: `the record ${A} is of record_version 2, which this basou does not draw`, + }); + await place(A, record({ prose: { summary: 1 } })); + expect(await boardPage(records)).toMatchObject({ + why: "unreadable", + detail: `the record ${A} is not in the shape of a record`, + }); + // 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: "unreadable" }); + }); +}); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts new file mode 100644 index 00000000..ecb59a1c --- /dev/null +++ b/packages/core/src/board/page.ts @@ -0,0 +1,485 @@ +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" | "not_found" | "unreadable"; + +/** 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; + last_review: { date: string; model: string; from: string; record?: string } | null; + }; + 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; + detail: string; + records: BoardPageRecordRef[]; + } + | { + 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, + detail: string, + records: BoardPageRecordRef[] = [], +): BoardPage { + return { status: "unavailable", why, detail, records }; +} + +/** + * 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( + "unreadable", + "the records/ beside the board is not a directory (a symlink or a file)", + ); + } + names = await readdir(recordsDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) return boardPageUnavailable("no_records", ""); + return boardPageUnavailable("unreadable", "the records/ beside the board could not be read"); + } + 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", shown, records); + + const read = await readPageRecord(recordsDir, shown); + if (!read.ok) return boardPageUnavailable("unreadable", read.reason, records); + const older = index > 0 ? (ids[index - 1] as string) : null; + const previous = older === null ? null : await readBoardRecordFile(recordsDir, older); + return { + status: "ok", + records, + id: shown, + older, + newer: ids[index + 1] ?? null, + 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; reason: string }> { + let value: unknown; + try { + value = JSON.parse(await readFile(join(recordsDir, `${id}.json`), "utf8")); + } catch { + return { ok: false, reason: `the record ${id} could not be read as JSON` }; + } + const version = (value as { record_version?: unknown } | null)?.record_version; + if (typeof version === "number" && !DRAWN_VERSIONS.has(version)) { + return { + ok: false, + reason: `the record ${id} is of record_version ${version}, which this basou does not draw`, + }; + } + const parsed = pageRecordSchema.safeParse(value); + if (!parsed.success || !DRAWN_VERSIONS.has(parsed.data.record_version)) { + return { ok: false, reason: `the record ${id} is not in the shape of 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 }), + }, + }, + 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..8f73fa39 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -826,3 +826,337 @@ 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; + loading: 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; + 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; + reviewNeeded: string; + reviewNeededYes: string; + reviewNeededNo: string; + reviewNeededUnknown: string; + judgedBy: string; + reportedNote: string; + notMeasured: string; + measuredWith: string; + }; + unavailable: { + noBoard: string; + noRecords: string; + notFound: string; + unreadable: string; + }; +}; + +const BOARD_PAGE_EN: BoardPageStrings = { + pageTitle: "Progress board", + latest: "Latest", + older: "Older record", + newer: "Newer record", + loading: "Loading the board...", + 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: "From {date} ({zone}), {days} days", + elapsed: "{days} days", + active: "Active", + claude: "Claude", + codex: "Codex", + noCodex: "no Codex session", + outputTokens: "Output tokens", + withoutTokens: "{n} sessions recorded none", + 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", + 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. Run basou board record to write the first one.", + notFound: "There is no record {detail}.", + unreadable: "The record could not be shown: {detail}", + }, +}; + +const BOARD_PAGE_JA: BoardPageStrings = { + pageTitle: "進捗盤", + latest: "最新", + older: "前の記録", + newer: "次の記録", + loading: "盤を読み込んでいます…", + 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: "{date} から({zone}){days} 日", + elapsed: "{days} 日", + active: "実働", + claude: "Claude", + codex: "Codex", + noCodex: "Codex の session なし", + outputTokens: "出力トークン", + withoutTokens: "記録の無い session {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: "見直しの記録なし", + 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: "盤の記録がまだない。basou board record で最初の記録を書く。", + notFound: "記録 {detail} はない。", + unreadable: "記録を描けない: {detail}", + }, +}; + +/** 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; +} From f60fabfe0234fc166349c988e2b1bf3da54f0ba5 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 18:28:01 +0900 Subject: [PATCH 2/4] test(board): hold the page to new cells, lanes not in operation, absent previous values and the anchor's language Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/view.test.ts | 2 ++ packages/core/src/board/page.test.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index f9d00520..04fc443b 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1072,6 +1072,8 @@ describe("basou view: the board page", () => { 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"), diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 2be28684..a20ee873 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -182,6 +182,24 @@ describe("boardPage", () => { { 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.detail); + 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" }, @@ -227,6 +245,20 @@ describe("boardPage", () => { 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.detail); + 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); From fb43f52e4018e8bbc2cc7e4448f335434c3c8229 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 19:01:38 +0900 Subject: [PATCH 3/4] fix(board): say in the page's language why a record is not drawn, and fill each string once The board page now gets why a record is not drawn as a kind with the record's id and version, and words it from the string table, so a page in Japanese no longer shows an English reason. A last review that a record that could not be read may hold is shown as not known, not as none. Each fixed string is filled in one pass, so what a stage's meaning or a model's name holds is shown as written; a lane's marks, counted from the cells, are marked as reported; the period leaves out what was not measured; a page that cannot be drawn still links to the records around the one asked for; and a pair of backticks with nothing between stays as written. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- packages/cli/src/commands/view.test.ts | 198 ++++++++++++++++++++++++- packages/cli/src/commands/view.ts | 3 +- packages/cli/src/lib/board-ui.ts | 79 ++++++---- packages/cli/src/lib/view-server.ts | 7 +- packages/core/src/board/page.test.ts | 69 ++++++--- packages/core/src/board/page.ts | 86 +++++++---- packages/core/src/lib/view-strings.ts | 48 ++++-- 8 files changed, 394 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a27014..bf7c6f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,8 +163,9 @@ All notable changes to **basou** are recorded here. The project follows 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 reads the records and nothing else, takes no new flag, and is not - served in portfolio mode. Like every page of `basou view`, it is a local + 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 diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 04fc443b..b52c5c85 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -961,6 +961,11 @@ 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}` }]), ); @@ -1136,24 +1141,198 @@ describe("basou view: the board page", () => { expect(view?.options.map((o) => o.long)).not.toContain("--board"); }); - it("links to the page from the single-mode header", () => { + it("links to the page from the single-mode header, and only there", () => { expect(VIEW_HTML).toContain(''); - expect(VIEW_HTML).toContain("$('board-link').style.display = '';"); + 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 the board page. */ - function lift(name: string): string { + /** Lift a named function out of a page. */ + function liftFrom(html: string, name: string): string { const marker = `function ${name}(`; - const start = BOARD_HTML.indexOf(marker); + const start = html.indexOf(marker); expect(start).toBeGreaterThan(-1); let depth = 0; - for (let i = BOARD_HTML.indexOf("{", start); i < BOARD_HTML.length; i++) { - const ch = BOARD_HTML[i]; + for (let i = html.indexOf("{", start); i < html.length; i++) { + const ch = html[i]; if (ch === "{") depth++; - else if (ch === "}" && --depth === 0) return BOARD_HTML.slice(start, i + 1); + 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"); @@ -1177,5 +1356,8 @@ describe("basou view: the board page", () => { 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" }]); }); }); diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index 6ecb4993..f55d0d07 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -234,7 +234,8 @@ async function buildSingleDeps(ctx: ViewContext, cwd: string): Promise): Promise { let own: { visibility?: string | undefined } | undefined; try { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index fd9f6910..7de79bff 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -93,29 +93,42 @@ export const BOARD_HTML = ` }); return node; } - // Fill each {name} of a fixed string. A test lifts this function. + // Fill each {name} of a fixed string, in one pass over the fixed string, so + // that what a value holds is never filled in itself. A test lifts this function. function fill(template, values) { - var out = String(template); - Object.keys(values || {}).forEach(function (k) { - out = out.split('{' + k + '}').join(String(values[k])); - }); + var t = String(template); + var given = values || {}; + var out = ''; + var i = 0; + while (i < t.length) { + var open = t.indexOf('{', i); + var close = open === -1 ? -1 : t.indexOf('}', open + 1); + if (close === -1) { out += t.slice(i); break; } + var key = t.slice(open + 1, close); + out += t.slice(i, open); + out += Object.prototype.hasOwnProperty.call(given, key) ? String(given[key]) : t.slice(open, close + 1); + i = close + 1; + } return out; } // Split prose into text and the spans between backticks, which become code. - // An unmatched backtick stays as it is. A test lifts this function. + // An unmatched backtick, and a pair with nothing between, stay as written. + // A test lifts this function. function codeSpans(text) { var tick = String.fromCharCode(96); var parts = String(text).split(tick); var out = []; + var plain = function (s) { + if (s === '') return; + var last = out.length > 0 ? out[out.length - 1] : null; + if (last !== null && !last.code) last.text += s; + else out.push({ code: false, text: s }); + }; for (var i = 0; i < parts.length; i++) { - if (i % 2 === 1 && i === parts.length - 1) { - var last = out.length > 0 ? out[out.length - 1] : null; - if (last !== null && !last.code) last.text += tick + parts[i]; - else out.push({ code: false, text: tick + parts[i] }); - continue; - } - if (parts[i] === '') continue; - out.push({ code: i % 2 === 1, text: parts[i] }); + if (i % 2 === 0) plain(parts[i]); + else if (i === parts.length - 1) plain(tick + parts[i]); + else if (parts[i] === '') plain(tick + tick); + else out.push({ code: true, text: parts[i] }); } return out; } @@ -130,7 +143,7 @@ export const BOARD_HTML = ` function num(n) { if (n === null || n === undefined) return S.tiles.notMeasured; if (typeof n !== 'number') return String(n); - return (Math.round(n * 10000) / 10000).toLocaleString('en-US'); + return n.toLocaleString('en-US', { maximumFractionDigits: 4 }); } function hm(ms) { if (ms === null || ms === undefined) return S.effort.notMeasured; @@ -168,15 +181,23 @@ export const BOARD_HTML = ` var link = function (id, label) { return el('a', { href: '/board?record=' + encodeURIComponent(id), text: label }); }; - if (page.status === 'ok' && page.older) nav.appendChild(link(page.older, S.older)); - if (page.status === 'ok' && page.newer) nav.appendChild(link(page.newer, S.newer)); + // Around the record asked for, whether or not it could be drawn. + if (page.older) nav.appendChild(link(page.older, S.older)); + if (page.newer) nav.appendChild(link(page.newer, S.newer)); nav.appendChild(el('a', { href: '/board', text: S.latest })); var select = el('select', { onchange: function () { location.href = '/board?record=' + encodeURIComponent(select.value); } }); + var listed = list.some(function (r) { return r.id === page.id; }); + if (!listed) { + var none = el('option', { value: '', text: '-' }); + none.selected = true; + none.disabled = true; + select.appendChild(none); + } list.slice().reverse().forEach(function (r) { var option = el('option', { value: r.id, text: when(r.at) + ' ' + r.id }); - if (page.status === 'ok' && r.id === page.id) option.selected = true; + if (r.id === page.id) option.selected = true; select.appendChild(option); }); nav.appendChild(select); @@ -251,10 +272,8 @@ export const BOARD_HTML = ` var e = b.effort; var rows = []; var row = function (k, v) { rows.push(el('tr', null, [el('td', { class: 'muted', text: k }), el('td', { text: v })])); }; - row('', fill(S.effort.start, { - date: e.start, zone: e.time_zone === null ? S.effort.notMeasured : e.time_zone, - days: e.elapsed_days === null ? S.effort.notMeasured : e.elapsed_days - })); + row(S.effort.start, e.start + (e.time_zone === null ? '' : ' (' + e.time_zone + ')')); + row(S.effort.elapsedLabel, e.elapsed_days === null ? S.effort.notMeasured : fill(S.effort.elapsed, { days: e.elapsed_days })); row(S.effort.active, hm(e.active_ms.union)); row(S.effort.claude, hm(e.active_ms.claude)); row(S.effort.codex, e.active_ms.codex === null && e.active_ms.union !== null ? S.effort.noCodex : hm(e.active_ms.codex)); @@ -310,6 +329,8 @@ export const BOARD_HTML = ` if (lane.flags.live) head.appendChild(el('span', { class: 'chip live', text: S.lane.live })); if (lane.flags.blocked) head.appendChild(el('span', { class: 'chip blocked', text: S.lane.blocked })); if (lane.flags.unverified) head.appendChild(el('span', { class: 'chip unverified', text: S.lane.unverified })); + // The marks are counted from the cells, which the judge reported. + if (lane.flags.live || lane.flags.blocked || lane.flags.unverified) head.appendChild(reported()); var body = [head]; if (lane.about) body.push(el('div', { class: 'muted', text: lane.about })); body.push(el('div', null, [ @@ -362,7 +383,8 @@ export const BOARD_HTML = ` var f = b.footnotes; var items = f.notes.map(function (n) { return el('li', null, [prose(n, 'span'), reported()]); }); var last = f.axis.last_review; - var review = last === null ? S.footnotes.noReview + var review = !f.axis.last_review_known ? S.footnotes.reviewUnknown + : last === null ? S.footnotes.noReview : last.from === 'record' ? fill(S.footnotes.lastReviewFromRecord, { date: last.date, model: last.model, record: last.record || '' }) : fill(S.footnotes.lastReview, { date: last.date, model: last.model }); var answer = f.axis.review_needed === true ? S.footnotes.reviewNeededYes @@ -391,11 +413,14 @@ export const BOARD_HTML = ` var page = d.page; if (page.status !== 'ok') { var messages = { - no_board: S.unavailable.noBoard, - no_records: S.unavailable.noRecords, not_found: S.unavailable.notFound, - unreadable: S.unavailable.unreadable + no_board: S.unavailable.noBoard, no_records: S.unavailable.noRecords, + records_not_directory: S.unavailable.recordsNotDirectory, + records_unreadable: S.unavailable.recordsUnreadable, + not_found: S.unavailable.notFound, not_json: S.unavailable.notJson, + unknown_version: S.unavailable.unknownVersion, not_a_record: S.unavailable.notARecord }; - setStatus(fill(messages[page.why] || page.why, { detail: page.detail }), page.why === 'unreadable'); + var quiet = page.why === 'no_board' || page.why === 'no_records'; + setStatus(fill(messages[page.why] || page.why, { record: page.id || '', version: page.version }), !quiet); return; } setStatus('', false); diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index afc9622f..f3f09d16 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -441,7 +441,8 @@ function matchWsRoute(pathname: string): { key: string; sub: string } | null { /** * What the board page draws, with its fixed strings in the language the - * workspace's anchor declares. Read-only: only the records are read. + * 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, @@ -450,9 +451,7 @@ async function boardView( ): Promise> { const language = await resolveViewLanguageFromPaths(ws.paths); const page = - "why" in board - ? boardPageUnavailable(board.why, "") - : await boardPage(board.recordsDir, recordId); + "why" in board ? boardPageUnavailable(board.why) : await boardPage(board.recordsDir, recordId); return { language, strings: boardPageStrings(language), page }; } diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index a20ee873..63bede69 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -133,8 +133,10 @@ describe("boardPage", () => { expect(await boardPage(records)).toEqual({ status: "unavailable", why: "no_records", - detail: "", records: [], + id: null, + older: null, + newer: null, }); await mkdir(records); await writeFile(join(records, ".record-1.tmp"), "{"); @@ -156,7 +158,9 @@ describe("boardPage", () => { expect(await boardPage(records, C)).toMatchObject({ status: "unavailable", why: "not_found", - detail: C, + id: C, + older: null, + newer: null, }); }); @@ -164,7 +168,7 @@ describe("boardPage", () => { await mkdir(records); await place(A, record()); const page = await boardPage(records); - if (page.status !== "ok") throw new Error(page.detail); + if (page.status !== "ok") throw new Error(page.why); const b = page.board; expect(b.heading).toEqual({ title: "Test board", @@ -193,7 +197,7 @@ describe("boardPage", () => { }), ); const latest = await boardPage(records); - if (latest.status !== "ok") throw new Error(latest.detail); + if (latest.status !== "ok") throw new Error(latest.why); expect(latest.board.summary.tiles[0]).toEqual({ key: "live_lanes", value: 0, @@ -228,7 +232,7 @@ describe("boardPage", () => { ); await place(B, record()); const page = await boardPage(records); - if (page.status !== "ok") throw new Error(page.detail); + 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([ @@ -255,14 +259,14 @@ describe("boardPage", () => { }), ); const fresh = await boardPage(records); - if (fresh.status !== "ok") throw new Error(fresh.detail); + 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.detail); + 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); }); @@ -274,7 +278,7 @@ describe("boardPage", () => { record({ order_anomalies: [{ lane: "core", stage: "05", state: "none", before: "06" }] }), ); const page = await boardPage(records); - if (page.status !== "ok") throw new Error(page.detail); + 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([ @@ -308,7 +312,7 @@ describe("boardPage", () => { await mkdir(records); await place(A, record()); const page = await boardPage(records); - if (page.status !== "ok") throw new Error(page.detail); + if (page.status !== "ok") throw new Error(page.why); const b = page.board; expect(b.composition).toEqual([ { @@ -339,31 +343,53 @@ describe("boardPage", () => { 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"); - expect(await boardPage(records)).toMatchObject({ + 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: "unreadable", - detail: `the record ${A} could not be read as JSON`, + 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)).toMatchObject({ - why: "unreadable", - detail: `the record ${A} is of record_version 2, which this basou does not draw`, + expect(await boardPage(records, A)).toMatchObject({ + why: "unknown_version", + id: A, + version: 2, }); await place(A, record({ prose: { summary: 1 } })); - expect(await boardPage(records)).toMatchObject({ - why: "unreadable", - detail: `the record ${A} is not in the shape of a record`, - }); + 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"); @@ -373,6 +399,9 @@ describe("boardPage", () => { const elsewhere = join(dir, "elsewhere"); await mkdir(elsewhere); await symlink(elsewhere, records); - expect(await boardPage(records)).toMatchObject({ status: "unavailable", why: "unreadable" }); + 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 index ecb59a1c..80b384a6 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -88,7 +88,15 @@ type PageRecord = z.output; export type BoardPageRecordRef = { id: string; at: string }; /** Why there is no board to draw. */ -export type BoardPageUnavailable = "no_board" | "no_records" | "not_found" | "unreadable"; +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 = { @@ -176,7 +184,12 @@ export type BoardPageBody = { 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 }[]; @@ -189,8 +202,13 @@ export type BoardPage = | { status: "unavailable"; why: BoardPageUnavailable; - detail: string; 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"; @@ -204,10 +222,23 @@ export type BoardPage = /** Say why there is no board to draw. */ export function boardPageUnavailable( why: BoardPageUnavailable, - detail: string, - records: BoardPageRecordRef[] = [], + around: { + records?: BoardPageRecordRef[]; + id?: string | null; + older?: string | null; + newer?: string | null; + version?: number; + } = {}, ): BoardPage { - return { status: "unavailable", why, detail, records }; + 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 }), + }; } /** @@ -221,34 +252,38 @@ export async function boardPage(recordsDir: string, id?: string): Promise ({ id: name, at: new Date(decodeTime(name)).toISOString() })); - if (ids.length === 0) return boardPageUnavailable("no_records", "", records); + 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", shown, records); + if (index === -1) return boardPageUnavailable("not_found", { records, id: shown }); - const read = await readPageRecord(recordsDir, shown); - if (!read.ok) return boardPageUnavailable("unreadable", read.reason, records); 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: ids[index + 1] ?? null, + newer, board: bodyOf( read.record, previous === null @@ -263,23 +298,23 @@ export async function boardPage(recordsDir: string, id?: string): Promise { +): 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, reason: `the record ${id} could not be read as JSON` }; + 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, - reason: `the record ${id} is of record_version ${version}, which this basou does not draw`, - }; + 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, reason: `the record ${id} is not in the shape of a record` }; + return { ok: false, why: "not_a_record" }; } return { ok: true, record: parsed.data }; } @@ -466,6 +501,7 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ? {} : { 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 })), diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 8f73fa39..febae7a2 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -838,7 +838,6 @@ export type BoardPageStrings = { latest: string; older: string; newer: string; - loading: string; loadFailed: string; /** Marks what the judge reported, as against what basou measured. */ reported: string; @@ -881,6 +880,7 @@ export type BoardPageStrings = { }; effort: { start: string; + elapsedLabel: string; elapsed: string; active: string; claude: string; @@ -923,6 +923,7 @@ export type BoardPageStrings = { lastReview: string; lastReviewFromRecord: string; noReview: string; + reviewUnknown: string; reviewNeeded: string; reviewNeededYes: string; reviewNeededNo: string; @@ -935,8 +936,12 @@ export type BoardPageStrings = { unavailable: { noBoard: string; noRecords: string; + recordsNotDirectory: string; + recordsUnreadable: string; notFound: string; - unreadable: string; + notJson: string; + unknownVersion: string; + notARecord: string; }; }; @@ -945,7 +950,6 @@ const BOARD_PAGE_EN: BoardPageStrings = { latest: "Latest", older: "Older record", newer: "Newer record", - loading: "Loading the board...", loadFailed: "The board could not be loaded: {message}", reported: "reported", heading: { @@ -986,14 +990,15 @@ const BOARD_PAGE_EN: BoardPageStrings = { missingPreviousUnreadable: "not observed (the previous record could not be read)", }, effort: { - start: "From {date} ({zone}), {days} days", + start: "Start", + elapsedLabel: "Elapsed", elapsed: "{days} days", active: "Active", claude: "Claude", codex: "Codex", noCodex: "no Codex session", outputTokens: "Output tokens", - withoutTokens: "{n} sessions recorded none", + withoutTokens: "imports that recorded no tokens: {n}", commits: "Commits", milestones: "Milestones", noMilestones: "No milestones declared.", @@ -1029,6 +1034,7 @@ const BOARD_PAGE_EN: BoardPageStrings = { 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", @@ -1042,9 +1048,16 @@ const BOARD_PAGE_EN: BoardPageStrings = { 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. Run basou board record to write the first one.", - notFound: "There is no record {detail}.", - unreadable: "The record could not be shown: {detail}", + 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.", }, }; @@ -1053,7 +1066,6 @@ const BOARD_PAGE_JA: BoardPageStrings = { latest: "最新", older: "前の記録", newer: "次の記録", - loading: "盤を読み込んでいます…", loadFailed: "盤を読み込めませんでした: {message}", reported: "申告", heading: { @@ -1094,14 +1106,15 @@ const BOARD_PAGE_JA: BoardPageStrings = { missingPreviousUnreadable: "未確認(前回の記録が読めない)", }, effort: { - start: "{date} から({zone}){days} 日", + start: "開始", + elapsedLabel: "経過", elapsed: "{days} 日", active: "実働", claude: "Claude", codex: "Codex", noCodex: "Codex の session なし", outputTokens: "出力トークン", - withoutTokens: "記録の無い session {n}", + withoutTokens: "トークンを記録しなかった取り込み {n}", commits: "commit", milestones: "節目", noMilestones: "節目の宣言はない。", @@ -1137,6 +1150,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { lastReview: "最終見直し {date}・{model}(宣言)", lastReviewFromRecord: "最終見直し {date}・{model}(記録 {record})", noReview: "見直しの記録なし", + reviewUnknown: "前回の見直しは分からない(それを含みうる記録が読めない)", reviewNeeded: "見直しの要否: {answer}", reviewNeededYes: "要", reviewNeededNo: "不要", @@ -1150,9 +1164,15 @@ const BOARD_PAGE_JA: BoardPageStrings = { unavailable: { noBoard: "盤のページは workspace の board/board.yaml の隣の記録を描く。board/board.yaml を読むのは、manifest がこの workspace の repo(path: .)を private と宣言しているときだけ。", - noRecords: "盤の記録がまだない。basou board record で最初の記録を書く。", - notFound: "記録 {detail} はない。", - unreadable: "記録を描けない: {detail}", + 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} は記録の形をしていない。", }, }; From a3c863129754ee3ad6e38701e4c873e711888d56 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 19:04:38 +0900 Subject: [PATCH 4/4] test(board): hold the page's numbers to four places Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/view.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index b52c5c85..88423fbf 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1359,5 +1359,14 @@ describe("basou view: the board page", () => { // 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", + ]); }); });