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:
parent
076e8c4894
commit
79896b4377
21 changed files with 990 additions and 78 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
56
src/resources/extensions/sf/tests/queue-order-db.test.mjs
Normal file
56
src/resources/extensions/sf/tests/queue-order-db.test.mjs
Normal 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],
|
||||
],
|
||||
);
|
||||
});
|
||||
157
src/resources/extensions/sf/tests/sf-db-migration.test.mjs
Normal file
157
src/resources/extensions/sf/tests/sf-db-migration.test.mjs
Normal 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",
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue