import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { BOARD_NOTE_LIMIT } from "./board-constants.ts"; import type { BundleDocumentRecord, DocumentMetadata } from "./documents.ts"; import { resolveGovernedPath, SystemDocumentError } from "./project-root.ts"; import { parseDocumentId } from "./system-config.ts"; export const BOARD_SCHEMA_VERSION = 0; export const BOARD_PATH = ".system/board.json"; export { BOARD_NOTE_LIMIT } from "./board-constants.ts"; export type BoardItemState = "todo" | "done" | "open"; export type BoardItemSource = "manual" | "assess" | "agent"; export type BoardEntry = Readonly<{ state: BoardItemState; updatedAt: string; source: BoardItemSource; note?: string; }>; export type BoardProposal = Readonly<{ id: string; state: BoardItemState; rationale: string; }>; export type BoardState = Readonly<{ schemaVersion: number; items: Readonly>; }>; const objectValue = ( value: unknown, label: string, ): Record => { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SystemDocumentError( "board.shape", `${label} must be a JSON object`, ); return value as Record; }; const exactKeys = ( value: Record, required: readonly string[], label: string, optional: readonly string[] = [], ) => { const actual = Object.keys(value).sort(); if ( required.some((key) => !(key in value)) || actual.some((key) => !required.includes(key) && !optional.includes(key)) ) throw new SystemDocumentError( "board.keys", `${label} expected keys ${[...required].sort().join(", ")}; received ${actual.join(", ")}`, ); }; const validState = (type: DocumentMetadata["type"], state: string): boolean => { switch (type) { case "spec": case "plan": return state === "todo" || state === "done"; case "issue": return state === "open" || state === "done"; default: return false; } }; export function parseBoardState(source: string, prefix: string): BoardState { let parsed: unknown; try { parsed = JSON.parse(source); } catch { throw new SystemDocumentError("board.invalid-json", "Invalid board JSON"); } const value = objectValue(parsed, "Board"); if (value.schemaVersion !== BOARD_SCHEMA_VERSION) throw new SystemDocumentError( "board.version", `Board schema version ${String(value.schemaVersion)} does not match ${BOARD_SCHEMA_VERSION}; run /system init`, ); exactKeys(value, ["items", "schemaVersion"], "Board"); const rawItems = objectValue(value.items, "Board items"); const items: Record = {}; for (const [id, rawEntry] of Object.entries(rawItems)) { const parsedId = parseDocumentId(id, prefix); if (!["spec", "plan", "issue"].includes(parsedId.type)) throw new SystemDocumentError( "board.item-type", `Board item must reference a spec, plan, or issue: ${id}`, ); const entry = objectValue(rawEntry, `Board item ${id}`); exactKeys(entry, ["source", "state", "updatedAt"], `Board item ${id}`, [ "note", ]); if (typeof entry.state !== "string") throw new SystemDocumentError( "board.state", `Board item state must be a string: ${id}`, ); if (!validState(parsedId.type, entry.state)) throw new SystemDocumentError( "board.state", `Invalid ${parsedId.type} board state ${entry.state}: ${id}`, ); if ( entry.source !== "manual" && entry.source !== "assess" && entry.source !== "agent" ) throw new SystemDocumentError( "board.source", `Invalid board source for ${id}`, ); if ( entry.note !== undefined && (typeof entry.note !== "string" || entry.note.trim().length === 0 || entry.note.length > BOARD_NOTE_LIMIT) ) throw new SystemDocumentError( "board.note", `Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`, ); if ( typeof entry.updatedAt !== "string" || Number.isNaN(Date.parse(entry.updatedAt)) || new Date(entry.updatedAt).toISOString() !== entry.updatedAt ) throw new SystemDocumentError( "board.updated-at", `Board updatedAt must be a canonical ISO timestamp: ${id}`, ); items[id] = { state: entry.state as BoardItemState, updatedAt: entry.updatedAt, source: entry.source, ...(entry.note === undefined ? {} : { note: entry.note as string }), }; } return { schemaVersion: BOARD_SCHEMA_VERSION, items }; } export function boardStateForInitialization( source: string, prefix: string, ): { state: BoardState; sourceSchemaVersion?: number } { try { return { state: parseBoardState(source, prefix) }; } catch (error) { if ( !(error instanceof SystemDocumentError) || error.code !== "board.version" ) throw error; const parsed = objectValue(JSON.parse(source), "Board"); if ( typeof parsed.schemaVersion !== "number" || !Number.isInteger(parsed.schemaVersion) ) throw error; const state = parseBoardState( JSON.stringify({ schemaVersion: BOARD_SCHEMA_VERSION, items: parsed.items, }), prefix, ); return { state, sourceSchemaVersion: parsed.schemaVersion }; } } export const emptyBoardState = (): BoardState => ({ schemaVersion: BOARD_SCHEMA_VERSION, items: {}, }); export function boardStateSource(state: BoardState): string { return `${JSON.stringify( { schemaVersion: BOARD_SCHEMA_VERSION, items: Object.fromEntries( Object.entries(state.items).sort(([left], [right]) => left.localeCompare(right), ), ), }, null, "\t", )}\n`; } export function readBoardState(root: string, prefix: string): BoardState { return parseBoardState( readFileSync(resolveGovernedPath(root, BOARD_PATH), "utf8"), prefix, ); } export function defaultBoardState(metadata: DocumentMetadata): BoardItemState { return metadata.type === "issue" ? "open" : "todo"; } export function boardStateFor( board: BoardState, metadata: DocumentMetadata, ): BoardItemState { return board.items[metadata.id]?.state ?? defaultBoardState(metadata); } export function updateBoardState( board: BoardState, documents: readonly BundleDocumentRecord[], id: string, state: BoardItemState, source: BoardItemSource, updatedAt = new Date().toISOString(), note?: string, ): BoardState { const document = documents.find((candidate) => candidate.metadata.id === id); if (!document) throw new SystemDocumentError( "board.missing-item", `No System board item: ${id}`, ); if (!validState(document.metadata.type, state)) throw new SystemDocumentError( "board.state", `Invalid ${document.metadata.type} board state ${state}: ${id}`, ); const trimmedNote = note?.trim(); if (trimmedNote !== undefined && trimmedNote.length > BOARD_NOTE_LIMIT) throw new SystemDocumentError( "board.note", `Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`, ); const items: Record = { ...board.items }; items[id] = { state, source, updatedAt, ...(trimmedNote ? { note: trimmedNote } : {}), }; if (document.metadata.type === "spec" && state === "done") { const unfinished = documents.filter( (candidate) => candidate.metadata.type === "plan" && candidate.metadata.spec === id && (items[candidate.metadata.id]?.state ?? "todo") !== "done", ); if (unfinished.length) throw new SystemDocumentError( "board.spec-plans", `Spec ${id} cannot be done while plans remain todo: ${unfinished.map((item) => item.metadata.id).join(", ")}`, ); } if (document.metadata.type === "plan" && state === "todo") { const specId = document.metadata.spec; const spec = documents.find( (candidate) => candidate.metadata.type === "spec" && candidate.metadata.id === specId, ); if (spec && (items[spec.metadata.id]?.state ?? "todo") === "done") items[spec.metadata.id] = { state: "todo", source, updatedAt }; } return { schemaVersion: BOARD_SCHEMA_VERSION, items }; } export function writeBoardState(root: string, state: BoardState): void { const target = resolveGovernedPath(root, BOARD_PATH); const temporary = path.join( path.dirname(target), `.board-${process.pid}-${Date.now()}.json`, ); writeFileSync(temporary, boardStateSource(state), { encoding: "utf8", flag: "wx", }); try { renameSync(temporary, target); } finally { rmSync(temporary, { force: true }); } }