fix: persist live planning specs in db

This commit is contained in:
Mikael Hugo 2026-05-07 04:44:09 +02:00
parent 8f5f33611a
commit ffde54e05a
2 changed files with 172 additions and 0 deletions

View file

@ -2362,9 +2362,43 @@ export function insertMilestone(m) {
: "",
":sequence": m.sequence ?? 0,
});
insertMilestoneSpecIfAbsent(m.id, m.planning ?? {});
}
function insertMilestoneSpecIfAbsent(milestoneId, planning = {}) {
currentDb
.prepare(`INSERT OR IGNORE INTO milestone_specs (
id, 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,
spec_version, created_at
) VALUES (
:id, :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,
1, :created_at
)`)
.run({
":id": milestoneId,
":vision": planning.vision ?? "",
":success_criteria": JSON.stringify(planning.successCriteria ?? []),
":key_risks": JSON.stringify(planning.keyRisks ?? []),
":proof_strategy": JSON.stringify(planning.proofStrategy ?? []),
":verification_contract": planning.verificationContract ?? "",
":verification_integration": planning.verificationIntegration ?? "",
":verification_operational": planning.verificationOperational ?? "",
":verification_uat": planning.verificationUat ?? "",
":definition_of_done": JSON.stringify(planning.definitionOfDone ?? []),
":requirement_coverage": planning.requirementCoverage ?? "",
":boundary_map_markdown": planning.boundaryMapMarkdown ?? "",
":vision_meeting_json": planning.visionMeeting
? JSON.stringify(planning.visionMeeting)
: "",
":created_at": new Date().toISOString(),
});
}
export function upsertMilestonePlanning(milestoneId, planning) {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
insertMilestoneSpecIfAbsent(milestoneId, planning);
currentDb
.prepare(`UPDATE milestones SET
title = COALESCE(NULLIF(:title, ''), title),
@ -2487,6 +2521,37 @@ export function insertSlice(s) {
":raw_is_sketch": s.isSketch === undefined ? null : s.isSketch ? 1 : 0,
":raw_sketch_scope": s.sketchScope === undefined ? null : s.sketchScope,
});
insertSliceSpecIfAbsent(s.milestoneId, s.id, s.planning ?? {});
}
function insertSliceSpecIfAbsent(milestoneId, sliceId, planning = {}) {
currentDb
.prepare(`INSERT OR IGNORE INTO slice_specs (
milestone_id, slice_id, goal, success_criteria, proof_level,
integration_closure, observability_impact,
adversarial_partner, adversarial_combatant, adversarial_architect,
planning_meeting_json, spec_version, created_at
) VALUES (
:milestone_id, :slice_id, :goal, :success_criteria, :proof_level,
:integration_closure, :observability_impact,
:adversarial_partner, :adversarial_combatant, :adversarial_architect,
:planning_meeting_json, 1, :created_at
)`)
.run({
":milestone_id": milestoneId,
":slice_id": sliceId,
":goal": planning.goal ?? "",
":success_criteria": planning.successCriteria ?? "",
":proof_level": planning.proofLevel ?? "",
":integration_closure": planning.integrationClosure ?? "",
":observability_impact": planning.observabilityImpact ?? "",
":adversarial_partner": planning.adversarialReview?.partner ?? "",
":adversarial_combatant": planning.adversarialReview?.combatant ?? "",
":adversarial_architect": planning.adversarialReview?.architect ?? "",
":planning_meeting_json": planning.planningMeeting
? JSON.stringify(planning.planningMeeting)
: "",
":created_at": new Date().toISOString(),
});
}
/**
* gsd-2 ADR-011: clear the is_sketch flag after refine-slice fills in the full plan.
@ -2554,6 +2619,7 @@ export function listEscalationArtifacts(milestoneId, includeResolved = false) {
}
export function upsertSlicePlanning(milestoneId, sliceId, planning) {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
insertSliceSpecIfAbsent(milestoneId, sliceId, planning);
currentDb
.prepare(`UPDATE slices SET
goal = COALESCE(:goal, goal),
@ -2649,6 +2715,26 @@ export function insertTask(t) {
":observability_impact": t.planning?.observabilityImpact ?? "",
":sequence": t.sequence ?? 0,
});
insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {});
}
function insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning = {}) {
currentDb
.prepare(`INSERT OR IGNORE INTO task_specs (
milestone_id, slice_id, task_id, verify, inputs, expected_output,
spec_version, created_at
) VALUES (
:milestone_id, :slice_id, :task_id, :verify, :inputs, :expected_output,
1, :created_at
)`)
.run({
":milestone_id": milestoneId,
":slice_id": sliceId,
":task_id": taskId,
":verify": planning.verify ?? "",
":inputs": JSON.stringify(planning.inputs ?? []),
":expected_output": JSON.stringify(planning.expectedOutput ?? []),
":created_at": new Date().toISOString(),
});
}
export function updateTaskStatus(
milestoneId,
@ -2788,6 +2874,7 @@ export function setTaskBlockerDiscovered(
}
export function upsertTaskPlanning(milestoneId, sliceId, taskId, planning) {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
insertTaskSpecIfAbsent(milestoneId, sliceId, taskId, planning);
currentDb
.prepare(`UPDATE tasks SET
title = COALESCE(:title, title),

View file

@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import {
closeDatabase,
getMilestoneSpec,
getSliceSpec,
getTaskSpec,
insertMilestone,
insertSlice,
insertTask,
openDatabase,
upsertMilestonePlanning,
upsertSlicePlanning,
upsertTaskPlanning,
} from "../sf-db.js";
afterEach(() => {
closeDatabase();
});
test("specTables_when_live_planning_written_persist_write_once_intent", () => {
openDatabase(":memory:");
insertMilestone({
id: "M001",
title: "Spec source",
status: "active",
planning: {
vision: "Initial milestone vision",
successCriteria: ["initial criterion"],
},
});
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Spec slice",
planning: {
goal: "Initial slice goal",
successCriteria: "initial slice criterion",
},
});
insertTask({
milestoneId: "M001",
sliceId: "S01",
id: "T01",
title: "Spec task",
planning: {
verify: "npm test",
inputs: ["initial input"],
expectedOutput: ["initial output"],
},
});
upsertMilestonePlanning("M001", {
vision: "Changed milestone vision",
successCriteria: ["changed criterion"],
});
upsertSlicePlanning("M001", "S01", {
goal: "Changed slice goal",
successCriteria: "changed slice criterion",
});
upsertTaskPlanning("M001", "S01", "T01", {
verify: "npm run test:unit",
inputs: ["changed input"],
expectedOutput: ["changed output"],
});
const milestoneSpec = getMilestoneSpec("M001");
assert.equal(milestoneSpec.vision, "Initial milestone vision");
assert.deepEqual(JSON.parse(milestoneSpec.success_criteria), [
"initial criterion",
]);
assert.equal(milestoneSpec.spec_version, 1);
const sliceSpec = getSliceSpec("M001", "S01");
assert.equal(sliceSpec.goal, "Initial slice goal");
assert.equal(sliceSpec.success_criteria, "initial slice criterion");
assert.equal(sliceSpec.spec_version, 1);
const taskSpec = getTaskSpec("M001", "S01", "T01");
assert.equal(taskSpec.verify, "npm test");
assert.deepEqual(JSON.parse(taskSpec.inputs), ["initial input"]);
assert.deepEqual(JSON.parse(taskSpec.expected_output), ["initial output"]);
assert.equal(taskSpec.spec_version, 1);
});