import { StringEnum } from "@earendil-works/pi-ai"; import { defineTool, keyHint } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { BOARD_NOTE_LIMIT } from "./board-constants.ts"; import type { BoardServer } from "./board-server.ts"; import type { BoardProposal } from "./board-state.ts"; import { loadBoardServer, loadBoardState, loadOpenBrowser, loadProjectIndex, loadSystemRenderer, } from "./loaders.js"; import { requireProjectRoot } from "./project-root.ts"; import { expandableFallback } from "./tool-rendering.ts"; const STATES = ["todo", "done", "open"] as const; const proposalParameters = Type.Object({ items: Type.Array( Type.Object({ id: Type.String({ description: "Existing spec, plan, or issue ID." }), state: StringEnum(STATES, { description: "Proposed board state for the item type.", }), rationale: Type.String({ minLength: 1, maxLength: 1000, description: "Terse evidence supporting this proposed state.", }), }), { minItems: 0, maxItems: 100 }, ), }); const updateParameters = Type.Object({ items: Type.Array( Type.Object({ id: Type.String({ description: "Existing spec, plan, or issue ID." }), state: StringEnum(STATES, { description: "Board state for the item type.", }), note: Type.String({ minLength: 1, maxLength: BOARD_NOTE_LIMIT, description: "Terse verified evidence stored with the board entry.", }), }), { minItems: 0, maxItems: 100 }, ), }); interface CallTheme { fg(style: string, text: string): string; bold(text: string): string; } function renderBoardItems( name: string, args: { items?: unknown }, theme: CallTheme, context: { expanded: boolean; lastComponent?: unknown }, ): Text { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const items = Array.isArray(args.items) ? (args.items as Record[]) : []; const count = items.length; let output = `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("toolOutput", `${count} item${count === 1 ? "" : "s"}`)}`; if (!context.expanded && count > 0) { output += theme.fg("dim", ` (${keyHint("app.tools.expand", "to expand")})`); } else { for (const item of items) { output += `\n${theme.fg("accent", String(item.state).toUpperCase())} ${theme.fg("toolOutput", String(item.id))}`; const evidence = item.rationale ?? item.note; if (evidence) output += `\n${theme.fg("dim", String(evidence))}`; } } text.setText(output); return text; } const renderBoardToolResult = ( result: Readonly<{ content: Parameters[0]; details?: unknown; }>, expanded: boolean, theme: CallTheme, isError: boolean, lastComponent: unknown, messages: Readonly<{ empty: string; summary: (count: number) => string }>, ): Text => { const text = (lastComponent as Text | undefined) ?? new Text("", 0, 0); const items = (result.details as { items?: BoardProposal[] } | undefined) ?.items; if (!items) { text.setText( expandableFallback( result.content, expanded, theme, isError ? "error" : "muted", ), ); return text; } text.setText( theme.fg( "success", items.length === 0 ? messages.empty : messages.summary(items.length), ), ); return text; }; export function createBoardRuntime() { let proposalRoot: string | undefined; let proposalFinished = false; let updateRoot: string | undefined; let updateFinished = false; const proposals = new Map>(); const servers = new Set(); const loadBoard = async (cwd: string) => { const root = requireProjectRoot(cwd); const [projectIndex, boardState] = await Promise.all([ loadProjectIndex(), loadBoardState(), ]); const index = projectIndex.loadProjectIndex(root); const state = boardState.readBoardState(root, index.prefix); return { boardState, index, root, state }; }; const tool = defineTool({ name: "system_board_propose", label: "system_board_propose", description: "Record evidence-backed proposed board states for human review. Accepts existing spec, plan, or issue IDs and does not modify .system/board.json.", promptSnippet: "Propose System board states for human review", promptGuidelines: [ "During /system assess, call system_board_propose exactly once with evidence-backed state changes; use an empty items list when no changes are justified.", ], parameters: proposalParameters, renderCall(args, theme, context) { return renderBoardItems("system_board_propose", args, theme, context); }, renderResult(result, { expanded }, theme, context) { return renderBoardToolResult( result, expanded, theme, Boolean(context.isError), context.lastComponent, { empty: "✓ no changes proposed", summary: (count) => `✓ ${count} change${count === 1 ? "" : "s"} proposed`, }, ); }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const root = requireProjectRoot(ctx.cwd); if (root !== proposalRoot) throw new Error( "system_board_propose is only available during /system assess or /system exec", ); const { index } = await loadBoard(root); if (root !== proposalRoot) throw new Error( "system_board_propose is only available during /system assess or /system exec", ); const byId = new Map( index.documents.map((document) => [document.metadata.id, document]), ); const proposed = proposals.get(root) ?? new Map(); for (const raw of params.items) { const document = byId.get(raw.id); if (!document) throw new Error(`No System board item: ${raw.id}`); const valid = document.metadata.type === "issue" ? raw.state === "open" || raw.state === "done" : (document.metadata.type === "spec" || document.metadata.type === "plan") && (raw.state === "todo" || raw.state === "done"); if (!valid) throw new Error( `Invalid ${document.metadata.type} board state ${raw.state}: ${raw.id}`, ); proposed.set(raw.id, { id: raw.id, state: raw.state, rationale: raw.rationale.trim(), }); } proposals.set(root, proposed); proposalFinished = true; const items = [...proposed.values()].sort((left, right) => left.id.localeCompare(right.id), ); return { content: [ { type: "text" as const, text: `${params.items.length} board state proposal(s) recorded for human review. Run /system board to inspect and apply them.`, }, ], details: { items }, }; }, }); const updateTool = defineTool({ name: "system_board_update", label: "system_board_update", description: "Apply verified board states to .system/board.json and record their source as agent. Accepts existing spec, plan, or issue IDs and rejects states that violate board invariants.", promptSnippet: "Update System board states from verified work", promptGuidelines: [ "After fresh verification in /system exec, call system_board_update exactly once with the states your work proves; use an empty items list when no change is justified.", ], parameters: updateParameters, renderCall(args, theme, context) { return renderBoardItems("system_board_update", args, theme, context); }, renderResult(result, { expanded }, theme, context) { return renderBoardToolResult( result, expanded, theme, Boolean(context.isError), context.lastComponent, { empty: "✓ no board changes", summary: (count) => `✓ ${count} board change${count === 1 ? "" : "s"} applied`, }, ); }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const root = requireProjectRoot(ctx.cwd); if (root !== updateRoot) throw new Error( "system_board_update is only available during /system exec", ); const loaded = await loadBoard(root); if (root !== updateRoot) throw new Error( "system_board_update is only available during /system exec", ); let state = loaded.state; const updatedAt = new Date().toISOString(); const applied: BoardProposal[] = []; for (const raw of params.items) { state = loaded.boardState.updateBoardState( state, loaded.index.documents, raw.id, raw.state, "agent", updatedAt, raw.note, ); applied.push({ id: raw.id, state: raw.state, rationale: raw.note.trim(), }); } if (applied.length > 0) loaded.boardState.writeBoardState(root, state); updateFinished = true; const items = [...applied].sort((left, right) => left.id.localeCompare(right.id), ); return { content: [ { type: "text" as const, text: applied.length === 0 ? "No board states changed." : `${applied.length} board state(s) applied. Run /system board to review or override them.`, }, ], details: { items }, }; }, }); return { tool, updateTool, async beginUpdate(cwd: string) { const { root } = await loadBoard(cwd); updateRoot = root; updateFinished = false; }, updateComplete() { return updateRoot !== undefined && updateFinished; }, endUpdate() { updateRoot = undefined; updateFinished = false; }, async beginProposal(cwd: string) { const { root } = await loadBoard(cwd); proposalRoot = root; proposalFinished = false; proposals.set(root, new Map()); }, proposalComplete() { return proposalRoot !== undefined && proposalFinished; }, endProposal() { proposalRoot = undefined; proposalFinished = false; }, async open(cwd: string): Promise { const { index } = await loadBoard(cwd); const [{ renderSystemBoardSite }, { startBoardServer }, { openUrl }] = await Promise.all([ loadSystemRenderer(), loadBoardServer(), loadOpenBrowser(), ]); const current = proposals.get(index.root) ?? new Map(); proposals.set(index.root, current); const server = await startBoardServer( index.root, current, (site, state, pending, revision) => renderSystemBoardSite(site, state, pending, revision), ); servers.add(server); const opened = await openUrl(server.url); return opened.ok ? `System board opened: ${server.url}\nBrowser: ${opened.command}` : `System board running: ${server.url}\nBrowser: ${opened.error}`; }, shutdown() { proposalRoot = undefined; proposalFinished = false; updateRoot = undefined; updateFinished = false; for (const server of servers) server.close(); servers.clear(); }, }; }