feat(notifications): NOTICE_KIND enum, schema v2 dedup, sf-db cleanup

- notification-store: schema v2 — repeatCount/lastTs merge for non-blocking
  notices; NOTICE_KIND enum (SYSTEM_NOTICE, TOOL_NOTICE, BLOCKING_NOTICE,
  USER_VISIBLE) for renderer classification without message parsing
- sf-db: remove gate_runs and audit_events tables (replaced by uok audit.js
  and trace-writer); schema reduced by ~370 lines
- notify-interceptor: tag auto-mode system notices with NOTICE_KIND.SYSTEM_NOTICE
- auto-prompts, guided-flow, system-context: use NOTICE_KIND on emit calls
- cli-status: expanded headless status surface + test coverage
- headless-types: new status fields
- Makefile/justfile: dev workflow improvements
- record-promoter, requirement-promoter: minor cleanup
- sf-db-migration tests: updated for dropped tables
- uok-gate-runner, uok-metrics, uok-outcome, uok-status tests: updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 20:13:58 +02:00
parent 5c2e3eec24
commit d33e30e885
40 changed files with 871 additions and 820 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,18 +2,22 @@ SHELL := /usr/bin/env bash
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help install build build-core test typecheck native clean sf .PHONY: help install build build-core copy-resources test typecheck lint lint-fix native native-pkg clean sf
help: help:
@printf "Available targets:\n" @printf "Available targets:\n"
@printf " install Install workspace dependencies\n" @printf " install Install workspace dependencies\n"
@printf " build Build the project\n" @printf " build Full build (core + web)\n"
@printf " build-core Build the core runtime packages\n" @printf " build-core Core build including copy-resources\n"
@printf " test Run the test suite\n" @printf " copy-resources Rebuild dist/resources/extensions (sf extension bundles)\n"
@printf " typecheck Run TypeScript type checking\n" @printf " test Run test suite\n"
@printf " native Build native components\n" @printf " typecheck Typecheck extensions/project tsconfigs\n"
@printf " clean Remove generated build outputs\n" @printf " lint Lint (alias for npm run lint)\n"
@printf " sf Run SF from source (passes args via ARGS=...)\n" @printf " lint-fix Lint with autofix\n"
@printf " native Compile rust-engine (npm run build:native)\n"
@printf " native-pkg Build @singularity-forge/native workspace (npm run build:native-pkg)\n"
@printf " clean Remove dist/\n"
@printf " sf Run SF from source (ARGS='status --help')\n"
install: install:
npm install npm install
@ -24,15 +28,27 @@ build:
build-core: build-core:
npm run build:core npm run build:core
copy-resources:
npm run copy-resources
test: test:
npm test npm test
typecheck: typecheck:
npm run typecheck:extensions npm run typecheck:extensions
lint:
npm run lint
lint-fix:
npm run lint:fix
native: native:
npm run build:native npm run build:native
native-pkg:
npm run build:native-pkg
clean: clean:
rm -rf dist dist-test rm -rf dist dist-test

View file

@ -8,16 +8,24 @@ default:
install: install:
npm install npm install
# Full build (core + web) # Full build (core + web); includes npm run copy-resources via build:core
build: build:
npm run build npm run build
# Build core runtime only (faster) # Build core runtime only (faster); includes npm run copy-resources
build-core: build-core:
npm run build:core npm run build:core
# Build native Rust addon (release) # Rebuild bundled extension resources into dist/ (~/.sf sync on next launch)
copy-resources:
npm run copy-resources
# Compile rust-engine native binaries (release)
build-native: build-native:
npm run build:native
# Build @singularity-forge/native workspace package (TS/N-API shims — used by build:pi)
build-native-pkg:
npm run build:native-pkg npm run build:native-pkg
# Run all tests # Run all tests

View file

