sf snapshot: uncommitted changes after 78m inactivity

This commit is contained in:
Mikael Hugo 2026-05-11 01:01:03 +02:00
parent 605cd712be
commit 852bf8c5aa
19 changed files with 1040 additions and 28 deletions

View file

@ -1,3 +1,3 @@
{
"lastFullVacuumAt": "2026-05-10T13:59:26.619Z"
"lastFullVacuumAt": "2026-05-10T23:00:57.885Z"
}

Binary file not shown.

View file

@ -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:**

View file

@ -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<string, unknown> = {}) {
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");
});
});

View file

@ -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,

View file

@ -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;

View file

@ -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);
},
});
}

View file

@ -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 <subcommand>
Subcommands:
list List all registered persistent agents
inspect <name> Show identity, memory blocks, and inbox for an agent
reset <name> Clear all memory blocks and archival data for an agent
delete <name> 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:<name>:
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",
);
}

View file

@ -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",

View file

@ -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]",

View file

@ -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;

View file

@ -45,10 +45,13 @@
"skip_slice",
"update_requirement",
"validate_milestone",
"write"
"write",
"chapter_open",
"chapter_close"
],
"commands": [
"add-tests",
"agent",
"ask",
"autonomous",
"backlog",

View file

@ -16,6 +16,8 @@ A researcher explored the codebase and a planner decomposed the work — you are
{{phaseAnchorSection}}
{{openChaptersSection}}
{{resumeSection}}
{{carryForwardSection}}

View file

@ -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;
}

View file

@ -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'",

View file

@ -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 = [

View file

@ -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() {

View file

@ -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";

View file

@ -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)*