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>
This commit is contained in:
Mikael Hugo 2026-05-07 04:35:03 +02:00
parent 076e8c4894
commit 79896b4377
21 changed files with 990 additions and 78 deletions

View file

@ -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": [

View file

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

View file

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

View file

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

View file

@ -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: <entity>_specs (immutable intent), <entity> (runtime state), <entity>_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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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