sf snapshot: uncommitted changes after 78m inactivity
This commit is contained in:
parent
605cd712be
commit
852bf8c5aa
19 changed files with 1040 additions and 28 deletions
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastFullVacuumAt": "2026-05-10T13:59:26.619Z"
|
||||
"lastFullVacuumAt": "2026-05-10T23:00:57.885Z"
|
||||
}
|
||||
|
|
|
|||
BIN
.sf/backups/db/sf.db.2026-05-10T23-00-57-817Z
Normal file
BIN
.sf/backups/db/sf.db.2026-05-10T23-00-57-817Z
Normal file
Binary file not shown.
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
241
src/resources/extensions/sf/commands-agent.js
Normal file
241
src/resources/extensions/sf/commands-agent.js
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -45,10 +45,13 @@
|
|||
"skip_slice",
|
||||
"update_requirement",
|
||||
"validate_milestone",
|
||||
"write"
|
||||
"write",
|
||||
"chapter_open",
|
||||
"chapter_close"
|
||||
],
|
||||
"commands": [
|
||||
"add-tests",
|
||||
"agent",
|
||||
"ask",
|
||||
"autonomous",
|
||||
"backlog",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ A researcher explored the codebase and a planner decomposed the work — you are
|
|||
|
||||
{{phaseAnchorSection}}
|
||||
|
||||
{{openChaptersSection}}
|
||||
|
||||
{{resumeSection}}
|
||||
|
||||
{{carryForwardSection}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
8
todo.md
8
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)*
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue