diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index c05a3408b..bcf394cbc 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -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), diff --git a/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs b/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs new file mode 100644 index 000000000..743aafc1d --- /dev/null +++ b/src/resources/extensions/sf/tests/spec-tables-live-planning.test.mjs @@ -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); +});