/** * In-memory state for the SDD cockpit (DRAFT-001, v0.3.0). * * Tracks the `.md` files the cockpit knows about, the snapshot taken at * first observation (for session-scoped diff in checkpoint B), per-file * edit count, recents order, and the "current" (most recently touched) * file. Reset on session_start. */ import * as fs from "node:fs"; import * as path from "node:path"; import { readSpecStatus, type SpecStatus } from "./frontmatter"; export interface FileEntry { /** Absolute path. */ absPath: string; /** Path relative to repo root, used for display. */ relPath: string; /** * Content at first observation. `null` if the file did not exist at * snapshot time (i.e. the agent created it). Used to compute the * session-scoped diff. */ snapshot: string | null; /** Number of agent-driven edits observed this session. */ edits: number; /** Whether the file came from discovery (true) or only from a tool touch (false). */ discovered: boolean; /** Monotonic counter; higher = more recently touched. 0 = never touched. */ touchedAt: number; } export class CockpitState { private files = new Map(); private touchCounter = 0; private listeners = new Set<() => void>(); /** Parsed spec status cache. `null` = file parsed and had no `status:`. */ private statusCache = new Map(); readonly repoRoot: string; constructor(repoRoot: string) { this.repoRoot = repoRoot; } // --- subscription --- onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private emit() { for (const l of this.listeners) { try { l(); } catch { // listener errors must not break state updates } } } // --- queries --- get(absPath: string): FileEntry | undefined { return this.files.get(absPath); } all(): FileEntry[] { return Array.from(this.files.values()); } /** Most recently touched file, or undefined if nothing has been touched. */ current(): FileEntry | undefined { let best: FileEntry | undefined; for (const e of this.files.values()) { if (e.touchedAt > 0 && (!best || e.touchedAt > best.touchedAt)) { best = e; } } return best; } /** Total edits across all tracked files this session. */ totalEdits(): number { let n = 0; for (const e of this.files.values()) n += e.edits; return n; } /** * Lazy-parsed spec status for a file. Cached; the cache is invalidated by * `afterEdit` and by `invalidateStatus` (after the cockpit writes a flip). * Files without spec-shaped frontmatter resolve to `undefined` (cached as * `null` internally to distinguish from "not yet parsed"). */ getStatus(absPath: string): SpecStatus | undefined { if (this.statusCache.has(absPath)) { const v = this.statusCache.get(absPath)!; return v === null ? undefined : v; } const { status } = readSpecStatus(absPath); this.statusCache.set(absPath, status ?? null); return status; } /** Drop a single file's status cache entry; next `getStatus` re-reads disk. */ invalidateStatus(absPath: string): void { this.statusCache.delete(absPath); this.emit(); } // --- mutations --- /** * Register a discovered file (no snapshot yet — we snapshot lazily on * first tool touch to avoid reading every spec into memory upfront). */ registerDiscovered(absPath: string) { if (this.files.has(absPath)) return; this.files.set(absPath, { absPath, relPath: path.relative(this.repoRoot, absPath) || absPath, snapshot: null, edits: 0, discovered: true, touchedAt: 0, }); this.emit(); } /** * Called from `tool_call` (before execution). Ensures we have a snapshot * of the file's current on-disk content *before* the agent writes to it. * Safe to call repeatedly — snapshots only on first call per path. */ beforeEdit(absPath: string) { let entry = this.files.get(absPath); if (!entry) { entry = { absPath, relPath: path.relative(this.repoRoot, absPath) || absPath, snapshot: null, edits: 0, discovered: false, touchedAt: 0, }; this.files.set(absPath, entry); } if (entry.snapshot === null && !entry.discovered) { // First observation of a previously-unknown file — try to read. // If it doesn't exist, snapshot stays null (file is being created). try { entry.snapshot = fs.readFileSync(absPath, "utf8"); } catch { entry.snapshot = null; } } else if (entry.snapshot === null && entry.discovered) { // Discovered files snapshot lazily on first edit. try { entry.snapshot = fs.readFileSync(absPath, "utf8"); } catch { entry.snapshot = null; } } this.emit(); } /** * Called from `tool_result` (after execution). Bumps the edit counter * and recency. */ afterEdit(absPath: string) { let entry = this.files.get(absPath); if (!entry) { // Should be rare — tool_result without preceding tool_call snapshot. // Track it anyway so the widget reflects reality. entry = { absPath, relPath: path.relative(this.repoRoot, absPath) || absPath, snapshot: null, edits: 0, discovered: false, touchedAt: 0, }; this.files.set(absPath, entry); } entry.edits += 1; entry.touchedAt = ++this.touchCounter; // Frontmatter may have changed; re-read on next access. this.statusCache.delete(absPath); this.emit(); } }