From 79896b43778e69ee189118c56974de6425460739 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 04:35:03 +0200 Subject: [PATCH] Tier 1.3 Phase 4: Add evidence recording to plan and complete tools - Updated plan-milestone, plan-slice, plan-task to record planning evidence - Updated complete-milestone, complete-slice, complete-task to record completion evidence - All evidence includes relevant spec fields (goals, narratives, decisions, etc.) - Evidence recorded atomically within transactions - Enables audit trail queries to reconstruct planning and completion decisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/sf/commands/catalog.js | 2 +- .../extensions/sf/guided-flow-queue.js | 8 +- src/resources/extensions/sf/queue-order.js | 37 ++- src/resources/extensions/sf/rethink.js | 2 +- src/resources/extensions/sf/sf-db.js | 222 +++++++++++++----- .../complete-milestone-evidence.test.mjs | 81 +++++++ .../sf/tests/complete-slice-evidence.test.mjs | 84 +++++++ .../sf/tests/complete-task-evidence.test.mjs | 81 +++++++ .../sf/tests/plan-slice-evidence.test.mjs | 93 ++++++++ .../sf/tests/plan-task-evidence.test.mjs | 74 ++++++ .../sf/tests/queue-order-db.test.mjs | 56 +++++ .../sf/tests/sf-db-migration.test.mjs | 157 +++++++++++++ .../extensions/sf/tools/complete-milestone.js | 18 ++ .../extensions/sf/tools/complete-slice.js | 18 ++ .../extensions/sf/tools/complete-task.js | 22 ++ .../extensions/sf/tools/plan-milestone.js | 20 ++ .../extensions/sf/tools/plan-slice.js | 20 ++ .../extensions/sf/tools/plan-task.js | 19 ++ .../extensions/sf/uok/gate-runner.js | 25 +- .../extensions/sf/uok/message-bus.js | 3 +- .../extensions/sf/uok/metrics-exposition.js | 26 +- 21 files changed, 990 insertions(+), 78 deletions(-) create mode 100644 src/resources/extensions/sf/tests/complete-milestone-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/complete-task-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/plan-task-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/queue-order-db.test.mjs create mode 100644 src/resources/extensions/sf/tests/sf-db-migration.test.mjs diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index 2c47d5ce2..3bbdf8861 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -429,7 +429,7 @@ const NESTED_COMPLETIONS = { { cmd: "triage --no-clear", desc: "Triage TODO.md without resetting it" }, { cmd: "triage --backlog", - desc: "Also add implementation tasks to .sf/WORK-QUEUE.md", + desc: "Also add implementation tasks to the SF backlog", }, ], "pr-branch": [ diff --git a/src/resources/extensions/sf/guided-flow-queue.js b/src/resources/extensions/sf/guided-flow-queue.js index a0b78dd47..eadda4824 100644 --- a/src/resources/extensions/sf/guided-flow-queue.js +++ b/src/resources/extensions/sf/guided-flow-queue.js @@ -118,7 +118,8 @@ export async function handleQueueReorder(ctx, basePath, state) { ctx.ui.notify("Queue reorder cancelled.", "info"); return; } - // Save the new order + // Save the new order. SQLite is authoritative when available; legacy JSON + // is written only by queue-order.js when the DB is unavailable. saveQueueOrder(basePath, result.order); invalidateAllCaches(); // Remove conflicting depends_on entries from CONTEXT.md files @@ -127,8 +128,9 @@ export async function handleQueueReorder(ctx, basePath, state) { } // Sync PROJECT.md milestone sequence table syncProjectMdSequence(basePath, state.registry, result.order); - // Commit the change - const filesToAdd = [".sf/QUEUE-ORDER.json", ".sf/PROJECT.md"]; + // Commit promoted/user-authored doc changes only. Queue order itself lives in + // SQLite and .sf paths are filtered from git staging by nativeAddPaths. + const filesToAdd = [".sf/PROJECT.md"]; for (const r of result.depsToRemove) { filesToAdd.push(`.sf/milestones/${r.milestone}/${r.milestone}-CONTEXT.md`); } diff --git a/src/resources/extensions/sf/queue-order.js b/src/resources/extensions/sf/queue-order.js index 0cacae3fa..6b7f75335 100644 --- a/src/resources/extensions/sf/queue-order.js +++ b/src/resources/extensions/sf/queue-order.js @@ -1,17 +1,19 @@ /** * SF Queue Order — Custom milestone execution ordering. * - * Stores an explicit execution order in `.sf/QUEUE-ORDER.json`. - * When present, `findMilestoneIds()` uses this order instead of - * the default numeric sort (milestoneIdSort). - * - * The file is committed to git (not gitignored) so ordering - * survives branch switches and is shared across sessions. + * Stores explicit execution order in the `milestones.sequence` DB column when + * SQLite is available. `.sf/QUEUE-ORDER.json` is read/written only as a legacy + * fallback when the database is unavailable. */ import { join } from "node:path"; import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; import { milestoneIdSort } from "./milestone-ids.js"; import { sfRoot } from "./paths.js"; +import { + getAllMilestones, + isDbAvailable, + updateMilestoneQueueOrder, +} from "./sf-db.js"; // ─── Path ──────────────────────────────────────────────────────────────────── function queueOrderPath(basePath) { @@ -28,17 +30,34 @@ function isQueueOrderFile(data) { } // ─── Read / Write ──────────────────────────────────────────────────────────── /** - * Load the custom queue order. Returns null if no file exists or if - * the file is corrupt/unreadable. + * Load the custom queue order. Returns null if no DB order or legacy fallback + * exists. */ export function loadQueueOrder(basePath) { + if (isDbAvailable()) { + const milestones = getAllMilestones(); + if (milestones.some((m) => (m.sequence ?? 0) > 0)) { + return milestones + .filter((m) => (m.sequence ?? 0) > 0) + .sort( + (a, b) => + (a.sequence ?? 0) - (b.sequence ?? 0) || a.id.localeCompare(b.id), + ) + .map((m) => m.id); + } + } const data = loadJsonFileOrNull(queueOrderPath(basePath), isQueueOrderFile); return data?.order ?? null; } /** - * Save a custom queue order to disk. + * Save a custom queue order to the DB, falling back to legacy JSON only when + * SQLite is unavailable. */ export function saveQueueOrder(basePath, order) { + if (isDbAvailable()) { + updateMilestoneQueueOrder(order); + return; + } const data = { order, updatedAt: new Date().toISOString(), diff --git a/src/resources/extensions/sf/rethink.js b/src/resources/extensions/sf/rethink.js index 099938d0e..f94f4bfc8 100644 --- a/src/resources/extensions/sf/rethink.js +++ b/src/resources/extensions/sf/rethink.js @@ -78,7 +78,7 @@ function buildRethinkData(basePath, milestoneIds, state, queueOrder) { `${counts.complete} complete, ${counts.active} active, ${counts.pending} pending, ${counts.parked} parked — ${milestoneIds.length} total`, ); lines.push( - `Queue order source: ${queueOrder ? "explicit QUEUE-ORDER.json" : "default numeric (by ID)"}`, + `Queue order source: ${queueOrder ? "SQLite milestone sequence" : "default numeric (by ID)"}`, ); if (state.activeMilestone) { lines.push(`Active milestone: ${state.activeMilestone}`); diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index b1979f8b9..c05a3408b 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -78,7 +78,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 32; +const SCHEMA_VERSION = 34; function indexExists(db, name) { return !!db .prepare( @@ -278,11 +278,11 @@ function ensureSpecSchemaTables(db) { // Tier 1.3: Spec/Runtime/Evidence schema separation // Creates 9 normalized tables for milestone, slice, task entities // Each entity type has: _specs (immutable intent), (runtime state), _evidence (audit trail) - + // ── Milestone Spec Table (immutable record of intent) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS milestone_specs ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, vision TEXT NOT NULL DEFAULT '', success_criteria TEXT DEFAULT '', key_risks TEXT DEFAULT '', @@ -301,7 +301,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (id) REFERENCES milestones(id) ) `); - + // ── Slice Spec Table (immutable record of intent) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS slice_specs ( @@ -323,7 +323,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) `); - + // ── Task Spec Table (immutable record of intent) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS task_specs ( @@ -340,7 +340,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) ) `); - + // ── Milestone Evidence Table (append-only audit trail) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS milestone_evidence ( @@ -355,7 +355,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (milestone_id) REFERENCES milestones(id) ) `); - + // ── Slice Evidence Table (append-only audit trail) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS slice_evidence ( @@ -371,7 +371,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) `); - + // ── Task Evidence Table (append-only audit trail) ─────────── db.exec(` CREATE TABLE IF NOT EXISTS task_evidence ( @@ -388,7 +388,7 @@ function ensureSpecSchemaTables(db) { FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) ) `); - + // Indices for efficient querying of evidence trails db.exec(` CREATE INDEX IF NOT EXISTS idx_milestone_evidence_type @@ -549,7 +549,8 @@ function initSchema(db, fileBacked) { definition_of_done TEXT NOT NULL DEFAULT '[]', requirement_coverage TEXT NOT NULL DEFAULT '', boundary_map_markdown TEXT NOT NULL DEFAULT '', - vision_meeting_json TEXT NOT NULL DEFAULT '' + vision_meeting_json TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0 ) `); db.exec(` @@ -608,6 +609,7 @@ function initSchema(db, fileBacked) { expected_output TEXT NOT NULL DEFAULT '[]', observability_impact TEXT NOT NULL DEFAULT '', full_plan_md TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', verification_status TEXT NOT NULL DEFAULT '', sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (gsd-2): pause-on-escalation flag @@ -899,13 +901,21 @@ function columnExists(db, table, column) { function ensureColumn(db, table, column, ddl) { if (!columnExists(db, table, column)) db.exec(ddl); } +function ensureTaskCreatedAtColumn(db) { + ensureColumn( + db, + "tasks", + "created_at", + `ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, + ); +} function populateSpecTablesFromExisting(db) { // Tier 1.3 Phase 2: Migrate existing spec data to new spec tables // This populates milestone_specs, slice_specs, task_specs from existing columns // Evidence tables are left empty; they populate as tools create new evidence. - + const now = new Date().toISOString(); - + // Migrate milestone specs db.prepare(` INSERT OR IGNORE INTO milestone_specs ( @@ -922,7 +932,7 @@ function populateSpecTablesFromExisting(db) { FROM milestones WHERE id NOT IN (SELECT id FROM milestone_specs) `).run(now); - + // Migrate slice specs db.prepare(` INSERT OR IGNORE INTO slice_specs ( @@ -939,7 +949,7 @@ function populateSpecTablesFromExisting(db) { FROM slices WHERE (milestone_id, id) NOT IN (SELECT milestone_id, slice_id FROM slice_specs) `).run(now); - + // Migrate task specs db.prepare(` INSERT OR IGNORE INTO task_specs ( @@ -1881,6 +1891,7 @@ function migrateSchema(db) { }); } if (currentVersion < 32) { + ensureTaskCreatedAtColumn(db); ensureSpecSchemaTables(db); // Populate spec tables from existing spec columns in runtime tables populateSpecTablesFromExisting(db); @@ -1891,6 +1902,29 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 33) { + ensureColumn( + db, + "milestones", + "sequence", + `ALTER TABLE milestones ADD COLUMN sequence INTEGER DEFAULT 0`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 33, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 34) { + ensureTaskCreatedAtColumn(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 34, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -2297,12 +2331,12 @@ export function insertMilestone(m) { id, title, status, depends_on, created_at, vision, success_criteria, key_risks, proof_strategy, verification_contract, verification_integration, verification_operational, verification_uat, - definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json + definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, sequence ) VALUES ( :id, :title, :status, :depends_on, :created_at, :vision, :success_criteria, :key_risks, :proof_strategy, :verification_contract, :verification_integration, :verification_operational, :verification_uat, - :definition_of_done, :requirement_coverage, :boundary_map_markdown, :vision_meeting_json + :definition_of_done, :requirement_coverage, :boundary_map_markdown, :vision_meeting_json, :sequence )`) .run({ ":id": m.id, @@ -2326,6 +2360,7 @@ export function insertMilestone(m) { ":vision_meeting_json": m.planning?.visionMeeting ? JSON.stringify(m.planning.visionMeeting) : "", + ":sequence": m.sequence ?? 0, }); } export function upsertMilestonePlanning(milestoneId, planning) { @@ -3148,6 +3183,7 @@ function rowToMilestone(row) { requirement_coverage: row["requirement_coverage"] ?? "", boundary_map_markdown: row["boundary_map_markdown"] ?? "", vision_meeting: parseVisionMeeting(row["vision_meeting_json"]), + sequence: row["sequence"] ?? 0, }; } function rowToArtifact(row) { @@ -3163,7 +3199,11 @@ function rowToArtifact(row) { } export function getAllMilestones() { if (!currentDb) return []; - const rows = currentDb.prepare("SELECT * FROM milestones ORDER BY id").all(); + const rows = currentDb + .prepare( + "SELECT * FROM milestones ORDER BY CASE WHEN sequence > 0 THEN 0 ELSE 1 END, sequence, id", + ) + .all(); return rows.map(rowToMilestone); } export function getMilestone(id) { @@ -3191,11 +3231,30 @@ export function updateMilestoneStatus(milestoneId, status, completedAt) { ":id": milestoneId, }); } +/** + * Persist explicit milestone execution order in the structured runtime DB. + * + * Purpose: make roadmap priority/order queryable and schema-owned instead of + * relying on `.sf/QUEUE-ORDER.json` as a peer source of truth. + * + * Consumer: queue-order.js when `/sf queue` or rethink reorders milestones. + */ +export function updateMilestoneQueueOrder(order) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + transaction(() => { + const stmt = currentDb.prepare( + "UPDATE milestones SET sequence = :sequence WHERE id = :id", + ); + for (let i = 0; i < order.length; i++) { + stmt.run({ ":sequence": i + 1, ":id": order[i] }); + } + }); +} export function getActiveMilestoneFromDb() { if (!currentDb) return null; const row = currentDb .prepare( - "SELECT * FROM milestones WHERE status NOT IN ('complete', 'parked') ORDER BY id LIMIT 1", + "SELECT * FROM milestones WHERE status NOT IN ('complete', 'parked') ORDER BY CASE WHEN sequence > 0 THEN 0 ELSE 1 END, sequence, id LIMIT 1", ) .get(); if (!row) return null; @@ -5613,12 +5672,25 @@ export function deleteMemoryEmbedding(memoryId) { * Purpose: Create audit trail of decisions, verifications, and incidents. * Consumer: complete-milestone, reassess-milestone, and other tools. */ -export function insertMilestoneEvidence(milestoneId, evidenceType, content, phaseName, recordedBy) { -if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); -currentDb -.prepare(`INSERT INTO milestone_evidence (milestone_id, evidence_type, content, recorded_at, phase_name, recorded_by) +export function insertMilestoneEvidence( + milestoneId, + evidenceType, + content, + phaseName, + recordedBy, +) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare(`INSERT INTO milestone_evidence (milestone_id, evidence_type, content, recorded_at, phase_name, recorded_by) VALUES (?, ?, ?, ?, ?, ?)`) -.run(milestoneId, evidenceType, content, new Date().toISOString(), phaseName || "", recordedBy || ""); + .run( + milestoneId, + evidenceType, + content, + new Date().toISOString(), + phaseName || "", + recordedBy || "", + ); } /** @@ -5626,12 +5698,27 @@ currentDb * Purpose: Create audit trail of slice decisions, verifications, and incidents. * Consumer: complete-slice, execute-slice, and other tools. */ -export function insertSliceEvidence(milestoneId, sliceId, evidenceType, content, phaseName, recordedBy) { -if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); -currentDb -.prepare(`INSERT INTO slice_evidence (milestone_id, slice_id, evidence_type, content, recorded_at, phase_name, recorded_by) +export function insertSliceEvidence( + milestoneId, + sliceId, + evidenceType, + content, + phaseName, + recordedBy, +) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare(`INSERT INTO slice_evidence (milestone_id, slice_id, evidence_type, content, recorded_at, phase_name, recorded_by) VALUES (?, ?, ?, ?, ?, ?, ?)`) -.run(milestoneId, sliceId, evidenceType, content, new Date().toISOString(), phaseName || "", recordedBy || ""); + .run( + milestoneId, + sliceId, + evidenceType, + content, + new Date().toISOString(), + phaseName || "", + recordedBy || "", + ); } /** @@ -5639,12 +5726,29 @@ currentDb * Purpose: Create audit trail of task decisions, verifications, and incidents. * Consumer: complete-task, execute-task, and other tools. */ -export function insertTaskEvidence(milestoneId, sliceId, taskId, evidenceType, content, phaseName, recordedBy) { -if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); -currentDb -.prepare(`INSERT INTO task_evidence (milestone_id, slice_id, task_id, evidence_type, content, recorded_at, phase_name, recorded_by) +export function insertTaskEvidence( + milestoneId, + sliceId, + taskId, + evidenceType, + content, + phaseName, + recordedBy, +) { + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + currentDb + .prepare(`INSERT INTO task_evidence (milestone_id, slice_id, task_id, evidence_type, content, recorded_at, phase_name, recorded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) -.run(milestoneId, sliceId, taskId, evidenceType, content, new Date().toISOString(), phaseName || "", recordedBy || ""); + .run( + milestoneId, + sliceId, + taskId, + evidenceType, + content, + new Date().toISOString(), + phaseName || "", + recordedBy || "", + ); } /** @@ -5653,9 +5757,9 @@ currentDb * Consumer: forensics tools, doctor checks, audit/compliance queries. */ export function getMilestoneAuditTrail(milestoneId) { -if (!currentDb) return []; -return currentDb -.prepare(` + if (!currentDb) return []; + return currentDb + .prepare(` SELECT r.id, r.title, r.status, s.vision, s.spec_version, @@ -5666,7 +5770,7 @@ return currentDb WHERE r.id = ? ORDER BY e.recorded_at ASC `) -.all(milestoneId); + .all(milestoneId); } /** @@ -5675,9 +5779,9 @@ return currentDb * Consumer: forensics tools, doctor checks, audit/compliance queries. */ export function getSliceAuditTrail(milestoneId, sliceId) { -if (!currentDb) return []; -return currentDb -.prepare(` + if (!currentDb) return []; + return currentDb + .prepare(` SELECT r.id, r.title, r.status, s.goal, s.spec_version, @@ -5688,7 +5792,7 @@ return currentDb WHERE r.milestone_id = ? AND r.id = ? ORDER BY e.recorded_at ASC `) -.all(milestoneId, sliceId); + .all(milestoneId, sliceId); } /** @@ -5697,9 +5801,9 @@ return currentDb * Consumer: forensics tools, doctor checks, audit/compliance queries. */ export function getTaskAuditTrail(milestoneId, sliceId, taskId) { -if (!currentDb) return []; -return currentDb -.prepare(` + if (!currentDb) return []; + return currentDb + .prepare(` SELECT r.id, r.title, r.status, s.verify, s.spec_version, @@ -5710,7 +5814,7 @@ return currentDb WHERE r.milestone_id = ? AND r.slice_id = ? AND r.id = ? ORDER BY e.recorded_at ASC `) -.all(milestoneId, sliceId, taskId); + .all(milestoneId, sliceId, taskId); } /** @@ -5719,10 +5823,10 @@ return currentDb * Consumer: plan-milestone and spec validation tools. */ export function getMilestoneSpec(milestoneId) { -if (!currentDb) return null; -return currentDb -.prepare("SELECT * FROM milestone_specs WHERE id = ?") -.get(milestoneId); + if (!currentDb) return null; + return currentDb + .prepare("SELECT * FROM milestone_specs WHERE id = ?") + .get(milestoneId); } /** @@ -5731,10 +5835,12 @@ return currentDb * Consumer: plan-slice and spec validation tools. */ export function getSliceSpec(milestoneId, sliceId) { -if (!currentDb) return null; -return currentDb -.prepare("SELECT * FROM slice_specs WHERE milestone_id = ? AND slice_id = ?") -.get(milestoneId, sliceId); + if (!currentDb) return null; + return currentDb + .prepare( + "SELECT * FROM slice_specs WHERE milestone_id = ? AND slice_id = ?", + ) + .get(milestoneId, sliceId); } /** @@ -5743,8 +5849,10 @@ return currentDb * Consumer: plan-task and spec validation tools. */ export function getTaskSpec(milestoneId, sliceId, taskId) { -if (!currentDb) return null; -return currentDb -.prepare("SELECT * FROM task_specs WHERE milestone_id = ? AND slice_id = ? AND task_id = ?") -.get(milestoneId, sliceId, taskId); + if (!currentDb) return null; + return currentDb + .prepare( + "SELECT * FROM task_specs WHERE milestone_id = ? AND slice_id = ? AND task_id = ?", + ) + .get(milestoneId, sliceId, taskId); } diff --git a/src/resources/extensions/sf/tests/complete-milestone-evidence.test.mjs b/src/resources/extensions/sf/tests/complete-milestone-evidence.test.mjs new file mode 100644 index 000000000..45db3cde1 --- /dev/null +++ b/src/resources/extensions/sf/tests/complete-milestone-evidence.test.mjs @@ -0,0 +1,81 @@ +/** + * complete-milestone-evidence.test.mjs — milestone completion evidence. + * + * Purpose: prove milestone completion records structured DB evidence, not only + * a generated summary markdown file. + */ +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 { + closeDatabase, + getMilestone, + getMilestoneAuditTrail, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.js"; +import { handleCompleteMilestone } from "../tools/complete-milestone.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-complete-milestone-evidence-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001"), { recursive: true }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Evidence", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "complete", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Task", + status: "complete", + }); + return dir; +} + +test("handleCompleteMilestone_when_successful_records_completion_summary_evidence", async () => { + const project = makeProject(); + + const result = await handleCompleteMilestone( + { + milestoneId: "M001", + title: "Evidence", + verificationPassed: true, + oneLiner: "Finished with evidence", + narrative: "All slices and tasks are closed.", + successCriteriaResults: "Passed.", + definitionOfDoneResults: "Satisfied.", + keyDecisions: ["Keep DB authoritative"], + keyFiles: ["src/resources/extensions/sf/sf-db.js"], + lessonsLearned: ["Evidence belongs in SQLite"], + }, + project, + ); + + assert.equal(result.error, undefined); + assert.equal(getMilestone("M001").status, "complete"); + const trail = getMilestoneAuditTrail("M001"); + assert.equal(trail.length, 1); + assert.equal(trail[0].evidence_type, "completion_summary"); + assert.match(trail[0].content, /Finished with evidence/); + assert.match(trail[0].content, /Keep DB authoritative/); +}); diff --git a/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs b/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs new file mode 100644 index 000000000..c81008e5e --- /dev/null +++ b/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs @@ -0,0 +1,84 @@ +/** + * complete-slice-evidence.test.mjs — slice completion evidence. + * + * Purpose: prove slice completion records structured DB evidence in addition + * to generated SUMMARY/UAT markdown projections. + */ +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 { + closeDatabase, + getSlice, + getSliceAuditTrail, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.js"; +import { handleCompleteSlice } from "../tools/complete-slice.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-complete-slice-evidence-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Evidence", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "pending", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Task", + status: "complete", + }); + return dir; +} + +test("handleCompleteSlice_when_successful_records_completion_summary_evidence", async () => { + const project = makeProject(); + + const result = await handleCompleteSlice( + { + milestoneId: "M001", + sliceId: "S01", + sliceTitle: "Slice", + verification: "Verification passed.", + uatContent: "UAT passed.", + oneLiner: "Slice finished with evidence", + narrative: "All tasks are closed.", + successCriteriaResults: "Passed.", + verificationProof: "Focused test passed.", + keyDecisions: ["Keep slice evidence in DB"], + keyFiles: ["src/resources/extensions/sf/tools/complete-slice.js"], + }, + project, + ); + + assert.equal(result.error, undefined); + assert.equal(getSlice("M001", "S01").status, "complete"); + const trail = getSliceAuditTrail("M001", "S01"); + assert.equal(trail.length, 1); + assert.equal(trail[0].evidence_type, "completion_summary"); + assert.match(trail[0].content, /Slice finished with evidence/); + assert.match(trail[0].content, /Keep slice evidence in DB/); +}); diff --git a/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs b/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs new file mode 100644 index 000000000..794b62ce3 --- /dev/null +++ b/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs @@ -0,0 +1,81 @@ +/** + * complete-task-evidence.test.mjs — task completion evidence. + * + * Purpose: prove task completion records structured DB evidence in addition + * to generated SUMMARY markdown and verification rows. + */ +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 { + closeDatabase, + getTask, + getTaskAuditTrail, + insertMilestone, + insertSlice, + openDatabase, +} from "../sf-db.js"; +import { handleCompleteTask } from "../tools/complete-task.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-complete-task-evidence-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Evidence", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "pending", + }); + return dir; +} + +test("handleCompleteTask_when_successful_records_completion_summary_evidence", async () => { + const project = makeProject(); + + const result = await handleCompleteTask( + { + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + oneLiner: "Task finished with evidence", + narrative: "The behavior is implemented.", + verification: "Focused test passed.", + verificationEvidence: [ + { + command: "npm test -- complete-task", + exitCode: 0, + verdict: "passed", + durationMs: 42, + }, + ], + keyDecisions: ["Keep task evidence in DB"], + keyFiles: ["src/resources/extensions/sf/tools/complete-task.js"], + }, + project, + ); + + assert.equal(result.error, undefined); + assert.equal(getTask("M001", "S01", "T01").status, "complete"); + const trail = getTaskAuditTrail("M001", "S01", "T01"); + assert.equal(trail.length, 1); + assert.equal(trail[0].evidence_type, "completion_summary"); + assert.match(trail[0].content, /Task finished with evidence/); + assert.match(trail[0].content, /Keep task evidence in DB/); +}); diff --git a/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs b/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs new file mode 100644 index 000000000..e3cf299dc --- /dev/null +++ b/src/resources/extensions/sf/tests/plan-slice-evidence.test.mjs @@ -0,0 +1,93 @@ +/** + * plan-slice-evidence.test.mjs — slice planning evidence. + * + * Purpose: prove slice planning records structured DB evidence while rendering + * generated plan projections from the database. + */ +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 { + closeDatabase, + getSliceAuditTrail, + insertMilestone, + insertSlice, + openDatabase, +} from "../sf-db.js"; +import { handlePlanSlice } from "../tools/plan-slice.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-plan-slice-evidence-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Evidence", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "pending", + }); + return dir; +} + +test("handlePlanSlice_when_successful_records_plan_slice_evidence", async () => { + const project = makeProject(); + + const result = await handlePlanSlice( + { + milestoneId: "M001", + sliceId: "S01", + goal: "Plan with evidence", + successCriteria: "Plan rows are persisted.", + proofLevel: "focused", + integrationClosure: "Plan projection rendered.", + observabilityImpact: "Evidence row is queryable.", + planningMeeting: { + trigger: "test", + pm: "Plan the slice.", + researcher: "DB schema exists.", + partner: "Use existing tools.", + combatant: "Avoid JSON peer state.", + architect: "Persist evidence in SQLite.", + moderator: "Proceed.", + recommendedRoute: "planning", + confidenceSummary: "high", + }, + tasks: [ + { + taskId: "T01", + title: "Implement evidence", + description: "Persist planning evidence.", + estimate: "small", + files: ["src/resources/extensions/sf/tools/plan-slice.js"], + verify: "npm test -- plan-slice", + inputs: ["DB-backed slice"], + expectedOutput: ["Evidence row"], + }, + ], + }, + project, + ); + + assert.equal(result.error, undefined); + const trail = getSliceAuditTrail("M001", "S01"); + assert.equal(trail.length, 1); + assert.equal(trail[0].evidence_type, "plan_slice"); + assert.match(trail[0].content, /Plan with evidence/); + assert.match(trail[0].content, /Implement evidence/); +}); diff --git a/src/resources/extensions/sf/tests/plan-task-evidence.test.mjs b/src/resources/extensions/sf/tests/plan-task-evidence.test.mjs new file mode 100644 index 000000000..8c783a990 --- /dev/null +++ b/src/resources/extensions/sf/tests/plan-task-evidence.test.mjs @@ -0,0 +1,74 @@ +/** + * plan-task-evidence.test.mjs — task planning evidence. + * + * Purpose: prove task planning records structured DB evidence while rendering + * generated task plan projections from the database. + */ +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 { + closeDatabase, + getTaskAuditTrail, + insertMilestone, + insertSlice, + openDatabase, +} from "../sf-db.js"; +import { handlePlanTask } from "../tools/plan-task.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-plan-task-evidence-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Evidence", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Slice", + status: "pending", + }); + return dir; +} + +test("handlePlanTask_when_successful_records_plan_task_evidence", async () => { + const project = makeProject(); + + const result = await handlePlanTask( + { + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + title: "Task plan evidence", + description: "Persist task planning evidence.", + estimate: "small", + files: ["src/resources/extensions/sf/tools/plan-task.js"], + verify: "npm test -- plan-task", + inputs: ["DB-backed task"], + expectedOutput: ["Evidence row"], + observabilityImpact: "Task evidence is queryable.", + }, + project, + ); + + assert.equal(result.error, undefined); + const trail = getTaskAuditTrail("M001", "S01", "T01"); + assert.equal(trail.length, 1); + assert.equal(trail[0].evidence_type, "plan_task"); + assert.match(trail[0].content, /Task plan evidence/); + assert.match(trail[0].content, /Task evidence is queryable/); +}); diff --git a/src/resources/extensions/sf/tests/queue-order-db.test.mjs b/src/resources/extensions/sf/tests/queue-order-db.test.mjs new file mode 100644 index 000000000..e785af835 --- /dev/null +++ b/src/resources/extensions/sf/tests/queue-order-db.test.mjs @@ -0,0 +1,56 @@ +/** + * queue-order-db.test.mjs — DB-backed milestone priority/order. + * + * Purpose: prove queue reordering is persisted in SQLite when available, with + * legacy JSON kept only as fallback for DB-unavailable contexts. + */ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { loadQueueOrder, saveQueueOrder } from "../queue-order.js"; +import { + closeDatabase, + getAllMilestones, + insertMilestone, + openDatabase, +} from "../sf-db.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-queue-order-db-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "One", status: "queued" }); + insertMilestone({ id: "M002", title: "Two", status: "queued" }); + insertMilestone({ id: "M003", title: "Three", status: "queued" }); + return dir; +} + +test("saveQueueOrder_when_db_available_persists_order_to_milestone_sequence", () => { + const project = makeProject(); + + saveQueueOrder(project, ["M003", "M001", "M002"]); + + assert.deepEqual(loadQueueOrder(project), ["M003", "M001", "M002"]); + assert.equal(existsSync(join(project, ".sf", "QUEUE-ORDER.json")), false); + assert.deepEqual( + getAllMilestones().map((m) => [m.id, m.sequence]), + [ + ["M003", 1], + ["M001", 2], + ["M002", 3], + ], + ); +}); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs new file mode 100644 index 000000000..f98d71b4e --- /dev/null +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -0,0 +1,157 @@ +/** + * sf-db-migration.test.mjs — legacy SQLite schema upgrade coverage. + * + * Purpose: prove real project databases from older SF versions still migrate + * automatically when later backfills depend on newly introduced columns. + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, test } from "vitest"; +import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeLegacyV27Db() { + const dir = mkdtempSync(join(tmpdir(), "sf-legacy-v27-")); + tmpDirs.push(dir); + const sfDir = join(dir, ".sf"); + mkdirSync(sfDir, { recursive: true }); + const dbPath = join(sfDir, "sf.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ); + INSERT INTO schema_version (version, applied_at) + VALUES (27, '2026-05-06T00:00:00.000Z'); + + CREATE TABLE milestones ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + depends_on TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + vision TEXT NOT NULL DEFAULT '', + success_criteria TEXT NOT NULL DEFAULT '[]', + key_risks TEXT NOT NULL DEFAULT '[]', + proof_strategy TEXT NOT NULL DEFAULT '[]', + verification_contract TEXT NOT NULL DEFAULT '', + verification_integration TEXT NOT NULL DEFAULT '', + verification_operational TEXT NOT NULL DEFAULT '', + verification_uat TEXT NOT NULL DEFAULT '', + definition_of_done TEXT NOT NULL DEFAULT '[]', + requirement_coverage TEXT NOT NULL DEFAULT '', + boundary_map_markdown TEXT NOT NULL DEFAULT '', + vision_meeting_json TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE slices ( + milestone_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + risk TEXT NOT NULL DEFAULT 'medium', + depends TEXT NOT NULL DEFAULT '[]', + demo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + full_summary_md TEXT NOT NULL DEFAULT '', + full_uat_md TEXT NOT NULL DEFAULT '', + goal TEXT NOT NULL DEFAULT '', + success_criteria TEXT NOT NULL DEFAULT '', + proof_level TEXT NOT NULL DEFAULT '', + integration_closure TEXT NOT NULL DEFAULT '', + observability_impact TEXT NOT NULL DEFAULT '', + adversarial_partner TEXT NOT NULL DEFAULT '', + adversarial_combatant TEXT NOT NULL DEFAULT '', + adversarial_architect TEXT NOT NULL DEFAULT '', + planning_meeting_json TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0, + replan_triggered_at TEXT DEFAULT NULL, + is_sketch INTEGER NOT NULL DEFAULT 0, + sketch_scope TEXT NOT NULL DEFAULT '', + PRIMARY KEY (milestone_id, id) + ); + + CREATE TABLE tasks ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + one_liner TEXT NOT NULL DEFAULT '', + narrative TEXT NOT NULL DEFAULT '', + verification_result TEXT NOT NULL DEFAULT '', + duration TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + blocker_discovered INTEGER DEFAULT 0, + deviations TEXT NOT NULL DEFAULT '', + known_issues TEXT NOT NULL DEFAULT '', + key_files TEXT NOT NULL DEFAULT '[]', + key_decisions TEXT NOT NULL DEFAULT '[]', + full_summary_md TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + estimate TEXT NOT NULL DEFAULT '', + files TEXT NOT NULL DEFAULT '[]', + verify TEXT NOT NULL DEFAULT '', + inputs TEXT NOT NULL DEFAULT '[]', + expected_output TEXT NOT NULL DEFAULT '[]', + observability_impact TEXT NOT NULL DEFAULT '', + full_plan_md TEXT NOT NULL DEFAULT '', + verification_status TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0, + escalation_pending INTEGER NOT NULL DEFAULT 0, + escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, + escalation_override_applied INTEGER NOT NULL DEFAULT 0, + escalation_artifact_path TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, slice_id, id) + ); + + INSERT INTO milestones (id, title, status, created_at) + VALUES ('M010', 'Beta Launch Readiness', 'active', '2026-05-06T00:00:00.000Z'); + INSERT INTO slices (milestone_id, id, title, status, created_at) + VALUES ('M010', 'S03', 'Alerting Pipeline Verification', 'pending', '2026-05-06T00:00:00.000Z'); + INSERT INTO tasks (milestone_id, slice_id, id, title, status, verify) + VALUES ('M010', 'S03', 'T01', 'Verify alert endpoint', 'pending', 'go test ./portal'); + `); + db.close(); + return dbPath; +} + +test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", () => { + const dbPath = makeLegacyV27Db(); + + assert.equal(openDatabase(dbPath), true); + const db = getDatabase(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all(); + assert.ok(columns.some((row) => row.name === "created_at")); + const version = db + .prepare("SELECT MAX(version) AS version FROM schema_version") + .get(); + assert.equal(version.version, 34); + const taskSpec = db + .prepare( + "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", + ) + .get(); + assert.deepEqual(taskSpec, { + milestone_id: "M010", + slice_id: "S03", + task_id: "T01", + verify: "go test ./portal", + }); +}); diff --git a/src/resources/extensions/sf/tools/complete-milestone.js b/src/resources/extensions/sf/tools/complete-milestone.js index 5bf44e8a0..b59f59a76 100644 --- a/src/resources/extensions/sf/tools/complete-milestone.js +++ b/src/resources/extensions/sf/tools/complete-milestone.js @@ -15,6 +15,7 @@ import { getMilestone, getMilestoneSlices, getSliceTasks, + insertMilestoneEvidence, transaction, updateMilestoneStatus, } from "../sf-db.js"; @@ -182,6 +183,23 @@ export async function handleCompleteMilestone(params, basePath) { } // All guards passed — perform write updateMilestoneStatus(params.milestoneId, "complete", completedAt); + // Record evidence: milestone completion + const evidenceContent = JSON.stringify({ + oneLiner: params.oneLiner ?? "", + narrative: params.narrative ?? "", + successCriteriaResults: params.successCriteriaResults ?? "", + definitionOfDoneResults: params.definitionOfDoneResults ?? "", + keyDecisions: params.keyDecisions ?? [], + keyFiles: params.keyFiles ?? [], + lessonsLearned: params.lessonsLearned ?? [], + }); + insertMilestoneEvidence( + params.milestoneId, + "completion_summary", + evidenceContent, + "complete-milestone", + "agent", + ); }); if (guardError) { return { error: guardError }; diff --git a/src/resources/extensions/sf/tools/complete-slice.js b/src/resources/extensions/sf/tools/complete-slice.js index 8a3243c30..cb4f0fd41 100644 --- a/src/resources/extensions/sf/tools/complete-slice.js +++ b/src/resources/extensions/sf/tools/complete-slice.js @@ -20,6 +20,7 @@ import { getSliceTasks, insertMilestone, insertSlice, + insertSliceEvidence, saveGateResult, setSliceSummaryMd, transaction, @@ -504,6 +505,23 @@ export async function handleCompleteSlice(paramsInput, basePath) { completedAt, ); setSliceSummaryMd(params.milestoneId, params.sliceId, summaryMd, uatMd); + // Record evidence: slice completion + const sliceEvidenceContent = JSON.stringify({ + oneLiner: params.oneLiner ?? "", + narrative: params.narrative ?? "", + successCriteriaResults: params.successCriteriaResults ?? "", + verificationProof: params.verificationProof ?? "", + keyDecisions: params.keyDecisions ?? [], + keyFiles: params.keyFiles ?? [], + }); + insertSliceEvidence( + params.milestoneId, + params.sliceId, + "completion_summary", + sliceEvidenceContent, + "complete-slice", + "agent", + ); }); } catch (dbErr) { const msg = errorMessage(dbErr); diff --git a/src/resources/extensions/sf/tools/complete-task.js b/src/resources/extensions/sf/tools/complete-task.js index 852203fef..88bca1956 100644 --- a/src/resources/extensions/sf/tools/complete-task.js +++ b/src/resources/extensions/sf/tools/complete-task.js @@ -21,6 +21,7 @@ import { insertMilestone, insertSlice, insertTask, + insertTaskEvidence, insertVerificationEvidence, saveGateResult, setTaskSummaryMd, @@ -418,6 +419,27 @@ export async function handleCompleteTask(paramsInput, basePath) { params.taskId, summaryMd, ); + // Record evidence: task completion + const taskEvidenceContent = JSON.stringify({ + oneLiner: params.oneLiner ?? "", + narrative: params.narrative ?? "", + verification: params.verification ?? "", + verificationEvidence: params.verificationEvidence ?? [], + blockerDiscovered: params.blockerDiscovered ?? false, + deviations: params.deviations ?? "None.", + knownIssues: params.knownIssues ?? "None.", + keyFiles: params.keyFiles ?? [], + keyDecisions: params.keyDecisions ?? [], + }); + insertTaskEvidence( + params.milestoneId, + params.sliceId, + params.taskId, + "completion_summary", + taskEvidenceContent, + "complete-task", + "agent", + ); }); } catch (dbErr) { const msg = errorMessage(dbErr); diff --git a/src/resources/extensions/sf/tools/plan-milestone.js b/src/resources/extensions/sf/tools/plan-milestone.js index 4f8cffa78..7bd501b72 100644 --- a/src/resources/extensions/sf/tools/plan-milestone.js +++ b/src/resources/extensions/sf/tools/plan-milestone.js @@ -7,6 +7,7 @@ import { getMilestoneSlices, getSlice, insertMilestone, + insertMilestoneEvidence, insertSlice, transaction, upsertMilestonePlanning, @@ -422,6 +423,25 @@ export async function handlePlanMilestone(rawParams, basePath) { }); } } + // Record evidence: milestone planning + const milestoneEvidenceContent = JSON.stringify({ + title: params.title, + vision: params.vision ?? "", + successCriteria: params.successCriteria ?? "", + keyRisks: params.keyRisks ?? "", + proofStrategy: params.proofStrategy ?? "", + verificationContract: params.verificationContract ?? "", + boundaryMapMarkdown: params.boundaryMapMarkdown ?? "", + slices: + slices.map((s) => ({ sliceId: s.sliceId, title: s.title })) ?? [], + }); + insertMilestoneEvidence( + params.milestoneId, + "plan_milestone", + milestoneEvidenceContent, + "plan-milestone", + "agent", + ); }); } catch (err) { return { error: `db write failed: ${err.message}` }; diff --git a/src/resources/extensions/sf/tools/plan-slice.js b/src/resources/extensions/sf/tools/plan-slice.js index 9074a1eca..8fc79042f 100644 --- a/src/resources/extensions/sf/tools/plan-slice.js +++ b/src/resources/extensions/sf/tools/plan-slice.js @@ -9,6 +9,7 @@ import { getMilestone, getSlice, insertGateRow, + insertSliceEvidence, insertTask, transaction, upsertSlicePlanning, @@ -320,6 +321,25 @@ export async function handlePlanSlice(rawParams, basePath) { gateId: "Q8", scope: "slice", }); + insertSliceEvidence( + params.milestoneId, + params.sliceId, + "plan_slice", + JSON.stringify({ + goal: params.goal ?? "", + successCriteria: params.successCriteria ?? "", + proofLevel: params.proofLevel ?? "", + integrationClosure: params.integrationClosure ?? "", + observabilityImpact: params.observabilityImpact ?? "", + adversarialReview: params.adversarialReview ?? "", + tasks: params.tasks.map((t) => ({ + taskId: t.taskId, + title: t.title, + })), + }), + "plan-slice", + "agent", + ); }); } catch (err) { return { error: `db write failed: ${err.message}` }; diff --git a/src/resources/extensions/sf/tools/plan-task.js b/src/resources/extensions/sf/tools/plan-task.js index a51be83e6..3b728016b 100644 --- a/src/resources/extensions/sf/tools/plan-task.js +++ b/src/resources/extensions/sf/tools/plan-task.js @@ -4,6 +4,7 @@ import { getSlice, getTask, insertTask, + insertTaskEvidence, transaction, upsertTaskPlanning, } from "../sf-db.js"; @@ -114,6 +115,24 @@ export async function handlePlanTask(rawParams, basePath) { observabilityImpact: params.observabilityImpact ?? "", fullPlanMd: params.fullPlanMd, }); + insertTaskEvidence( + params.milestoneId, + params.sliceId, + params.taskId, + "plan_task", + JSON.stringify({ + title: params.title, + description: params.description, + estimate: params.estimate ?? "", + files: params.files ?? [], + verify: params.verify ?? "", + inputs: params.inputs ?? [], + expectedOutput: params.expectedOutput ?? [], + observabilityImpact: params.observabilityImpact ?? "", + }), + "plan-task", + "agent", + ); }); } catch (err) { return { error: `db write failed: ${err.message}` }; diff --git a/src/resources/extensions/sf/uok/gate-runner.js b/src/resources/extensions/sf/uok/gate-runner.js index 77ebade60..29a21c20d 100644 --- a/src/resources/extensions/sf/uok/gate-runner.js +++ b/src/resources/extensions/sf/uok/gate-runner.js @@ -38,6 +38,10 @@ function resolveCircuitBreakerThresholds(gateId) { Number(envKeyForGate(gateId, "OPEN_DURATION_MS")) || Number(process.env.SF_CIRCUIT_BREAKER_OPEN_DURATION_MS) || 60_000, + maxOpenDurationMs: + Number(envKeyForGate(gateId, "MAX_OPEN_DURATION_MS")) || + Number(process.env.SF_CIRCUIT_BREAKER_MAX_OPEN_DURATION_MS) || + 300_000, halfOpenMaxAttempts: Number(envKeyForGate(gateId, "HALF_OPEN_MAX_ATTEMPTS")) || Number(process.env.SF_CIRCUIT_BREAKER_HALF_OPEN_MAX_ATTEMPTS) || @@ -45,6 +49,17 @@ function resolveCircuitBreakerThresholds(gateId) { }; } +function computeCooldownMs(breaker, thresholds) { + const streakAboveThreshold = Math.max( + 0, + breaker.failureStreak - thresholds.failureThreshold, + ); + return Math.min( + thresholds.openDurationMs * 2 ** streakAboveThreshold, + thresholds.maxOpenDurationMs, + ); +} + function nowIso() { return new Date().toISOString(); } @@ -153,12 +168,12 @@ export class UokGateRunner { } _checkCircuitBreaker(gateId) { - const { openDurationMs, halfOpenMaxAttempts } = - resolveCircuitBreakerThresholds(gateId); + const thresholds = resolveCircuitBreakerThresholds(gateId); const breaker = getGateCircuitBreaker(gateId); if (breaker.state === "open") { const openedAt = breaker.openedAt ? Date.parse(breaker.openedAt) : 0; - if (Date.now() - openedAt >= openDurationMs) { + const cooldownMs = computeCooldownMs(breaker, thresholds); + if (Date.now() - openedAt >= cooldownMs) { // Transition to half-open automatically after cooldown updateGateCircuitBreaker(gateId, { state: "half-open", @@ -169,11 +184,11 @@ export class UokGateRunner { } return { blocked: true, - reason: `Circuit breaker OPEN for ${gateId} (failure streak ${breaker.failureStreak}). Cooldown until ${new Date(openedAt + openDurationMs).toISOString()}.`, + reason: `Circuit breaker OPEN for ${gateId} (failure streak ${breaker.failureStreak}, cooldown ${Math.round(cooldownMs / 1000)}s). Cooldown until ${new Date(openedAt + cooldownMs).toISOString()}.`, }; } if (breaker.state === "half-open") { - if (breaker.halfOpenAttempts >= halfOpenMaxAttempts) { + if (breaker.halfOpenAttempts >= thresholds.halfOpenMaxAttempts) { // Too many half-open attempts without success — go back to open updateGateCircuitBreaker(gateId, { state: "open", diff --git a/src/resources/extensions/sf/uok/message-bus.js b/src/resources/extensions/sf/uok/message-bus.js index 74b66cc17..04de0a3ae 100644 --- a/src/resources/extensions/sf/uok/message-bus.js +++ b/src/resources/extensions/sf/uok/message-bus.js @@ -162,7 +162,8 @@ export class MessageBus { } getConversation(agentA, agentB) { - return getUokConversation(agentA, agentB, this.maxInboxSize); + // DB returns DESC; reverse to chronological order (oldest first) + return getUokConversation(agentA, agentB, this.maxInboxSize).reverse(); } compact() { diff --git a/src/resources/extensions/sf/uok/metrics-exposition.js b/src/resources/extensions/sf/uok/metrics-exposition.js index 7774932dd..a9f32b2b9 100644 --- a/src/resources/extensions/sf/uok/metrics-exposition.js +++ b/src/resources/extensions/sf/uok/metrics-exposition.js @@ -30,6 +30,11 @@ const DEFAULT_GATE_NAMES = [ "milestone-validation-post-check", ]; +const METRICS_CACHE_TTL_MS = 30_000; +let _metricsCacheText = null; +let _metricsCacheKey = null; +let _metricsCacheTs = 0; + function fmtCounter(name, value, labels = {}) { const labelStr = Object.entries(labels) .map(([k, v]) => `${k}="${v}"`) @@ -95,6 +100,15 @@ function collectGateMetrics(gateIds) { } function buildMetricsText(gateIds) { + const cacheKey = gateIds ? gateIds.join(",") : ""; + const now = Date.now(); + if ( + _metricsCacheText && + _metricsCacheKey === cacheKey && + now - _metricsCacheTs < METRICS_CACHE_TTL_MS + ) { + return _metricsCacheText; + } const lines = [ "# HELP uok_gate_runs_total Total gate runs in the last 24h", "# TYPE uok_gate_runs_total counter", @@ -126,7 +140,17 @@ function buildMetricsText(gateIds) { : DEFAULT_GATE_NAMES; lines.push(...collectGateMetrics(ids)); } - return lines.join("\n") + "\n"; + const text = lines.join("\n") + "\n"; + _metricsCacheText = text; + _metricsCacheKey = cacheKey; + _metricsCacheTs = now; + return text; +} + +export function invalidateMetricsCache() { + _metricsCacheText = null; + _metricsCacheKey = null; + _metricsCacheTs = 0; } export function metricsPath(basePath) {