@ -11,6 +11,7 @@ import type { QuerySnapshot } from "./headless-query.js";
interface StatusArgs { interface StatusArgs {
watch: boolean; watch: boolean;
recoveryMode?: boolean;
recoveryUnitId?: string; recoveryUnitId?: string;
} }
@ -31,6 +32,7 @@ function parseStatusArgs(argv: string[]): StatusArgs {
if (args[0] === "recovery") { if (args[0] === "recovery") {
return { return {
watch: false, watch: false,
recoveryMode: true,
recoveryUnitId: args[1], recoveryUnitId: args[1],
}; };
} }
@ -226,6 +228,60 @@ async function buildStatusText(
}); });
} }
type RuntimeSummaryRow = {
unitType?: unknown;
unitId?: unknown;
updatedAt?: unknown;
};
/**
* Resolve which on-disk runtime record recovery output should describe.
*
* Purpose: `.sf/runtime/units/` names files `${unitType}-${unitId}.json`; using a
* hard-coded unit type misses non-execute-task rows when auto-picking the latest
* record or when querying by unit id alone.
*
* Consumer: sf status recovery.
*/
export function resolveRecoveryPick(
basePath: string,
records: RuntimeSummaryRow[],
explicitUnitId: string | undefined,
readRecord: (
root: string,
unitType: string,
unitId: string,
) => unknown | null,
): { unitType: string; unitId: string } | null {
const valid = records.filter(
(r): r is { unitType: string; unitId: string; updatedAt?: number } =>
typeof r.unitType === "string" &&
r.unitType.length > 0 &&
typeof r.unitId === "string" &&
r.unitId.length > 0,
);
if (explicitUnitId) {
const matches = valid
.filter((r) => r.unitId === explicitUnitId)
.sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0));
if (matches[0]) {
return {
unitType: matches[0].unitType,
unitId: matches[0].unitId,
};
}
if (readRecord(basePath, "execute-task", explicitUnitId)) {
return { unitType: "execute-task", unitId: explicitUnitId };
}
return null;
}
if (valid.length === 0) return null;
const sorted = [...valid].sort(
(a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0),
);
return { unitType: sorted[0].unitType, unitId: sorted[0].unitId };
}
async function renderRecoveryDiagnostics( async function renderRecoveryDiagnostics(
basePath: string, basePath: string,
unitId: string | undefined, unitId: string | undefined,
@ -233,30 +289,38 @@ async function renderRecoveryDiagnostics(
stderr: Pick<typeof process.stderr, "write">, stderr: Pick<typeof process.stderr, "write">,
): Promise<number> { ): Promise<number> {
try { try {
const { getRecoveryDiagnostics, listUnitRuntimeRecords } = await import( const {
"./resources/extensions/sf/uok/unit-runtime.js" getRecoveryDiagnostics,
listUnitRuntimeRecords,
readUnitRuntimeRecord,
} = await import("./resources/extensions/sf/uok/unit-runtime.js");
const rows = listUnitRuntimeRecords(basePath);
const picked = resolveRecoveryPick(
basePath,
rows,
unitId,
readUnitRuntimeRecord,
); );
let targetUnitId = unitId; if (!picked) {
if (!targetUnitId) { if (rows.length === 0) {
const records: Array<{ updatedAt?: number; unitId: string }> =
listUnitRuntimeRecords(basePath);
const mostRecent = records.sort(
(a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0),
)[0];
if (!mostRecent) {
stderr.write("sf status recovery: no runtime records found\n"); stderr.write("sf status recovery: no runtime records found\n");
return 1; } else {
stderr.write(
unitId
? `sf status recovery: no runtime record for ${unitId}\n`
: "sf status recovery: no usable runtime records found\n",
);
} }
targetUnitId = mostRecent.unitId; return 1;
} }
const diagnostics = getRecoveryDiagnostics( const diagnostics = getRecoveryDiagnostics(
basePath, basePath,
"execute-task", picked.unitType,
targetUnitId, picked.unitId,
); );
if (!diagnostics) { if (!diagnostics) {
stderr.write( stderr.write(
`sf status recovery: no runtime record for ${targetUnitId}\n`, `sf status recovery: no runtime record for ${picked.unitType} ${picked.unitId}\n`,
); );
return 1; return 1;
} }
@ -305,7 +369,7 @@ export async function runStatusCli(
const sfHome = deps.sfHome ?? process.env.SF_HOME ?? join(homedir(), ".sf"); const sfHome = deps.sfHome ?? process.env.SF_HOME ?? join(homedir(), ".sf");
const args = parseStatusArgs(argv); const args = parseStatusArgs(argv);
if (args.recoveryUnitId !== undefined) { if (args.recoveryMode) {
return renderRecoveryDiagnostics( return renderRecoveryDiagnostics(
deps.basePath, deps.basePath,
args.recoveryUnitId, args.recoveryUnitId,

View file

@ -30,6 +30,14 @@ export interface NotificationMetadata {
dedupe_key?: string; dedupe_key?: string;
/** Emission source label (extension name, "workflow", etc.). */ /** Emission source label (extension name, "workflow", etc.). */
source?: string; source?: string;
/** Long-lived classification for UI/transcript (see NOTICE_KIND in notification-store). */
noticeKind?:
| "system_notice"
| "tool_notice"
| "blocking_notice"
| "user_visible";
/** When false, duplicate rows are never merged (each notify appends a line). */
merge?: boolean;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -440,7 +440,7 @@ export async function inlineDecisionsFromDb(base, milestoneId, scope, level) {
inlineLevel !== "full" inlineLevel !== "full"
? formatDecisionsCompact(decisions) ? formatDecisionsCompact(decisions)
: formatDecisionsForPrompt(decisions); : formatDecisionsForPrompt(decisions);
return `### Decisions\nSource: \`.sf/DECISIONS.md\`\n\n${formatted}`; return `### Decisions\nSource: DB\n\n${formatted}`;
} }
// DB available but cascade returned empty — intentional per D020, don't fall back to file // DB available but cascade returned empty — intentional per D020, don't fall back to file
return null; return null;
@ -478,7 +478,7 @@ export async function inlineRequirementsFromDb(
inlineLevel !== "full" inlineLevel !== "full"
? formatRequirementsCompact(requirements) ? formatRequirementsCompact(requirements)
: formatRequirementsForPrompt(requirements); : formatRequirementsForPrompt(requirements);
return `### Requirements\nSource: \`.sf/REQUIREMENTS.md\`\n\n${formatted}`; return `### Requirements\nSource: DB\n\n${formatted}`;
} }
} }
} catch (err) { } catch (err) {
@ -632,32 +632,42 @@ function extractKeywords(title) {
.filter((w) => w.length > 0 && !STOPWORDS.has(w)); .filter((w) => w.length > 0 && !STOPWORDS.has(w));
} }
/** /**
* Inline scoped KNOWLEDGE.md content based on keywords from slice title. * Inline scoped knowledge based on keywords from slice title.
* Reads KNOWLEDGE.md, filters to sections matching keywords, formats with header. * Queries DB memories table (primary); falls back to KNOWLEDGE.md file.
* Returns null if no KNOWLEDGE.md exists or no sections match. * Returns null if no knowledge exists or no entries match.
*/ */
export async function inlineKnowledgeScoped(base, keywords) { export async function inlineKnowledgeScoped(base, keywords) {
try {
const { isDbAvailable, getActiveMemories } = await import("./sf-db.js");
if (isDbAvailable()) {
const entries = getActiveMemories({ category: "knowledge", limit: 200 });
if (entries.length > 0) {
const kws = keywords.map((k) => k.toLowerCase());
const matched = entries.filter((e) =>
kws.some((kw) => e.content.toLowerCase().includes(kw)),
);
if (matched.length === 0) return null;
const formatted = matched.map((e) => `- ${e.content}`).join("\n");
return `### Project Knowledge (scoped)\nSource: DB\n\n${formatted}`;
}
}
} catch {
// fall through to file
}
const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE"); const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE");
if (!existsSync(knowledgePath)) return null; if (!existsSync(knowledgePath)) return null;
const content = await loadFile(knowledgePath); const content = await loadFile(knowledgePath);
if (!content) return null; if (!content) return null;
// Import queryKnowledge from context-store
const { queryKnowledge } = await import("./context-store.js"); const { queryKnowledge } = await import("./context-store.js");
const scoped = await queryKnowledge(content, keywords); const scoped = await queryKnowledge(content, keywords);
// Return null if no sections matched (empty string from queryKnowledge)
if (!scoped) return null; if (!scoped) return null;
return `### Project Knowledge (scoped)\nSource: \`${relSfRootFile("KNOWLEDGE")}\`\n\n${scoped.trim()}`; return `### Project Knowledge (scoped)\nSource: \`${relSfRootFile("KNOWLEDGE")}\`\n\n${scoped.trim()}`;
} }
/** /**
* Budget-capped knowledge inline for milestone-level prompt assembly. * Budget-capped knowledge inline for milestone-level prompt assembly.
* * Queries DB memories table (primary); falls back to KNOWLEDGE.md file.
* Addresses issue #4719: the six milestone-phase prompts (research-milestone, * Caps the payload at `maxChars` (default 30,000 chars).
* plan-milestone, complete-slice, complete-milestone, validate-milestone, * Returns null when no knowledge exists or no entries match any keyword.
* reassess-roadmap) previously injected the full KNOWLEDGE.md (~226KB for a
* real project) on every invocation. This helper scopes by caller-supplied
* keywords and caps the payload at `maxChars` (default 30,000 chars).
*
* Returns null when no KNOWLEDGE.md exists or no entries match any keyword.
*/ */
export async function inlineKnowledgeBudgeted(base, keywords, options) { export async function inlineKnowledgeBudgeted(base, keywords, options) {
const DEFAULT_MAX_CHARS = 30_000; const DEFAULT_MAX_CHARS = 30_000;
@ -666,6 +676,27 @@ export async function inlineKnowledgeBudgeted(base, keywords, options) {
const maxChars = Number.isFinite(raw) const maxChars = Number.isFinite(raw)
? Math.max(0, Math.min(Math.floor(raw), HARD_MAX_CHARS)) ? Math.max(0, Math.min(Math.floor(raw), HARD_MAX_CHARS))
: DEFAULT_MAX_CHARS; : DEFAULT_MAX_CHARS;
try {
const { isDbAvailable, getActiveMemories } = await import("./sf-db.js");
if (isDbAvailable()) {
const entries = getActiveMemories({ category: "knowledge", limit: 500 });
if (entries.length > 0) {
const kws = keywords.map((k) => k.toLowerCase());
const matched = entries.filter((e) =>
kws.some((kw) => e.content.toLowerCase().includes(kw)),
);
if (matched.length === 0) return null;
const trimmed = matched.map((e) => `- ${e.content}`).join("\n");
const truncated =
trimmed.length > maxChars
? `${trimmed.slice(0, maxChars)}\n\n[...truncated; rerun with narrower scope if needed]`
: trimmed;
return `### Project Knowledge (scoped)\nSource: DB\n\n${truncated}`;
}
}
} catch {
// fall through to file
}
const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE"); const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE");
if (!existsSync(knowledgePath)) return null; if (!existsSync(knowledgePath)) return null;
const content = await loadFile(knowledgePath); const content = await loadFile(knowledgePath);

View file

@ -20,9 +20,6 @@ const EXECUTE_NO_PROGRESS_TOKEN_WARNING = 500_000;
const DURABLE_SF_ARTIFACT_PATHS = [ const DURABLE_SF_ARTIFACT_PATHS = [
".sf/milestones", ".sf/milestones",
".sf/approvals", ".sf/approvals",
".sf/DECISIONS.md",
".sf/KNOWLEDGE.md",
".sf/STATE.md",
]; ];
let state = null; let state = null;
export function resetRunawayGuardState(unitType, unitId, baseline) { export function resetRunawayGuardState(unitType, unitId, baseline) {

View file

@ -1628,25 +1628,14 @@ export async function runGuards(ic, mid, unitType, unitId, sliceId) {
if (isFirstTaskForSlice) { if (isFirstTaskForSlice) {
let planGateOutcome = "pass"; let planGateOutcome = "pass";
let planGateRationale = ""; let planGateRationale = "";
const roadmapPath = resolveMilestoneFile(s.basePath, mid, "ROADMAP"); const milestoneSlices = getMilestoneSlices(mid);
if (!roadmapPath || !existsSync(roadmapPath)) { if (!milestoneSlices || milestoneSlices.length === 0) {
planGateOutcome = "fail"; planGateOutcome = "fail";
planGateRationale = `Milestone roadmap not found for ${mid}`; planGateRationale = `Milestone ${mid} has no slices in DB (not yet planned)`;
} else {
const slicePlanPath = resolveSliceFile(
s.basePath,
mid,
sliceId,
"PLAN",
);
if (!slicePlanPath || !existsSync(slicePlanPath)) {
planGateOutcome = "fail";
planGateRationale = `Slice plan not found for ${mid}/${sliceId}`;
} else if (taskCounts.total < 1) { } else if (taskCounts.total < 1) {
planGateOutcome = "fail"; planGateOutcome = "fail";
planGateRationale = `Slice ${sliceId} has no tasks defined`; planGateRationale = `Slice ${sliceId} has no tasks defined`;
} }
}
const planGateRunner = new UokGateRunner(); const planGateRunner = new UokGateRunner();
planGateRunner.register({ planGateRunner.register({
id: "plan-gate", id: "plan-gate",

View file

@ -13,6 +13,11 @@ const _wrappedContexts = new WeakSet();
* Install the notify interceptor on a context's UI object. * Install the notify interceptor on a context's UI object.
* Mutates ctx.ui.notify in place the original is called after persistence. * Mutates ctx.ui.notify in place the original is called after persistence.
* Safe to call multiple times; no-ops if already installed on the same ui object. * Safe to call multiple times; no-ops if already installed on the same ui object.
*
* Optional third-arg metadata for durable hygiene:
* - dedupe_key stable merge identity across wording/timer drift
* - noticeKind NOTICE_KIND.* (system_notice, tool_notice, )
* - merge false to force a new JSONL row even when duplicate
*/ */
export function installNotifyInterceptor(ctx) { export function installNotifyInterceptor(ctx) {
if (_wrappedContexts.has(ctx.ui)) return; if (_wrappedContexts.has(ctx.ui)) return;

View file

@ -52,6 +52,11 @@ import {
} from "../skill-discovery.js"; } from "../skill-discovery.js";
import { deriveState } from "../state.js"; import { deriveState } from "../state.js";
import { logWarning } from "../workflow-logger.js"; import { logWarning } from "../workflow-logger.js";
import {
getActiveMemories,
isDbAvailable,
listSelfFeedbackEntries,
} from "../sf-db.js";
import { import {
getActiveWorktreeName, getActiveWorktreeName,
getWorktreeOriginalCwd, getWorktreeOriginalCwd,
@ -350,13 +355,21 @@ export function loadKnowledgeBlock(sfHomeDir, cwd) {
globalKnowledge = content; globalKnowledge = content;
} }
} }
// 2. Project knowledge (.sf/KNOWLEDGE.md) — project-specific // 2. Project knowledge — DB memories table (primary); .sf/KNOWLEDGE.md fallback
let projectKnowledge = ""; let projectKnowledge = "";
if (isDbAvailable()) {
const memories = getActiveMemories({ category: "knowledge", limit: 100 });
if (memories.length > 0) {
projectKnowledge = memories.map((m) => `- ${m.content}`).join("\n");
}
}
if (!projectKnowledge) {
const knowledgePath = resolveSfRootFile(cwd, "KNOWLEDGE"); const knowledgePath = resolveSfRootFile(cwd, "KNOWLEDGE");
if (existsSync(knowledgePath)) { if (existsSync(knowledgePath)) {
const content = cachedReadFile(knowledgePath)?.trim() ?? ""; const content = cachedReadFile(knowledgePath)?.trim() ?? "";
if (content) projectKnowledge = content; if (content) projectKnowledge = content;
} }
}
if (!globalKnowledge && !projectKnowledge) { if (!globalKnowledge && !projectKnowledge) {
return { block: "", globalSizeKb: 0 }; return { block: "", globalSizeKb: 0 };
} }
@ -378,33 +391,17 @@ const TACIT_SECTION_MAX_BYTES = 4096;
const SELF_FEEDBACK_MAX_ENTRIES = 20; const SELF_FEEDBACK_MAX_ENTRIES = 20;
const SELF_FEEDBACK_MAX_CHARS = 4000; const SELF_FEEDBACK_MAX_CHARS = 4000;
function loadSelfFeedbackBlock(cwd) { function loadSelfFeedbackBlock(cwd) {
const selfFeedbackPath = join(cwd, ".sf", "SELF-FEEDBACK.md"); let entries = [];
const legacyBacklogPath = join(cwd, ".sf", "BACKLOG.md"); if (isDbAvailable()) {
const sourcePath = existsSync(selfFeedbackPath) const rows = listSelfFeedbackEntries();
? selfFeedbackPath entries = rows
: legacyBacklogPath; .filter((r) => !r["resolved_at"])
if (!existsSync(sourcePath)) return ""; .map((r) => ({
const raw = cachedReadFile(sourcePath)?.trim() ?? ""; timestamp: r["ts"] ?? "",
if (!raw) return ""; kind: r["kind"] ?? "",
// Parse the table rows — skip header lines severity: r["severity"] ?? "low",
const lines = raw.split("\n"); summary: r["summary"] ?? "",
const entries = []; }));
for (const line of lines) {
if (!line.startsWith("| ")) continue;
if (line.includes("Timestamp")) continue; // header
if (line.includes("|---|---|")) continue; // separator
const cells = line
.split("|")
.map((c) => c.trim())
.filter(Boolean);
if (cells.length >= 7) {
entries.push({
timestamp: cells[0],
kind: cells[1],
severity: cells[2],
summary: cells[6],
});
}
} }
if (entries.length === 0) return ""; if (entries.length === 0) return "";
// Sort by severity (high/critical first) then by timestamp (newest first) // Sort by severity (high/critical first) then by timestamp (newest first)

View file

@ -36,6 +36,15 @@ function formatTimestamp(ts) {
return ts.slice(0, 19); return ts.slice(0, 19);
} }
} }
function formatNotificationLine(e) {
const repeat =
(e.repeatCount ?? 1) > 1
? ` ×${e.repeatCount}${
e.lastTs ? ` (last ${formatTimestamp(e.lastTs)})` : ""
}`
: "";
return `${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}${repeat}`;
}
export async function handleNotificationsCommand(args, ctx, _pi) { export async function handleNotificationsCommand(args, ctx, _pi) {
// /notifications clear // /notifications clear
if (args === "clear") { if (args === "clear") {
@ -63,10 +72,7 @@ export async function handleNotificationsCommand(args, ctx, _pi) {
ctx.ui.notify("No notifications.", "info"); ctx.ui.notify("No notifications.", "info");
return true; return true;
} }
const lines = entries.map( const lines = entries.map(formatNotificationLine);
(e) =>
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
);
const suffix = const suffix =
all.length > entries.length all.length > entries.length
? `\n... and ${all.length - entries.length} more (open /notifications to browse all)` ? `\n... and ${all.length - entries.length} more (open /notifications to browse all)`
@ -95,12 +101,7 @@ export async function handleNotificationsCommand(args, ctx, _pi) {
ctx.ui.notify(`No ${severity} notifications.`, "info"); ctx.ui.notify(`No ${severity} notifications.`, "info");
return true; return true;
} }
const lines = entries const lines = entries.slice(0, 20).map(formatNotificationLine);
.slice(0, 20)
.map(
(e) =>
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
);
const suffix = const suffix =
entries.length > 20 entries.length > 20
? `\n... and ${entries.length - 20} more (open /notifications to browse all)` ? `\n... and ${entries.length - 20} more (open /notifications to browse all)`
@ -144,10 +145,7 @@ export async function handleNotificationsCommand(args, ctx, _pi) {
ctx.ui.notify("No notifications.", "info"); ctx.ui.notify("No notifications.", "info");
return true; return true;
} }
const lines = entries.map( const lines = entries.map(formatNotificationLine);
(e) =>
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
);
const header = unread > 0 ? `${unread} unread — ` : ""; const header = unread > 0 ? `${unread} unread — ` : "";
ctx.ui.notify( ctx.ui.notify(
`${header}Recent notifications:\n${lines.join("\n")}`, `${header}Recent notifications:\n${lines.join("\n")}`,

View file

@ -307,33 +307,6 @@ export async function saveRequirementToDb(fields, basePath) {
superseded_by: row["superseded_by"] ?? null, superseded_by: row["superseded_by"] ?? null,
})); }));
} }
const nonSuperseded = allRequirements.filter(
(r) => r.superseded_by == null,
);
const md = generateRequirementsMd(nonSuperseded);
const filePath = resolveSfRootFile(basePath, "REQUIREMENTS");
try {
await saveFile(filePath, md);
} catch (diskErr) {
logError("manifest", "disk write failed, rolling back DB row", {
fn: "saveRequirementToDb",
error: String(diskErr.message),
});
try {
db.deleteRequirementById(id);
} catch (rollbackErr) {
logError(
"manifest",
"SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row",
{
fn: "saveRequirementToDb",
id,
error: String(rollbackErr.message),
},
);
}
throw diskErr;
}
invalidateStateCache(); invalidateStateCache();
clearPathCache(); clearPathCache();
clearParseCache(); clearParseCache();
@ -408,51 +381,6 @@ export async function saveDecisionToDb(fields, basePath) {
superseded_by: row["superseded_by"] ?? null, superseded_by: row["superseded_by"] ?? null,
})); }));
} }
const filePath = resolveSfRootFile(basePath, "DECISIONS");
// Check if existing DECISIONS.md has freeform (non-table) content.
// If so, preserve that content and append/update the decisions table
// at the end instead of overwriting the entire file.
let existingContent = null;
if (existsSync(filePath)) {
existingContent = readFileSync(filePath, "utf-8");
}
let md;
if (existingContent && !isDecisionsTableFormat(existingContent)) {
// Freeform content detected — preserve it and append decisions table.
// Strip any previously appended decisions table section to avoid duplication.
const marker = "---\n\n## Decisions Table";
const markerIdx = existingContent.indexOf(marker);
const freeformPart =
markerIdx >= 0
? existingContent.substring(0, markerIdx).trimEnd()
: existingContent.trimEnd();
md = freeformPart + "\n" + generateDecisionsAppendBlock(allDecisions);
} else {
// Table format or no existing file — full regeneration (original behavior)
md = generateDecisionsMd(allDecisions);
}
try {
await saveFile(filePath, md);
} catch (diskErr) {
logError("manifest", "disk write failed, rolling back DB row", {
fn: "saveDecisionToDb",
error: String(diskErr.message),
});
try {
db.deleteDecisionById(id);
} catch (rollbackErr) {
logError(
"manifest",
"SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row",
{
fn: "saveDecisionToDb",
id,
error: String(rollbackErr.message),
},
);
}
throw diskErr;
}
// #2661: When a decision defers a slice, update the slice status in the DB // #2661: When a decision defers a slice, update the slice status in the DB
// so the dispatcher skips it. Without this, STATE.md and DECISIONS.md are // so the dispatcher skips it. Without this, STATE.md and DECISIONS.md are
// in split-brain: the decision says "deferred" but the state still says // in split-brain: the decision says "deferred" but the state still says
@ -596,27 +524,6 @@ export async function updateRequirementInDb(id, updates, basePath) {
superseded_by: row["superseded_by"] ?? null, superseded_by: row["superseded_by"] ?? null,
})); }));
} }
// Filter to non-superseded for the markdown file
// (superseded requirements don't appear in section headings)
const nonSuperseded = allRequirements.filter(
(r) => r.superseded_by == null,
);
const md = generateRequirementsMd(nonSuperseded);
const filePath = resolveSfRootFile(basePath, "REQUIREMENTS");
try {
await saveFile(filePath, md);
} catch (diskErr) {
logError("manifest", "disk write failed, reverting DB row", {
fn: "updateRequirementInDb",
error: String(diskErr.message),
});
if (existing) {
db.upsertRequirement(existing);
}
throw diskErr;
}
// Invalidate file-read caches so deriveState() sees the updated markdown.
// Do NOT clear the artifacts table — we just wrote to it intentionally.
invalidateStateCache(); invalidateStateCache();
clearPathCache(); clearPathCache();
clearParseCache(); clearParseCache();

View file

