#!/usr/bin/env node // src/cli/goals.ts — Phase 6 standalone read-only CLI over the goals store. // // Dist-runnable: `node dist/src/cli/goals.js [--state-dir ] // [goalId]` (npm run cli -- ...). Read-only: // status/tree reuse engine.getGoal (the SAME restore path the extension // uses, overlay included); list/stats/export reuse restoreAllGoals plus the // canonical store path helpers. The CLI adds NO business logic — only // argument parsing and output shaping. Honest errors and uniform exit codes // (batch-#2 fix): 0 ok; 1 store/runtime failure (restore-blocked, missing // store); 2 usage errors (unknown command/flag, bad arguments — usage hint // on stderr). A missing store answers "no goal exists", a restore-blocked // store prints its diagnostics and exits 1 (fail-closed, never a partial // view), and list/stats/export print a stderr warning when the quarantine // directory is non-empty (post-quarantine views may be incomplete). import { readdirSync } from "node:fs"; import path from "node:path"; import { formatGoalStatusLines, DEFAULT_GOAL_ACTIVATION_MODE } from "../extension/tools.js"; import { renderGoalTodoTree } from "../extension/hud.js"; import { createRuntimeGoalEngine } from "../runtime/engine.js"; import type { GoalRuntimeEngine } from "../runtime/engine.js"; import { restoreAllGoals } from "../store/restore.js"; import type { RestoredGoalStore } from "../store/restore.js"; import { casReceiptsPath, readStreamText } from "../store/log.js"; import { stateDir as resolveStateDir } from "../shared/paths.js"; const USAGE = [ "usage: goals [--state-dir ] [goalId]", "commands:", " status [goalId] — active (or given) goal status block", " tree [goalId] — TODO tree with icons and progress", " list — one line per goal in the store", " stats — aggregate counts over the whole store", " export — deterministic JSON dump (goal + todos + claims + receipts)", ].join("\n"); interface CliArgs { stateDir?: string; command?: string; goalId?: string; error?: string; } function parseArgs(argv: readonly string[]): CliArgs { const args: CliArgs = {}; const rest: string[] = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]!; if (arg === "--state-dir") { const value = argv[index + 1]; if (value === undefined || value.trim().length === 0) return { error: "--state-dir requires a non-empty directory path" }; args.stateDir = value; index += 1; continue; } if (arg === "--help" || arg === "-h") return { command: "help" }; if (arg.startsWith("--")) return { error: `unknown flag ${arg}` }; rest.push(arg); } if (rest.length > 2) return { error: `too many arguments: ${rest.slice(2).join(", ")}` }; args.command = rest[0]; args.goalId = rest[1]; return args; } function fail(message: string): never { process.stderr.write(`goals: ${message}\n`); process.exit(1); } /** Usage errors exit 2 (batch-#2 fix): exit 1 stays reserved for * store/runtime failures so scripts can tell "bad invocation" from * "store-blocked". The usage hint rides along on stderr. */ function failUsage(message: string): never { process.stderr.write(`goals: ${message}\n`); process.exit(2); } /** Count quarantined stream EVIDENCE files (the .diagnostic.json sidecars * are metadata, not streams). */ function countQuarantinedStreams(stateDir: string): number { let count = 0; const walk = (directory: string): void => { let entries; try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return; // no quarantine directory (or unreadable) -> nothing to warn } for (const entry of entries) { if (entry.isDirectory()) walk(path.join(directory, entry.name)); else if (!entry.name.endsWith(".diagnostic.json")) count += 1; } }; walk(path.join(stateDir, "quarantine")); return count; } /** Post-quarantine honesty (batch-#2 fix): after restore quarantined a * corrupt stream, later reads see a "clean" but possibly decimated store — * list/stats/export say so on stderr (exit stays 0). */ function warnQuarantined(stateDir: string): void { const count = countQuarantinedStreams(stateDir); if (count > 0) process.stderr.write(`warning: ${count} quarantined stream(s) — goals may be incomplete\n`); } interface StoreView { readonly goals: readonly { goalId: string; restored: RestoredGoalStore }[]; } /** Read the whole store fail-closed: blocked diagnostics abort the CLI. */ function readStore(stateDir: string): StoreView { let index; try { index = restoreAllGoals(stateDir); } catch (error) { fail(`store read failed under ${stateDir}: ${error instanceof Error ? error.message : String(error)}`); } const diagnostics = [...index.receipts.diagnostics]; const goals: { goalId: string; restored: RestoredGoalStore }[] = []; for (const [goalId, result] of Object.entries(index.goals)) { if (result.status === "blocked") diagnostics.push(...result.diagnostics); else if (result.goal !== undefined) goals.push({ goalId, restored: result }); } if (diagnostics.length > 0) { const first = diagnostics[0]!; process.stderr.write( `goals: restore-blocked store (${diagnostics.length} diagnostic(s); first: [${first.stream}] ${first.code}: ${first.message})\n`, ); for (const diagnostic of diagnostics.slice(0, 10)) { process.stderr.write(` - [${diagnostic.stream}] ${diagnostic.code}: ${diagnostic.message}\n`); } process.exit(1); } return { goals }; } function requireActiveGoal(store: StoreView, goalId: string | undefined): { goalId: string; restored: RestoredGoalStore } | undefined { if (goalId !== undefined) { const match = store.goals.find((entry) => entry.goalId === goalId); if (match === undefined) fail(`no goal ${goalId} exists in the store`); return match; } const open = store.goals.filter((entry) => entry.restored.goal?.status !== "complete"); if (open.length > 1) fail(`multiple non-complete goals exist: ${open.map((entry) => entry.goalId).sort().join(", ")}`); if (open.length === 1) return open[0]; // no open goal: fall back to the most recently updated complete goal (clear semantics) let best: { goalId: string; restored: RestoredGoalStore } | undefined; for (const entry of store.goals) { const goal = entry.restored.goal; if (goal === undefined) continue; if ( best === undefined || goal.updatedAt > best.restored.goal!.updatedAt || (goal.updatedAt === best.restored.goal!.updatedAt && entry.restored.revisions.goal > best.restored.revisions.goal) ) { best = entry; } } return best; } /** Read the global CAS receipts stream as plain receipt records (export). */ function readReceipts(stateDir: string): { count: number; entries: readonly Record[] } { const outcome = readStreamText(casReceiptsPath(stateDir)); if (!outcome.ok) fail(`cannot read the CAS receipts stream: ${outcome.message}`); const lines = outcome.text === "" ? [] : outcome.text.split("\n").slice(0, -1); const entries: Record[] = []; for (const line of lines) { try { const parsed = JSON.parse(line) as Record; entries.push((parsed.data as { receipt?: Record })?.receipt ?? parsed); } catch { // unreachable: readStore already failed closed on malformed receipts } } return { count: entries.length, entries }; } function main(): void { const args = parseArgs(process.argv.slice(2)); if (args.error !== undefined) failUsage(`${args.error}\n${USAGE}`); const command = args.command ?? "status"; if (command === "help") { process.stdout.write(`${USAGE}\n`); return; } if (command !== "status" && command !== "tree" && command !== "list" && command !== "stats" && command !== "export") { failUsage(`unknown command '${command}'\n${USAGE}`); } const shellEnv: NodeJS.ProcessEnv = process["env"]; const stateDir = args.stateDir ?? resolveStateDir(shellEnv); const store = readStore(stateDir); if (command === "status" || command === "tree") { const target = requireActiveGoal(store, args.goalId); if (target === undefined) fail(`no goal exists under ${stateDir} — create one with the extension (/goal ) or pass --state-dir`); // same restore path as the extension: engine.getGoal replays streams + overlay const engine: GoalRuntimeEngine = createRuntimeGoalEngine({ stateDir, // read-only: the lock lives outside the store and is never acquired here runtimeDir: path.join(stateDir, "runtime-lock"), }); const view = engine.getGoal(target.goalId); if (view.diagnostics !== undefined && view.diagnostics.length > 0) fail(`goal ${target.goalId} is restore-blocked (${view.diagnostics.length} diagnostic(s))`); if (view.goal === undefined) fail(`goal ${target.goalId} could not be restored`); const rendered = command === "status" ? formatGoalStatusLines(view.goal, DEFAULT_GOAL_ACTIVATION_MODE) : renderGoalTodoTree(view.goal.summary, view.goal.nodes).join("\n"); process.stdout.write(`${rendered}\n`); return; } if (command === "list") { warnQuarantined(stateDir); if (store.goals.length === 0) fail(`no goals found under ${stateDir}`); const sorted = [...store.goals].sort((a, b) => (a.restored.goal?.createdAt ?? 0) - (b.restored.goal?.createdAt ?? 0)); for (const entry of sorted) { const goal = entry.restored.goal; process.stdout.write(`${entry.goalId} ${goal?.status ?? "?"} rev ${entry.restored.revisions.goal} todos ${entry.restored.todoGraph.nodes.length} ${goal?.objective ?? ""}\n`); } return; } if (command === "stats") { warnQuarantined(stateDir); const byStatus: Record = {}; let todosTotal = 0; let todosClosed = 0; for (const entry of store.goals) { const status = entry.restored.goal?.status ?? "unknown"; byStatus[status] = (byStatus[status] ?? 0) + 1; todosTotal += entry.restored.todoGraph.nodes.length; todosClosed += entry.restored.todoGraph.nodes.filter((node) => node.status === "done" || node.status === "skipped").length; } const receipts = readReceipts(stateDir); const lines = [ `stateDir: ${stateDir}`, `goals: ${store.goals.length}`, ...Object.keys(byStatus).sort().map((status) => ` ${status}: ${byStatus[status]}`), `todos: ${todosClosed} closed / ${todosTotal} total`, `mutation receipts: ${receipts.count}`, ]; process.stdout.write(`${lines.join("\n")}\n`); return; } // export: deterministic JSON dump of goal + todos + claims + receipts warnQuarantined(stateDir); const goals = [...store.goals] .sort((a, b) => (a.goalId < b.goalId ? -1 : 1)) .map((entry) => ({ goalId: entry.goalId, goal: entry.restored.goal, revisions: entry.restored.revisions, todos: entry.restored.todoGraph.nodes, claims: entry.restored.claims, })); const dump = { schema: "pi-goals.export.v1", stateDir, goals, receipts: readReceipts(stateDir) }; process.stdout.write(`${JSON.stringify(dump, null, 2)}\n`); } main();