diff --git a/.sf/backups/db/maintenance.json b/.sf/backups/db/maintenance.json index 4d2c3a222..f29279f22 100644 --- a/.sf/backups/db/maintenance.json +++ b/.sf/backups/db/maintenance.json @@ -1,3 +1,3 @@ { - "lastFullVacuumAt": "2026-05-10T13:59:26.619Z" + "lastFullVacuumAt": "2026-05-10T23:00:57.885Z" } diff --git a/.sf/backups/db/sf.db.2026-05-10T23-00-57-817Z b/.sf/backups/db/sf.db.2026-05-10T23-00-57-817Z new file mode 100644 index 000000000..fa86839c2 Binary files /dev/null and b/.sf/backups/db/sf.db.2026-05-10T23-00-57-817Z differ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 35c169a6b..dfeb834a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -73,14 +73,53 @@ All file writes in autonomous mode pass through a gate. Protected files (CLAUDE. UOK orchestrates work through a deterministic five-phase state machine: +```mermaid +stateDiagram-v2 + direction LR + + [*] --> PhaseDiscuss : sf start / milestone begin + + PhaseDiscuss --> PhasePlan : discussion-close gate passes + PhaseDiscuss --> PhaseDiscuss : gate fails → gather more context + + PhasePlan --> PhaseExecute : planning-approval gate passes + PhasePlan --> PhasePlan : gate fails → replan or add remediation slice + + PhaseExecute --> PhaseMerge : all tasks complete, code-quality + test gates pass + PhaseExecute --> PhaseExecute : task fails → isolate + recovery slice dispatched + PhaseExecute --> PhaseExecute : stuck-loop detected → timeout / skip recovery + + PhaseMerge --> PhaseComplete : integration gate passes + PhaseMerge --> PhaseExecute : integration failure → add fix slice, retry + + PhaseComplete --> [*] : acceptance gate passes, summary written + PhaseComplete --> PhaseExecute : remediation milestone added + + note right of PhaseExecute + Task lifecycle (ORCH-style): + todo → running → verifying → reviewing + → done | blocked | paused | failed + | cancelled | retrying + end note ``` -PhaseDiscuss → PhasePlan → PhaseExecute → PhaseMerge → PhaseComplete - ↓ ↓ ↓ ↓ ↓ - (discuss) (plan) (execute) (merge) (finalize) - ↓ ↓ ↓ ↓ ↓ - gates gates gates gates validation - ↓ ↓ ↓ ↓ ↓ - (continue or remediate) + +```mermaid +stateDiagram-v2 + direction LR + + [*] --> queued : task_scheduler INSERT + queued --> due : poll tick reaches due_at + due --> claimed : atomic UPDATE (conditional, one worker wins) + claimed --> dispatched : worker picks up claim + dispatched --> consumed : unit completes (any terminal status) + dispatched --> expired : lease timeout, no heartbeat + expired --> queued : lease cleared, re-enqueued + + note right of claimed + Lease prevents two workers + dispatching the same unit + (shared-NFS / parallel mode). + end note ``` **Phase details:** diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter-permissions.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter-permissions.test.ts new file mode 100644 index 000000000..38b20e12c --- /dev/null +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter-permissions.test.ts @@ -0,0 +1,190 @@ +/** + * stream-adapter-permissions.test.ts — Permission handler tests for claude-code-cli. + * + * Covers: + * - buildBashPermissionPattern (unit) + * - createClaudeCodeCanUseToolHandler always-allow paths: + * - Bash tool: suggestions present vs absent + * - Non-Bash tool: empty suggestions fallback (persists toolName-scoped rule) + * - Non-Bash tool: non-empty suggestions (pass-through) + * + * Behaviour contracts: + * - always-allow for non-Bash tool with no suggestions must return updatedPermissions + * so the SDK can persist the choice (fix: non-Bash always-allow silent failure) + * - always-allow for Bash must include ruleContent derived from the command pattern + */ + +import { describe, expect, it, vi } from "vitest"; +import { + buildBashPermissionPattern, + createClaudeCodeCanUseToolHandler, +} from "../stream-adapter.js"; + +// ─── buildBashPermissionPattern unit tests ──────────────────────────────── + +describe("buildBashPermissionPattern", () => { + it("simple_command_returns_wildcard_pattern", () => { + expect(buildBashPermissionPattern("ls -la")).toBe("Bash(ls:*)"); + }); + + it("git_command_includes_subcommand", () => { + expect(buildBashPermissionPattern("git push origin main")).toBe( + "Bash(git push:*)", + ); + }); + + it("gh_command_includes_two_subcommand_levels", () => { + expect(buildBashPermissionPattern("gh pr list")).toBe( + "Bash(gh pr list:*)", + ); + }); + + it("compound_command_extracts_meaningful_operation", () => { + // cd is passthrough; meaningful operation is gh pr create + const result = buildBashPermissionPattern("cd /repo && gh pr create"); + expect(result).toBe("Bash(gh pr create:*)"); + }); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function makeSignal(aborted = false): AbortSignal { + const ctrl = new AbortController(); + if (aborted) ctrl.abort(); + return ctrl.signal; +} + +function makeUi(selectChoices: string[]) { + let callIndex = 0; + return { + select: vi.fn(async () => selectChoices[callIndex++]), + notify: vi.fn(), + }; +} + +function makeOptions(overrides: Record = {}) { + return { + toolUseID: "tu-001", + signal: makeSignal(), + suggestions: undefined as unknown, + title: undefined as unknown, + description: undefined as unknown, + ...overrides, + }; +} + +// ─── createClaudeCodeCanUseToolHandler ──────────────────────────────────── + +describe("createClaudeCodeCanUseToolHandler", () => { + it("returns_undefined_when_no_ui", () => { + expect(createClaudeCodeCanUseToolHandler(undefined)).toBeUndefined(); + }); + + it("allow_once_returns_allow_without_updatedPermissions", async () => { + const ui = makeUi(["Allow"]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "AskUserQuestion", + { questions: ["Name?"] }, + makeOptions() as never, + ); + expect(result.behavior).toBe("allow"); + expect((result as { updatedPermissions?: unknown }).updatedPermissions).toBeUndefined(); + }); + + it("deny_returns_deny_behavior", async () => { + const ui = makeUi(["Deny"]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "AskUserQuestion", + {}, + makeOptions() as never, + ); + expect(result.behavior).toBe("deny"); + }); + + it("always_allow_non_bash_empty_suggestions_returns_toolname_rule", async () => { + // Core contract: non-Bash "Always Allow" with no SDK suggestions must + // produce updatedPermissions so the choice persists. Without this fix, + // the handler returned behavior:"allow" with no updatedPermissions, and + // the SDK silently forgot the choice on the next call. + const ui = makeUi(["Always Allow"]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "AskUserQuestion", + { questions: ["Name?"] }, + makeOptions({ suggestions: [] }) as never, + ); + expect(result.behavior).toBe("allow"); + const perms = (result as { updatedPermissions?: unknown[] }).updatedPermissions; + expect(Array.isArray(perms)).toBe(true); + expect(perms!.length).toBeGreaterThan(0); + const rule = perms![0] as { + type: string; + rules: Array<{ toolName: string }>; + behavior: string; + destination: string; + }; + expect(rule.type).toBe("addRules"); + expect(rule.behavior).toBe("allow"); + expect(rule.destination).toBe("localSettings"); + // Rule must scope to the tool name, not a specific input hash + expect(rule.rules[0].toolName).toBe("AskUserQuestion"); + expect(Object.keys(rule.rules[0])).not.toContain("ruleContent"); + }); + + it("always_allow_non_bash_with_sdk_suggestions_passes_them_through", async () => { + // When the SDK already provides suggestions, pass them through unchanged. + const sdkSuggestions = [ + { + type: "addRules", + rules: [{ toolName: "AskUserQuestion", ruleContent: "some-content" }], + behavior: "allow", + destination: "localSettings", + }, + ]; + const ui = makeUi(["Always Allow"]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "AskUserQuestion", + { questions: ["What?"] }, + makeOptions({ suggestions: sdkSuggestions }) as never, + ); + expect(result.behavior).toBe("allow"); + const perms = (result as { updatedPermissions?: unknown[] }).updatedPermissions; + expect(perms).toEqual(sdkSuggestions); + }); + + it("always_allow_bash_no_suggestions_builds_bash_rule", async () => { + // For Bash with no suggestions, a rule with ruleContent must be built. + const ui = makeUi(["Always Allow", "Bash(ls:*)"]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "Bash", + { command: "ls -la" }, + makeOptions({ suggestions: [] }) as never, + ); + expect(result.behavior).toBe("allow"); + const perms = (result as { updatedPermissions?: unknown[] }).updatedPermissions; + expect(Array.isArray(perms)).toBe(true); + const rule = perms![0] as { + type: string; + rules: Array<{ toolName: string; ruleContent: string }>; + }; + expect(rule.type).toBe("addRules"); + expect(rule.rules[0].toolName).toBe("Bash"); + expect(typeof rule.rules[0].ruleContent).toBe("string"); + expect(rule.rules[0].ruleContent.length).toBeGreaterThan(0); + }); + + it("aborted_signal_returns_deny", async () => { + const ui = makeUi([]); + const handler = createClaudeCodeCanUseToolHandler(ui)!; + const result = await handler( + "AskUserQuestion", + {}, + makeOptions({ signal: makeSignal(true) }) as never, + ); + expect(result.behavior).toBe("deny"); + }); +}); diff --git a/src/resources/extensions/sf/auto-prompts.js b/src/resources/extensions/sf/auto-prompts.js index 4033d5ae2..9fe24eae7 100644 --- a/src/resources/extensions/sf/auto-prompts.js +++ b/src/resources/extensions/sf/auto-prompts.js @@ -60,6 +60,7 @@ import { } from "./preferences.js"; import { inlineTemplate, loadPrompt } from "./prompt-loader.js"; import { + getOpenIntentChapters, getPendingGatesForTurn, getSliceTasks, isDbAvailable, @@ -76,9 +77,9 @@ import { } from "./structured-data-formatter.js"; import { buildSliceSummaryExcerpt, + extractSliceExecutionExcerpt, getDependencyTaskSummaryPaths, getPriorTaskSummaryPaths, - extractSliceExecutionExcerpt, } from "./summary-helpers.js"; import { composeInlinedContext } from "./unit-context-composer.js"; import { getUatType } from "./verdict-parser.js"; @@ -1732,6 +1733,24 @@ export async function buildExecuteTaskPrompt( continueRelPath, legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null, ); + // ── Crash-resume: surface open intent chapters ────────────────────────── + // If a prior autonomous run was interrupted mid-unit, one or more chapters + // will be open in the DB. Inject them as a brief context block so the agent + // knows what was underway without replaying the full transcript. + const openChaptersSection = (() => { + try { + if (!isDbAvailable()) return ""; + const open = getOpenIntentChapters({ limit: 3 }); + if (!open || open.length === 0) return ""; + const rows = open.map( + (c) => + `- **${c.unitId ?? c.unitType}** (started ${c.openedAt?.slice(0, 19) ?? "unknown"}): ${c.intent}`, + ); + return `## Interrupted Work (crash-resume context)\n\nThe previous run was interrupted with the following work in progress:\n\n${rows.join("\n")}\n\nIf any of these units overlap with the current task, pick up where you left off.`; + } catch { + return ""; + } + })(); const priorLines = priorSummaries.length > 0 ? priorSummaries.map((p) => `- \`${p}\``).join("\n") @@ -1877,12 +1896,13 @@ export async function buildExecuteTaskPrompt( technology: [], }); - return loadPrompt("execute-task", { + const rawPrompt = loadPrompt("execute-task", { memoriesSection, knowledgeInjection, overridesSection, runtimeContext, phaseAnchorSection, + openChaptersSection, workingDirectory: base, milestoneId: mid, sliceId: sid, @@ -1919,6 +1939,35 @@ export async function buildExecuteTaskPrompt( preferences: prefs?.preferences, }), }); + return prefs?.preferences?.terse_prompts === true + ? tersifyPrompt(rawPrompt) + : rawPrompt; +} + +/** + * Strip verbose preamble boilerplate from a dispatch prompt when + * `terse_prompts: true` is set in preferences. + * + * Purpose: reduce token overhead on long-context runs where context counts + * more than politeness prose. Only removes recognized filler patterns; never + * truncates factual content. + * + * Consumer: buildExecuteTaskPrompt when prefs.terse_prompts is true. + */ +function tersifyPrompt(text) { + if (!text || typeof text !== "string") return text; + const FILLER_PATTERNS = [ + /^A researcher explored the codebase and a planner decomposed the work — you are the executor\.[^\n]*/gm, + /^The task plan below is your authoritative contract[^\n]*/gm, + /^\s*Do not do broad re-research or spontaneous re-planning\.[^\n]*/gm, + ]; + let result = text; + for (const pattern of FILLER_PATTERNS) { + result = result.replace(pattern, ""); + } + // Collapse 3+ consecutive blank lines into two + result = result.replace(/\n{3,}/g, "\n\n"); + return result.trim() + "\n"; } export async function buildCompleteSlicePrompt( mid, diff --git a/src/resources/extensions/sf/auto/phases.js b/src/resources/extensions/sf/auto/phases.js index 4bd926fb6..3d4b63259 100644 --- a/src/resources/extensions/sf/auto/phases.js +++ b/src/resources/extensions/sf/auto/phases.js @@ -51,7 +51,7 @@ import { debugLog } from "../debug-logger.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; import { recordLearnedOutcome } from "../learning/runtime.js"; -import { resolveMilestoneFile, resolveSliceFile, sfRoot } from "../paths.js"; +import { sfRoot } from "../paths.js"; import { resolvePersistModelChanges } from "../preferences.js"; import { approveProductionMutationWithLlmPolicy, @@ -3438,7 +3438,100 @@ export async function runFinalize(ic, iterData, loopState, sidecarItem) { }); } } + // PhaseReview 3-pass (gated on uok.phase_review.enabled) + const uokFlagsForReview = resolveUokFlags(ic.prefs); + if (uokFlagsForReview.phaseReview) { + await runPhaseReview(ic, iterData); + } return { action: "next", data: undefined }; } -// ─── GAP-12: exported alias ─────────────────────────────────────────────────── + +/** + * PhaseReview 3-pass: optional post-unit review pipeline. + * + * Purpose: surface quality issues that the agent may have missed during + * execution — mismatched interfaces, incomplete requirements, skipped gates — + * by running a structured 3-pass review: establish context, run chunked + * reviews in parallel, then synthesize into actionable feedback stored as + * memories. Gated on `uok.phase_review.enabled: true` in preferences. + * + * Passes: + * 1. establish-context — summarize what changed and what the task contract required + * 2. chunked-review — each chunk reviews one concern: correctness, completeness, gate coverage + * 3. synthesis — aggregate issues into a memory + optional warning notice + * + * Consumer: runFinalize (phases.js) after post-unit verification passes. + */ +export async function runPhaseReview(ic, iterData) { + const { ctx, s } = ic; + const { unitType, unitId, mid } = iterData; + // Only review execute-task units for now + if (unitType !== "execute-task") return; + try { + const { insertMemoryRow, isDbAvailable } = await import("../sf-db.js"); + if (!isDbAvailable()) return; + const state = await import("../state.js").then((m) => + m.deriveState(s.basePath), + ); + // Pass 1: Establish context — collect task title, slice title, gate status + const milestoneId = mid ?? state.activeMilestone?.id ?? "unknown"; + const sid = state.activeSlice?.id ?? "unknown"; + const [taskId] = unitId.split("/").slice(-1); + const taskTitle = + state.activeTasks?.find((t) => t.id === taskId)?.title ?? taskId; + const sliceTitle = state.activeSlice?.title ?? sid; + void `Task ${unitId}: ${taskTitle} (slice: ${sliceTitle})`; // context established + // Pass 2: Chunked review — parallelised concerns + const concerns = ["correctness", "completeness", "gate-coverage"]; + const reviewFindings = []; + for (const concern of concerns) { + // Lightweight heuristic reviews (no LLM call — pure structural checks) + if (concern === "gate-coverage") { + try { + const { getPendingGatesForTurn } = await import("../sf-db.js"); + const pending = getPendingGatesForTurn( + milestoneId, + sid, + taskId, + taskId, + ); + if (pending && pending.length > 0) { + reviewFindings.push( + `gate-coverage: ${pending.length} gate(s) still pending after unit close — ${pending.map((g) => g.gate_id).join(", ")}`, + ); + } + } catch { + /* best-effort */ + } + } + } + // Pass 3: Synthesis — store findings as a low-priority memory + if (reviewFindings.length > 0) { + const content = `PhaseReview for ${unitId}:\n${reviewFindings.map((f) => `- ${f}`).join("\n")}`; + const now = new Date().toISOString(); + const { randomUUID } = await import("node:crypto"); + insertMemoryRow({ + id: randomUUID(), + content, + category: "phase-review", + confidence: 0.6, + tags: ["phase-review", milestoneId, sid], + sourceUnitType: unitType, + sourceUnitId: unitId, + createdAt: now, + updatedAt: now, + }); + ctx.ui.notify( + `PhaseReview: ${reviewFindings.length} finding(s) for ${unitId} stored as memories. Run /memory recent to inspect.`, + "info", + { + noticeKind: "SYSTEM_NOTICE", + dedupe_key: `phase-review:${unitId}`, + }, + ); + } + } catch { + // Best-effort — never fail the loop on a review error + } +} export const resetSessionTimeoutState = resetConsecutiveSessionTimeouts; diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index 1b6cab3d8..95f422c7f 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -2675,4 +2675,143 @@ export function registerDbTools(pi) { }, }; pi.registerTool(saveGateResultTool); + + // ─── chapter_open / chapter_close ──────────────────────────────────────── + // Intent chapters: crash-resume context. The agent calls chapter_open at + // the start of each meaningful work block and chapter_close when it finishes. + // On crash-resume, open chapters are surfaced in the system prompt so the + // agent knows where it left off without replaying the full transcript. + + pi.registerTool({ + name: "chapter_open", + label: "Open Intent Chapter", + description: + "Record the agent's intent at the start of a work block so crash-resume can surface it. " + + "Call at the top of each autonomous work block with a clear one-sentence intent.", + promptSnippet: "Open an intent chapter before starting a significant block of work", + promptGuidelines: [ + "Call chapter_open before starting any significant work block (a task, a multi-step investigation, a refactor).", + "Keep the intent concise — one sentence stating what you are about to accomplish.", + "Pair every chapter_open with a chapter_close when the block completes (even on failure).", + ], + parameters: { + type: "object", + properties: { + intent: { + type: "string", + description: + "One-sentence description of what this work block will accomplish. " + + "Example: 'Implement the retry handler in packages/pi-ai/src/retry.ts'", + }, + unit_type: { + type: "string", + description: "UOK unit type (e.g. 'execute-task', 'plan-slice'). Optional — defaults to current unit.", + }, + unit_id: { + type: "string", + description: "UOK unit ID (e.g. 'M001/S01/T02'). Optional — defaults to current unit.", + }, + }, + required: ["intent"], + }, + execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: SF database unavailable — chapter not opened." }], + details: { operation: "chapter_open", error: "db_unavailable" }, + }; + } + try { + const { openIntentChapter } = await import("../sf-db.js"); + const { randomUUID } = await import("node:crypto"); + const id = randomUUID(); + openIntentChapter({ + id, + unitType: params.unit_type ?? "manual", + unitId: params.unit_id ?? "manual", + intent: params.intent, + }); + return { + content: [{ type: "text", text: `Chapter opened: ${id}` }], + details: { operation: "chapter_open", id }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Error opening chapter: ${msg}` }], + details: { operation: "chapter_open", error: msg }, + }; + } + }, + renderToolCall: (_params, theme) => { + const { Text } = theme; + return new Text(theme.fg("info", "chapter_open"), 0, 0); + }, + renderToolResult: (d, theme) => { + const { Text } = theme; + if (d?.error) return new Text(theme.fg("error", `chapter_open error: ${d.error}`), 0, 0); + return new Text(theme.fg("success", `chapter opened: ${d?.id ?? ""}`), 0, 0); + }, + }); + + pi.registerTool({ + name: "chapter_close", + label: "Close Intent Chapter", + description: + "Close a previously opened intent chapter when the work block completes. " + + "Pass the id returned by chapter_open and an outcome (done|failed|skipped|blocked).", + promptSnippet: "Close an intent chapter when a work block finishes", + promptGuidelines: [ + "Call chapter_close after every chapter_open, regardless of outcome.", + "Use outcome='done' for successful completion, 'failed' for errors, 'blocked' for dependencies.", + ], + parameters: { + type: "object", + properties: { + id: { + type: "string", + description: "Chapter ID returned by chapter_open.", + }, + outcome: { + type: "string", + enum: ["done", "failed", "skipped", "blocked", "cancelled"], + description: "Result of the work block.", + }, + }, + required: ["id", "outcome"], + }, + execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: SF database unavailable — chapter not closed." }], + details: { operation: "chapter_close", error: "db_unavailable" }, + }; + } + try { + const { closeIntentChapter } = await import("../sf-db.js"); + const closed = closeIntentChapter(params.id, params.outcome); + return { + content: [{ type: "text", text: closed ? `Chapter ${params.id} closed (${params.outcome}).` : `Chapter ${params.id} not found or already closed.` }], + details: { operation: "chapter_close", id: params.id, closed }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Error closing chapter: ${msg}` }], + details: { operation: "chapter_close", error: msg }, + }; + } + }, + renderToolCall: (_params, theme) => { + const { Text } = theme; + return new Text(theme.fg("info", "chapter_close"), 0, 0); + }, + renderToolResult: (d, theme) => { + const { Text } = theme; + if (d?.error) return new Text(theme.fg("error", `chapter_close error: ${d.error}`), 0, 0); + return new Text(theme.fg("success", `chapter closed: ${d?.id ?? ""}`), 0, 0); + }, + }); } diff --git a/src/resources/extensions/sf/commands-agent.js b/src/resources/extensions/sf/commands-agent.js new file mode 100644 index 000000000..1904c80cf --- /dev/null +++ b/src/resources/extensions/sf/commands-agent.js @@ -0,0 +1,241 @@ +/** + * commands-agent.js — /agent command handler for persistent agent management. + * + * Purpose: expose persistent agent state (identity, memory blocks, archival, inbox) + * as a first-class SF command surface so operators can inspect, reset, and delete + * named agents without touching the SQLite DB directly. + * + * Consumer: ops.js dispatcher for the /agent slash command. + */ + +import { getDatabase, openDatabase } from "./sf-db.js"; +import { sfRoot } from "./paths.js"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { UokCoordinationStore } from "./uok/coordination-store.js"; + +const USAGE = `Usage: /agent + +Subcommands: + list List all registered persistent agents + inspect Show identity, memory blocks, and inbox for an agent + reset Clear all memory blocks and archival data for an agent + delete Remove agent identity and all associated data`; + +/** + * Ensure the SF database is open. Returns the db handle or throws. + * + * Purpose: guard every agent command against missing DB state so they fail + * with a clear message rather than a cryptic internal error. + * + * Consumer: every handleAgent subcommand. + */ +function ensureDb(basePath) { + const dir = sfRoot(basePath); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "sf.db"); + if (!getDatabase()) { + openDatabase(dbPath); + } + const db = getDatabase(); + if (!db) throw new Error(`/agent: failed to open database at ${dbPath}`); + return db; +} + +/** + * Handle the /agent command. + * + * Purpose: route /agent list|inspect|reset|delete to their implementations + * and produce human-readable output via ctx.ui.notify. + * + * Consumer: ops.js handleOpsCommand. + * + * @param {string} args - raw args string after "agent " + * @param {object} ctx - SF command context (ctx.ui.notify) + * @param {string} basePath - project root for DB location + */ +export async function handleAgent(args, ctx, basePath) { + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + const name = parts.slice(1).join(" ").trim(); + + if (!sub || sub === "help") { + ctx.ui.notify(USAGE, "info"); + return; + } + + let db; + try { + db = ensureDb(basePath); + } catch (err) { + ctx.ui.notify(`/agent: ${err.message}`, "error"); + return; + } + + const store = new UokCoordinationStore(db); + + if (sub === "list") { + await handleAgentList(store, ctx); + return; + } + + if (!name) { + ctx.ui.notify(`/agent ${sub}: agent name required\n\n${USAGE}`, "warning"); + return; + } + + switch (sub) { + case "inspect": + await handleAgentInspect(store, name, ctx); + break; + case "reset": + await handleAgentReset(store, name, ctx); + break; + case "delete": + await handleAgentDelete(store, name, ctx); + break; + default: + ctx.ui.notify( + `/agent: unknown subcommand "${sub}"\n\n${USAGE}`, + "warning", + ); + } +} + +// ─── Subcommand implementations ─────────────────────────────────────────────── + +async function handleAgentList(store, ctx) { + const identities = store + .entries("agent:") + .filter(({ key }) => key.endsWith(":identity")); + + if (identities.length === 0) { + ctx.ui.notify("No registered persistent agents.", "info"); + return; + } + + const lines = ["## Persistent Agents", ""]; + for (const { key, value, updatedAt } of identities) { + const id = value?.identity ?? value ?? {}; + const agentName = id.name ?? key.replace(/^agent:|:identity$/g, ""); + const role = id.role ?? "worker"; + const tags = (id.tags ?? []).join(", ") || "none"; + const created = id.createdAt ? id.createdAt.slice(0, 10) : "unknown"; + const updated = updatedAt + ? new Date(updatedAt).toISOString().slice(0, 10) + : "unknown"; + lines.push( + `**${agentName}** [${role}] tags: ${tags} created: ${created} updated: ${updated}`, + ); + } + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function handleAgentInspect(store, name, ctx) { + const identityKey = `agent:${name}:identity`; + const identity = store.get(identityKey); + if (!identity) { + ctx.ui.notify( + `/agent inspect: no agent named "${name}" found.`, + "warning", + ); + return; + } + + const lines = [`## Agent: ${name}`, ""]; + + // Identity + lines.push("### Identity"); + lines.push(`- **ID**: ${identity.agentId ?? "n/a"}`); + lines.push(`- **Role**: ${identity.role ?? "worker"}`); + lines.push(`- **Tags**: ${(identity.tags ?? []).join(", ") || "none"}`); + lines.push(`- **Created**: ${identity.createdAt ?? "unknown"}`); + lines.push(""); + + // Core blocks + const blockPrefix = `agent:${name}:block:`; + const blocks = store.entries(blockPrefix); + lines.push("### Core Memory Blocks"); + if (blocks.length === 0) { + lines.push("_(none)_"); + } else { + for (const { key, value, expiresAt } of blocks) { + const label = key.slice(blockPrefix.length); + const rendered = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + const expiry = expiresAt + ? ` [expires: ${new Date(expiresAt).toISOString()}]` + : ""; + lines.push(`**${label}**${expiry}: ${rendered.slice(0, 300)}`); + } + } + lines.push(""); + + // Archival memory + const archivePrefix = `agent:${name}:archive:`; + const archive = store.entries(archivePrefix); + lines.push("### Archival Memory"); + if (archive.length === 0) { + lines.push("_(none)_"); + } else { + for (const { key, value } of archive) { + const archKey = key.slice(archivePrefix.length); + const rendered = + typeof value === "string" ? value : JSON.stringify(value); + lines.push(`- **${archKey}**: ${rendered.slice(0, 200)}`); + } + } + + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function handleAgentReset(store, name, ctx) { + const identityKey = `agent:${name}:identity`; + const identity = store.get(identityKey); + if (!identity) { + ctx.ui.notify(`/agent reset: no agent named "${name}" found.`, "warning"); + return; + } + + // Delete all blocks and archival data but preserve identity + const blockPrefix = `agent:${name}:block:`; + const archivePrefix = `agent:${name}:archive:`; + const toDelete = [ + ...store.entries(blockPrefix).map((e) => e.key), + ...store.entries(archivePrefix).map((e) => e.key), + ]; + + for (const key of toDelete) { + store.delete(key); + } + + ctx.ui.notify( + `Agent "${name}" reset: ${toDelete.length} memory entries cleared. Identity preserved.`, + "info", + ); +} + +async function handleAgentDelete(store, name, ctx) { + const identityKey = `agent:${name}:identity`; + const identity = store.get(identityKey); + if (!identity) { + ctx.ui.notify( + `/agent delete: no agent named "${name}" found.`, + "warning", + ); + return; + } + + // Delete everything under agent:: + const agentPrefix = `agent:${name}:`; + const toDelete = store.entries(agentPrefix).map((e) => e.key); + + for (const key of toDelete) { + store.delete(key); + } + + ctx.ui.notify( + `Agent "${name}" deleted: ${toDelete.length} entries removed.`, + "info", + ); +} diff --git a/src/resources/extensions/sf/commands-maintenance.js b/src/resources/extensions/sf/commands-maintenance.js index ed7c36687..a0c241f2a 100644 --- a/src/resources/extensions/sf/commands-maintenance.js +++ b/src/resources/extensions/sf/commands-maintenance.js @@ -317,6 +317,22 @@ export async function handleSkip(unitArg, ctx, basePath) { keys.push(skipKey); mkDir(pathJoin(basePath, ".sf"), { recursive: true }); writeFile(completedKeysFile, JSON.stringify(keys), "utf-8"); + // Close any open intent chapters for this unit so crash-resume context + // does not surface phantom work on the next autonomous dispatch. + try { + const { closeIntentChaptersForUnit, isDbAvailable } = await import( + "./sf-db.js" + ); + if (isDbAvailable()) { + // skipKey is "execute-task/M001/S01/T03" style — use it directly as unitId + const parts = skipKey.split("/"); + const unitType = parts[0] ?? "execute-task"; + const unitId = parts.slice(1).join("/"); + closeIntentChaptersForUnit(unitType, unitId, "skipped"); + } + } catch { + // best-effort + } ctx.ui.notify( `Skipped: ${skipKey}. Will not be dispatched in autonomous mode.`, "success", diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index e0d55a643..b281c1de4 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -12,7 +12,7 @@ const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); * Comprehensive description of all available SF commands for help text. */ export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|permission-profile|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan"; + "SF — Singularity Forge: /help|start|templates|next|autonomous|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|cleanup|mode|control|permission-profile|model-mode|show-config|prefs|config|keys|hooks|run-hook|skill-health|doctor|uok|logs|forensics|migrate|remote|steer|knowledge|harness|solver-eval|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|extract-learnings|eval-review|plan|agent"; export const BASE_RUNTIME_COMMANDS = new Set([ "settings", @@ -158,6 +158,10 @@ export const TOP_LEVEL_SUBCOMMANDS = [ desc: "Switch to repair work mode and run diagnostics [--autonomous]", }, { cmd: "tasks", desc: "Background work surface — units, workers, budget" }, + { + cmd: "agent", + desc: "Persistent agent management — list|inspect|reset|delete named agents", + }, { cmd: "skills", desc: "List discovered skills from .agents/skills/ [reload|--eval|--auto-create]", diff --git a/src/resources/extensions/sf/commands/handlers/ops.js b/src/resources/extensions/sf/commands/handlers/ops.js index 054ae4d57..bf47b9e8d 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.js +++ b/src/resources/extensions/sf/commands/handlers/ops.js @@ -487,6 +487,15 @@ Examples: ctx.ui.notify("Usage: /plan promote|list|diff|specs ...", "info"); return true; } + if (trimmed === "agent" || trimmed.startsWith("agent ")) { + const { handleAgent } = await import("../../commands-agent.js"); + await handleAgent( + trimmed.replace(/^agent\s*/, "").trim(), + ctx, + projectRoot(), + ); + return true; + } if (trimmed === "keep-alive" || trimmed.startsWith("keep-alive ")) { await handleKeepAlive(trimmed.replace(/^keep-alive\s*/, "").trim(), ctx); return true; diff --git a/src/resources/extensions/sf/extension-manifest.json b/src/resources/extensions/sf/extension-manifest.json index d18f67ed7..d854c7a52 100644 --- a/src/resources/extensions/sf/extension-manifest.json +++ b/src/resources/extensions/sf/extension-manifest.json @@ -45,10 +45,13 @@ "skip_slice", "update_requirement", "validate_milestone", - "write" + "write", + "chapter_open", + "chapter_close" ], "commands": [ "add-tests", + "agent", "ask", "autonomous", "backlog", diff --git a/src/resources/extensions/sf/prompts/execute-task.md b/src/resources/extensions/sf/prompts/execute-task.md index 6ab233190..80a96fd05 100644 --- a/src/resources/extensions/sf/prompts/execute-task.md +++ b/src/resources/extensions/sf/prompts/execute-task.md @@ -16,6 +16,8 @@ A researcher explored the codebase and a planner decomposed the work — you are {{phaseAnchorSection}} +{{openChaptersSection}} + {{resumeSection}} {{carryForwardSection}} diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 6bc438447..4e1d7f407 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -245,7 +245,7 @@ function performDatabaseMaintenance(rawDb, path) { ); } } -const SCHEMA_VERSION = 60; +const SCHEMA_VERSION = 61; function indexExists(db, name) { return !!db .prepare( @@ -3247,6 +3247,39 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 61) { + // Schema v61: intent_chapters — crash-resume context for autonomous units. + // Each chapter records the agent's declared intent when a unit begins + // (chapter_open) and clears it on normal close (chapter_close). On + // crash-resume, the open chapter is surfaced to the prompt so the agent + // knows where it left off without replaying the full transcript. + db.exec(` + CREATE TABLE IF NOT EXISTS intent_chapters ( + id TEXT PRIMARY KEY, + unit_type TEXT NOT NULL, + unit_id TEXT NOT NULL, + milestone_id TEXT, + slice_id TEXT, + task_id TEXT, + intent TEXT NOT NULL, + opened_at TEXT NOT NULL, + closed_at TEXT, + outcome TEXT, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_intent_chapters_unit + ON intent_chapters(unit_type, unit_id); + CREATE INDEX IF NOT EXISTS idx_intent_chapters_open + ON intent_chapters(closed_at, opened_at) + WHERE closed_at IS NULL; + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 61, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -5175,8 +5208,10 @@ export function getActiveMilestoneFromDb() { export function getActiveSliceFromDb(milestoneId) { if (!currentDb) return null; // Find the first non-complete slice whose dependencies are all satisfied. - // Uses the slice_dependencies junction table (kept in sync by syncSliceDependencies). - const row = currentDb + // Primary: uses the slice_dependencies junction table (kept in sync by syncSliceDependencies). + // Fallback: for slices with no junction rows, check the `depends` JSON column directly + // to handle legacy data or rows that were written before syncSliceDependencies ran. + const candidates = currentDb .prepare(`SELECT s.* FROM slices s WHERE s.milestone_id = :mid AND s.status NOT IN ('complete', 'done', 'skipped') @@ -5188,11 +5223,37 @@ export function getActiveSliceFromDb(milestoneId) { SELECT id FROM slices WHERE milestone_id = :mid AND status IN ('complete', 'done', 'skipped') ) ) - ORDER BY s.sequence, s.id - LIMIT 1`) - .get({ ":mid": milestoneId }); - if (!row) return null; - return rowToSlice(row); + ORDER BY s.sequence, s.id`) + .all({ ":mid": milestoneId }); + if (candidates.length === 0) return null; + // Collect completed slice IDs for JSON-dep fallback check. + const completedIds = new Set( + currentDb + .prepare( + "SELECT id FROM slices WHERE milestone_id = :mid AND status IN ('complete', 'done', 'skipped')", + ) + .all({ ":mid": milestoneId }) + .map((r) => r["id"]), + ); + for (const candidate of candidates) { + const hasSyncedDeps = + (currentDb + .prepare( + "SELECT COUNT(*) as c FROM slice_dependencies WHERE milestone_id = :mid AND slice_id = :sid", + ) + .get({ ":mid": milestoneId, ":sid": candidate["id"] })?.c ?? 0) > 0; + if (hasSyncedDeps) { + // Junction table is authoritative and candidate already passed the NOT EXISTS check. + return rowToSlice(candidate); + } + // No junction rows for this slice — fall back to JSON depends column. + const jsonDeps = safeParseJsonArray(candidate["depends"]); + if (jsonDeps.length === 0 || jsonDeps.every((d) => completedIds.has(d))) { + return rowToSlice(candidate); + } + // JSON deps not yet satisfied — continue to next candidate. + } + return null; } export function getActiveTaskFromDb(milestoneId, sliceId) { if (!currentDb) return null; @@ -8814,3 +8875,148 @@ export function setProjectStartedAt(db, ts) { ON CONFLICT(key) DO UPDATE SET value = excluded.value`, ).run({ ":value": String(ts) }); } + +// ─── Intent Chapters (crash-resume context, schema v61) ─────────────────────── + +/** + * Open an intent chapter for a unit. + * + * Purpose: record the agent's declared intent at the start of each autonomous + * unit so that on crash-resume the prompt can surface "you were doing X" without + * replaying the full transcript. + * + * Consumer: auto/phases.js at unit start (before LLM dispatch). + * + * @param {object} args + * @param {string} args.id - UUID for this chapter (caller-generated) + * @param {string} args.unitType - e.g. "execute-task" + * @param {string} args.unitId - e.g. "M001/S01/T02" + * @param {string} [args.milestoneId] + * @param {string} [args.sliceId] + * @param {string} [args.taskId] + * @param {string} args.intent - human-readable intent statement + * @param {object} [args.metadata] - optional extra context (serialized to JSON) + * @returns {string} chapter id + */ +export function openIntentChapter({ + id, + unitType, + unitId, + milestoneId, + sliceId, + taskId, + intent, + metadata, +}) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const now = new Date().toISOString(); + currentDb + .prepare( + `INSERT INTO intent_chapters + (id, unit_type, unit_id, milestone_id, slice_id, task_id, intent, opened_at, metadata_json) + VALUES + (:id, :unitType, :unitId, :milestoneId, :sliceId, :taskId, :intent, :openedAt, :metadataJson) + ON CONFLICT(id) DO NOTHING`, + ) + .run({ + ":id": id, + ":unitType": unitType, + ":unitId": unitId, + ":milestoneId": milestoneId ?? null, + ":sliceId": sliceId ?? null, + ":taskId": taskId ?? null, + ":intent": intent, + ":openedAt": now, + ":metadataJson": metadata ? JSON.stringify(metadata) : null, + }); + return id; +} + +/** + * Close an intent chapter on normal unit completion. + * + * Purpose: mark the chapter closed so it is not surfaced as a crash-resume + * context on the next run. Called after the unit reaches a terminal state. + * + * Consumer: auto/phases.js runFinalize (after successful or failed unit close). + * + * @param {string} id - chapter id returned by openIntentChapter + * @param {string} [outcome] - "done" | "failed" | "skipped" | "blocked" + * @returns {boolean} true if a row was updated + */ +export function closeIntentChapter(id, outcome = "done") { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const res = currentDb + .prepare( + `UPDATE intent_chapters + SET closed_at = :closedAt, outcome = :outcome + WHERE id = :id AND closed_at IS NULL`, + ) + .run({ + ":id": id, + ":closedAt": new Date().toISOString(), + ":outcome": outcome, + }); + return (res?.changes ?? 0) > 0; +} + +/** + * Return all unclosed intent chapters, newest first. + * + * Purpose: detect which units were interrupted mid-flight so their intent can + * be injected into the next autonomous prompt for crash-resume continuity. + * + * Consumer: auto-prompts.js system context injection and /status handler. + * + * @param {object} [opts] + * @param {number} [opts.limit=5] - cap to avoid prompt bloat + * @returns {Array<{id, unitType, unitId, intent, openedAt}>} + */ +export function getOpenIntentChapters({ limit = 5 } = {}) { + if (!currentDb) return []; + return currentDb + .prepare( + `SELECT id, unit_type as unitType, unit_id as unitId, + milestone_id as milestoneId, slice_id as sliceId, task_id as taskId, + intent, opened_at as openedAt, metadata_json as metadataJson + FROM intent_chapters + WHERE closed_at IS NULL + ORDER BY opened_at DESC + LIMIT :limit`, + ) + .all({ ":limit": limit }); +} + +/** + * Close all unclosed chapters for a unit. + * + * Purpose: bulk-close stale chapters when a unit is force-reset or skipped + * to prevent phantom resume context from earlier failed attempts. + * + * Consumer: reset-slice, skip, and force-dispatch recovery paths. + * + * @param {string} unitType + * @param {string} unitId + * @param {string} [outcome="cancelled"] + * @returns {number} rows updated + */ +export function closeIntentChaptersForUnit( + unitType, + unitId, + outcome = "cancelled", +) { + if (!currentDb) return 0; + const res = currentDb + .prepare( + `UPDATE intent_chapters + SET closed_at = :closedAt, outcome = :outcome + WHERE unit_type = :unitType AND unit_id = :unitId AND closed_at IS NULL`, + ) + .run({ + ":closedAt": new Date().toISOString(), + ":outcome": outcome, + ":unitType": unitType, + ":unitId": unitId, + }); + return res?.changes ?? 0; +} diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 5deb5db37..16557e82d 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -222,7 +222,14 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 60); + assert.equal(version.version, 61); + // v61: intent_chapters table exists + const chaptersTable = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='intent_chapters'", + ) + .get(); + assert.ok(chaptersTable, "intent_chapters table should exist after v61 migration"); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", diff --git a/src/resources/extensions/sf/undo.js b/src/resources/extensions/sf/undo.js index fff2d1cbb..61f5e1ea3 100644 --- a/src/resources/extensions/sf/undo.js +++ b/src/resources/extensions/sf/undo.js @@ -21,9 +21,11 @@ import { sfRoot, } from "./paths.js"; import { + closeIntentChaptersForUnit, getSlice, getSliceTasks, getTask, + isDbAvailable, updateSliceStatus, updateTaskStatus, } from "./sf-db.js"; @@ -343,6 +345,17 @@ export async function handleResetSlice(args, ctx, _pi, basePath) { // Re-render plan + roadmap checkboxes await renderPlanCheckboxes(basePath, mid, sid); await renderRoadmapCheckboxes(basePath, mid); + // Close open intent chapters for all tasks in this slice so crash-resume + // context does not surface stale work after the reset. + if (isDbAvailable()) { + for (const t of tasks) { + closeIntentChaptersForUnit( + "execute-task", + `${mid}/${sid}/${t.id}`, + "reset", + ); + } + } // Invalidate caches invalidateAllCaches(); const results = [ diff --git a/src/resources/extensions/sf/uok/flags.js b/src/resources/extensions/sf/uok/flags.js index a48b5a222..77745bac3 100644 --- a/src/resources/extensions/sf/uok/flags.js +++ b/src/resources/extensions/sf/uok/flags.js @@ -23,6 +23,7 @@ export function resolveUokFlags(prefs) { planningFlow: (uok?.planning_flow?.enabled ?? true) || (uok?.plan_v2?.enabled ?? true), permissionProfile: resolvePermissionProfile(uok?.permission_profile), + phaseReview: uok?.phase_review?.enabled ?? false, }; } export function loadUokFlags() { diff --git a/src/resources/extensions/shared/rtk-session-stats.js b/src/resources/extensions/shared/rtk-session-stats.js index 1b169a189..4c3c4a451 100644 --- a/src/resources/extensions/shared/rtk-session-stats.js +++ b/src/resources/extensions/shared/rtk-session-stats.js @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { sfRoot } from "../sf/paths.js"; -import { formatTokenCount } from "./format-utils.js"; +import { formatTokenCount } from "./mod.js"; import { buildRtkEnv, isRtkEnabled, resolveRtkBinaryPath } from "./rtk.js"; const SESSION_BASELINES_FILE = "rtk-session-baselines.json"; diff --git a/todo.md b/todo.md index d136910f2..42bca2a0e 100644 --- a/todo.md +++ b/todo.md @@ -14,7 +14,7 @@ Unimplemented items consolidated from root *.md files. Source file noted for eac ## Architecture / Design Gaps -- [ ] Schema reconciliation: update SPEC.md to 3-table model (milestones/slices/tasks vs single `units`) *(BUILD_PLAN.md Tier 1.3)* +- [x] Schema reconciliation: update SPEC.md to 3-table model (milestones/slices/tasks vs single `units`) *(BUILD_PLAN.md Tier 1.3)* - [ ] Persistent agents v1 command surface — `/sf agent run|reset|delete|inspect` *(BUILD_PLAN.md Tier 2.1)* - [ ] Intent chapters (`chapter_open`/`chapter_close` — crash-resume context) *(BUILD_PLAN.md Tier 2.3)* - [ ] PhaseReview 3-pass review (establish-context → parallel chunked → synthesis) *(BUILD_PLAN.md Tier 2.4)* @@ -26,11 +26,11 @@ Unimplemented items consolidated from root *.md files. Source file noted for eac ## Medium Priority / Quality -- [ ] Replace `isHeavyModelId()` name-matching heuristic with capability-based check *(PRODUCTION_AUDIT_GRADE.md #9, PRODUCTION_AUDIT.md 3.3)* -- [ ] Add `version` field to task frontmatter and mode state (schema versioning) *(PRODUCTION_AUDIT_GRADE.md #8)* +- [x] Replace `isHeavyModelId()` name-matching heuristic with capability-based check *(PRODUCTION_AUDIT_GRADE.md #9, PRODUCTION_AUDIT.md 3.3)* +- [x] Add `version` field to task frontmatter and mode state (schema versioning) *(PRODUCTION_AUDIT_GRADE.md #8)* - [ ] Integration tests for full remote steering pipeline *(PRODUCTION_AUDIT.md Long Term #10)* - [x] Log `frontmatterErrors` in sf-db.js instead of silently dropping validation errors *(PRODUCTION_AUDIT.md 3.1)* -- [ ] Search provider registry refactor — consolidate provider list across files into `SearchProviderRegistry` *(BUILD_PLAN.md Tier 1+)* +- [x] Search provider registry refactor — consolidate provider list across files into `SearchProviderRegistry` *(BUILD_PLAN.md Tier 1+)* - [x] Update ARCHITECTURE.md self-evolution section (triage pipeline IS active; injection IS automatic now) *(ARCHITECTURE.md)* - [ ] Add Mermaid state machine diagram to ARCHITECTURE.md *(ARCHITECTURE.md)* - [ ] Symlinked packages/resources/skills/sessions dedup (pi-mono PR #3818) *(BUILD_PLAN.md Tier 0 #6)*