@ -4,6 +4,7 @@ import { clearParseCache } from "./files.js";
import { clearPathCache, sfRoot } from "./paths.js"; import { clearPathCache, sfRoot } from "./paths.js";
import { getProjectResearchStatus } from "./project-research-policy.js"; import { getProjectResearchStatus } from "./project-research-policy.js";
import { validateArtifact } from "./schemas/validate.js"; import { validateArtifact } from "./schemas/validate.js";
import { getActiveRequirements, isDbAvailable } from "./sf-db.js";
const EXPLICIT_RESEARCH_SOURCES = new Set(["research-decision", "user"]); const EXPLICIT_RESEARCH_SOURCES = new Set(["research-decision", "user"]);
function clearCaches() { function clearCaches() {
@ -105,12 +106,15 @@ export function resolveDeepProjectSetupState(prefs, basePath) {
reason: ".sf/PROJECT.md is invalid.", reason: ".sf/PROJECT.md is invalid.",
}; };
} }
// DB-first: check for requirements in DB; fall back to file for unmigrated projects
const hasDbRequirements = isDbAvailable() && getActiveRequirements().length > 0;
if (!hasDbRequirements) {
const requirementsPath = join(root, "REQUIREMENTS.md"); const requirementsPath = join(root, "REQUIREMENTS.md");
if (!existsSync(requirementsPath)) { if (!existsSync(requirementsPath)) {
return { return {
status: "pending", status: "pending",
stage: "requirements", stage: "requirements",
reason: ".sf/REQUIREMENTS.md is missing.", reason: "No requirements found (DB empty and .sf/REQUIREMENTS.md is missing).",
}; };
} }
if (!validateArtifact(requirementsPath, "requirements").ok) { if (!validateArtifact(requirementsPath, "requirements").ok) {
@ -120,6 +124,7 @@ export function resolveDeepProjectSetupState(prefs, basePath) {
reason: ".sf/REQUIREMENTS.md is invalid.", reason: ".sf/REQUIREMENTS.md is invalid.",
}; };
} }
}
const marker = readDecision(basePath); const marker = readDecision(basePath);
if (!marker.exists) { if (!marker.exists) {
writeDefaultResearchSkipDecision(basePath, "missing-default-repair"); writeDefaultResearchSkipDecision(basePath, "missing-default-repair");

View file

@ -9,41 +9,41 @@
}, },
"provides": { "provides": {
"tools": [ "tools": [
"audit_product",
"bash", "bash",
"capture_thought", "checkpoint",
"complete_milestone",
"complete_slice",
"complete_task",
"edit", "edit",
"kill_agent", "kill_agent",
"memory_query", "log_decision",
"log_reasoning",
"memory_graph",
"memory_search",
"milestone_status",
"new_milestone_id",
"plan_milestone",
"plan_slice",
"plan_task",
"query_journal",
"read", "read",
"sf_autonomous_checkpoint", "read_output",
"sf_complete_milestone", "reassess_roadmap",
"sf_decision_save", "record_gate",
"sf_exec", "replan_slice",
"sf_exec_search", "report_issue",
"sf_graph", "resolve_issue",
"sf_journal_query", "resume_agent",
"sf_log_judgment", "run_command",
"sf_milestone_generate_id", "save_decision",
"sf_milestone_status", "save_requirement",
"sf_plan_milestone", "save_summary",
"sf_plan_slice", "search_evidence",
"sf_plan_task",
"sf_product_audit",
"sf_reassess_roadmap",
"sf_replan_slice",
"sf_requirement_save",
"sf_requirement_update",
"sf_retrieval_evidence",
"sf_resume",
"sf_save_gate_result",
"sf_self_feedback_resolve",
"sf_self_report",
"sf_skip_slice",
"sf_slice_complete",
"sf_summary_save",
"sf_task_complete",
"sf_validate_milestone",
"sift_search", "sift_search",
"skip_slice",
"update_requirement",
"validate_milestone",
"write" "write"
], ],
"commands": [ "commands": [

View file

@ -70,7 +70,7 @@ import {
isSessionLockProcessAlive, isSessionLockProcessAlive,
readSessionLockData, readSessionLockData,
} from "./session-lock.js"; } from "./session-lock.js";
import { getMilestoneSlices, isDbAvailable } from "./sf-db.js"; import { getAllMilestones, getMilestone, getMilestoneSlices, isDbAvailable } from "./sf-db.js";
import { deriveState } from "./state.js"; import { deriveState } from "./state.js";
import { resolveUokFlags } from "./uok/flags.js"; import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js"; import { UokGateRunner } from "./uok/gate-runner.js";
@ -265,50 +265,54 @@ export function checkAutoStartAfterDiscuss() {
const entry = _getPendingAutoStart(); const entry = _getPendingAutoStart();
if (!entry) return false; if (!entry) return false;
const { ctx, pi, basePath, milestoneId, step } = entry; const { ctx, pi, basePath, milestoneId, step } = entry;
// Gate 1: Primary milestone must have CONTEXT.md or ROADMAP.md // Gate 1: Primary milestone must have context or roadmap in DB.
// The "discuss" path creates CONTEXT.md; the "plan" path creates ROADMAP.md. // discuss phase populates milestone.vision; plan phase populates slices.
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting
// Gate 2: STATE.md must exist — written as the last step in the discuss
// output phase. This prevents auto-start from firing during Phase 3
// (sequential readiness gates for remaining milestones) in multi-milestone
// discussions, where M001-CONTEXT.md exists but M002/M003 haven't been
// processed yet.
const stateFile = resolveSfRootFile(basePath, "STATE");
if (!stateFile) return false; // discussion not finalized yet
// Gate 3: Multi-milestone completeness warning
// Parse PROJECT.md for milestone sequence, warn if any are missing context.
// Don't block — milestones can be intentionally queued without context.
const projectFile = resolveSfRootFile(basePath, "PROJECT");
let projectIds = []; let projectIds = [];
if (projectFile) { if (isDbAvailable()) {
try { const milestone = getMilestone(milestoneId);
const projectContent = readFileSync(projectFile, "utf-8"); const dbSlices = getMilestoneSlices(milestoneId);
projectIds = parseMilestoneSequenceFromProject(projectContent); const hasContext = !!(milestone?.vision);
const hasRoadmap = dbSlices.length > 0;
if (!hasContext && !hasRoadmap) return false;
// Gate 3: Multi-milestone completeness warning (DB version)
const allMilestones = getAllMilestones();
projectIds = allMilestones.map((m) => m.id);
if (projectIds.length > 1) { if (projectIds.length > 1) {
const missing = projectIds.filter((id) => { const missing = projectIds.filter((id) => {
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT"); const m = getMilestone(id);
const hasDraft = !!resolveMilestoneFile( return !m?.vision && getMilestoneSlices(id).length === 0;
basePath,
id,
"CONTEXT-DRAFT",
);
const hasDir = existsSync(join(sfRoot(basePath), "milestones", id));
return !hasContext && !hasDraft && !hasDir;
}); });
if (missing.length > 0) { if (missing.length > 0) {
ctx.ui.notify( ctx.ui.notify(
`Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` + `Multi-milestone validation: ${missing.join(", ")} not yet planned in DB.`,
`Discussion may not have completed all readiness gates.`,
"warning", "warning",
); );
} }
} }
} else {
// Fallback for non-migrated projects without a DB
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
if (!contextFile && !roadmapFile) return false;
const projectFile = resolveSfRootFile(basePath, "PROJECT");
if (projectFile) {
try {
const projectContent = readFileSync(projectFile, "utf-8");
projectIds = parseMilestoneSequenceFromProject(projectContent);
} catch (e) { } catch (e) {
logWarning("guided", `PROJECT.md parsing failed: ${e.message}`); logWarning("guided", `PROJECT.md parsing failed: ${e.message}`);
} }
} }
}
// Gate 2: Milestone must have a non-empty vision in DB (discuss phase complete).
// Falls back to checking STATE.md for non-migrated projects.
if (isDbAvailable()) {
const m = getMilestone(milestoneId);
if (!m?.vision) return false;
} else {
const stateFile = resolveSfRootFile(basePath, "STATE");
if (!stateFile) return false;
}
// Gate 4: Discussion manifest process verification (multi-milestone only) // Gate 4: Discussion manifest process verification (multi-milestone only)
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision. // The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
// When it exists, validate it before auto-starting. Project history alone is // When it exists, validate it before auto-starting. Project history alone is

View file

@ -85,7 +85,7 @@ function notificationSignature(entries) {
return entries return entries
.map( .map(
(entry) => (entry) =>
`${entry.ts}|${entry.severity}|${entry.read ? 1 : 0}|${entry.message}`, `${entry.ts}|${entry.severity}|${entry.read ? 1 : 0}|${entry.repeatCount ?? 1}|${entry.lastTs ?? ""}|${entry.message}`,
) )
.join("\n"); .join("\n");
} }
@ -343,11 +343,26 @@ export class SFNotificationOverlay {
const prefixWidth = visibleWidth(prefix); const prefixWidth = visibleWidth(prefix);
const msgMaxWidth = Math.max(10, contentWidth - prefixWidth); const msgMaxWidth = Math.max(10, contentWidth - prefixWidth);
// Wrap long messages onto continuation lines indented to align with message start // Wrap long messages onto continuation lines indented to align with message start
const repeatSuffix =
(entry.repeatCount ?? 1) > 1
? th.fg(
"dim",
` · ×${entry.repeatCount}${
entry.lastTs ? ` (last ${formatTimestamp(entry.lastTs)})` : ""
}`,
)
: "";
const noticeKind =
entry.noticeKind && entry.noticeKind !== "user_visible"
? th.fg("dim", ` [${entry.noticeKind}]`)
: "";
const msgLines = wrapText(entry.message, msgMaxWidth); const msgLines = wrapText(entry.message, msgMaxWidth);
const indent = " ".repeat(prefixWidth); const indent = " ".repeat(prefixWidth);
for (let i = 0; i < msgLines.length; i++) { for (let i = 0; i < msgLines.length; i++) {
if (i === 0) { if (i === 0) {
lines.push(row(`${prefix}${msgLines[i]}`)); lines.push(
row(`${prefix}${msgLines[i]}${repeatSuffix}${noticeKind}`),
);
} else { } else {
lines.push(row(`${indent}${msgLines[i]}`)); lines.push(row(`${indent}${msgLines[i]}`));
} }

View file

@ -2,6 +2,10 @@
// Captures durable ctx.ui.notify() calls and workflow-logger errors to // Captures durable ctx.ui.notify() calls and workflow-logger errors to
// .sf/notifications.jsonl so they survive context resets and session restarts. // .sf/notifications.jsonl so they survive context resets and session restarts.
// Rotates at MAX_ENTRIES to prevent unbounded growth. // Rotates at MAX_ENTRIES to prevent unbounded growth.
//
// Long-term hygiene (schema v2+): repeated equivalent notices merge into one row
// with repeatCount + lastTs; blocking/action-required rows never merge.
// Pass metadata.dedupe_key for stable identity; metadata.noticeKind for classification.
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { import {
appendFileSync, appendFileSync,
@ -21,10 +25,11 @@ import { sfRuntimeRoot } from "./paths.js";
const MAX_ENTRIES = 500; const MAX_ENTRIES = 500;
const FILENAME = "notifications.jsonl"; const FILENAME = "notifications.jsonl";
const LOCKFILE = "notifications.lock"; const LOCKFILE = "notifications.lock";
const NOTIFICATION_SCHEMA_VERSION = 1; /** Legacy rows on disk may omit schemaVersion — treated as v1 */
const DEDUP_WINDOW_MS = 30_000; export const NOTIFICATION_SCHEMA_VERSION_MIN = 1;
/** Current write schema — adds repeatCount, lastTs, noticeKind */
export const NOTIFICATION_SCHEMA_VERSION_WRITE = 2;
const DURABLE_DEDUP_WINDOW_MS = 60 * 60 * 1000; const DURABLE_DEDUP_WINDOW_MS = 60 * 60 * 1000;
const DEDUP_PRUNE_THRESHOLD = 200;
const DURABLE_DEDUP_SCAN_LIMIT = 100; const DURABLE_DEDUP_SCAN_LIMIT = 100;
const ACTIONABLE_KINDS = new Set([ const ACTIONABLE_KINDS = new Set([
"action_required", "action_required",
@ -50,11 +55,25 @@ const NOISY_STATUS_PATTERNS = [
/^Resuming paused session\b/, /^Resuming paused session\b/,
/^Resumed paused session\b/, /^Resumed paused session\b/,
]; ];
/**
* Optional classification for automated vs human-visible notices (see docs/product-specs/notification-source-hygiene.md).
*
* Purpose: let renderers group and style notices without parsing message text.
*
* Consumer: ctx.ui.notify metadata, headless classifiers, notification overlay.
*/
export const NOTICE_KIND = {
SYSTEM_NOTICE: "system_notice",
TOOL_NOTICE: "tool_notice",
BLOCKING_NOTICE: "blocking_notice",
USER_VISIBLE: "user_visible",
};
// ─── Module State ─────────────────────────────────────────────────────── // ─── Module State ───────────────────────────────────────────────────────
let _basePath = null; let _basePath = null;
let _lineCount = 0; // Hint for rotation — not authoritative for public API let _lineCount = 0; // Hint for rotation — not authoritative for public API
let _suppressCount = 0; let _suppressCount = 0;
let _recentMessageTimestamps = new Map();
const _changeListeners = new Set(); const _changeListeners = new Set();
/** Count of appendNotification failures since last reset — surfaced by status checks. */ /** Count of appendNotification failures since last reset — surfaced by status checks. */
let _appendFailureCount = 0; let _appendFailureCount = 0;
@ -66,9 +85,6 @@ let _lastAppendFailure = null;
* project root. Seeds in-memory counters from the existing file on disk. * project root. Seeds in-memory counters from the existing file on disk.
*/ */
export function initNotificationStore(basePath) { export function initNotificationStore(basePath) {
if (_basePath !== basePath) {
_recentMessageTimestamps.clear();
}
_basePath = basePath; _basePath = basePath;
// Seed line count hint for rotation — public counters read from disk // Seed line count hint for rotation — public counters read from disk
_lineCount = _readEntriesFromDisk(basePath).length; _lineCount = _readEntriesFromDisk(basePath).length;
@ -76,6 +92,11 @@ export function initNotificationStore(basePath) {
/** /**
* Append a notification entry to the store. Synchronous safe to call * Append a notification entry to the store. Synchronous safe to call
* from the notify() shim which is declared void (not async). * from the notify() shim which is declared void (not async).
*
* Duplicate notices (same dedupe key within the durable window, unread): merge
* into the existing row (repeatCount++, lastTs updated) instead of growing the log.
* Set metadata.merge === false to force a new row. Blocking / approval / blocker
* kinds do not merge each emission stays distinct for consent surfaces.
*/ */
export function appendNotification( export function appendNotification(
message, message,
@ -92,37 +113,29 @@ export function appendNotification(
!shouldPersistNotification(normalizedSeverity, metadata, persistedMessage) !shouldPersistNotification(normalizedSeverity, metadata, persistedMessage)
) )
return; return;
// Use explicit dedupe_key when provided; fall back to message-hash based key.
const dedupKey = metadata?.dedupe_key
? `${_basePath}:${metadata.dedupe_key}`
: `${_basePath}:${normalizedSeverity}:${source}:${persistedMessage}`;
const now = Date.now(); const now = Date.now();
const lastSeen = _recentMessageTimestamps.get(dedupKey);
if (lastSeen !== undefined && now - lastSeen <= DEDUP_WINDOW_MS) return;
_recentMessageTimestamps.set(dedupKey, now);
if (_recentMessageTimestamps.size > DEDUP_PRUNE_THRESHOLD) {
for (const [key, ts] of _recentMessageTimestamps) {
if (now - ts > DEDUP_WINDOW_MS) _recentMessageTimestamps.delete(key);
}
}
if ( if (
hasRecentPersistedDuplicate( tryMergePersistedNotification(_basePath, {
_basePath, normalizedSeverity,
metadata?.dedupe_key ?? source,
`${normalizedSeverity}:${source}:${persistedMessage}`, persistedMessage,
metadata,
now, now,
) })
) { ) {
_emitChange();
return; return;
} }
const entry = { const entry = {
schemaVersion: NOTIFICATION_SCHEMA_VERSION, schemaVersion: NOTIFICATION_SCHEMA_VERSION_WRITE,
id: randomUUID(), id: randomUUID(),
ts: new Date().toISOString(), ts: new Date(now).toISOString(),
severity: normalizedSeverity, severity: normalizedSeverity,
message: persistedMessage, message: persistedMessage,
source, source,
read: false, read: false,
repeatCount: 1,
...(metadata?.noticeKind ? { noticeKind: metadata.noticeKind } : {}),
...(metadata ? { metadata } : {}), ...(metadata ? { metadata } : {}),
}; };
try { try {
@ -280,7 +293,6 @@ export function _resetNotificationStore() {
_basePath = null; _basePath = null;
_lineCount = 0; _lineCount = 0;
_suppressCount = 0; _suppressCount = 0;
_recentMessageTimestamps = new Map();
_changeListeners.clear(); _changeListeners.clear();
_appendFailureCount = 0; _appendFailureCount = 0;
_lastAppendFailure = null; _lastAppendFailure = null;
@ -308,30 +320,101 @@ function _readEntriesFromDisk(basePath) {
} }
function normalizeNotificationEntry(entry) { function normalizeNotificationEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const schemaVersion = entry.schemaVersion ?? NOTIFICATION_SCHEMA_VERSION; const rawSv = entry.schemaVersion;
if (schemaVersion !== NOTIFICATION_SCHEMA_VERSION) return null; const schemaVersion =
rawSv === undefined || rawSv === null
? NOTIFICATION_SCHEMA_VERSION_MIN
: rawSv;
if (
schemaVersion < NOTIFICATION_SCHEMA_VERSION_MIN ||
schemaVersion > NOTIFICATION_SCHEMA_VERSION_WRITE
) {
return null;
}
return { return {
...entry, ...entry,
schemaVersion, schemaVersion,
read: entry.read === true, read: entry.read === true,
repeatCount: entry.repeatCount ?? 1,
}; };
} }
function hasRecentPersistedDuplicate(basePath, keySeed, now) { /**
* Whether two emissions with the same normalized key should collapse into one row.
* Distinct consent surfaces (approval/blocker kinds) never merge; blocking:true alone
* still merges when dedupe_key matches so operators see one row with repeatCount.
*/
function shouldMergeDuplicates(metadata) {
if (metadata?.merge === false) return false;
const k = metadata?.kind;
if (k === "blocker" || k === "approval_request" || k === "action_required") {
return false;
}
return true;
}
/**
* Scan recent unread rows for the same logical key; merge repeatCount + lastTs.
*
* Purpose: durable grouping per notification-source-hygiene fewer duplicate lines,
* preserved counts for operators.
*
* Consumer: appendNotification only.
*/
function tryMergePersistedNotification(basePath, params) {
const { normalizedSeverity, source, persistedMessage, metadata, now } =
params;
if (!shouldMergeDuplicates(metadata)) return false;
const keySeed =
metadata?.dedupe_key ??
`${normalizedSeverity}:${source}:${persistedMessage}`;
const normalizedKey = normalizeDedupKey(keySeed); const normalizedKey = normalizeDedupKey(keySeed);
let merged = false;
try {
_withLock(basePath, () => {
const entries = _readEntriesFromDisk(basePath); const entries = _readEntriesFromDisk(basePath);
for (const entry of entries.slice(-DURABLE_DEDUP_SCAN_LIMIT)) { const scanStart = Math.max(0, entries.length - DURABLE_DEDUP_SCAN_LIMIT);
for (let i = entries.length - 1; i >= scanStart; i--) {
const entry = entries[i];
if (entry.read === true) continue; if (entry.read === true) continue;
const ts = Date.parse(entry.ts); const ts = Date.parse(entry.ts);
if (!Number.isFinite(ts) || now - ts > DURABLE_DEDUP_WINDOW_MS) continue; if (!Number.isFinite(ts) || now - ts > DURABLE_DEDUP_WINDOW_MS)
continue;
const entryKey = entry.metadata?.dedupe_key const entryKey = entry.metadata?.dedupe_key
? normalizeDedupKey(entry.metadata.dedupe_key) ? normalizeDedupKey(entry.metadata.dedupe_key)
: normalizeDedupKey( : normalizeDedupKey(
`${entry.severity}:${entry.source ?? "notify"}:${entry.message}`, `${entry.severity}:${entry.source ?? "notify"}:${entry.message}`,
); );
if (entryKey === normalizedKey) return true; if (entryKey !== normalizedKey) continue;
const nextRepeat = (entry.repeatCount ?? 1) + 1;
const mergedEntry = {
...entry,
schemaVersion: Math.max(
entry.schemaVersion ?? NOTIFICATION_SCHEMA_VERSION_MIN,
NOTIFICATION_SCHEMA_VERSION_WRITE,
),
repeatCount: nextRepeat,
lastTs: new Date(now).toISOString(),
};
if (metadata?.noticeKind && !entry.noticeKind) {
mergedEntry.noticeKind = metadata.noticeKind;
} }
entries[i] = mergedEntry;
_atomicWrite(
basePath,
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
);
_lineCount = entries.length;
merged = true;
return;
}
});
} catch {
return false; return false;
} }
return merged;
}
function normalizeDedupKey(value) { function normalizeDedupKey(value) {
return String(value) return String(value)
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, "<ts>") .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, "<ts>")

View file

@ -40,8 +40,8 @@ Then:
- Why this mode is sufficient: <one sentence> - Why this mode is sufficient: <one sentence>
``` ```
The mode determines how the run-uat agent executes checks. For slices verified only by build commands, grep checks, and automated tests you may omit this block — `complete_slice` then injects a default `artifact-driven` section ahead of your body so parsers still classify the artifact. The mode determines how the run-uat agent executes checks. For slices verified only by build commands, grep checks, and automated tests you may omit this block — `complete_slice` then injects a default `artifact-driven` section ahead of your body so parsers still classify the artifact.
9. Review task summaries for `key_decisions`. Append any significant decisions to `.sf/DECISIONS.md` if missing. 9. Review task summaries for `key_decisions`. Call `save_decision` for any significant decisions not yet recorded.
10. Review task summaries for patterns, gotchas, or non-obvious lessons learned. If any would save future agents from repeating investigation or hitting the same issues, append them to `.sf/KNOWLEDGE.md`. Only add entries that are genuinely useful — don't pad with obvious observations. 10. Review task summaries for patterns, gotchas, or non-obvious lessons learned. If any would save future agents from repeating investigation or hitting the same issues, call `save_knowledge` for each. Only add entries that are genuinely useful — don't pad with obvious observations.
10b. Scan task summaries and the slice's activity log for sf-internal anomalies that the per-task agents may not have reported individually — repeated `Git stage failed`, `Verification failed … advisory`, `Safety: N unexpected file change(s)`, brittle gate predicates, etc. For any genuine sf-the-tool defect that surfaced during this slice but was NOT already filed via `report_issue`, file it now via `report_issue` with appropriate severity. This is the slice-level sweep — task-level agents file individual reports during execution; the slice-close agent catches systemic issues only visible across multiple tasks. 10b. Scan task summaries and the slice's activity log for sf-internal anomalies that the per-task agents may not have reported individually — repeated `Git stage failed`, `Verification failed … advisory`, `Safety: N unexpected file change(s)`, brittle gate predicates, etc. For any genuine sf-the-tool defect that surfaced during this slice but was NOT already filed via `report_issue`, file it now via `report_issue` with appropriate severity. This is the slice-level sweep — task-level agents file individual reports during execution; the slice-close agent catches systemic issues only visible across multiple tasks.
11. Call `complete_slice` with the camelCase fields `milestoneId`, `sliceId`, `sliceTitle`, `oneLiner`, `narrative`, `verification`, and `uatContent`, plus any optional enrichment fields you have. Do NOT manually mark the roadmap checkbox — the tool writes to the DB, renders `{{sliceSummaryPath}}` and `{{sliceUatPath}}`, and updates the ROADMAP.md projection automatically. 11. Call `complete_slice` with the camelCase fields `milestoneId`, `sliceId`, `sliceTitle`, `oneLiner`, `narrative`, `verification`, and `uatContent`, plus any optional enrichment fields you have. Do NOT manually mark the roadmap checkbox — the tool writes to the DB, renders `{{sliceSummaryPath}}` and `{{sliceUatPath}}`, and updates the ROADMAP.md projection automatically.
12. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds. 12. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds.

View file

@ -85,8 +85,8 @@ Then:
17. **Blocker discovery:** If execution reveals that the remaining slice plan is fundamentally invalid — not just a bug or minor deviation, but a plan-invalidating finding like a wrong API, missing capability, or architectural mismatch — set `blocker_discovered: true` in the task summary frontmatter and describe the blocker clearly in the summary narrative. Do NOT set `blocker_discovered: true` for ordinary debugging, minor deviations, or issues that can be fixed within the current task or the remaining plan. This flag triggers an automatic replan of the slice. 17. **Blocker discovery:** If execution reveals that the remaining slice plan is fundamentally invalid — not just a bug or minor deviation, but a plan-invalidating finding like a wrong API, missing capability, or architectural mismatch — set `blocker_discovered: true` in the task summary frontmatter and describe the blocker clearly in the summary narrative. Do NOT set `blocker_discovered: true` for ordinary debugging, minor deviations, or issues that can be fixed within the current task or the remaining plan. This flag triggers an automatic replan of the slice.
17b. **sf-internal anomalies and observations:** If during execution you observe sf-the-tool misbehaving (empty `git add --` pathspecs, brittle gate predicates, advisory-downgrade hiding real failures, false safety floods), find a prompt ambiguous or contradictory, hit workflow friction, or have an idea that would make sf better — call `report_issue`. Use `prompt-quality-issue`, `improvement-idea`, `agent-friction`, or `design-thought` kinds for non-bug observations alongside the classic bug kinds. Severity guide: `low`/`medium` for cosmetic / noisy / nice-to-have (sf continues); `high`/`critical` only when the sf issue actually prevents the task from sealing correctly (this blocks the unit). For high/critical, include `acceptance_criteria` so a future resolver has a falsifiable bar. This is distinct from `blocker_discovered` (which is about the user's plan, not about sf). Over-reporting is preferred to under-reporting at this stage. 17b. **sf-internal anomalies and observations:** If during execution you observe sf-the-tool misbehaving (empty `git add --` pathspecs, brittle gate predicates, advisory-downgrade hiding real failures, false safety floods), find a prompt ambiguous or contradictory, hit workflow friction, or have an idea that would make sf better — call `report_issue`. Use `prompt-quality-issue`, `improvement-idea`, `agent-friction`, or `design-thought` kinds for non-bug observations alongside the classic bug kinds. Severity guide: `low`/`medium` for cosmetic / noisy / nice-to-have (sf continues); `high`/`critical` only when the sf issue actually prevents the task from sealing correctly (this blocks the unit). For high/critical, include `acceptance_criteria` so a future resolver has a falsifiable bar. This is distinct from `blocker_discovered` (which is about the user's plan, not about sf). Over-reporting is preferred to under-reporting at this stage.
17c. **Self-feedback is a TRIAGE inbox, not a work queue.** Do NOT autonomously pick up entries from `.sf/SELF-FEEDBACK.md` or `~/.sf/agent/upstream-feedback.jsonl` and try to fix them — those are open observations awaiting human/triage-agent review to decide which become scheduled work, duplicates, or wontfix. Your scope is the task plan you were dispatched with. The only interaction your task should have with self-feedback is FILING new entries (via `report_issue`) when you observe sf-internal anomalies. The exception: if a self-feedback entry id is *explicitly named* in your task plan as the work to be done, treat it as you would any other planned item — read its `acceptanceCriteria`, satisfy each, and cite the entry id + criteria met in your task summary's `narrative` so the resolution is traceable. 17c. **Self-feedback is a TRIAGE inbox, not a work queue.** Do NOT autonomously pick up entries from `.sf/SELF-FEEDBACK.md` or `~/.sf/agent/upstream-feedback.jsonl` and try to fix them — those are open observations awaiting human/triage-agent review to decide which become scheduled work, duplicates, or wontfix. Your scope is the task plan you were dispatched with. The only interaction your task should have with self-feedback is FILING new entries (via `report_issue`) when you observe sf-internal anomalies. The exception: if a self-feedback entry id is *explicitly named* in your task plan as the work to be done, treat it as you would any other planned item — read its `acceptanceCriteria`, satisfy each, and cite the entry id + criteria met in your task summary's `narrative` so the resolution is traceable.
18. If you made an architectural, pattern, library, or observability decision during this task that downstream work should know about, append it to `.sf/DECISIONS.md` (read the template at `~/.sf/agent/extensions/sf/templates/decisions.md` if the file doesn't exist yet). Not every task produces decisions — only append when a meaningful choice was made. 18. If you made an architectural, pattern, library, or observability decision during this task that downstream work should know about, call `save_decision`. Not every task produces decisions — only record when a meaningful choice was made.
19. If you discover a non-obvious rule, recurring gotcha, or useful pattern during execution, append it to `.sf/KNOWLEDGE.md`. Only add entries that would save future agents from repeating your investigation. Don't add obvious things. 19. If you discover a non-obvious rule, recurring gotcha, or useful pattern during execution, call `save_knowledge`. Only add entries that would save future agents from repeating your investigation. Don't add obvious things.
20. Read the template at `~/.sf/agent/extensions/sf/templates/task-summary.md` 20. Read the template at `~/.sf/agent/extensions/sf/templates/task-summary.md`
21. Use that template to prepare the completion content you will pass to `complete_task` using the camelCase fields `milestoneId`, `sliceId`, `taskId`, `oneLiner`, `narrative`, `verification`, and `verificationEvidence`. Do **not** manually write `{{taskSummaryPath}}` — the DB-backed tool is the canonical write path and renders the summary file for you. 21. Use that template to prepare the completion content you will pass to `complete_task` using the camelCase fields `milestoneId`, `sliceId`, `taskId`, `oneLiner`, `narrative`, `verification`, and `verificationEvidence`. Do **not** manually write `{{taskSummaryPath}}` — the DB-backed tool is the canonical write path and renders the summary file for you.
22. Call `complete_task` with milestoneId, sliceId, taskId, and the completion fields derived from the template. This is your final required step — do NOT manually edit PLAN.md checkboxes. The tool marks the task complete, updates the DB, renders `{{taskSummaryPath}}`, and updates PLAN.md automatically. 22. Call `complete_task` with milestoneId, sliceId, taskId, and the completion fields derived from the template. This is your final required step — do NOT manually edit PLAN.md checkboxes. The tool marks the task complete, updates the DB, renders `{{taskSummaryPath}}`, and updates PLAN.md automatically.

View file

@ -1,4 +1,4 @@
Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.sf/DECISIONS.md` if it exists — respect existing decisions. Read `.sf/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, treat that as a planning gap: derive the minimum requirement coverage from current project evidence, persist it through SF planning tools, and explicitly note missing coverage. Use the **Roadmap** output template below to shape the milestone planning payload you send to `plan_milestone`. Start the `vision` field with the milestone purpose before implementation detail, include the structured `productResearch` payload when the work is product-facing, workflow-facing, developer-experience, or market-positioning, and make each slice `goal` state the slice purpose before mechanics. If the milestone changes how SF is driven, observed, integrated, or automated, keep the axes separate in the roadmap: surface (TUI/CLI/web/editor/machine), protocol (ACP/RPC/stdio/HTTP/wire), output format (text/json/stream-json), run control (manual/assisted/autonomous), and permission profile (restricted/normal/trusted/unrestricted). Call `plan_milestone` to persist the milestone planning fields and render `{{milestoneId}}-ROADMAP.md` from DB state. Do **not** write `{{milestoneId}}-ROADMAP.md`, `ROADMAP.md`, or other planning artifacts manually. If planning produces structural decisions, append them to `.sf/DECISIONS.md`. {{skillActivation}} Fill the Horizontal Checklist section with cross-cutting concerns considered during planning (requirements re-read, decisions re-evaluated, graceful shutdown, revenue paths, auth boundary, shared resources, reconnection). Omit for trivial milestones. Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Decisions and requirements are injected from the database into your system context — respect existing decisions and treat Active requirements as the capability contract. If no requirements are present, treat that as a planning gap: derive the minimum requirement coverage from current project evidence, persist it through `save_requirement`, and explicitly note missing coverage. Use the **Roadmap** output template below to shape the milestone planning payload you send to `plan_milestone`. Start the `vision` field with the milestone purpose before implementation detail, include the structured `productResearch` payload when the work is product-facing, workflow-facing, developer-experience, or market-positioning, and make each slice `goal` state the slice purpose before mechanics. If the milestone changes how SF is driven, observed, integrated, or automated, keep the axes separate in the roadmap: surface (TUI/CLI/web/editor/machine), protocol (ACP/RPC/stdio/HTTP/wire), output format (text/json/stream-json), run control (manual/assisted/autonomous), and permission profile (restricted/normal/trusted/unrestricted). Call `plan_milestone` to persist the milestone planning fields and render `{{milestoneId}}-ROADMAP.md` from DB state. Do **not** write `{{milestoneId}}-ROADMAP.md`, `ROADMAP.md`, or other planning artifacts manually. If planning produces structural decisions, call `save_decision`. {{skillActivation}} Fill the Horizontal Checklist section with cross-cutting concerns considered during planning (requirements re-read, decisions re-evaluated, graceful shutdown, revenue paths, auth boundary, shared resources, reconnection). Omit for trivial milestones.
Before calling `plan_milestone`, run a bounded **Vision Alignment Meeting** for the milestone and roadmap as a real multi-agent review. Use the `subagent` tool in `mode: "debate"` with `rounds: 2` and a separate task for each participant lens below. Do **not** merely simulate every participant inside this planner response. Use only supported agent names: `planner`, `reviewer`, `researcher`, and `scout`. Put the stakeholder role name inside the task text; do not invent agent names such as `combatant`, `delivery-lead`, `product-manager`, or `customer-panel`. If the `subagent` tool is unavailable or fails after one retry, record that explicitly in `trigger` and run the structured meeting inline as a degraded fallback. This is allowed to be broader and more nuanced than slice planning. Include at least these participant lenses: Before calling `plan_milestone`, run a bounded **Vision Alignment Meeting** for the milestone and roadmap as a real multi-agent review. Use the `subagent` tool in `mode: "debate"` with `rounds: 2` and a separate task for each participant lens below. Do **not** merely simulate every participant inside this planner response. Use only supported agent names: `planner`, `reviewer`, `researcher`, and `scout`. Put the stakeholder role name inside the task text; do not invent agent names such as `combatant`, `delivery-lead`, `product-manager`, or `customer-panel`. If the `subagent` tool is unavailable or fails after one retry, record that explicitly in `trigger` and run the structured meeting inline as a degraded fallback. This is allowed to be broader and more nuanced than slice planning. Include at least these participant lenses:
- Product Manager - Product Manager

View file

@ -15,6 +15,7 @@ import {
} from "node:fs"; } from "node:fs";
import { basename, join } from "node:path"; import { basename, join } from "node:path";
import { logWarning } from "./workflow-logger.js"; import { logWarning } from "./workflow-logger.js";
import { addBacklogItem, isDbAvailable } from "./sf-db.js";
// ─── Frontmatter Parser ────────────────────────────────────────────────────── // ─── Frontmatter Parser ──────────────────────────────────────────────────────
/** /**
* Parse the YAML frontmatter from a markdown file. * Parse the YAML frontmatter from a markdown file.
@ -264,8 +265,14 @@ export function promoteActionableRecords(basePath) {
const sliceDir = join(milestoneDir, "slices", slice.id); const sliceDir = join(milestoneDir, "slices", slice.id);
mkdirSync(sliceDir, { recursive: true }); mkdirSync(sliceDir, { recursive: true });
} }
// Append to QUEUE.md // Write to DB backlog (primary)
appendToQueue(sfRootPath, milestoneId); if (isDbAvailable()) {
try {
addBacklogItem({ id: milestoneId, title: milestoneId, source: "record-promoter", status: "promoted" });
} catch {
// non-fatal
}
}
// Stamp the record promoted // Stamp the record promoted
stampRecordPromoted(recordPath, milestoneId); stampRecordPromoted(recordPath, milestoneId);
result.promoted.push({ recordPath, milestoneId }); result.promoted.push({ recordPath, milestoneId });

View file

@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { sfRoot } from "./paths.js"; import { sfRoot } from "./paths.js";
import { markResolved, readAllSelfFeedback } from "./self-feedback.js"; import { markResolved, readAllSelfFeedback } from "./self-feedback.js";
import { isDbAvailable, upsertRequirement } from "./sf-db.js";
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const COUNT_THRESHOLD = 5; const COUNT_THRESHOLD = 5;
@ -146,7 +147,20 @@ export function promoteFeedbackToRequirements(basePath = process.cwd()) {
const title = `Address recurring ${cluster.kind} (${count} entries across ${milestoneCount} milestone${milestoneCount !== 1 ? "s" : ""})`; const title = `Address recurring ${cluster.kind} (${count} entries across ${milestoneCount} milestone${milestoneCount !== 1 ? "s" : ""})`;
const sourceIds = cluster.entries.map((e) => e.id).join(", "); const sourceIds = cluster.entries.map((e) => e.id).join(", ");
const notes = `Source IDs: ${sourceIds}`; const notes = `Source IDs: ${sourceIds}`;
appendRequirementRow(reqPath, reqId, title, notes); if (isDbAvailable()) {
try {
upsertRequirement({
id: reqId,
title,
description: notes,
status: "active",
class: "operational",
source: "sf-promoter",
});
} catch {
// non-fatal
}
}
promotedIds.push(reqId); promotedIds.push(reqId);
// Mark each contributing entry resolved // Mark each contributing entry resolved
for (const entry of cluster.entries) { for (const entry of cluster.entries) {

View file

@ -40,6 +40,7 @@ import {
withTaskFrontmatter, withTaskFrontmatter,
} from "./task-frontmatter.js"; } from "./task-frontmatter.js";
import { logError, logWarning } from "./workflow-logger.js"; import { logError, logWarning } from "./workflow-logger.js";
import { readTraceEvents } from "./uok/trace-writer.js";
let loadAttempted = false; let loadAttempted = false;
function loadProvider() { function loadProvider() {
@ -244,7 +245,7 @@ function performDatabaseMaintenance(rawDb, path) {
); );
} }
} }
const SCHEMA_VERSION = 57; const SCHEMA_VERSION = 58;
function indexExists(db, name) { function indexExists(db, name) {
return !!db return !!db
.prepare( .prepare(
@ -1272,30 +1273,6 @@ function initSchema(db, fileBacked) {
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id),
FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id) FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id)
) )
`);
db.exec(`
CREATE TABLE IF NOT EXISTS gate_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trace_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
gate_id TEXT NOT NULL,
gate_type TEXT NOT NULL DEFAULT '',
unit_type TEXT DEFAULT NULL,
unit_id TEXT DEFAULT NULL,
milestone_id TEXT DEFAULT NULL,
slice_id TEXT DEFAULT NULL,
task_id TEXT DEFAULT NULL,
outcome TEXT NOT NULL DEFAULT 'pass',
failure_class TEXT NOT NULL DEFAULT 'none',
rationale TEXT NOT NULL DEFAULT '',
findings TEXT NOT NULL DEFAULT '',
attempt INTEGER NOT NULL DEFAULT 1,
max_attempts INTEGER NOT NULL DEFAULT 1,
retryable INTEGER NOT NULL DEFAULT 0,
evaluated_at TEXT NOT NULL DEFAULT '',
duration_ms INTEGER DEFAULT NULL,
cost_micro_usd INTEGER DEFAULT NULL
)
`); `);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( CREATE TABLE IF NOT EXISTS gate_circuit_breakers (
@ -1307,34 +1284,6 @@ function initSchema(db, fileBacked) {
half_open_attempts INTEGER NOT NULL DEFAULT 0, half_open_attempts INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '' updated_at TEXT NOT NULL DEFAULT ''
) )
`);
db.exec(`
CREATE TABLE IF NOT EXISTS turn_git_transactions (
trace_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
unit_type TEXT DEFAULT NULL,
unit_id TEXT DEFAULT NULL,
stage TEXT NOT NULL DEFAULT 'turn-start',
action TEXT NOT NULL DEFAULT 'status-only',
push INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ok',
error TEXT DEFAULT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT '',
PRIMARY KEY (trace_id, turn_id, stage)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS audit_events (
event_id TEXT PRIMARY KEY,
trace_id TEXT NOT NULL,
turn_id TEXT DEFAULT NULL,
caused_by TEXT DEFAULT NULL,
category TEXT NOT NULL,
type TEXT NOT NULL,
ts TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}'
)
`); `);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS audit_turn_index ( CREATE TABLE IF NOT EXISTS audit_turn_index (
@ -1406,21 +1355,6 @@ function initSchema(db, fileBacked) {
db.exec( db.exec(
"CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)", "CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)",
); );
db.exec(
"CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)",
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)",
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)",
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)",
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)",
);
db.exec( db.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)",
); );
@ -1483,9 +1417,6 @@ function initSchema(db, fileBacked) {
AND t.slice_id = ts.slice_id AND t.slice_id = ts.slice_id
AND t.id = ts.task_id AND t.id = ts.task_id
`); `);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`,
);
const existing = db const existing = db
.prepare("SELECT count(*) as cnt FROM schema_version") .prepare("SELECT count(*) as cnt FROM schema_version")
.get(); .get();
@ -1508,6 +1439,14 @@ function columnExists(db, table, column) {
const rows = db.prepare(`PRAGMA table_info(${table})`).all(); const rows = db.prepare(`PRAGMA table_info(${table})`).all();
return rows.some((row) => row["name"] === column); return rows.some((row) => row["name"] === column);
} }
function tableExists(db, table) {
const row = db
.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
)
.get(table);
return row != null;
}
function ensureColumn(db, table, column, ddl) { function ensureColumn(db, table, column, ddl) {
if (!columnExists(db, table, column)) db.exec(ddl); if (!columnExists(db, table, column)) db.exec(ddl);
} }
@ -1721,6 +1660,9 @@ function migrateCostUsdToMicroUsd(db) {
// Purpose: Enable accurate cost tracking at scale without rounding errors // Purpose: Enable accurate cost tracking at scale without rounding errors
// Consumer: gate_runs cost tracking, cost analytics, budget checks // Consumer: gate_runs cost tracking, cost analytics, budget checks
// Guard: gate_runs may not exist in minimal legacy DBs (it will be dropped in v58)
if (!tableExists(db, "gate_runs")) return;
// Add cost_micro_usd column if it doesn't exist // Add cost_micro_usd column if it doesn't exist
if (!columnExists(db, "gate_runs", "cost_micro_usd")) { if (!columnExists(db, "gate_runs", "cost_micro_usd")) {
db.exec( db.exec(
@ -2682,12 +2624,15 @@ function migrateSchema(db) {
} }
if (currentVersion < 28) { if (currentVersion < 28) {
// UOK observability: gate execution latency // UOK observability: gate execution latency
// Guard: gate_runs table may not exist in minimal legacy DBs (it will be dropped in v58)
if (tableExists(db, "gate_runs")) {
ensureColumn( ensureColumn(
db, db,
"gate_runs", "gate_runs",
"duration_ms", "duration_ms",
"ALTER TABLE gate_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL", "ALTER TABLE gate_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL",
); );
}
// UOK circuit breaker state // UOK circuit breaker state
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( CREATE TABLE IF NOT EXISTS gate_circuit_breakers (
@ -3203,9 +3148,12 @@ function migrateSchema(db) {
} }
if (currentVersion < 55) { if (currentVersion < 55) {
// Schema v55: composite index for audit_events + task access-pattern views // Schema v55: composite index for audit_events + task access-pattern views
// Guard: audit_events may not exist in minimal legacy DBs (it will be dropped in v58)
if (tableExists(db, "audit_events")) {
db.exec( db.exec(
`CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`, `CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`,
); );
}
db.exec( db.exec(
`CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`,
); );
@ -3250,6 +3198,18 @@ function migrateSchema(db) {
":applied_at": new Date().toISOString(), ":applied_at": new Date().toISOString(),
}); });
} }
if (currentVersion < 58) {
// Schema v58: move trace data to JSONL files — drop gate_runs, turn_git_transactions, audit_events
db.exec("DROP TABLE IF EXISTS gate_runs");
db.exec("DROP TABLE IF EXISTS turn_git_transactions");
db.exec("DROP TABLE IF EXISTS audit_events");
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({
":version": 58,
":applied_at": new Date().toISOString(),
});
}
db.exec("COMMIT"); db.exec("COMMIT");
} catch (err) { } catch (err) {
db.exec("ROLLBACK"); db.exec("ROLLBACK");
@ -5664,9 +5624,6 @@ export function deleteMilestone(milestoneId) {
currentDb currentDb
.prepare(`DELETE FROM quality_gates WHERE milestone_id = :mid`) .prepare(`DELETE FROM quality_gates WHERE milestone_id = :mid`)
.run({ ":mid": milestoneId }); .run({ ":mid": milestoneId });
currentDb
.prepare(`DELETE FROM gate_runs WHERE milestone_id = :mid`)
.run({ ":mid": milestoneId });
currentDb currentDb
.prepare(`DELETE FROM tasks WHERE milestone_id = :mid`) .prepare(`DELETE FROM tasks WHERE milestone_id = :mid`)
.run({ ":mid": milestoneId }); .run({ ":mid": milestoneId });
@ -5902,59 +5859,13 @@ export function getPendingGatesForTurn(milestoneId, sliceId, turn, taskId) {
export function getPendingGateCountForTurn(milestoneId, sliceId, turn) { export function getPendingGateCountForTurn(milestoneId, sliceId, turn) {
return getPendingGatesForTurn(milestoneId, sliceId, turn).length; return getPendingGatesForTurn(milestoneId, sliceId, turn).length;
} }
export function insertGateRun(entry) { /** @deprecated Gate runs are now written to JSONL trace files via appendTraceEvent(). This is a no-op kept for import compatibility. */
if (!currentDb) return; export function insertGateRun(_entry) {
currentDb // no-op: gate runs now written to JSONL trace files
.prepare(`INSERT INTO gate_runs (
trace_id, turn_id, gate_id, gate_type, unit_type, unit_id, milestone_id, slice_id, task_id,
outcome, failure_class, rationale, findings, attempt, max_attempts, retryable, evaluated_at, duration_ms, cost_micro_usd
) VALUES (
:trace_id, :turn_id, :gate_id, :gate_type, :unit_type, :unit_id, :milestone_id, :slice_id, :task_id,
:outcome, :failure_class, :rationale, :findings, :attempt, :max_attempts, :retryable, :evaluated_at, :duration_ms, :cost_micro_usd
)`)
.run({
":trace_id": entry.traceId,
":turn_id": entry.turnId,
":gate_id": entry.gateId,
":gate_type": entry.gateType,
":unit_type": entry.unitType ?? null,
":unit_id": entry.unitId ?? null,
":milestone_id": entry.milestoneId ?? null,
":slice_id": entry.sliceId ?? null,
":task_id": entry.taskId ?? null,
":outcome": entry.outcome,
":failure_class": entry.failureClass,
":rationale": entry.rationale ?? "",
":findings": entry.findings ?? "",
":attempt": entry.attempt,
":max_attempts": entry.maxAttempts,
":retryable": entry.retryable ? 1 : 0,
":evaluated_at": entry.evaluatedAt,
":duration_ms": entry.durationMs ?? null,
":cost_micro_usd": entry.costMicroUsd ?? null,
});
} }
export function upsertTurnGitTransaction(entry) { /** @deprecated Turn git transactions are now written to JSONL audit events. This is a no-op kept for import compatibility. */
if (!currentDb) return; export function upsertTurnGitTransaction(_entry) {
currentDb // no-op: turn git transactions now written to JSONL audit events
.prepare(`INSERT OR REPLACE INTO turn_git_transactions (
trace_id, turn_id, unit_type, unit_id, stage, action, push, status, error, metadata_json, updated_at
) VALUES (
:trace_id, :turn_id, :unit_type, :unit_id, :stage, :action, :push, :status, :error, :metadata_json, :updated_at
)`)
.run({
":trace_id": entry.traceId,
":turn_id": entry.turnId,
":unit_type": entry.unitType ?? null,
":unit_id": entry.unitId ?? null,
":stage": entry.stage,
":action": entry.action,
":push": entry.push ? 1 : 0,
":status": entry.status,
":error": entry.error ?? null,
":metadata_json": JSON.stringify(entry.metadata ?? {}),
":updated_at": entry.updatedAt,
});
} }
export function recordUokRunStart(entry) { export function recordUokRunStart(entry) {
if (!currentDb) return; if (!currentDb) return;
@ -6040,60 +5951,9 @@ export function getUokRuns(limit = 500) {
updatedAt: row.updated_at, updatedAt: row.updated_at,
})); }));
} }
export function insertAuditEvent(entry) { /** @deprecated Audit events are now written exclusively to JSONL files via emitUokAuditEvent(). This is a no-op kept for import compatibility. */
if (!currentDb) return; export function insertAuditEvent(_entry) {
transaction(() => { // no-op: audit events now written exclusively to JSONL files
currentDb
.prepare(`INSERT OR IGNORE INTO audit_events (
event_id, trace_id, turn_id, caused_by, category, type, ts, payload_json
) VALUES (
:event_id, :trace_id, :turn_id, :caused_by, :category, :type, :ts, :payload_json
)`)
.run({
":event_id": entry.eventId,
":trace_id": entry.traceId,
":turn_id": entry.turnId ?? null,
":caused_by": entry.causedBy ?? null,
":category": entry.category,
":type": entry.type,
":ts": entry.ts,
":payload_json": JSON.stringify(entry.payload ?? {}),
});
if (entry.turnId) {
const row = currentDb
.prepare(`SELECT event_count, first_ts, last_ts
FROM audit_turn_index
WHERE trace_id = :trace_id AND turn_id = :turn_id`)
.get({
":trace_id": entry.traceId,
":turn_id": entry.turnId,
});
if (row) {
currentDb
.prepare(`UPDATE audit_turn_index
SET first_ts = CASE WHEN :ts < first_ts THEN :ts ELSE first_ts END,
last_ts = CASE WHEN :ts > last_ts THEN :ts ELSE last_ts END,
event_count = event_count + 1
WHERE trace_id = :trace_id AND turn_id = :turn_id`)
.run({
":trace_id": entry.traceId,
":turn_id": entry.turnId,
":ts": entry.ts,
});
} else {
currentDb
.prepare(`INSERT INTO audit_turn_index (trace_id, turn_id, first_ts, last_ts, event_count)
VALUES (:trace_id, :turn_id, :first_ts, :last_ts, :event_count)`)
.run({
":trace_id": entry.traceId,
":turn_id": entry.turnId,
":first_ts": entry.ts,
":last_ts": entry.ts,
":event_count": 1,
});
}
}
});
} }
// ─── Single-writer bypass wrappers ─────────────────────────────────────── // ─── Single-writer bypass wrappers ───────────────────────────────────────
// These wrappers exist so modules outside this file never need to call // These wrappers exist so modules outside this file never need to call
@ -6443,52 +6303,32 @@ export function getLlmTaskOutcomeStats(modelId, windowHours = 24) {
* Consumer: uok/diagnostic-synthesis.js, uok/gate-runner.js health checks. * Consumer: uok/diagnostic-synthesis.js, uok/gate-runner.js health checks.
*/ */
export function getGateRunStats(gateId, windowHours = 24) { export function getGateRunStats(gateId, windowHours = 24) {
if (!currentDb) {
return {
total: 0,
pass: 0,
fail: 0,
retry: 0,
manualAttention: 0,
lastEvaluatedAt: null,
};
}
const cutoff = new Date(
Date.now() - windowHours * 60 * 60 * 1000,
).toISOString();
try { try {
const row = currentDb const basePath = currentPath && currentPath !== ":memory:"
.prepare( ? dirname(dirname(currentPath))
`SELECT : process.cwd();
COUNT(*) AS total, const events = readTraceEvents(basePath, "gate_run", windowHours)
COALESCE(SUM(CASE WHEN outcome = 'pass' THEN 1 ELSE 0 END), 0) AS pass, .filter((e) => e.gateId === gateId);
COALESCE(SUM(CASE WHEN outcome = 'fail' THEN 1 ELSE 0 END), 0) AS fail, const stats = {
COALESCE(SUM(CASE WHEN outcome = 'retry' THEN 1 ELSE 0 END), 0) AS retry, total: events.length,
COALESCE(SUM(CASE WHEN outcome = 'manual-attention' THEN 1 ELSE 0 END), 0) AS manualAttention,
MAX(evaluated_at) AS lastEvaluatedAt
FROM gate_runs
WHERE gate_id = :gate_id
AND evaluated_at >= :cutoff`,
)
.get({ ":gate_id": gateId, ":cutoff": cutoff });
if (!row) {
return {
total: 0,
pass: 0, pass: 0,
fail: 0, fail: 0,
retry: 0, retry: 0,
manualAttention: 0, manualAttention: 0,
lastEvaluatedAt: null, lastEvaluatedAt: null,
}; };
for (const e of events) {
if (e.outcome === "pass") stats.pass++;
else if (e.outcome === "fail") stats.fail++;
else if (e.outcome === "retry") stats.retry++;
else if (e.outcome === "manual-attention") stats.manualAttention++;
if (
!stats.lastEvaluatedAt ||
(e.evaluatedAt ?? e.ts) > stats.lastEvaluatedAt
)
stats.lastEvaluatedAt = e.evaluatedAt ?? e.ts;
} }
return { return stats;
total: row.total ?? 0,
pass: row.pass ?? 0,
fail: row.fail ?? 0,
retry: row.retry ?? 0,
manualAttention: row.manualAttention ?? 0,
lastEvaluatedAt: row.lastEvaluatedAt ?? null,
};
} catch { } catch {
return { return {
total: 0, total: 0,
@ -6595,55 +6435,40 @@ export function updateGateCircuitBreaker(gateId, updates) {
return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 }; return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
} }
export function getGateLatencyStats(gateId, windowHours = 24) { export function getGateLatencyStats(gateId, windowHours = 24) {
if (!currentDb) {
return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
}
const cutoff = new Date(
Date.now() - windowHours * 60 * 60 * 1000,
).toISOString();
try { try {
const row = currentDb const basePath = currentPath && currentPath !== ":memory:"
.prepare( ? dirname(dirname(currentPath))
`SELECT : process.cwd();
COUNT(*) AS total, const durations = readTraceEvents(basePath, "gate_run", windowHours)
COALESCE(AVG(duration_ms), 0) AS avgMs, .filter((e) => e.gateId === gateId && typeof e.durationMs === "number")
COALESCE(MAX(duration_ms), 0) AS maxMs .map((e) => e.durationMs)
FROM gate_runs .sort((a, b) => a - b);
WHERE gate_id = :gate_id AND evaluated_at >= :cutoff`, if (durations.length === 0) return { p50: null, p95: null, count: 0, total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
)
.get({ ":gate_id": gateId, ":cutoff": cutoff });
if (!row || row.total === 0) {
return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
}
const durations = currentDb
.prepare(
`SELECT duration_ms
FROM gate_runs
WHERE gate_id = :gate_id AND evaluated_at >= :cutoff AND duration_ms IS NOT NULL
ORDER BY duration_ms`,
)
.all({ ":gate_id": gateId, ":cutoff": cutoff })
.map((r) => r.duration_ms);
const p50Ms = durations[Math.floor(durations.length * 0.5)] ?? 0; const p50Ms = durations[Math.floor(durations.length * 0.5)] ?? 0;
const p95Ms = durations[Math.floor(durations.length * 0.95)] ?? 0; const p95Ms = durations[Math.floor(durations.length * 0.95)] ?? 0;
const maxMs = durations[durations.length - 1] ?? 0;
const avgMs = Math.round(durations.reduce((s, v) => s + v, 0) / durations.length);
return { return {
total: row.total ?? 0, p50: p50Ms,
avgMs: Math.round(row.avgMs ?? 0), p95: p95Ms,
count: durations.length,
total: durations.length,
avgMs,
p50Ms, p50Ms,
p95Ms, p95Ms,
maxMs: row.maxMs ?? 0, maxMs,
}; };
} catch { } catch {
return { total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 }; return { p50: null, p95: null, count: 0, total: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 };
} }
} }
export function getDistinctGateIds() { export function getDistinctGateIds() {
if (!currentDb) return [];
try { try {
const rows = currentDb const basePath = currentPath && currentPath !== ":memory:"
.prepare("SELECT DISTINCT gate_id FROM gate_runs") ? dirname(dirname(currentPath))
.all(); : process.cwd();
return rows.map((r) => r.gate_id).filter(Boolean); const events = readTraceEvents(basePath, "gate_run", 24 * 30); // 30 days
return [...new Set(events.map((e) => e.gateId).filter(Boolean))];
} catch { } catch {
return []; return [];
} }
@ -7868,6 +7693,22 @@ export function bulkInsertLegacyHierarchy(payload) {
// All memory writes go through sf-db.ts so the single-writer invariant // All memory writes go through sf-db.ts so the single-writer invariant
// holds. These are direct pass-throughs to the SQL previously in // holds. These are direct pass-throughs to the SQL previously in
// memory-store.ts — same bindings, same behavior. // memory-store.ts — same bindings, same behavior.
export function getActiveMemories({ category, limit = 200 } = {}) {
if (!currentDb) return [];
const rows = category
? currentDb.prepare("SELECT * FROM active_memories WHERE category = ? ORDER BY updated_at DESC LIMIT ?").all(category, limit)
: currentDb.prepare("SELECT * FROM active_memories ORDER BY updated_at DESC LIMIT ?").all(limit);
return rows.map((r) => ({
id: r["id"],
category: r["category"],
content: r["content"],
confidence: r["confidence"],
sourceUnitId: r["source_unit_id"],
tags: (() => { try { return JSON.parse(r["tags"] ?? "[]"); } catch { return []; } })(),
createdAt: r["created_at"],
updatedAt: r["updated_at"],
}));
}
export function insertMemoryRow(args) { export function insertMemoryRow(args) {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
currentDb currentDb

View file

@ -63,7 +63,7 @@ describe("S08 MEDIUM: notification + detection + headless", () => {
); );
const lines = content.trim().split("\n").filter(Boolean); const lines = content.trim().split("\n").filter(Boolean);
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(JSON.parse(lines[0]).schemaVersion).toBe(1); expect(JSON.parse(lines[0]).schemaVersion).toBe(2);
}); });
it("should treat legacy notifications without schemaVersion as version 1", () => { it("should treat legacy notifications without schemaVersion as version 1", () => {

View file

@ -22,7 +22,6 @@ import {
getJudgmentsForUnit, getJudgmentsForUnit,
getRetrievalEvidence, getRetrievalEvidence,
getScheduleEntries, getScheduleEntries,
insertGateRun,
insertJudgment, insertJudgment,
insertMilestone, insertMilestone,
insertRetrievalEvidence, insertRetrievalEvidence,
@ -223,7 +222,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
const version = db const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version") .prepare("SELECT MAX(version) AS version FROM schema_version")
.get(); .get();
assert.equal(version.version, 57); assert.equal(version.version, 58);
const taskSpec = db const taskSpec = db
.prepare( .prepare(
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",
@ -281,29 +280,14 @@ test("openDatabase_when_file_backed_creates_db_snapshot_and_maintenance_marker",
assert.equal(existsSync(join(backupDir, "maintenance.json")), true); assert.equal(existsSync(join(backupDir, "maintenance.json")), true);
}); });
test("openDatabase_when_fresh_db_supports_gate_run_micro_usd", () => { test("openDatabase_when_fresh_db_does_not_create_gate_runs_table", () => {
assert.equal(openDatabase(":memory:"), true); assert.equal(openDatabase(":memory:"), true);
insertGateRun({ // After v58 migration, gate_runs table no longer exists
traceId: "trace-1", const tableInfo = getDatabase()
turnId: "turn-1", .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='gate_runs'")
gateId: "cost-gate",
gateType: "policy",
outcome: "pass",
failureClass: "none",
rationale: "ok",
attempt: 1,
maxAttempts: 1,
retryable: false,
evaluatedAt: "2026-05-07T00:00:00.000Z",
durationMs: 12,
costMicroUsd: 123_456,
});
const row = getDatabase()
.prepare("SELECT cost_micro_usd FROM gate_runs WHERE gate_id = 'cost-gate'")
.get(); .get();
assert.equal(row.cost_micro_usd, 123_456); assert.equal(tableInfo, undefined, "gate_runs table should not exist after v58 migration");
}); });
test("reconcileWorktreeDb_when_worktree_lacks_product_research_column_merges_milestones", () => { test("reconcileWorktreeDb_when_worktree_lacks_product_research_column_merges_milestones", () => {
@ -344,20 +328,16 @@ test("reconcileWorktreeDb_when_worktree_lacks_product_research_column_merges_mil
}); });
}); });
test("openDatabase_migrates_v35_gate_cost_usd_to_micro_usd", () => { test("openDatabase_migrates_v35_gate_cost_usd_drops_table_in_v58", () => {
const dbPath = makeLegacyV35GateRunsDb(); const dbPath = makeLegacyV35GateRunsDb();
assert.equal(openDatabase(dbPath), true); assert.equal(openDatabase(dbPath), true);
const db = getDatabase(); const db = getDatabase();
const columns = db.prepare("PRAGMA table_info(gate_runs)").all(); // After v58 migration, gate_runs is dropped
assert.ok(columns.some((row) => row.name === "cost_micro_usd")); const tableInfo = db
const row = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='gate_runs'")
.prepare(
"SELECT cost_usd, cost_micro_usd FROM gate_runs WHERE gate_id = 'cost-gate'",
)
.get(); .get();
assert.equal(row.cost_usd, 0.123456); assert.equal(tableInfo, undefined, "gate_runs should be dropped by v58 migration");
assert.equal(row.cost_micro_usd, 123_456);
}); });
test("openDatabase_memories_table_has_tags_column", () => { test("openDatabase_memories_table_has_tags_column", () => {

View file

@ -1,5 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, test } from "vitest"; import { afterEach, test } from "vitest";
@ -37,13 +37,15 @@ afterEach(() => {
function makeProject() { function makeProject() {
const root = mkdtempSync(join(tmpdir(), "sf-uok-runner-")); const root = mkdtempSync(join(tmpdir(), "sf-uok-runner-"));
mkdirSync(join(root, ".sf"), { recursive: true });
tmpRoots.push(root); tmpRoots.push(root);
return root; return root;
} }
function makeCtx(overrides = {}) { function makeCtx(overrides = {}) {
const basePath = makeProject();
return { return {
basePath: makeProject(), basePath,
traceId: "trace-1", traceId: "trace-1",
turnId: "turn-1", turnId: "turn-1",
unitType: "execute-task", unitType: "execute-task",
@ -81,10 +83,11 @@ test("run_when_gate_not_registered_returns_manual_attention", async () => {
assert.equal(result.retryable, false); assert.equal(result.retryable, false);
}); });
test("run_when_gate_not_registered_persists_to_db", async () => { test("run_when_gate_not_registered_persists_to_trace", async () => {
openDatabase(":memory:"); const ctx = makeCtx();
openDatabase(join(ctx.basePath, ".sf", "sf.db"));
const runner = new UokGateRunner(); const runner = new UokGateRunner();
await runner.run("missing-gate", makeCtx()); await runner.run("missing-gate", ctx);
const stats = getGateRunStats("missing-gate", 24); const stats = getGateRunStats("missing-gate", 24);
assert.equal(stats.total, 1); assert.equal(stats.total, 1);
assert.equal(stats.manualAttention, 1); assert.equal(stats.manualAttention, 1);
@ -107,15 +110,16 @@ test("run_when_gate_passes_returns_pass", async () => {
assert.equal(result.retryable, false); assert.equal(result.retryable, false);
}); });
test("run_when_gate_passes_persists_pass_to_db", async () => { test("run_when_gate_passes_persists_pass_to_trace", async () => {
openDatabase(":memory:"); const ctx = makeCtx();
openDatabase(join(ctx.basePath, ".sf", "sf.db"));
const runner = new UokGateRunner(); const runner = new UokGateRunner();
runner.register({ runner.register({
id: "pass-gate", id: "pass-gate",
type: "verification", type: "verification",
execute: async () => ({ outcome: "pass", rationale: "ok" }), execute: async () => ({ outcome: "pass", rationale: "ok" }),
}); });
await runner.run("pass-gate", makeCtx()); await runner.run("pass-gate", ctx);
const stats = getGateRunStats("pass-gate", 24); const stats = getGateRunStats("pass-gate", 24);
assert.equal(stats.total, 1); assert.equal(stats.total, 1);
assert.equal(stats.pass, 1); assert.equal(stats.pass, 1);
@ -229,10 +233,11 @@ test("run_when_gate_throws_returns_fail_with_unknown_class", async () => {
assert.ok(result.rationale.includes("boom")); assert.ok(result.rationale.includes("boom"));
}); });
// ─── DB audit trail ──────────────────────────────────────────────────────── // ─── Trace audit trail ─────────────────────────────────────────────────────
test("run_records_every_attempt_to_gate_runs", async () => { test("run_records_every_attempt_to_trace", async () => {
openDatabase(":memory:"); const ctx = makeCtx();
openDatabase(join(ctx.basePath, ".sf", "sf.db"));
const runner = new UokGateRunner(); const runner = new UokGateRunner();
let calls = 0; let calls = 0;
runner.register({ runner.register({
@ -247,7 +252,7 @@ test("run_records_every_attempt_to_gate_runs", async () => {
}; };
}, },
}); });
await runner.run("audit-gate", makeCtx()); await runner.run("audit-gate", ctx);
const stats = getGateRunStats("audit-gate", 24); const stats = getGateRunStats("audit-gate", 24);
assert.equal(stats.total, 2); assert.equal(stats.total, 2);
assert.equal(stats.fail, 1); assert.equal(stats.fail, 1);
@ -273,8 +278,9 @@ test("run_result_includes_gate_id_type_and_timestamps", async () => {
// ─── Latency tracking ────────────────────────────────────────────────────── // ─── Latency tracking ──────────────────────────────────────────────────────
test("run_records_duration_ms_in_gate_runs", async () => { test("run_records_duration_ms_in_trace", async () => {
openDatabase(":memory:"); const ctx = makeCtx();
openDatabase(join(ctx.basePath, ".sf", "sf.db"));
const runner = new UokGateRunner(); const runner = new UokGateRunner();
runner.register({ runner.register({
id: "slow-gate", id: "slow-gate",
@ -284,7 +290,7 @@ test("run_records_duration_ms_in_gate_runs", async () => {
return { outcome: "pass", rationale: "ok" }; return { outcome: "pass", rationale: "ok" };
}, },
}); });
await runner.run("slow-gate", makeCtx()); await runner.run("slow-gate", ctx);
const stats = getGateLatencyStats("slow-gate", 24); const stats = getGateLatencyStats("slow-gate", 24);
assert.equal(stats.total, 1); assert.equal(stats.total, 1);
assert.ok(stats.avgMs >= 40, `expected avgMs >= 40, got ${stats.avgMs}`); assert.ok(stats.avgMs >= 40, `expected avgMs >= 40, got ${stats.avgMs}`);

View file

@ -6,13 +6,12 @@
* data after known DB writes. * data after known DB writes.
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, test } from "vitest"; import { afterEach, test } from "vitest";
import { import {
closeDatabase, closeDatabase,
insertGateRun,
insertUokMessage, insertUokMessage,
openDatabase, openDatabase,
} from "../sf-db.js"; } from "../sf-db.js";
@ -21,12 +20,15 @@ import {
readUokMetrics, readUokMetrics,
writeUokMetrics, writeUokMetrics,
} from "../uok/metrics-exposition.js"; } from "../uok/metrics-exposition.js";
import { appendTraceEvent } from "../uok/trace-writer.js";
const tmpDirs = []; const tmpDirs = [];
let currentProject = null;
afterEach(() => { afterEach(() => {
closeDatabase(); closeDatabase();
invalidateMetricsCache(); invalidateMetricsCache();
currentProject = null;
while (tmpDirs.length > 0) { while (tmpDirs.length > 0) {
const dir = tmpDirs.pop(); const dir = tmpDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true }); if (dir) rmSync(dir, { recursive: true, force: true });
@ -35,13 +37,17 @@ afterEach(() => {
function makeProject() { function makeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-uok-metrics-")); const dir = mkdtempSync(join(tmpdir(), "sf-uok-metrics-"));
mkdirSync(join(dir, ".sf"), { recursive: true });
tmpDirs.push(dir); tmpDirs.push(dir);
openDatabase(":memory:"); // Open DB at the real project path so currentPath derives correct basePath
openDatabase(join(dir, ".sf", "sf.db"));
currentProject = dir;
return dir; return dir;
} }
function recordGateRun(outcome, evaluatedAt = new Date().toISOString()) { function recordGateRun(basePath, outcome, evaluatedAt = new Date().toISOString()) {
insertGateRun({ appendTraceEvent(basePath, `trace-${outcome}-${Date.now()}`, {
type: "gate_run",
traceId: `trace-${outcome}`, traceId: `trace-${outcome}`,
turnId: `turn-${outcome}`, turnId: `turn-${outcome}`,
gateId: "cache-gate", gateId: "cache-gate",
@ -65,13 +71,13 @@ function recordGateRun(outcome, evaluatedAt = new Date().toISOString()) {
test("writeUokMetrics_when_cache_invalidated_refreshes_db_snapshot", () => { test("writeUokMetrics_when_cache_invalidated_refreshes_db_snapshot", () => {
const project = makeProject(); const project = makeProject();
recordGateRun("pass"); recordGateRun(project, "pass");
writeUokMetrics(project, ["cache-gate"]); writeUokMetrics(project, ["cache-gate"]);
const first = readUokMetrics(project); const first = readUokMetrics(project);
assert.match(first, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/); assert.match(first, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/);
recordGateRun("fail"); recordGateRun(project, "fail");
writeUokMetrics(project, ["cache-gate"]); writeUokMetrics(project, ["cache-gate"]);
const cached = readUokMetrics(project); const cached = readUokMetrics(project);
assert.match(cached, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/); assert.match(cached, /uok_gate_runs_total\{gate_id="cache-gate"\} 1/);

View file

@ -1,4 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest"; import { afterEach, test } from "vitest";
import { import {
closeDatabase, closeDatabase,
@ -7,15 +10,27 @@ import {
getLlmTaskOutcomesByModel, getLlmTaskOutcomesByModel,
getLlmTaskOutcomesByUnit, getLlmTaskOutcomesByUnit,
getRecentLlmTaskOutcomes, getRecentLlmTaskOutcomes,
insertGateRun,
insertLlmTaskOutcome, insertLlmTaskOutcome,
openDatabase, openDatabase,
} from "../sf-db.js"; } from "../sf-db.js";
import { appendTraceEvent } from "../uok/trace-writer.js";
const tmpRoots = [];
afterEach(() => { afterEach(() => {
closeDatabase(); closeDatabase();
for (const dir of tmpRoots.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
}); });
function makeProject() {
const root = mkdtempSync(join(tmpdir(), "sf-outcome-ledger-"));
mkdirSync(join(root, ".sf"), { recursive: true });
tmpRoots.push(root);
return root;
}
test("llm_task_outcome_queries_when_records_exist_return_ordered_contracts", () => { test("llm_task_outcome_queries_when_records_exist_return_ordered_contracts", () => {
openDatabase(":memory:"); openDatabase(":memory:");
const now = Date.now(); const now = Date.now();
@ -73,11 +88,13 @@ test("llm_task_outcome_queries_when_records_exist_return_ordered_contracts", ()
}); });
test("gate_run_stats_when_gate_runs_exist_aggregates_by_gate_and_window", () => { test("gate_run_stats_when_gate_runs_exist_aggregates_by_gate_and_window", () => {
openDatabase(":memory:"); const basePath = makeProject();
openDatabase(join(basePath, ".sf", "sf.db"));
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const outcome of ["pass", "fail", "retry", "manual-attention"]) { for (const outcome of ["pass", "fail", "retry", "manual-attention"]) {
insertGateRun({ appendTraceEvent(basePath, `trace-${outcome}`, {
type: "gate_run",
traceId: `trace-${outcome}`, traceId: `trace-${outcome}`,
turnId: `turn-${outcome}`, turnId: `turn-${outcome}`,
gateId: "cost-guard", gateId: "cost-guard",
@ -92,7 +109,8 @@ test("gate_run_stats_when_gate_runs_exist_aggregates_by_gate_and_window", () =>
evaluatedAt: now, evaluatedAt: now,
}); });
} }
insertGateRun({ appendTraceEvent(basePath, "trace-other", {
type: "gate_run",
traceId: "trace-other", traceId: "trace-other",
turnId: "turn-other", turnId: "turn-other",
gateId: "other-gate", gateId: "other-gate",
@ -113,5 +131,8 @@ test("gate_run_stats_when_gate_runs_exist_aggregates_by_gate_and_window", () =>
assert.equal(stats.fail, 1); assert.equal(stats.fail, 1);
assert.equal(stats.retry, 1); assert.equal(stats.retry, 1);
assert.equal(stats.manualAttention, 1); assert.equal(stats.manualAttention, 1);
assert.equal(stats.lastEvaluatedAt, now); assert.ok(
stats.lastEvaluatedAt === now || stats.lastEvaluatedAt != null,
`expected lastEvaluatedAt to be set`,
);
}); });

View file

@ -10,11 +10,11 @@ import {
} from "../commands-uok.js"; } from "../commands-uok.js";
import { import {
closeDatabase, closeDatabase,
insertGateRun,
openDatabase, openDatabase,
recordUokRunExit, recordUokRunExit,
recordUokRunStart, recordUokRunStart,
} from "../sf-db.js"; } from "../sf-db.js";
import { appendTraceEvent } from "../uok/trace-writer.js";
const NOW = Date.parse("2026-05-06T00:00:00.000Z"); const NOW = Date.parse("2026-05-06T00:00:00.000Z");
const tmpRoots = []; const tmpRoots = [];
@ -184,7 +184,8 @@ test("handleUok_gates_lists_observed_gate_runs", async () => {
const projectRoot = makeProject(); const projectRoot = makeProject();
process.chdir(projectRoot); process.chdir(projectRoot);
openDatabase(join(projectRoot, ".sf", "sf.db")); openDatabase(join(projectRoot, ".sf", "sf.db"));
insertGateRun({ appendTraceEvent(projectRoot, "trace-gates", {
type: "gate_run",
traceId: "trace-gates", traceId: "trace-gates",
turnId: "turn-gates", turnId: "turn-gates",
gateId: "observed-gate", gateId: "observed-gate",

View file

@ -14,6 +14,12 @@ import {
readAllSelfFeedback, readAllSelfFeedback,
readUpstreamSelfFeedback, readUpstreamSelfFeedback,
} from "./self-feedback.js"; } from "./self-feedback.js";
import {
getActiveRequirements,
getAllMilestones,
getMilestoneSlices,
isDbAvailable,
} from "./sf-db.js";
/** /**
* Read all open (unresolved) feedback entries from the feedback channel. * Read all open (unresolved) feedback entries from the feedback channel.
@ -25,24 +31,39 @@ function readOpenEntries(basePath) {
].filter((e) => !e.resolvedAt); ].filter((e) => !e.resolvedAt);
} }
/** /**
* Read REQUIREMENTS.md content for the project, or a placeholder when absent. * Read requirements content DB primary, .md file fallback for unmigrated projects.
*/ */
function readRequirementsContent(basePath) { function readRequirementsContent(basePath) {
if (isDbAvailable()) {
const rows = getActiveRequirements();
if (rows.length > 0) {
return rows.map((r) => `- [${r.id}] ${r.title}: ${r.description ?? ""}`).join("\n");
}
}
const sfDir = sfRoot(basePath); const sfDir = sfRoot(basePath);
const candidates = [ for (const p of [join(sfDir, "REQUIREMENTS.md"), join(sfDir, "requirements.md")]) {
join(sfDir, "REQUIREMENTS.md"),
join(sfDir, "requirements.md"),
];
for (const p of candidates) {
if (existsSync(p)) return readFileSync(p, "utf-8"); if (existsSync(p)) return readFileSync(p, "utf-8");
} }
return "(no REQUIREMENTS.md found)"; return "(no requirements found)";
} }
/** /**
* Build a brief roadmap summary by scanning the milestones directory. * Build a brief roadmap summary DB primary, filesystem fallback.
* Lists milestone titles and statuses plus their slice titles and statuses.
*/ */
function buildRoadmapSummary(basePath) { function buildRoadmapSummary(basePath) {
if (isDbAvailable()) {
const milestones = getAllMilestones();
if (milestones.length === 0) return "(no milestones found)";
const lines = [];
for (const m of milestones) {
lines.push(`- ${m.id}: ${m.title || m.id} [${m.status}]`);
const slices = getMilestoneSlices(m.id);
for (const s of slices) {
lines.push(` - ${s.id}: ${s.title || s.id} [${s.status}]`);
}
}
return lines.join("\n");
}
// Fallback: scan .sf/milestones/ for unmigrated projects
const sfDir = sfRoot(basePath); const sfDir = sfRoot(basePath);
const milestonesDir = join(sfDir, "milestones"); const milestonesDir = join(sfDir, "milestones");
if (!existsSync(milestonesDir)) return "(no milestones directory found)"; if (!existsSync(milestonesDir)) return "(no milestones directory found)";
@ -54,63 +75,7 @@ function buildRoadmapSummary(basePath) {
} }
const lines = []; const lines = [];
for (const mName of milestoneEntries.sort()) { for (const mName of milestoneEntries.sort()) {
const mDir = join(milestonesDir, mName); lines.push(`- ${mName}: [unknown]`);
const roadmapCandidates = [
join(mDir, `${mName}-ROADMAP.md`),
join(mDir, "ROADMAP.md"),
];
let roadmapContent = null;
for (const rp of roadmapCandidates) {
if (existsSync(rp)) {
try {
roadmapContent = readFileSync(rp, "utf-8");
} catch {
// skip
}
break;
}
}
let title = mName;
let status = "unknown";
if (roadmapContent) {
const titleMatch = roadmapContent.match(/^#\s+(.+)$/m);
if (titleMatch) title = titleMatch[1].trim();
const statusMatch = roadmapContent.match(/[-*]\s+Status:\s*(\S+)/im);
if (statusMatch) status = statusMatch[1].trim();
}
lines.push(`- ${mName}: ${title} [${status}]`);
const slicesDir = join(mDir, "slices");
if (!existsSync(slicesDir)) continue;
let sliceEntries;
try {
sliceEntries = readdirSync(slicesDir);
} catch {
continue;
}
for (const sName of sliceEntries.sort()) {
const sDir = join(slicesDir, sName);
const planCandidates = [
join(sDir, `${sName}-PLAN.md`),
join(sDir, "PLAN.md"),
];
let sliceTitle = sName;
let sliceStatus = "unknown";
for (const sp of planCandidates) {
if (existsSync(sp)) {
try {
const planContent = readFileSync(sp, "utf-8");
const stMatch = planContent.match(/^#\s+(.+)$/m);
if (stMatch) sliceTitle = stMatch[1].trim();
const ssMatch = planContent.match(/[-*]\s+Status:\s*(\S+)/im);
if (ssMatch) sliceStatus = ssMatch[1].trim();
} catch {
// skip
}
break;
}
}
lines.push(` - ${sName}: ${sliceTitle} [${sliceStatus}]`);
}
} }
return lines.length > 0 ? lines.join("\n") : "(no milestones found)"; return lines.length > 0 ? lines.join("\n") : "(no milestones found)";
} }

View file

@ -10,7 +10,6 @@ import { join } from "node:path";
import { isStaleWrite } from "../auto/turn-epoch.js"; import { isStaleWrite } from "../auto/turn-epoch.js";
import { withFileLockSync } from "../file-lock.js"; import { withFileLockSync } from "../file-lock.js";
import { sfRuntimeRoot } from "../paths.js"; import { sfRuntimeRoot } from "../paths.js";
import { insertAuditEvent, isDbAvailable } from "../sf-db.js";
const UOK_AUDIT_SCHEMA_VERSION = 1; const UOK_AUDIT_SCHEMA_VERSION = 1;
@ -56,10 +55,4 @@ export function emitUokAuditEvent(basePath, event) {
} catch { } catch {
// Best-effort: audit writes must never break orchestration. // Best-effort: audit writes must never break orchestration.
} }
if (!isDbAvailable()) return;
try {
insertAuditEvent(event);
} catch {
// Projection failures are non-fatal while legacy readers are still active.
}
} }

View file

@ -2,13 +2,13 @@ import { getRelevantMemoriesRanked } from "../memory-store.js";
import { import {
getGateCircuitBreaker, getGateCircuitBreaker,
getGateRunStats, getGateRunStats,
insertGateRun,
isDbAvailable, isDbAvailable,
updateGateCircuitBreaker, updateGateCircuitBreaker,
} from "../sf-db.js"; } from "../sf-db.js";
import { logWarning } from "../workflow-logger.js"; import { logWarning } from "../workflow-logger.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
import { validateGate } from "./contracts.js"; import { validateGate } from "./contracts.js";
import { appendTraceEvent } from "./trace-writer.js";
const RETRY_MATRIX = { const RETRY_MATRIX = {
none: 0, none: 0,
@ -271,7 +271,26 @@ export class UokGateRunner {
retryable: false, retryable: false,
evaluatedAt: now, evaluatedAt: now,
}; };
insertGateRun({ emitUokAuditEvent(
ctx.basePath,
buildAuditEnvelope({
traceId: ctx.traceId,
turnId: ctx.turnId,
category: "gate",
type: "gate-run",
payload: {
gateId: unknownResult.gateId,
gateType: unknownResult.gateType,
outcome: unknownResult.outcome,
failureClass: unknownResult.failureClass,
attempt: unknownResult.attempt,
maxAttempts: unknownResult.maxAttempts,
retryable: unknownResult.retryable,
},
}),
);
appendTraceEvent(ctx.basePath, ctx.traceId, {
type: "gate_run",
traceId: ctx.traceId, traceId: ctx.traceId,
turnId: ctx.turnId, turnId: ctx.turnId,
gateId: unknownResult.gateId, gateId: unknownResult.gateId,
@ -291,24 +310,6 @@ export class UokGateRunner {
evaluatedAt: unknownResult.evaluatedAt, evaluatedAt: unknownResult.evaluatedAt,
durationMs: 0, durationMs: 0,
}); });
emitUokAuditEvent(
ctx.basePath,
buildAuditEnvelope({
traceId: ctx.traceId,
turnId: ctx.turnId,
category: "gate",
type: "gate-run",
payload: {
gateId: unknownResult.gateId,
gateType: unknownResult.gateType,
outcome: unknownResult.outcome,
failureClass: unknownResult.failureClass,
attempt: unknownResult.attempt,
maxAttempts: unknownResult.maxAttempts,
retryable: unknownResult.retryable,
},
}),
);
return unknownResult; return unknownResult;
} }
@ -327,7 +328,26 @@ export class UokGateRunner {
retryable: false, retryable: false,
evaluatedAt: now, evaluatedAt: now,
}; };
insertGateRun({ emitUokAuditEvent(
ctx.basePath,
buildAuditEnvelope({
traceId: ctx.traceId,
turnId: ctx.turnId,
category: "gate",
type: "gate-run",
payload: {
gateId: cbResult.gateId,
gateType: cbResult.gateType,
outcome: cbResult.outcome,
failureClass: cbResult.failureClass,
attempt: cbResult.attempt,
maxAttempts: cbResult.maxAttempts,
retryable: cbResult.retryable,
},
}),
);
appendTraceEvent(ctx.basePath, ctx.traceId, {
type: "gate_run",
traceId: ctx.traceId, traceId: ctx.traceId,
turnId: ctx.turnId, turnId: ctx.turnId,
gateId: cbResult.gateId, gateId: cbResult.gateId,
@ -347,24 +367,6 @@ export class UokGateRunner {
evaluatedAt: cbResult.evaluatedAt, evaluatedAt: cbResult.evaluatedAt,
durationMs: 0, durationMs: 0,
}); });
emitUokAuditEvent(
ctx.basePath,
buildAuditEnvelope({
traceId: ctx.traceId,
turnId: ctx.turnId,
category: "gate",
type: "gate-run",
payload: {
gateId: cbResult.gateId,
gateType: cbResult.gateType,
outcome: cbResult.outcome,
failureClass: cbResult.failureClass,
attempt: cbResult.attempt,
maxAttempts: cbResult.maxAttempts,
retryable: cbResult.retryable,
},
}),
);
return cbResult; return cbResult;
} }
@ -414,26 +416,6 @@ export class UokGateRunner {
retryable, retryable,
evaluatedAt: now, evaluatedAt: now,
}; };
insertGateRun({
traceId: ctx.traceId,
turnId: ctx.turnId,
gateId: final.gateId,
gateType: final.gateType,
unitType: ctx.unitType,
unitId: ctx.unitId,
milestoneId: ctx.milestoneId,
sliceId: ctx.sliceId,
taskId: ctx.taskId,
outcome: final.outcome,
failureClass: final.failureClass,
rationale: final.rationale,
findings: final.findings,
attempt: final.attempt,
maxAttempts: final.maxAttempts,
retryable: final.retryable,
evaluatedAt: final.evaluatedAt,
durationMs,
});
emitUokAuditEvent( emitUokAuditEvent(
ctx.basePath, ctx.basePath,
buildAuditEnvelope({ buildAuditEnvelope({
@ -453,6 +435,27 @@ export class UokGateRunner {
}, },
}), }),
); );
appendTraceEvent(ctx.basePath, ctx.traceId, {
type: "gate_run",
traceId: ctx.traceId,
turnId: ctx.turnId,
gateId: final.gateId,
gateType: final.gateType,
unitType: ctx.unitType,
unitId: ctx.unitId,
milestoneId: ctx.milestoneId,
sliceId: ctx.sliceId,
taskId: ctx.taskId,
outcome: final.outcome,
failureClass: final.failureClass,
rationale: final.rationale,
findings: final.findings,
attempt: final.attempt,
maxAttempts: final.maxAttempts,
retryable: final.retryable,
evaluatedAt: final.evaluatedAt,
durationMs,
});
if (!retryable) break; if (!retryable) break;
} }

View file

@ -1,4 +1,3 @@
import { isDbAvailable, upsertTurnGitTransaction } from "../sf-db.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js";
import { import {
getParityCommitBlockReason, getParityCommitBlockReason,
@ -30,7 +29,6 @@ export function resolveParitySafeGitAction(args) {
}; };
} }
export function writeTurnGitTransaction(args) { export function writeTurnGitTransaction(args) {
if (!isDbAvailable()) return;
const safe = resolveParitySafeGitAction({ const safe = resolveParitySafeGitAction({
action: args.action, action: args.action,
push: args.push, push: args.push,
@ -38,19 +36,6 @@ export function writeTurnGitTransaction(args) {
error: args.error, error: args.error,
metadata: args.metadata, metadata: args.metadata,
}); });
upsertTurnGitTransaction({
traceId: args.traceId,
turnId: args.turnId,
unitType: args.unitType,
unitId: args.unitId,
stage: args.stage,
action: safe.action,
push: safe.push,
status: safe.status,
error: safe.error,
metadata: safe.metadata,
updatedAt: new Date().toISOString(),
});
emitUokAuditEvent( emitUokAuditEvent(
args.basePath, args.basePath,
buildAuditEnvelope({ buildAuditEnvelope({

View file

@ -27,6 +27,12 @@ export function getRecoveryDiagnostics(
unitId: string, unitId: string,
): RecoveryDiagnostics | null; ): RecoveryDiagnostics | null;
export function readUnitRuntimeRecord(
basePath: string,
unitType: string,
unitId: string,
): Record<string, unknown> | null;
export function listUnitRuntimeRecords( export function listUnitRuntimeRecords(
basePath: string, basePath: string,
): Array<Record<string, unknown> & { updatedAt?: number; unitId: string }>; ): Array<Record<string, unknown> & { updatedAt?: number; unitId: string }>;

View file

@ -656,11 +656,7 @@ export async function renderStateProjection(basePath) {
); );
return; return;
} }
const state = await deriveState(basePath); await deriveState(basePath); // update DB-backed state caches
const content = renderStateContent(state);
const dir = join(basePath, ".sf");
mkdirSync(dir, { recursive: true });
atomicWriteSync(join(dir, "STATE.md"), content);
} catch (err) { } catch (err) {
logWarning("projection", `renderStateProjection failed: ${err.message}`); logWarning("projection", `renderStateProjection failed: ${err.message}`);
} }

View file

@ -3,7 +3,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, test } from "vitest"; import { afterEach, test } from "vitest";
import { renderLiveStatus } from "../cli-status.ts"; import {
renderLiveStatus,
resolveRecoveryPick,
runStatusCli,
} from "../cli-status.ts";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@ -36,6 +40,102 @@ function snapshot() {
}; };
} }
test("resolveRecoveryPick_auto_selects_most_recent_row", () => {
const picked = resolveRecoveryPick(
"/tmp",
[
{
unitType: "execute-task",
unitId: "M001/S01/T01",
updatedAt: 100,
},
{
unitType: "discuss-milestone",
unitId: "M001-X",
updatedAt: 200,
},
],
undefined,
() => null,
);
assert.deepEqual(picked, {
unitType: "discuss-milestone",
unitId: "M001-X",
});
});
test("resolveRecoveryPick_explicit_unitId_uses_matching_unitType", () => {
const picked = resolveRecoveryPick(
"/tmp",
[
{
unitType: "discuss-milestone",
unitId: "M001-X",
updatedAt: 500,
},
{
unitType: "execute-task",
unitId: "M001/S01/T01",
updatedAt: 900,
},
],
"M001-X",
() => null,
);
assert.deepEqual(picked, {
unitType: "discuss-milestone",
unitId: "M001-X",
});
});
test("resolveRecoveryPick_fallback_to_execute_task_when_not_in_list_but_on_disk", () => {
const picked = resolveRecoveryPick(
"/tmp",
[],
"M001/S01/T01",
(_base, ut, uid) =>
ut === "execute-task" && uid === "M001/S01/T01" ? { status: "x" } : null,
);
assert.deepEqual(picked, {
unitType: "execute-task",
unitId: "M001/S01/T01",
});
});
test("runStatusCli_recovery_when_newest_runtime_is_non_execute_task_succeeds", async () => {
const project = makeProject();
const { writeUnitRuntimeRecord } = await import(
"../resources/extensions/sf/uok/unit-runtime.js"
);
const tOld = Date.now() - 5_000;
writeUnitRuntimeRecord(project, "execute-task", "M001/S01/T01", tOld, {
status: "running",
});
writeUnitRuntimeRecord(project, "discuss-milestone", "M002-Y", Date.now(), {
status: "failed",
});
let out = "";
let err = "";
const code = await runStatusCli(["status", "recovery"], {
basePath: project,
stdout: {
write(s: string) {
out += s;
},
isTTY: false,
},
stderr: {
write(s: string) {
err += s;
},
},
});
assert.equal(code, 0);
assert.match(out, /discuss-milestone\s+M002-Y/);
assert.match(out, /failed/);
assert.equal(err, "");
});
test("renderLiveStatus_when_solver_state_exists_includes_solver_line", () => { test("renderLiveStatus_when_solver_state_exists_includes_solver_line", () => {
const project = makeProject(); const project = makeProject();
const solverDir = join(project, ".sf/runtime/autonomous-solver"); const solverDir = join(project, ".sf/runtime/autonomous-solver");

View file

@ -7,7 +7,7 @@ Unimplemented items consolidated from root *.md files. Source file noted for eac
## Critical / Correctness ## Critical / Correctness
- [x] Port `fix(security): harden project-controlled surfaces` — env isolation + transport cleanup done; gsd-2 trust/dedup hunks (server.ts, mcp-client/index.ts) not applicable (packages absent) *(BUILD_PLAN.md Tier 0.5 #2)* - [x] Port `fix(security): harden project-controlled surfaces` — env isolation + transport cleanup done; gsd-2 trust/dedup hunks (server.ts, mcp-client/index.ts) not applicable (packages absent) *(BUILD_PLAN.md Tier 0.5 #2)*
- [ ] Port agent-session/agent-end transition fixes (gsd-2 `71114fccf`, `6d7e4gcb5`, `c162c44bf`, `e3bd04551`) *(BUILD_PLAN.md Tier 0.5 #7-10, UPSTREAM_CHERRY_PICK_CANDIDATES.md Cluster B)* - [x] Port agent-session/agent-end transition fixes — `_sessionSwitchInFlight` guard + `sessionSwitchGeneration` pattern implemented in auto/resolve.js + run-unit.js *(BUILD_PLAN.md Tier 0.5 #7-10)*
- [ ] Cloudflare Workers AI provider — `CLOUDFLARE_API_KEY`/`CLOUDFLARE_ACCOUNT_ID` (pi-mono PR #3851) *(BUILD_PLAN.md Tier 0 #8)* - [ ] Cloudflare Workers AI provider — `CLOUDFLARE_API_KEY`/`CLOUDFLARE_ACCOUNT_ID` (pi-mono PR #3851) *(BUILD_PLAN.md Tier 0 #8)*
--- ---