diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efd3512..1c78e05a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- **`basou view`'s board page measures the workspace on the spot when there + is no record to draw.** With no record yet, or no `board/board.yaml` read + (the manifest does not declare the workspace's own repo private), `/board` + now measures what `basou board measure` measures without a declaration and + draws it, saying it was measured now and is not a record: the period and + effort, from the day of the first session in this host's time zone; each + repository the manifest declares (the workspace's own repo when it declares + none), with HEAD, its branch, the last commit, the commits, the uncommitted + paths and the commits behind `origin/main` as of the last fetch; the + decisions and open tracks; how many sessions there are and how many + `basou verify` does not find verified; the review gaps; the newest session + and the sessions not yet imported, counted by the dry run `basou orient` + runs; and the components the markers find. A page drawing a record links + to the same measurement (`/board?live=1`), and a button measures again. + Nothing on it is judged (no reach matrix, lanes or operator's turns), it + writes nothing, so it needs no private repo, and it is not served in + portfolio mode. Like every page of `basou view`, it is a local UI and not + an API. + +### Fixed + +- **`basou view` no longer lets two imports, or dry runs of one, spoil each + other.** An import the view runs, and the dry run that counts the sessions + a refresh would import for a portfolio card, capture the console the + import writes to, which only one can do at a time. Two at once (the + portfolio opened in two tabs, or while a refresh or import runs) could + leave a card saying its freshness could not be checked, a refresh failing + with `Import produced no parseable result`, or the import's JSON result + printed by the view. Every capture now waits its turn, the board page's + measurement included. + +### Internal + +- **`@basou/core` measures a workspace with no board declared.** + `measureBoardLive` runs the built-in sections of a board's measurement that + need nothing from a declaration, and `boardLivePage` lays the result out for + the board page. The effort section's start may be left out: the days then + start on the day of the first session, or today when there is none or the + sessions cannot be read (which is said under `not_found`). With no time zone + declared and none named for this host, the effort is not measured, as + before, and such a start is only a label: today in UTC. A declared start is + measured as before. + ## 0.66.0 — 2026-10-08 ### Fixed diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 72512176..34d9cd6b 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, mkdir, @@ -7,6 +8,7 @@ import { readFile, realpath, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -24,7 +26,13 @@ import { } from "@basou/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeStaleness } from "../lib/provenance-actions.js"; -import { doRunBoardMeasure, doRunBoardRecord, runBoardMeasure, runBoardRecord } from "./board.js"; +import { + doRunBoardMeasure, + doRunBoardRecord, + measureLiveBoard, + runBoardMeasure, + runBoardRecord, +} from "./board.js"; import { doRunPortfolioList } from "./portfolio.js"; import { doRunReviewGaps } from "./review-gaps.js"; import { doRunStats } from "./stats.js"; @@ -1321,3 +1329,122 @@ describe("basou board measure and record against the previous record", () => { expect(out.join("\n")).toContain("Components:\n 0 found, 0 unacknowledged, gone not known\n"); }); }); + +describe("measureLiveBoard: the board page's measurement with no board", () => { + // A Claude Code transcript of the workspace that was never imported, beside + // it where ctx() points the dry run of an import. + async function neverImported(repo: string): Promise { + const projectDir = join(ctx(repo).claudeProjectsDir, repo.replace(/[^a-zA-Z0-9]/g, "-")); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, "sess-1.jsonl"), + [ + { + type: "user", + timestamp: "2026-10-01T00:00:00.000Z", + cwd: repo, + sessionId: "sess-1", + message: { role: "user", content: [{ type: "text", text: "go" }] }, + }, + { + type: "assistant", + timestamp: "2026-10-01T00:00:01.000Z", + cwd: repo, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] }, + }, + ] + .map((record) => JSON.stringify(record)) + .join("\n"), + ); + } + + // Every path under a directory with, for a file, its size, modification + // time and content. + async function snapshot(dir: string): Promise> { + const out: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out[path] = "dir"; + Object.assign(out, await snapshot(path)); + } else { + const s = await stat(path, { bigint: true }); + const sha = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + out[path] = `${s.size}:${s.mtimeNs}:${sha}`; + } + } + return out; + } + + it("writes nothing, the dry run of an import included", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await neverImported(repo); + const before = await snapshot(repo); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 }); + expect(m.freshness.newest_session_at).toBeNull(); + expect(await snapshot(repo)).toEqual(before); + }); + + it("counts the same when dry runs of an import are started at once", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await neverImported(repo); + const context = ctx(repo); + const args = { + ctx: { + cwd: repo, + claudeProjectsDir: context.claudeProjectsDir, + codexSessionsDir: context.codexSessionsDir, + }, + paths: basouPaths(repo), + nowIso: NOW.toISOString(), + }; + const log = console.log; + const error = console.error; + const [live, ...probes] = await Promise.all([ + measureLiveBoard(repo, context), + ...[1, 2, 3].map(() => probeStaleness(args)), + ]); + expect(live?.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 }); + for (const probe of probes) { + expect(probe).toEqual({ newSessions: 1, updatedSessions: 0, unverifiableSessions: 0 }); + } + // Each capture put the console back as it found it. + expect(console.log).toBe(log); + expect(console.error).toBe(error); + }); + + it("measures the repos the manifest declares, with no declaration and no board.yaml", async () => { + const repo = await workspace([ + { path: ".", visibility: "public" }, + { path: "../elsewhere", visibility: "private" }, + ]); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual([".", "../elsewhere"]); + expect(m.not_found.map((n) => n.at)).toContain("repos[../elsewhere]"); + expect(m.measured_at).toBe(NOW.toISOString()); + // The dry run of an import ran, over the logs beside the workspace. + expect(m.freshness.unimported).toEqual({ new: 0, updated: 0, unverifiable: 0 }); + }); + + it("measures the workspace's own repo when the manifest declares none", async () => { + const repo = await workspace(); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual(["."]); + expect(m.not_found.map((n) => n.at)).not.toContain("repos"); + }); + + it("measures the workspace's own repo, and says why, when the manifest cannot be read", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await writeFile(join(repo, ".basou", "manifest.yaml"), "repos: [: broken\n"); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual(["."]); + expect(m.complete).toBe(false); + expect(m.not_found[0]).toEqual({ + at: "repos", + reason: "the manifest could not be read, so only the workspace's own repo was measured", + }); + }); +}); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 302d6a2b..28720e3f 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -6,6 +6,7 @@ import { type BoardCellChange, type BoardDeclaration, type BoardDiff, + type BoardLiveMeasurement, type BoardMeasurement, type BoardNotFound, type BoardObservation, @@ -22,6 +23,7 @@ import { findErrorCode, type Manifest, measureBoard, + measureBoardLive, NO_PREVIOUS_RECORDS, parseBoardDeclaration, parseRecordInput, @@ -558,6 +560,53 @@ async function measureLoaded( }); } +/** + * Measure what the workspace at `root` shows with no board declared, for the + * board page of `basou view`: the built-in sections that need nothing from a + * declaration, over the repos the manifest declares (the workspace's own repo + * alone when it declares none, or cannot be read). Writes nothing, sends + * nothing, and runs no import: the sessions not yet imported are counted by + * the dry run `basou orient` runs. + */ +export async function measureLiveBoard( + root: string, + ctx: BoardContext, +): Promise { + const paths = basouPaths(root); + let repos: string[] = ["."]; + let manifestUnread = false; + try { + const declared = ((await readManifest(paths)).repos ?? []).map((repo) => repo.path); + if (declared.length > 0) repos = declared; + } catch { + manifestUnread = true; + } + const now = ctx.nowProvider?.() ?? new Date(); + const probeCtx: ImportContext = { cwd: root }; + if (ctx.claudeProjectsDir !== undefined) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir; + if (ctx.codexSessionsDir !== undefined) probeCtx.codexSessionsDir = ctx.codexSessionsDir; + const measured = await measureBoardLive({ + root, + repos, + paths, + now, + measuredWith: { basou: BASOU_CLI_VERSION, build: BASOU_BUILD?.commit ?? null }, + probeImports: () => probeStaleness({ ctx: probeCtx, paths, nowIso: now.toISOString() }), + }); + if (!manifestUnread) return measured; + return { + ...measured, + complete: false, + not_found: [ + { + at: "repos", + reason: "the manifest could not be read, so only the workspace's own repo was measured", + }, + ...measured.not_found, + ], + }; +} + /** Where a board.yaml is, how to show it, and whether --board gave it. */ type BoardLocation = { path: string; shown: string; given: boolean }; diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 6ef3705e..b74ed1b3 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -28,13 +28,14 @@ import { 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 { startViewServer, type ViewServerHandle } from "../lib/view-server.js"; import { VIEW_HTML } from "../lib/view-ui.js"; -import { doRunBoardMeasure, doRunBoardRecord } from "./board.js"; +import { doRunBoardMeasure, doRunBoardRecord, measureLiveBoard } from "./board.js"; import { doRunView, registerViewCommand, runView, + sharedWhileRunning, type ViewContext, type ViewOptions, } from "./view.js"; @@ -1052,6 +1053,8 @@ describe("basou view: the board page", () => { cwd: repo, signal: controller.signal, openBrowser: () => {}, + // The live board runs the dry run of an import: never over this host's logs. + claudeProjectsDir: getClaudeRoot(), codexSessionsDir: getCodexRoot(), onListening: (h) => { handle = h; @@ -1131,6 +1134,107 @@ describe("basou view: the board page", () => { 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); + expect((await getJson(h, `/api/ws/${WS_ID_A}/board/live`)).status).toBe(404); + }); + }); + + it("serves the measurement on the spot in single mode only, whatever it is given", async () => { + const root = await initWorkspaceAt(tmpRepo as string, WS_ID_A, "a"); + let measured = 0; + const entry = { + key: WS_ID_A, + label: "a", + paths: basouPaths(root), + repoRoot: root, + importCtx: { cwd: root }, + initialized: true, + }; + const boardLive = () => { + measured++; + return measureLiveBoard(root, { + claudeProjectsDir: getClaudeRoot(), + codexSessionsDir: getCodexRoot(), + }); + }; + for (const mode of ["portfolio", "single"] as const) { + const h = await startViewServer({ + port: 0, + deps: { workspaces: [entry], mode, nowProvider: () => FIXED_DATE, boardLive }, + }); + try { + const status = (await getJson(h, `/api/ws/${WS_ID_A}/board/live`)).status; + expect([mode, status]).toEqual([mode, mode === "single" ? 200 : 404]); + } finally { + await h.close(); + } + } + expect(measured).toBe(1); + }); + + it("shares one measurement among the requests made while it runs", async () => { + let runs = 0; + let finish: (value: number) => void = () => {}; + const shared = sharedWhileRunning(() => { + runs++; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const first = shared(); + const second = shared(); + expect(second).toBe(first); + finish(1); + expect(await first).toBe(1); + // A call after it settled runs again. + const third = shared(); + expect(third).not.toBe(first); + finish(2); + expect(await third).toBe(2); + expect(runs).toBe(2); + }); + + it("measures the workspace on the spot when there is no board, whatever its visibility", async () => { + const repo = await workspaceWith(); + await withView(repo, {}, async (h) => { + const live = await getJson(h, "/api/board/live"); + expect(live.status).toBe(200); + expect(live.data).toMatchObject({ + language: "en", + strings: boardPageStrings("en"), + live: { + heading: { title: "view-ws", measured_at: expect.any(String) }, + effort: { milestones: [] }, + // The manifest declares no repos: the workspace's own repo. + repos: [{ path: "." }], + trail: { decisions_all: 0, tracks_open: [] }, + footnotes: { measured_with: { basou: expect.any(String) } }, + }, + }); + const data = live.data as Record; + expect(data).not.toHaveProperty("page"); + for (const key of ["matrix", "lanes", "turns", "composition", "summary"]) { + expect(data.live).not.toHaveProperty(key); + } + const scoped = await getJson(h, `/api/ws/${FIXED_WS_ID}/board/live`); + expect(scoped.status).toBe(200); + expect(scoped.data).toMatchObject({ live: { heading: { title: "view-ws" } } }); + }); + }); + + it("measures beside the records, in the anchor's language, and takes live for no record", async () => { + const repo = await workspaceWith([{ path: ".", visibility: "private", language: "ja" }]); + const id = await recorded(repo); + await withView(repo, {}, async (h) => { + expect((await getJson(h, "/api/board/live")).data).toMatchObject({ + language: "ja", + strings: boardPageStrings("ja"), + live: { heading: { title: "view-ws" }, repos: [{ path: "." }] }, + }); + expect((await getJson(h, "/api/board")).data).toMatchObject({ page: { status: "ok", id } }); + expect((await getJson(h, `/api/board/${id}`)).data).toMatchObject({ page: { id } }); + // Nothing was written beside the records. + const records = await readdir(join(repo, "board", "records")); + expect(records).toEqual([`${id}.json`]); }); }); @@ -1252,17 +1356,17 @@ describe("basou view: the board page", () => { document, boardPageStrings("en"), "en", - ) as Record Node>; - const call = (name: string, arg: unknown): Node => { + ) as Record Node>; + const call = (name: string, ...args: unknown[]): Node => { const fn = fns[name]; if (fn === undefined) throw new Error(`no ${name}`); - return fn(arg); + return fn(...args); }; return { call, byId }; } it("draws a page it cannot draw around the record asked for", () => { - const { call, byId } = drawing(["records"]); + const { call, byId } = drawing(["records", "recordLinks"]); const records = [A_REC, B_REC, C_REC].map((id, i) => ({ id, at: `2026-10-0${i + 1}T00:00:00.000Z`, @@ -1282,6 +1386,7 @@ describe("basou view: the board page", () => { `/board?record=${A_REC}`, `/board?record=${C_REC}`, "/board", + "/board?live=1", ]); const chosen = nav.find((n) => n.tag === "option" && n.selected); expect(chosen.map((o) => o.attrs.value)).toEqual([B_REC]); @@ -1539,6 +1644,287 @@ describe("basou view: the board page", () => { expect(placed.map((p) => p.row)).toEqual([0, 1, 0]); }); + // A board measured on the spot, as the live route lays it out. + const LIVE = { + heading: { + title: "Board", + measured_at: "2026-10-05T03:00:00.000Z", + complete: false, + not_found: 1, + }, + effort: { + start: "2026-10-04", + time_zone: "UTC", + elapsed_days: 1, + active_ms: { union: 3_600_000, claude: 3_600_000, codex: null }, + output_tokens: 10, + sessions_without_tokens: 0, + commits: [{ repo: ".", count: 2 }], + milestones: [], + active_days: 1, + period_days: 2, + daily: [ + { + date: "2026-10-04", + union: 3_600_000, + claude: 3_600_000, + not_claude: 0, + cumulative: 3_600_000, + commits: 2, + }, + { + date: "2026-10-05", + union: 0, + claude: 0, + not_claude: 0, + cumulative: 3_600_000, + commits: 0, + }, + ], + weeks: [ + { + week: "2026-09-28", + union: 3_600_000, + claude: 3_600_000, + codex: null, + active_days: 1, + commits: 2, + }, + ], + }, + repos: [ + { + path: ".", + name: "app", + head: "0123456789abcdef0123456789abcdef01234567", + branch: null, + last_commit: null, + commits: 2, + uncommitted: 1, + behind_main: null, + }, + ], + trail: { + decisions_all: 3, + decisions_live: 2, + tracks_open: [{ id: "decision_01HXABCDEF1234567890ABCDEF", title: "open" }], + }, + integrity: { by_status: { verified: 1 }, not_verified: 0, sessions: 1 }, + review_gaps: { by_verdict: { omission: 4 }, gaps: 4 }, + freshness: { newest_session_at: null, unimported: { new: 1, updated: 0, unverifiable: 0 } }, + components: [{ key: "app", kinds: ["ci", "manifest"] }], + footnotes: { + not_found: [ + { at: "freshness.newest_session_at", reason: "a session.yaml could not be read" }, + ], + measured_with: { basou: "0.0.0-test", build: null }, + }, + }; + + it("draws a board measured on the spot: what basou measured, nothing judged", () => { + const names = [ + "missingIn", + "liveTile", + "liveHeading", + "liveRepos", + "liveTrail", + "liveComponents", + "liveFootnotes", + "effort", + ]; + const { call } = drawing(names); + const S = boardPageStrings("en"); + const heading = call("liveHeading", LIVE, null); + expect(heading.text()).toContain("Board"); + expect(heading.text()).toContain("Measured "); + expect(heading.text()).toContain("1 could not be measured"); + expect(heading.find((n) => n.tag === "button").map((b) => b.textContent)).toEqual([ + S.live.remeasure, + ]); + + const repos = call("liveRepos", LIVE); + const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); + // Nulls that mean so: a detached HEAD, no origin/main, a last commit unread. + expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]); + // Nulls the measurement could not measure are said to be. + const unmeasured = { + ...LIVE, + repos: [ + LIVE.repos[0], + { + ...LIVE.repos[0], + path: "../gone", + name: null, + head: null, + commits: null, + uncommitted: null, + }, + ], + footnotes: { + ...LIVE.footnotes, + not_found: [ + ...LIVE.footnotes.not_found, + { at: "repos[.].branch", reason: "x" }, + { at: "repos[../gone]", reason: "not a git repository" }, + ], + }, + }; + const rows = call("liveRepos", unmeasured) + .find((n) => n.tag === "tr") + .slice(1) + .map((row) => row.find((n) => n.tag === "td").map((c) => c.textContent)); + expect(rows).toEqual([ + ["app", "not measured", "0123456", "-", "2", "1", "-"], + [ + "../gone", + "not measured", + "not measured", + "not measured", + "not measured", + "not measured", + "not measured", + ], + ]); + + const trail = call("liveTrail", LIVE); + // The newest session was not measured: not "no session". + expect(trail.text()).toContain(S.tiles.notMeasured); + expect(trail.text()).not.toContain(S.live.noSession); + expect(trail.text()).toContain("not imported: 1 new, 0 updated, 0 unverifiable"); + expect(trail.text()).toContain("Sessions by basou verify status: verified 1"); + expect(trail.text()).toContain("Units of work by basou review-gaps verdict: omission 4"); + const unknown = call("liveTrail", { + ...LIVE, + integrity: { by_status: null, not_verified: null, sessions: null }, + review_gaps: { by_verdict: null, gaps: null }, + freshness: { newest_session_at: null, unimported: null }, + }); + expect(unknown.text()).toContain(S.live.unimportedNotMeasured); + expect(unknown.text()).toContain("Sessions by basou verify status: not measured"); + expect(unknown.text()).toContain("Units of work by basou review-gaps verdict: not measured"); + // A track's title is text, never markup. + expect(trail.find((n) => n.tag === "b")).toEqual([]); + expect(trail.text()).toContain("open"); + + const components = call("liveComponents", LIVE); + expect(components.find((n) => n.tag === "code").map((c) => c.textContent)).toEqual(["app"]); + expect(components.text()).toContain("ci, manifest"); + + const noBoard = call("liveFootnotes", LIVE, "no_board"); + for (const s of [S.live.note, S.live.judged, S.unavailable.noBoard, S.footnotes.notMeasured]) { + expect(noBoard.text()).toContain(s); + } + expect(noBoard.text()).toContain( + "freshness.newest_session_at: a session.yaml could not be read", + ); + expect(call("liveFootnotes", LIVE, null).text()).not.toContain(S.unavailable.noBoard); + + // The period and effort are drawn as a record's are. + expect(call("effort", LIVE).find((n) => n.tag === "figure").length).toBeGreaterThan(0); + }); + + // Run the page's script as a browser would, with a fake DOM and a fake fetch + // that answers each URL from `responses`. + async function runPage(search: string, responses: Record) { + const script = BOARD_HTML.slice( + BOARD_HTML.indexOf(""), + ); + const byId: Record = {}; + const fetched: string[] = []; + const document = { + createElement: (tag: string) => new Node(tag), + createElementNS: (_ns: string, 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]; + }, + documentElement: { lang: "" }, + title: "", + }; + const fetch = async (url: string) => { + fetched.push(url); + const body = responses[url]; + return { + ok: body !== undefined, + status: body === undefined ? 404 : 200, + json: async () => body ?? { error: "Not found" }, + }; + }; + new Function("document", "location", "fetch", script)(document, { search }, fetch); + for (let i = 0; i < 20; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + // A page that never drew has no navigation at all. + const links = (byId.records?.find((n) => n.tag === "a") ?? []).map((a) => a.attrs.href); + return { fetched, byId, links, document }; + } + + const STRINGS = { language: "en", strings: boardPageStrings("en") }; + const NO_RECORDS = { + ...STRINGS, + page: { status: "unavailable", why: "no_records", records: [] }, + }; + const LIVE_BODY = { ...STRINGS, live: LIVE }; + + it("measures on the spot when there is no board or no record to draw", async () => { + for (const why of ["no_board", "no_records"]) { + const page = await runPage("", { + "/api/board": { ...STRINGS, page: { status: "unavailable", why, records: [] } }, + "/api/board/live": LIVE_BODY, + }); + expect(page.fetched).toEqual(["/api/board", "/api/board/live"]); + const board = page.byId.board as Node; + expect(board.find((n) => n.tag === "h1").map((h) => h.textContent)).toEqual(["Board"]); + expect(board.text().includes(boardPageStrings("en").unavailable.noBoard)).toBe( + why === "no_board", + ); + // Not asked for: there is no record to lead back to. + expect(page.links).toEqual([]); + expect((page.byId.status as Node).textContent).toBe(""); + } + }); + + it("says why a record cannot be drawn, and links to the measurement on the spot", async () => { + const page = await runPage("", { + "/api/board": { + ...STRINGS, + page: { status: "unavailable", why: "records_unreadable", records: [] }, + }, + "/api/board/live": LIVE_BODY, + }); + expect(page.fetched).toEqual(["/api/board"]); + expect((page.byId.status as Node).textContent).toBe( + boardPageStrings("en").unavailable.recordsUnreadable, + ); + expect(page.links).toEqual(["/board?live=1"]); + }); + + it("measures when asked with ?live=1, leading back to the records only when there are some", async () => { + const alone = await runPage("?live=1", { + "/api/board/live": LIVE_BODY, + "/api/board": NO_RECORDS, + }); + expect(alone.fetched).toEqual(["/api/board/live", "/api/board"]); + expect((alone.byId.board as Node).find((n) => n.tag === "h1").length).toBe(1); + expect(alone.links).toEqual([]); + const withRecords = await runPage("?live=1", { + "/api/board/live": LIVE_BODY, + "/api/board": { + ...STRINGS, + page: { + status: "unavailable", + why: "not_json", + records: [{ id: A_REC, at: "2026-10-01T00:00:00.000Z" }], + }, + }, + }); + expect(withRecords.links).toEqual(["/board"]); + // A measurement that fails says so. + const failed = await runPage("?live=1", {}); + expect(failed.fetched).toEqual(["/api/board/live"]); + expect((failed.byId.status as Node).textContent).toBe("Not found"); + }); + it("builds prose of text nodes, with only a span between backticks as a code element", () => { const document = { createElement: (tag: string) => new Node(tag), diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index e87dd29e..17648629 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { basename, dirname, join, resolve } from "node:path"; import { assertBasouRootSafe, + type BoardLiveMeasurement, basouPaths, findErrorCode, loadPortfolioConfig, @@ -22,7 +23,7 @@ import { type ViewServerHandle, type WorkspaceEntry, } from "../lib/view-server.js"; -import { DEFAULT_BOARD_PATH } from "./board.js"; +import { DEFAULT_BOARD_PATH, measureLiveBoard } from "./board.js"; import type { ImportContext } from "./import.js"; const DEFAULT_PORT = 4319; @@ -234,6 +235,35 @@ async function buildSingleDeps(ctx: ViewContext, cwd: string): Promise Promise { + return sharedWhileRunning(() => + measureLiveBoard(root, { + ...(ctx.nowProvider !== undefined ? { nowProvider: ctx.nowProvider } : {}), + ...(ctx.claudeProjectsDir !== undefined ? { claudeProjectsDir: ctx.claudeProjectsDir } : {}), + ...(ctx.codexSessionsDir !== undefined ? { codexSessionsDir: ctx.codexSessionsDir } : {}), + }), + ); +} + +/** + * Share one run of `run` among the calls made while it runs: such a call gets + * that run's result, and a call after it settles starts another. + */ +export function sharedWhileRunning(run: () => Promise): () => Promise { + let running: Promise | null = null; + return () => { + running ??= run().finally(() => { + running = null; + }); + return running; }; } diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 070ceb07..44251da6 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -3,6 +3,9 @@ * 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. + * With no record to draw, or with `?live=1`, it draws what basou measures of + * the workspace now (`GET /api/board/live`): the period and effort, the + * repositories, the trail and the components, with nothing judged. * 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 @@ -126,6 +129,7 @@ export const BOARD_HTML = ` if (k === 'text') node.textContent = attrs[k]; else if (k === 'class') node.className = attrs[k]; else if (k === 'onchange') node.addEventListener('change', attrs[k]); + else if (k === 'onclick') node.addEventListener('click', attrs[k]); else node.setAttribute(k, attrs[k]); }); } @@ -219,7 +223,11 @@ export const BOARD_HTML = ` clear(nav); var page = d.page; var list = page.records || []; - if (list.length === 0) return; + if (list.length > 0) recordLinks(nav, page, list); + nav.appendChild(el('a', { href: '/board?live=1', text: S.live.measureNow })); + } + + function recordLinks(nav, page, list) { var link = function (id, label) { return el('a', { href: '/board?record=' + encodeURIComponent(id), text: label }); }; @@ -700,8 +708,12 @@ export const BOARD_HTML = ` not_found: S.unavailable.notFound, not_json: S.unavailable.notJson, unknown_version: S.unavailable.unknownVersion, not_a_record: S.unavailable.notARecord }; - var quiet = page.why === 'no_board' || page.why === 'no_records'; - setStatus(fill(messages[page.why] || page.why, { record: page.id || '', version: page.version }), !quiet); + // With no board or no record yet, draw what basou measures now instead. + if (page.why === 'no_board' || page.why === 'no_records') { + loadLive(page.why); + return; + } + setStatus(fill(messages[page.why] || page.why, { record: page.id || '', version: page.version }), true); return; } setStatus('', false); @@ -710,17 +722,153 @@ export const BOARD_HTML = ` .forEach(function (node) { root.appendChild(node); }); } - var record = new URLSearchParams(location.search).get('record'); - setStatus('...', false); - fetch('/api/board' + (record ? '/' + encodeURIComponent(record) : '')) - .then(function (res) { + // The board measured now: the sections basou measures with nothing declared + // or judged. why is the reason there was no record to draw, or null when + // asked for with ?live=1. + function liveTile(k, v, sub) { + return el('div', { class: 'tile' }, [ + el('div', { class: 'k', text: k }), el('div', { class: 'v', text: v }), + sub ? el('div', { class: 's', text: sub }) : null + ]); + } + function liveHeading(l, why) { + var h = l.heading; + var measured = h.complete ? S.heading.complete : fill(S.heading.incomplete, { n: h.not_found }); + var again = el('button', { type: 'button', text: S.live.remeasure, onclick: function () { loadLive(why); } }); + return el('header', null, [ + el('h1', { text: h.title }), + el('div', { class: 'stamp' }, [fill(S.live.measuredAt, { at: when(h.measured_at) }) + ' / ' + measured + ' ', again]) + ]); + } + // Whether the measurement says why the value at a place was not measured. + function missingIn(l) { + return function (at) { return l.footnotes.not_found.some(function (n) { return n.at === at; }); }; + } + function liveRepos(l) { + var missing = missingIn(l); + var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.behindMain]; + var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: i >= 4 ? 'n' : '', text: label }); })); + var rows = l.repos.map(function (r) { + var at = 'repos[' + r.path + ']'; + // A null the measurement says it could not measure, or one that means + // so (a detached HEAD, no commit yet, no origin/main), drawn as '-'. + var value = function (field, v, show) { + if (v !== null) return show(v); + return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-'; + }; + return el('tr', null, [ + el('td', { text: r.name === null ? r.path : r.name }), + el('td', { text: value('branch', r.branch, String) }), + el('td', { text: value('head', r.head, function (h) { return h.slice(0, 7); }) }), + el('td', { text: value('last_commit', r.last_commit, when) }), + el('td', { class: 'n', text: num(r.commits) }), + el('td', { class: 'n', text: num(r.uncommitted) }), + el('td', { class: 'n', text: value('behind_main', r.behind_main, num) }) + ]); + }); + return section(S.live.repos, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]); + } + function liveTrail(l) { + var t = l.trail; + var i = l.integrity; + var f = l.freshness; + var missing = missingIn(l); + var newest = f.newest_session_at !== null ? when(f.newest_session_at) + : missing('freshness.newest_session_at') ? S.tiles.notMeasured : S.live.noSession; + var unimported = f.unimported === null ? S.live.unimportedNotMeasured : fill(S.live.unimported, { + new: f.unimported.new, updated: f.unimported.updated, unverifiable: f.unimported.unverifiable + }); + var counts = function (byKey) { + if (byKey === null) return S.tiles.notMeasured; + return Object.keys(byKey).map(function (k) { return k + ' ' + num(byKey[k]); }).join(' / '); + }; + var tiles = el('div', { class: 'tiles' }, [ + liveTile(S.live.decisions, num(t.decisions_live), t.decisions_all === null ? null : fill(S.live.decisionsAll, { n: num(t.decisions_all) })), + liveTile(S.tiles.openTracks, t.tracks_open === null ? S.tiles.notMeasured : num(t.tracks_open.length), null), + liveTile(S.tiles.sessions, num(i.sessions), i.not_verified === null ? null : fill(S.tiles.sessionsNotVerified, { n: num(i.not_verified) })), + liveTile(S.live.reviewGaps, num(l.review_gaps.gaps), null), + liveTile(S.live.newestSession, newest, unimported) + ]); + var tracks = t.tracks_open === null ? el('p', { class: 'muted', text: S.tiles.notMeasured }) + : t.tracks_open.length === 0 ? el('p', { class: 'muted', text: S.live.noTracks }) + : el('ul', null, t.tracks_open.map(function (tr) { + return el('li', null, [el('span', { class: 'muted', text: tr.id + ' ' }), tr.title]); + })); + var breakdown = el('div', { class: 'muted' }, [ + el('div', { text: fill(S.live.byStatus, { list: counts(i.by_status) }) }), + el('div', { text: fill(S.live.byVerdict, { list: counts(l.review_gaps.by_verdict) }) }) + ]); + return section(S.live.trail, false, [tiles, breakdown, el('h3', { text: S.live.tracks }), tracks]); + } + function liveComponents(l) { + var c = l.components; + var body = c === null ? el('p', { class: 'muted', text: S.tiles.notMeasured }) + : c.length === 0 ? el('p', { class: 'muted', text: S.live.noComponents }) + : el('ul', null, c.map(function (x) { + return el('li', null, [el('code', { text: x.key }), el('span', { class: 'muted', text: ' ' + x.kinds.join(', ') })]); + })); + return section(S.live.components, false, [body]); + } + function liveFootnotes(l, why) { + var f = l.footnotes; + var items = [el('li', { text: S.live.note }), el('li', { text: S.live.judged })]; + if (why === 'no_board') items.push(el('li', { text: S.unavailable.noBoard })); + if (f.not_found.length > 0) { + items.push(el('li', null, [el('b', { text: S.footnotes.notMeasured }), el('ul', null, f.not_found.map(function (n) { + return el('li', { text: n.at + ': ' + n.reason }); + }))])); + } + var build = f.measured_with.build === null ? '' : ' (' + f.measured_with.build + ')'; + items.push(el('li', { text: fill(S.footnotes.measuredWith, { basou: f.measured_with.basou, build: build }) })); + return section(S.sections.footnotes, false, [el('ul', { class: 'foot' }, items)]); + } + var liveDrawn = 0; + function drawLive(d, why) { + S = d.strings; + lang = d.language; + document.documentElement.lang = lang; + document.title = S.pageTitle; + var drawn = ++liveDrawn; + var nav = $('records'); + clear(nav); + // Asked for with ?live=1: lead back to the records, when there are any. + if (why === null) { + getJson('/api/board').then(function (b) { + var list = b && b.page && b.page.records ? b.page.records : []; + if (drawn === liveDrawn && list.length > 0) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords })); + }).catch(function () {}); + } + var root = $('board'); + clear(root); + setStatus('', false); + var l = d.live; + [liveHeading(l, why), effort(l), liveRepos(l), liveTrail(l), liveComponents(l), liveFootnotes(l, why)] + .forEach(function (node) { root.appendChild(node); }); + } + + function getJson(url) { + return fetch(url).then(function (res) { return res.json().then(function (body) { if (!res.ok) throw new Error(body && body.error ? body.error : String(res.status)); return body; }); - }) - .then(draw) - .catch(function (err) { setStatus(S ? fill(S.loadFailed, { message: err.message }) : String(err.message), true); }); + }); + } + function failed(err) { setStatus(S ? fill(S.loadFailed, { message: err.message }) : String(err.message), true); } + // Measuring takes seconds, so the page says so until it is done. + function loadLive(why) { + setStatus(S ? S.live.measuring : '...', false); + getJson('/api/board/live').then(function (d) { drawLive(d, why); }).catch(failed); + } + + var params = new URLSearchParams(location.search); + var record = params.get('record'); + if (params.get('live') !== null) { + loadLive(null); + } else { + setStatus('...', false); + getJson('/api/board' + (record ? '/' + encodeURIComponent(record) : '')).then(draw).catch(failed); + } })(); diff --git a/packages/cli/src/lib/provenance-actions.ts b/packages/cli/src/lib/provenance-actions.ts index a5ce7e8e..7675dd9a 100644 --- a/packages/cli/src/lib/provenance-actions.ts +++ b/packages/cli/src/lib/provenance-actions.ts @@ -100,13 +100,27 @@ export type RefreshActionOptions = { dryRun?: boolean; }; +// The capture swaps the process-global console, so two at once take each +// other's output and the one that finishes last puts back the other's +// stand-in. Captures therefore run one at a time, in the order they are asked +// for, whoever asks: a dry run for a portfolio card or the board page, or an +// import or refresh the view runs. +let capturing: Promise = Promise.resolve(); + /** * Run `fn` (an import command invoked with `json: true`) while capturing its * console output, then return the single machine-readable JSON result line. * The import commands print their result as one JSON object on stdout; that * line is the result contract. `console` is always restored, even on throw. + * Captures in this process run one at a time. */ -async function captureImportJson(fn: () => Promise): Promise> { +function captureImportJson(fn: () => Promise): Promise> { + const turn = capturing.then(() => captureNow(fn)); + capturing = turn.catch(() => undefined); + return turn; +} + +async function captureNow(fn: () => Promise): Promise> { const stdout: string[] = []; const originalLog = console.log; const originalError = console.error; @@ -377,9 +391,9 @@ export type StalenessProbe = { * Returns `null` if the probe could not run (the caller renders "can't confirm" * rather than a false "current"). * - * NOTE: the import capture swaps the process-global console and is NOT - * reentrant, so callers must never run two probes concurrently (e.g. the - * portfolio runs them serially). + * The import capture swaps the process-global console, so probes, like every + * capture, run one at a time (see captureImportJson): callers may start them + * at once. */ export async function probeStaleness(args: { ctx: ImportContext; diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index e96e35af..0a235e9a 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -3,6 +3,8 @@ import type { AddressInfo } from "node:net"; import { basename, join, resolve } from "node:path"; import { type BasouPaths, + type BoardLiveMeasurement, + boardLivePage, boardPage, boardPageStrings, boardPageUnavailable, @@ -88,6 +90,11 @@ export type ViewServerDeps = { remoteUrlOf?: RemoteUrlResolver; /** The board the board page shows: single mode only (the page is not served otherwise). */ board?: ViewBoard; + /** + * Measure the workspace as it is now for the board page, with no record: + * single mode only, like the board. It writes nothing. + */ + boardLive?: () => Promise; }; /** A running view server, with the means to stop it. */ @@ -236,6 +243,7 @@ async function handleGet( deps.nowProvider, remoteUrlOf(deps), boardOf(deps), + boardLiveOf(deps), )) ) { sendError(res, 404, "Not found"); @@ -252,6 +260,7 @@ async function handleGet( deps.nowProvider, remoteUrlOf(deps), boardOf(deps), + boardLiveOf(deps), )) ) { sendError(res, 404, "Not found"); @@ -266,6 +275,11 @@ function boardOf(deps: ViewServerDeps): ViewBoard | undefined { return deps.mode === "single" ? deps.board : undefined; } +/** The measurement the live board route serves: none outside single mode. */ +function boardLiveOf(deps: ViewServerDeps): (() => Promise) | undefined { + return deps.mode === "single" ? deps.boardLive : undefined; +} + /** The configured live remote-URL resolver, or core's default. */ function remoteUrlOf(deps: ViewServerDeps): RemoteUrlResolver { return deps.remoteUrlOf ?? tryRemoteUrl; @@ -308,7 +322,13 @@ async function handleWorkspaceGet( nowProvider: () => Date, resolveRemoteUrl: RemoteUrlResolver, board: ViewBoard | undefined, + live: (() => Promise) | undefined, ): Promise { + // Before the records' route, which would take "live" for a record's id. + if (live !== undefined && sub === "board/live") { + sendJson(res, 200, await boardLiveView(ws, live)); + return true; + } if (board !== undefined) { if (sub === "board") { sendJson(res, 200, await boardView(ws, board, undefined)); @@ -467,6 +487,22 @@ async function boardView( return { language, strings: boardPageStrings(language), page }; } +/** + * What the board page draws of the workspace measured now, with no record and + * nothing judged, with its fixed strings in the workspace's language. + */ +async function boardLiveView( + ws: WorkspaceEntry, + live: () => Promise, +): Promise> { + const language = await resolveViewLanguageFromPaths(ws.paths); + return { + language, + strings: boardPageStrings(language), + live: boardLivePage(ws.label, await live()), + }; +} + /** * 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 @@ -481,9 +517,9 @@ async function portfolio(deps: ViewServerDeps): Promise> const workspaces = await Promise.all(deps.workspaces.map((ws) => portfolioCard(ws, nowIso))); // Each staleness probe runs a dry-run import, which swaps the process-global // console to capture output (see captureImportJson). That swap is NOT - // reentrant, so the probes must run ONE AT A TIME — running them inside the - // parallel map above let them clobber each other's capture and most failed. - // The summaries above are pure reads and stay parallel; only this loop serializes. + // reentrant: captures run one at a time whoever starts them (the board page + // and the imports included), and this loop also runs the cards' probes in + // turn. The summaries above are pure reads and stay parallel. for (let i = 0; i < deps.workspaces.length; i++) { const card = workspaces[i]; const ws = deps.workspaces[i]; diff --git a/packages/core/src/board/effort.test.ts b/packages/core/src/board/effort.test.ts index 2e9dbaf5..0bb72dc4 100644 --- a/packages/core/src/board/effort.test.ts +++ b/packages/core/src/board/effort.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stringify } from "yaml"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { measureEffort } from "./effort.js"; @@ -74,3 +75,97 @@ describe("measureEffort", () => { expect(notFound).toEqual([]); }); }); + +describe("measureEffort with no declared start", () => { + const SES = (s: string): string => `ses_01HXABCDEF1234567890ABC${s}`; + + // A session that started at `startedAt`, with no events. + async function placeStarted(id: string, startedAt: string): Promise { + const dir = join(paths.sessions, id); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "session.yaml"), + stringify({ + schema_version: "0.1.0", + session: { + id, + label: "fixture", + task_id: null, + workspace_id: "ws_01HXABCDEF1234567890ABCDEF", + source: { kind: "terminal", version: "0.1.0" }, + started_at: startedAt, + status: "completed", + working_directory: "/tmp/fixture", + invocation: { command: "echo", args: [], exit_code: 0 }, + related_files: [], + events_log: "events.jsonl", + }, + }), + ); + await writeFile(join(dir, "events.jsonl"), ""); + } + + const undeclared = () => { + const { start: _declared, ...rest } = input("Asia/Tokyo"); + return rest; + }; + + it("starts on the day the first session started, in the section's time zone", async () => { + await placeStarted(SES("S02"), "2026-09-25T00:00:00Z"); + // 08:30 on 2026-09-21 in Tokyo, though 2026-09-20 in UTC. + await placeStarted(SES("S01"), "2026-09-20T23:30:00Z"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-09-21"); + expect(effort.elapsed_days).toBe(14); + expect(effort.daily?.map((d) => d.date)).toHaveLength(15); + expect(effort.daily?.[0]?.date).toBe("2026-09-21"); + expect(notFound).toEqual([]); + }); + + it("starts today when there is no session", async () => { + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.elapsed_days).toBe(0); + expect(effort.daily?.map((d) => d.date)).toEqual(["2026-10-05"]); + expect(notFound).toEqual([]); + }); + + it("starts today, and says so, when the sessions cannot be read", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + await writeFile(join(paths.sessions, SES("S01"), "session.yaml"), "session: [broken]\n"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.active_ms).toEqual({ union: null, claude: null, codex: null }); + expect(notFound.map((n) => n.at)).toEqual([ + "effort.start", + "effort.active_ms", + "effort.output_tokens", + ]); + expect(notFound[0]?.reason).toBe( + "the first session is not known, so the days are counted from today", + ); + }); + + it("names today in UTC as its start when this host's zone has no name, measuring nothing", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + hostZoneIs(undefined); + const { start: _declared, timeZone: _zone, ...rest } = input("Asia/Tokyo"); + const { effort, notFound } = await measureEffort(rest); + expect(effort.start).toBe("2026-10-05"); + expect(effort.daily).toBeNull(); + expect(notFound.map((n) => n.at)).toEqual(["effort"]); + }); + + it("never starts after today, whatever a session's clock said", async () => { + await placeStarted(SES("S01"), "2026-12-01T00:00:00Z"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.daily?.map((d) => d.date)).toEqual(["2026-10-05"]); + expect(notFound).toEqual([]); + }); + + it("keeps a declared start, before or after the first session", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + expect((await measureEffort(input("Asia/Tokyo"))).effort.start).toBe("2026-10-01"); + }); +}); diff --git a/packages/core/src/board/effort.ts b/packages/core/src/board/effort.ts index cfb6d5f0..6bf22b08 100644 --- a/packages/core/src/board/effort.ts +++ b/packages/core/src/board/effort.ts @@ -60,7 +60,13 @@ export type BoardEffortDay = { * `effort`. */ export type BoardEffort = { - /** The declared start. */ + /** + * The declared start, or, with none declared, the day of the first session + * in the section's time zone (today when there is no session, or the + * sessions cannot be read). With neither a declared zone nor a name for + * this host's, there are no days to count, and an undeclared start is only + * a label: today in UTC. + */ start: string; /** The time zone the days are in: the declared one, or this host's, as named by `Intl`. */ time_zone: string | null; @@ -102,7 +108,13 @@ export type BoardEffort = { export type EffortInput = { paths: BasouPaths; now: Date; - start: string; + /** + * The declared start. Without one, the days start on the day of the first + * session, or today when there is none: the work the board counts is the + * work done with an agent, which an adopted repository's history predates. + * When the days cannot be counted (no zone can be named), it is today in UTC. + */ + start?: string | undefined; /** The declared time zone, if any. */ timeZone?: string | undefined; /** The manifest's paths, in its order. */ @@ -121,7 +133,9 @@ export async function measureEffort(input: EffortInput): Promise<{ const unknown = { union: null, claude: null, codex: null }; return { effort: { - start: input.start, + // With no zone there are no days, the first session's included: the + // date is only a label then, and every value is null. + start: input.start ?? input.now.toISOString().slice(0, 10), time_zone: null, elapsed_days: null, active_ms: unknown, @@ -141,8 +155,19 @@ export async function measureEffort(input: EffortInput): Promise<{ } const calendar = new Calendar(zone); const today = calendar.dateOf(input.now.getTime()); - const days = datesFrom(input.start, today); - const elapsedDays = Math.round((utcOf(today) - utcOf(input.start)) / 86_400_000); + const sessions = await readSessions(input.paths, input.now, zone); + let start = input.start; + if (start === undefined) { + start = sessions.ok ? firstDay(sessions.sessions, calendar, today) : today; + if (!sessions.ok) { + notFound.push({ + at: "effort.start", + reason: "the first session is not known, so the days are counted from today", + }); + } + } + const days = datesFrom(start, today); + const elapsedDays = Math.round((utcOf(today) - utcOf(start)) / 86_400_000); // Commits, each repository apart. const commits: Record = {}; @@ -170,7 +195,6 @@ export async function measureEffort(input: EffortInput): Promise<{ commits[path] = total; } - const sessions = await readSessions(input.paths, input.now, zone); let effort: BoardEffort; if (!sessions.ok) { for (const at of ["effort.active_ms", "effort.output_tokens"]) { @@ -178,7 +202,7 @@ export async function measureEffort(input: EffortInput): Promise<{ } const unknown = { union: null, claude: null, codex: null }; effort = { - start: input.start, + start, time_zone: zone, elapsed_days: elapsedDays, active_ms: unknown, @@ -215,7 +239,7 @@ export async function measureEffort(input: EffortInput): Promise<{ } const sum = (values: Iterable) => [...values].reduce((a, b) => a + b, 0); effort = { - start: input.start, + start, time_zone: zone, elapsed_days: elapsedDays, active_ms: { @@ -250,6 +274,23 @@ export function todayIn(declared: string | undefined, now: Date): string | undef return zone === undefined ? undefined : new Calendar(zone).dateOf(now.getTime()); } +// The day the first session started on, never later than today (a clock set +// back is not a day to come); today when there is no session. +function firstDay( + sessions: readonly SessionWorkStats[], + calendar: Calendar, + today: string, +): string { + let first = today; + for (const session of sessions) { + const at = Date.parse(session.startedAt); + if (Number.isNaN(at)) continue; + const day = calendar.dateOf(at); + if (day < first) first = day; + } + return first; +} + /** Whole days from one date to another, both `YYYY-MM-DD`. */ export function daysBetween(from: string, to: string): number { return Math.round((utcOf(to) - utcOf(from)) / 86_400_000); diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 999ecb81..f3c11e81 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -46,6 +46,8 @@ export type { GlobMatcher } from "./glob.js"; export { compileGlob, compileGlobs } from "./glob.js"; export type { BoardIntegrity } from "./integrity.js"; export { BOARD_INTEGRITY_METHOD } from "./integrity.js"; +export type { BoardLiveMeasurement, BoardLivePage, MeasureBoardLiveInput } from "./live.js"; +export { boardLivePage, measureBoardLive } from "./live.js"; export type { BoardMeasurement, BoardMeasureValue, diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts new file mode 100644 index 00000000..03643436 --- /dev/null +++ b/packages/core/src/board/live.test.ts @@ -0,0 +1,199 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stringify } from "yaml"; +import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { boardLivePage, measureBoardLive } from "./live.js"; + +const NULL_CONFIG = process.platform === "win32" ? "\\\\.\\nul" : "/dev/null"; +const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: NULL_CONFIG, GIT_CONFIG_SYSTEM: NULL_CONFIG }; +const NOW = new Date("2026-10-05T03:00:00.000Z"); +const WITH = { basou: "0.0.0-test", build: null }; +const SES = (s: string): string => `ses_01HXABCDEF1234567890ABC${s}`; +// A dry run of an import that finds nothing to import, in place of the one +// the CLI passes, which reads the host's native logs. +const NOTHING_TO_IMPORT = async () => ({ + newSessions: 0, + updatedSessions: 0, + unverifiableSessions: 0, +}); + +let root: string; +let paths: BasouPaths; + +const git = (...args: string[]) => + execFileSync("git", args, { cwd: root, env: GIT_ENV, stdio: "pipe" }).toString(); + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "basou-board-live-"))); + git("-c", "init.defaultBranch=main", "init", "-q"); + git("config", "user.email", "t@e.com"); + git("config", "user.name", "t"); + await writeFile(join(root, ".gitignore"), ".basou/\n"); + await writeFile(join(root, "package.json"), "{}\n"); + await mkdir(join(root, "web")); + await writeFile(join(root, "web", "package.json"), "{}\n"); + git("add", "."); + git("commit", "-q", "-m", "first"); + paths = await ensureBasouDirectory(root); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function placeStarted(id: string, startedAt: string): Promise { + const dir = join(paths.sessions, id); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "session.yaml"), + stringify({ + schema_version: "0.1.0", + session: { + id, + label: "fixture", + task_id: null, + workspace_id: "ws_01HXABCDEF1234567890ABCDEF", + source: { kind: "terminal", version: "0.1.0" }, + started_at: startedAt, + status: "completed", + working_directory: "/tmp/fixture", + invocation: { command: "echo", args: [], exit_code: 0 }, + related_files: [], + events_log: "events.jsonl", + }, + }), + ); + await writeFile(join(dir, "events.jsonl"), ""); +} + +// Every path under a directory with, for a file, its size, modification time +// and content, for telling that nothing was written, created or touched. +async function snapshot(dir: string): Promise> { + const out: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out[path] = "dir"; + Object.assign(out, await snapshot(path)); + } else { + const s = await stat(path, { bigint: true }); + const sha = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + out[path] = `${s.size}:${s.mtimeNs}:${sha}`; + } + } + return out; +} + +const measure = () => + measureBoardLive({ + root, + repos: ["."], + paths, + now: NOW, + measuredWith: WITH, + probeImports: NOTHING_TO_IMPORT, + }); + +describe("measureBoardLive", () => { + it("measures the built-in sections a board needs no declaration for, and nothing else", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + const m = await measure(); + expect(Object.keys(m).sort()).toEqual( + [ + "measured_at", + "measured_with", + "complete", + "not_found", + "methods", + "repos", + "trail", + "integrity", + "review_gaps", + "freshness", + "effort", + "components", + ].sort(), + ); + expect(m.methods).toEqual({ + repos: 1, + trail: 1, + integrity: 1, + review_gaps: 1, + freshness: 1, + effort: 1, + components: 2, + }); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + expect(m.measured_at).toBe(NOW.toISOString()); + expect(m.repos).toMatchObject([{ path: ".", branch: "main", commits: 1, uncommitted: 0 }]); + expect(m.trail).toEqual({ decisions_all: 0, decisions_live: 0, tracks_open: [] }); + expect(m.freshness).toEqual({ + newest_session_at: "2026-10-01T00:00:00Z", + unimported: { new: 0, updated: 0, unverifiable: 0 }, + }); + // From the day of the first session, in this host's time zone. + expect(m.effort.daily?.[0]?.date).toBe(m.effort.start); + expect(m.effort.time_zone).not.toBeNull(); + const name = basename(root); + expect(m.components).toEqual({ [name]: ["manifest"], [`${name}/web`]: ["manifest"] }); + }); + + it("writes nothing", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + // Before the snapshot: git status itself may refresh the index. + const status = git("status", "--porcelain"); + const before = await snapshot(root); + await measure(); + // Every file of the repository, its .git and its .basou/ included. + expect(await snapshot(root)).toEqual(before); + expect(Object.keys(before).some((p) => p.endsWith(join(".git", "index")))).toBe(true); + expect(git("status", "--porcelain")).toBe(status); + }); + + it("says what it could not measure, and is not complete then", async () => { + const m = await measureBoardLive({ + root, + repos: [".", "../missing"], + paths, + now: NOW, + measuredWith: WITH, + }); + expect(m.complete).toBe(false); + const at = m.not_found.map((n) => n.at); + expect(at).toContain("repos[../missing]"); + // No dry run of an import was given, so the sessions not imported are not known. + expect(at).toContain("freshness.unimported"); + expect(m.components).toBeNull(); + }); +}); + +describe("boardLivePage", () => { + it("lays the measurement out for the page, under the workspace's name", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + const m = await measure(); + const page = boardLivePage("ws", m); + const name = basename(root); + expect(page.heading).toEqual({ + title: "ws", + measured_at: NOW.toISOString(), + complete: true, + not_found: 0, + }); + expect(page.effort.milestones).toEqual([]); + expect(page.effort.daily?.length).toBe(page.effort.period_days); + expect(page.integrity.sessions).toBe(1); + expect(page.components).toEqual([ + { key: name, kinds: ["manifest"] }, + { key: `${name}/web`, kinds: ["manifest"] }, + ]); + expect(page.repos[0]).not.toHaveProperty("files"); + expect(page.footnotes).toEqual({ not_found: [], measured_with: WITH }); + }); +}); diff --git a/packages/core/src/board/live.ts b/packages/core/src/board/live.ts new file mode 100644 index 00000000..2911a378 --- /dev/null +++ b/packages/core/src/board/live.ts @@ -0,0 +1,215 @@ +import { resolve } from "node:path"; +import type { ReplayWarning } from "../events/event-replay.js"; +import type { BasouPaths } from "../storage/basou-dir.js"; +import type { TaskSkipReason } from "../storage/tasks.js"; +import { + BOARD_COMPONENTS_METHOD, + type BoardComponent, + byCodePoint, + measureComponents, +} from "./components.js"; +import { BOARD_DEFAULT_AT } from "./declaration.js"; +import { BOARD_EFFORT_METHOD, type BoardEffort, measureEffort } from "./effort.js"; +import { + BOARD_FRESHNESS_METHOD, + type BoardFreshness, + type BoardImportProbe, + measureFreshness, +} from "./freshness.js"; +import { BOARD_INTEGRITY_METHOD, type BoardIntegrity, measureIntegrity } from "./integrity.js"; +import type { BoardNotFound } from "./measure.js"; +import { type BoardPageBody, effortOf } from "./page.js"; +import { BOARD_REPOS_METHOD, type BoardRepo, measureRepos } from "./repos.js"; +import { + BOARD_REVIEW_GAPS_METHOD, + type BoardReviewGaps, + measureReviewGaps, +} from "./review-gaps.js"; +import { openRepoScope, type RepoScopeResult } from "./scope.js"; +import { BOARD_TRAIL_METHOD, type BoardTrail, readDecisions, trailSection } from "./trail.js"; + +/** + * What can be measured of a workspace with no board declared: the built-in + * sections of `basou board measure` that need nothing from a declaration, + * measured as that command measures them. The effort starts on the day of + * the first session, in this host's time zone; the components are the ones + * the markers find, held against no registry. There is no axis, no measure + * or ratio of a declaration, no digest and no diff: nothing here is recorded + * or judged. + */ +export type BoardLiveMeasurement = { + measured_at: string; + measured_with: { basou: string; build: string | null }; + complete: boolean; + not_found: BoardNotFound[]; + /** The version of how each section measures, as in a measurement of a declared board. */ + methods: { + repos: number; + trail: number; + integrity: number; + review_gaps: number; + freshness: number; + effort: number; + components: number; + }; + repos: BoardRepo[]; + trail: BoardTrail; + integrity: BoardIntegrity; + review_gaps: BoardReviewGaps; + freshness: BoardFreshness; + effort: BoardEffort; + /** The components found, by key; null, with an entry at `components`, when they were not measured. */ + components: Record | null; +}; + +export type MeasureBoardLiveInput = { + /** The absolute root the manifest's repo paths are relative to. */ + root: string; + /** The paths of the manifest's repos, in its order. */ + repos: readonly string[]; + paths: BasouPaths; + now: Date; + measuredWith: { basou: string; build: string | null }; + onReplayWarning?: (warning: ReplayWarning, sessionId: string) => void; + onTaskSkip?: (taskId: string, reason: TaskSkipReason) => void; + /** A dry run of `basou refresh`, to count the sessions not yet imported. */ + probeImports?: () => Promise; +}; + +/** Measure what a workspace shows with no board declared. Reads, never writes, and sends nothing. */ +export async function measureBoardLive( + input: MeasureBoardLiveInput, +): Promise { + const notFound: BoardNotFound[] = []; + const scopes = new Map>(); + const worktreeOf = (repo: string): Promise => { + let scope = scopes.get(repo); + if (scope === undefined) { + scope = openRepoScope(resolve(input.root, repo), BOARD_DEFAULT_AT); + scopes.set(repo, scope); + } + return scope; + }; + + const repos = await measureRepos(input.repos, input.root, worktreeOf); + notFound.push(...repos.notFound); + const trailed = trailSection(await readDecisions(input)); + notFound.push(...trailed.notFound); + const verified = await measureIntegrity(input.paths); + notFound.push(...verified.notFound); + const reviewed = await measureReviewGaps(input); + notFound.push(...reviewed.notFound); + const current = await measureFreshness(input); + notFound.push(...current.notFound); + const worked = await measureEffort({ + paths: input.paths, + now: input.now, + repos: input.repos, + authorDates: repos.authorDates, + }); + notFound.push(...worked.notFound); + const built = await measureComponents({ + repos: input.repos, + names: new Map(repos.repos.map((repo) => [repo.path, repo.name])), + worktreeOf, + registered: {}, + }); + notFound.push(...built.notFound); + const { found } = built.components; + + return { + measured_at: input.now.toISOString(), + measured_with: input.measuredWith, + complete: notFound.length === 0, + not_found: notFound, + methods: { + repos: BOARD_REPOS_METHOD, + trail: BOARD_TRAIL_METHOD, + integrity: BOARD_INTEGRITY_METHOD, + review_gaps: BOARD_REVIEW_GAPS_METHOD, + freshness: BOARD_FRESHNESS_METHOD, + effort: BOARD_EFFORT_METHOD, + components: BOARD_COMPONENTS_METHOD, + }, + repos: repos.repos, + trail: trailed.trail, + integrity: verified.integrity, + review_gaps: reviewed.reviewGaps, + freshness: current.freshness, + effort: worked.effort, + components: + found === null + ? null + : Object.fromEntries(Object.entries(found).map(([key, c]) => [key, [...c.kinds]])), + }; +} + +/** What the board page draws for a workspace measured now, with no record. */ +export type BoardLivePage = { + heading: { title: string; measured_at: string; complete: boolean; not_found: number }; + effort: BoardPageBody["effort"]; + repos: { + path: string; + name: string | null; + head: string | null; + branch: string | null; + last_commit: string | null; + commits: number | null; + uncommitted: number | null; + behind_main: number | null; + }[]; + trail: BoardTrail; + integrity: BoardIntegrity & { + /** Every session counted in `by_status`, null when it was not measured. */ + sessions: number | null; + }; + review_gaps: BoardReviewGaps; + freshness: BoardFreshness; + /** By key, in code point order; null when they were not measured. */ + components: { key: string; kinds: string[] }[] | null; + footnotes: { + not_found: BoardNotFound[]; + measured_with: { basou: string; build: string | null }; + }; +}; + +/** Lay out a measurement of a workspace with no board for the board page. */ +export function boardLivePage(title: string, m: BoardLiveMeasurement): BoardLivePage { + const byStatus = m.integrity.by_status; + return { + heading: { + title, + measured_at: m.measured_at, + complete: m.complete, + not_found: m.not_found.length, + }, + effort: effortOf(m.effort, []), + repos: m.repos.map((r) => ({ + path: r.path, + name: r.name, + head: r.head, + branch: r.branch, + last_commit: r.last_commit, + commits: r.commits, + uncommitted: r.uncommitted, + behind_main: r.behind_main, + })), + trail: m.trail, + integrity: { + ...m.integrity, + sessions: byStatus === null ? null : Object.values(byStatus).reduce((sum, n) => sum + n, 0), + }, + review_gaps: m.review_gaps, + freshness: m.freshness, + components: + m.components === null + ? null + : Object.keys(m.components) + .sort(byCodePoint) + .map((key) => ({ key, kinds: [...(m.components?.[key] ?? [])] })), + footnotes: { + not_found: m.not_found.map(({ at, reason }) => ({ at, reason })), + measured_with: { basou: m.measured_with.basou, build: m.measured_with.build }, + }, + }; +} diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 00df005b..f7e325dd 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -447,24 +447,7 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ...(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 })), - ...daysOf(m.effort.daily), - }, + effort: effortOf(m.effort, d.effort.milestones ?? []), matrix: { stages, lanes: d.lanes.map((lane) => ({ @@ -578,6 +561,46 @@ type MeasuredDay = { commits: Readonly>; }; +/** The effort section of a measurement, as a record holds it or as basou measured it now. */ +export type MeasuredEffort = { + 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: Readonly> | null; + daily: readonly MeasuredDay[] | null; +}; + +/** + * What the page draws of the period and effort: the measured effort, the + * milestones declared, and the days and weeks derived from its daily rows. + */ +export function effortOf( + effort: MeasuredEffort, + milestones: readonly { date: string; label: string; ref: string }[], +): BoardPageBody["effort"] { + return { + start: effort.start, + time_zone: effort.time_zone, + elapsed_days: effort.elapsed_days, + active_ms: { + union: effort.active_ms.union, + claude: effort.active_ms.claude, + codex: effort.active_ms.codex, + }, + output_tokens: effort.output_tokens, + sessions_without_tokens: effort.sessions_without_tokens, + commits: + effort.commits === null + ? null + : Object.entries(effort.commits).map(([repo, n]) => ({ repo, count: n })), + milestones: milestones.map(({ date, label, ref }) => ({ date, label, ref })), + ...daysOf(effort.daily), + }; +} + // The sum, null when any of it was not measured: a part is not the whole. function sumOf(values: readonly (number | null)[]): number | null { let sum = 0; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 956f0262..5487ca70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,8 @@ export type { BoardImportProbe, BoardIntegrity, BoardLastReview, + BoardLiveMeasurement, + BoardLivePage, BoardMeasure, BoardMeasureKind, BoardMeasurement, @@ -129,6 +131,7 @@ export type { BoardValueChange, BoardValueOnly, MeasureBoardInput, + MeasureBoardLiveInput, PreviousOutcome, ReadBoardRecord, } from "./board/index.js"; @@ -152,6 +155,7 @@ export { BOARD_TRAIL_METHOD, BOARD_VERSION, boardDigest, + boardLivePage, boardPage, boardPageUnavailable, buildRecord, @@ -160,6 +164,7 @@ export { diffMeasurements, diffObserved, measureBoard, + measureBoardLive, modelKey, NO_PREVIOUS_RECORDS, orderAnomalies, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 27d3a62d..67095498 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -958,6 +958,38 @@ export type BoardPageStrings = { unknownVersion: string; notARecord: string; }; + /** The board measured on the spot, with no record: what basou measures, nothing judged. */ + live: { + measureNow: string; + toRecords: string; + measuring: string; + remeasure: string; + measuredAt: string; + repos: string; + repo: string; + branch: string; + head: string; + lastCommit: string; + commits: string; + uncommitted: string; + behindMain: string; + trail: string; + decisions: string; + decisionsAll: string; + reviewGaps: string; + newestSession: string; + noSession: string; + unimported: string; + unimportedNotMeasured: string; + byStatus: string; + byVerdict: string; + tracks: string; + noTracks: string; + components: string; + noComponents: string; + note: string; + judged: string; + }; }; const BOARD_PAGE_EN: BoardPageStrings = { @@ -1089,6 +1121,38 @@ const BOARD_PAGE_EN: BoardPageStrings = { "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.", }, + live: { + measureNow: "Measure now", + toRecords: "Recorded board", + measuring: "Measuring the workspace...", + remeasure: "Measure again", + measuredAt: "Measured {at}, on the spot (not a record)", + repos: "Repositories", + repo: "Repository", + branch: "Branch", + head: "HEAD", + lastCommit: "Last commit", + commits: "Commits", + uncommitted: "Uncommitted", + behindMain: "Behind origin/main", + trail: "Trail", + decisions: "Decisions", + decisionsAll: "{n} in all, voided ones included", + reviewGaps: "Review gaps", + newestSession: "Newest session", + noSession: "no session", + unimported: "not imported: {new} new, {updated} updated, {unverifiable} unverifiable", + unimportedNotMeasured: "sessions not imported: not measured", + byStatus: "Sessions by basou verify status: {list}", + byVerdict: "Units of work by basou review-gaps verdict: {list}", + tracks: "Open tracks", + noTracks: "No open track.", + components: "Components", + noComponents: "No component was found.", + note: "basou measured these values from this workspace's repos and trail when the page was opened. They are not recorded, and nothing here is judged.", + judged: + "The reach matrix, the lanes and the operator's turns are drawn from a record: declare the board in board/board.yaml, have an agent judge it, and record it with basou board record.", + }, }; const BOARD_PAGE_JA: BoardPageStrings = { @@ -1219,6 +1283,38 @@ const BOARD_PAGE_JA: BoardPageStrings = { unknownVersion: "記録 {record} は record_version {version} で、この basou はこの版を描けない。", notARecord: "記録 {record} は記録の形をしていない。", }, + live: { + measureNow: "いま測る", + toRecords: "記録の盤へ", + measuring: "workspace を測っています...", + remeasure: "測り直す", + measuredAt: "{at} にその場で測った(記録ではない)", + repos: "repo", + repo: "repo", + branch: "branch", + head: "HEAD", + lastCommit: "最終 commit", + commits: "commit 数", + uncommitted: "未 commit", + behindMain: "origin/main からの遅れ", + trail: "証跡", + decisions: "判断", + decisionsAll: "取り消したものを含めて {n}", + reviewGaps: "レビューの抜け", + newestSession: "最新の session", + noSession: "session なし", + unimported: "未取り込み: 新規 {new}・更新 {updated}・確かめられない {unverifiable}", + unimportedNotMeasured: "未取り込み: 測れなかった", + byStatus: "basou verify の状態ごとの session: {list}", + byVerdict: "basou review-gaps の判定ごとの作業の単位: {list}", + tracks: "未完トラック", + noTracks: "未完トラックはない。", + components: "構成要素", + noComponents: "見つかった構成要素はない。", + note: "ページを開いたときに、basou がこの workspace の repo と証跡を測った値。記録には残らず、判定も含まない。", + judged: + "到達マトリクス・レーン・operator の手番は、判定の記録から描かれる。board/board.yaml に盤を宣言し、AI に判定させて basou board record で記録すると、ここに出る。", + }, }; /** The fixed strings of the board page for a resolved view language. */