diff --git a/src/resources/extensions/sf/auto-dispatch.js b/src/resources/extensions/sf/auto-dispatch.js index e517a7a1b..8117bbed3 100644 --- a/src/resources/extensions/sf/auto-dispatch.js +++ b/src/resources/extensions/sf/auto-dispatch.js @@ -81,10 +81,15 @@ import { getMilestoneSlices, getMilestoneValidationAssessment, getPendingGates, + getRuntimeCounter, getSlice, getSliceTasks, + getValidationAttentionMarker, + incrementRuntimeCounter, isDbAvailable, markAllGatesOmitted, + setRuntimeCounter, + upsertValidationAttentionMarker, } from "./sf-db.js"; import { isClosedStatus, isInactiveStatus } from "./status-guards.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; @@ -270,6 +275,7 @@ function rewriteCountPath(basePath) { return join(sfRoot(basePath), "runtime", "rewrite-count.json"); } export function getRewriteCount(basePath) { + if (isDbAvailable()) return getRuntimeCounter("rewrite-count"); try { const data = JSON.parse(readFileSync(rewriteCountPath(basePath), "utf-8")); return typeof data.count === "number" ? data.count : 0; @@ -278,6 +284,10 @@ export function getRewriteCount(basePath) { } } export function setRewriteCount(basePath, count) { + if (isDbAvailable()) { + setRuntimeCounter("rewrite-count", count); + return; + } const filePath = rewriteCountPath(basePath); mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true }); writeFileSync( @@ -293,6 +303,8 @@ function uatCountPath(basePath, mid, sid) { return join(sfRoot(basePath), "runtime", `uat-count-${mid}-${sid}.json`); } export function getUatCount(basePath, mid, sid) { + const key = `uat-count:${mid}:${sid}`; + if (isDbAvailable()) return getRuntimeCounter(key); try { const data = JSON.parse( readFileSync(uatCountPath(basePath, mid, sid), "utf-8"), @@ -303,6 +315,8 @@ export function getUatCount(basePath, mid, sid) { } } export function incrementUatCount(basePath, mid, sid) { + const key = `uat-count:${mid}:${sid}`; + if (isDbAvailable()) return incrementRuntimeCounter(key); const count = getUatCount(basePath, mid, sid) + 1; const filePath = uatCountPath(basePath, mid, sid); mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true }); @@ -360,6 +374,7 @@ function parseValidationRemediationRound(content) { return Number.isFinite(round) ? round : null; } function readValidationAttentionMarker(basePath, mid) { + if (isDbAvailable()) return getValidationAttentionMarker(mid); const markerPath = validationAttentionMarkerPath(basePath, mid); if (!existsSync(markerPath)) return null; try { @@ -371,6 +386,10 @@ function readValidationAttentionMarker(basePath, mid) { } } function writeValidationAttentionMarker(basePath, mid, marker) { + if (isDbAvailable()) { + upsertValidationAttentionMarker(mid, marker); + return; + } mkdirSync(join(sfRoot(basePath), "runtime", "validation-attention"), { recursive: true, }); diff --git a/src/resources/extensions/sf/commands-todo.js b/src/resources/extensions/sf/commands-todo.js index a2fbd7872..614a479fc 100644 --- a/src/resources/extensions/sf/commands-todo.js +++ b/src/resources/extensions/sf/commands-todo.js @@ -19,7 +19,14 @@ import { import { dirname, join } from "node:path"; import { projectRoot } from "./commands/context.js"; import { sfRoot } from "./paths.js"; -import { addBacklogItem, openDatabase } from "./sf-db.js"; +import { + addBacklogItem, + insertTriageEval, + insertTriageItem, + insertTriageRun, + insertTriageSkill, + openDatabase, +} from "./sf-db.js"; const _EMPTY_TODO = "# TODO\n\nDump anything here.\n"; const MAX_DUMP_CHARS = 48_000; @@ -546,33 +553,23 @@ export async function triageTodoDump(basePath, llmCall, options = {}) { const createdAt = (options.date ?? new Date()).toISOString(); const triageRoot = join(basePath, ".sf", "triage"); const reportsDir = join(triageRoot, "reports"); - const evalsDir = join(triageRoot, "evals"); - const inboxDir = join(triageRoot, "inbox"); - const skillsDir = join(triageRoot, "skills"); mkdirSync(reportsDir, { recursive: true }); - mkdirSync(evalsDir, { recursive: true }); - mkdirSync(inboxDir, { recursive: true }); - mkdirSync(skillsDir, { recursive: true }); const markdownPath = join(reportsDir, `${id}.md`); - const evalJsonlPath = join(evalsDir, `${id}.evals.jsonl`); - const normalizedJsonlPath = join(inboxDir, `${id}.jsonl`); - const skillJsonlPath = join(skillsDir, `${id}.skills.jsonl`); writeFileSync(markdownPath, renderTriageMarkdown(result, "TODO.md")); - writeFileSync(evalJsonlPath, renderEvalJsonl(result)); - writeFileSync(normalizedJsonlPath, renderNormalizedJsonl(result, createdAt)); - writeFileSync(skillJsonlPath, renderSkillProposals(result)); - // Schema validation in CI mode - if (options.ci) { - const validations = [ - validateJsonlFile(evalJsonlPath, "eval"), - validateJsonlFile(normalizedJsonlPath, "inbox"), - validateJsonlFile(skillJsonlPath, "skill"), - ]; - for (const v of validations) { - if (!v.ok) { - throw new Error(`Schema validation failed for ${v.error}`); - } - } + // Write triage results to DB (replaces JSONL files) + const dbRoot = sfRoot(basePath); + mkdirSync(dbRoot, { recursive: true }); + openDatabase(join(dbRoot, "sf.db")); + insertTriageRun(id, join(basePath, "TODO.md"), createdAt); + for (const item of result.eval_candidates) { + insertTriageEval(crypto.randomUUID(), id, item, createdAt); + } + for (const item of normalizedItems(result, createdAt)) { + insertTriageItem(crypto.randomUUID(), id, item.kind, item.content, item.evidence, createdAt); + } + const skillProposals = detectRecurringPatterns(result); + for (const skill of skillProposals) { + insertTriageSkill(crypto.randomUUID(), id, skill, createdAt); } const backlogItemsAdded = backlog === true @@ -587,9 +584,9 @@ export async function triageTodoDump(basePath, llmCall, options = {}) { } return { markdownPath, - evalJsonlPath, - normalizedJsonlPath, - skillJsonlPath, + evalJsonlPath: null, + normalizedJsonlPath: null, + skillJsonlPath: null, backlogItemsAdded, result, skipped: false, diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index d47e51df5..d53a1a470 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -244,7 +244,7 @@ function performDatabaseMaintenance(rawDb, path) { ); } } -const SCHEMA_VERSION = 49; +const SCHEMA_VERSION = 52; function indexExists(db, name) { return !!db .prepare( @@ -724,6 +724,82 @@ function ensureRetrievalEvidenceTables(db) { "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_status_recorded ON retrieval_evidence(status, recorded_at DESC)", ); } +function ensureTriageTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS triage_runs ( + id TEXT PRIMARY KEY, + source_file TEXT, + status TEXT NOT NULL DEFAULT 'complete', + result_summary_json TEXT, + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_evals ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + task_input TEXT NOT NULL, + expected_behavior TEXT, + evidence TEXT, + failure_mode TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_items ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + kind TEXT NOT NULL, + content TEXT NOT NULL, + evidence TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_skills ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + name TEXT, + description TEXT, + trigger TEXT, + raw_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_evals_run ON triage_evals(run_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_items_run_kind ON triage_items(run_id, kind)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_skills_run ON triage_skills(run_id)", + ); +} +function ensureRuntimeCounterTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS runtime_counters ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + `); +} +function ensureValidationAttentionMarkersTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS validation_attention_markers ( + milestone_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + source TEXT, + remediation_round INTEGER, + revalidation_round INTEGER, + revalidation_requested_at TEXT + ) + `); +} function ensureSpecSchemaTables(db) { // Tier 1.3: Spec/Runtime/Evidence schema separation // Creates 9 normalized tables for milestone, slice, task entities @@ -1376,6 +1452,9 @@ function initSchema(db, fileBacked) { ensureSpecSchemaTables(db); ensureTaskFrontmatterColumns(db); ensureRetrievalEvidenceTables(db); + ensureTriageTables(db); + ensureRuntimeCounterTable(db); + ensureValidationAttentionMarkersTable(db); db.exec( `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, ); @@ -3010,6 +3089,19 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 52) { + // Add triage_runs/evals/items/skills, runtime_counters, and + // validation_attention_markers tables — migrate JSONL structured state to DB. + ensureTriageTables(db); + ensureRuntimeCounterTable(db); + ensureValidationAttentionMarkersTable(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 52, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -8047,3 +8139,198 @@ export function getValidationHistory(milestoneId, sliceId, taskId, limit = 20) { ":limit": limit, }); } + +// ─── Triage DB CRUD ─────────────────────────────────────────────────────────── + +/** + * Insert a triage run record. + * Purpose: replace .sf/triage/evals|inbox|skills JSONL files with queryable DB rows. + * Consumer: commands-todo.js triageTodoDump after successful triage. + */ +export function insertTriageRun(id, sourceFile, createdAt) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO triage_runs (id, source_file, status, created_at) + VALUES (:id, :source_file, 'complete', :created_at) + ON CONFLICT(id) DO NOTHING`, + ) + .run({ + ":id": id, + ":source_file": sourceFile ?? null, + ":created_at": createdAt ?? new Date().toISOString(), + }); +} + +/** + * Insert a triage eval candidate row. + * Purpose: store eval candidates in DB instead of .evals.jsonl. + * Consumer: commands-todo.js triageTodoDump. + */ +export function insertTriageEval(id, runId, data, createdAt) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO triage_evals (id, run_id, task_input, expected_behavior, evidence, failure_mode, status, created_at) + VALUES (:id, :run_id, :task_input, :expected_behavior, :evidence, :failure_mode, 'pending', :created_at) + ON CONFLICT(id) DO NOTHING`, + ) + .run({ + ":id": id, + ":run_id": runId, + ":task_input": data.task_input ?? "", + ":expected_behavior": data.expected_behavior ?? "", + ":evidence": data.evidence ?? null, + ":failure_mode": data.failure_mode ?? null, + ":created_at": createdAt ?? new Date().toISOString(), + }); +} + +/** + * Insert a normalized triage inbox item row. + * Purpose: store triage inbox items (eval_candidate, implementation_task, etc.) in DB. + * Consumer: commands-todo.js triageTodoDump. + */ +export function insertTriageItem(id, runId, kind, content, evidence, createdAt) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO triage_items (id, run_id, kind, content, evidence, status, created_at) + VALUES (:id, :run_id, :kind, :content, :evidence, 'pending', :created_at) + ON CONFLICT(id) DO NOTHING`, + ) + .run({ + ":id": id, + ":run_id": runId, + ":kind": kind, + ":content": content, + ":evidence": evidence ?? null, + ":created_at": createdAt ?? new Date().toISOString(), + }); +} + +/** + * Insert a triage skill proposal row. + * Purpose: store skill proposals in DB instead of .skills.jsonl. + * Consumer: commands-todo.js triageTodoDump. + */ +export function insertTriageSkill(id, runId, data, createdAt) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO triage_skills (id, run_id, name, description, trigger, raw_json, status, created_at) + VALUES (:id, :run_id, :name, :description, :trigger, :raw_json, 'pending', :created_at) + ON CONFLICT(id) DO NOTHING`, + ) + .run({ + ":id": id, + ":run_id": runId, + ":name": data.title ?? data.name ?? null, + ":description": data.description ?? null, + ":trigger": data.trigger_pattern ?? data.trigger ?? null, + ":raw_json": JSON.stringify(data), + ":created_at": createdAt ?? new Date().toISOString(), + }); +} + +// ─── Runtime Counters ───────────────────────────────────────────────────────── + +/** + * Get a runtime counter value by key. Returns 0 if the key does not exist. + * Purpose: replace per-key JSON files in .sf/runtime/ with queryable DB rows. + * Consumer: auto-dispatch.js rewrite-count and uat-count logic. + */ +export function getRuntimeCounter(key) { + if (!currentDb) return 0; + const row = currentDb + .prepare("SELECT value FROM runtime_counters WHERE key = ?") + .get(key); + return typeof row?.value === "number" ? row.value : 0; +} + +/** + * Set a runtime counter to an explicit value. + * Purpose: replace JSON file writes for named counters. + * Consumer: auto-dispatch.js setRewriteCount. + */ +export function setRuntimeCounter(key, value) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO runtime_counters (key, value, updated_at) + VALUES (:key, :value, :updated_at) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ) + .run({ + ":key": key, + ":value": value, + ":updated_at": new Date().toISOString(), + }); +} + +/** + * Atomically increment a runtime counter and return the new value. + * Purpose: replace read-modify-write JSON file pattern for counters. + * Consumer: auto-dispatch.js incrementUatCount. + */ +export function incrementRuntimeCounter(key) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare( + `INSERT INTO runtime_counters (key, value, updated_at) + VALUES (:key, 1, :updated_at) + ON CONFLICT(key) DO UPDATE SET value = value + 1, updated_at = excluded.updated_at`, + ) + .run({ ":key": key, ":updated_at": new Date().toISOString() }); + const row = currentDb + .prepare("SELECT value FROM runtime_counters WHERE key = ?") + .get(key); + return typeof row?.value === "number" ? row.value : 1; +} + +// ─── Validation Attention Markers ───────────────────────────────────────────── + +/** + * Get a validation attention marker for a milestone, or null if absent. + * Purpose: replace .sf/runtime/validation-attention/{mid}.json reads. + * Consumer: auto-dispatch.js hasActiveValidationAttentionMarker. + */ +export function getValidationAttentionMarker(milestoneId) { + if (!currentDb) return null; + return ( + currentDb + .prepare( + "SELECT * FROM validation_attention_markers WHERE milestone_id = ?", + ) + .get(milestoneId) ?? null + ); +} + +/** + * Upsert a validation attention marker for a milestone. + * Purpose: replace .sf/runtime/validation-attention/{mid}.json writes. + * Consumer: auto-dispatch.js writeValidationAttentionMarker. + */ +export function upsertValidationAttentionMarker(milestoneId, marker) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + const now = new Date().toISOString(); + currentDb + .prepare( + `INSERT INTO validation_attention_markers + (milestone_id, created_at, source, remediation_round, revalidation_round, revalidation_requested_at) + VALUES (:milestone_id, :created_at, :source, :remediation_round, :revalidation_round, :revalidation_requested_at) + ON CONFLICT(milestone_id) DO UPDATE SET + source = excluded.source, + remediation_round = excluded.remediation_round, + revalidation_round = excluded.revalidation_round, + revalidation_requested_at = excluded.revalidation_requested_at`, + ) + .run({ + ":milestone_id": milestoneId, + ":created_at": marker.createdAt ?? now, + ":source": marker.source ?? null, + ":remediation_round": marker.remediationRound ?? null, + ":revalidation_round": marker.revalidationRound ?? null, + ":revalidation_requested_at": marker.revalidationRequestedAt ?? null, + }); +} diff --git a/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs b/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs index d06911a03..8e35f678d 100644 --- a/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs +++ b/src/resources/extensions/sf/tests/jsonl-schema-versioning.test.mjs @@ -27,7 +27,7 @@ import { emitJournalEvent, queryJournal } from "../journal.js"; import { readJudgmentLog } from "../judgment-log.js"; import { ModelLearner } from "../model-learner.js"; import { createScheduleStore } from "../schedule/schedule-store.js"; -import { closeDatabase } from "../sf-db.js"; +import { closeDatabase, getDatabase } from "../sf-db.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "../uok/audit.js"; import { parseParityEvents, @@ -304,7 +304,7 @@ describe("SF JSONL schema versioning", () => { assert.equal(summary.reasons.quality, 1); }); - test("todo_triage_jsonl_outputs_write_schema_versions", async () => { + test("todo_triage_outputs_written_to_db", async () => { const project = makeProject(); writeFileSync(join(project, "TODO.md"), "# TODO\n\nmake evals real\n"); const response = JSON.stringify({ @@ -330,25 +330,28 @@ describe("SF JSONL schema versioning", () => { unclear_notes: [], }); - const result = await triageTodoDump(project, async () => response, { + await triageTodoDump(project, async () => response, { clear: false, backlog: true, date: new Date("2026-05-07T01:02:03.000Z"), }); + + const db = getDatabase(); + assert.ok(db, "database should be open after triage"); + const evalRows = db.prepare("SELECT * FROM triage_evals").all(); + assert.ok(evalRows.length >= 2, "should have eval rows in DB"); + const itemRows = db.prepare("SELECT * FROM triage_items").all(); + assert.ok(itemRows.length > 0, "should have item rows in DB"); + const runRows = db.prepare("SELECT * FROM triage_runs").all(); + assert.ok(runRows.length > 0, "should have triage run in DB"); + + // Backlog JSONL evidence file is still written (it is a human-readable artifact) const backlogDir = join(project, ".sf", "triage", "backlog"); const [backlogFile] = readdirSync(backlogDir).filter((file) => file.endsWith(".jsonl"), ); - - for (const path of [ - result.evalJsonlPath, - result.normalizedJsonlPath, - result.skillJsonlPath, - join(backlogDir, backlogFile), - ]) { - const rows = readJsonl(path); - assert.ok(rows.length > 0, `${path} should contain rows`); - for (const row of rows) assert.equal(row.schemaVersion, 1); - } + const backlogRows = readJsonl(join(backlogDir, backlogFile)); + assert.ok(backlogRows.length > 0, "backlog JSONL should contain rows"); + for (const row of backlogRows) assert.equal(row.schemaVersion, 1); }); }); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 19af0c72d..afa0d21ca 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -223,7 +223,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 51); + assert.equal(version.version, 52); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",