import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from 'vitest'; import { parseRoadmap } from "../parsers.ts"; import { closeDatabase, getMilestone, getMilestoneSlices, getSlice, insertMilestone, openDatabase, updateSliceStatus, } from "../sf-db.ts"; import { handlePlanMilestone, type PlanMilestoneParams, } from "../tools/plan-milestone.ts"; function makeTmpBase(): string { const base = mkdtempSync(join(tmpdir(), "sf-plan-milestone-")); mkdirSync(join(base, ".sf", "milestones", "M001"), { recursive: true }); return base; } function cleanup(base: string): void { try { closeDatabase(); } catch { /* noop */ } try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ } } function validParams(): PlanMilestoneParams { return { milestoneId: "M001", title: "DB-backed planning", vision: "Make planning write through the database.", successCriteria: ["Planning persists", "Roadmap renders from DB"], keyRisks: [ { risk: "Renderer mismatch", whyItMatters: "Rendered roadmap may stop round-tripping.", }, ], proofStrategy: [ { riskOrUnknown: "Render correctness", retireIn: "S01", whatWillBeProven: "ROADMAP output matches DB state.", }, ], verificationContract: "Contract verification text", verificationIntegration: "Integration verification text", verificationOperational: "Operational verification text", verificationUat: "UAT verification text", definitionOfDone: ["Tests pass", "Tool reruns cleanly"], requirementCoverage: "Covers R015.", boundaryMapMarkdown: "| From | To | Produces | Consumes |\n|------|----|----------|----------|\n| S01 | terminal | roadmap | nothing |", visionMeeting: { trigger: "Top-level roadmap spans multiple user, business, and delivery concerns.", pm: "Primary product move is making DB-backed planning the real source of truth.", userAdvocate: "Users need planning artifacts that stay coherent after repeated planning turns.", customerPanel: "Power users care about fidelity, maintainers care about consistency, and new adopters care about understandable roadmap output.", business: "The system needs a credible planning surface that can scale to more guided automation.", researcher: "Comparable planning systems treat requirements, roadmap, and state as a connected contract rather than separate notes.", deliveryLead: "Keep the first milestone narrow: DB-backed write path, projection, and prompt migration.", partner: "The proposed roadmap is small enough to land and strong enough to prove the architecture shift.", combatant: "Do not overbuild planning metadata before the DB-backed write path is working.", architect: "Persist the meeting and render it into the roadmap so the state machine can reason over it.", moderator: "Weighted synthesis: preserve the narrow DB-backed milestone, but require explicit stakeholder and market reasoning before execution.", weightedSynthesis: "User trust, product coherence, and business viability all point to a small but fully real DB-backed planning milestone. The strongest cut is avoiding speculative extra automation in this milestone.", confidenceByArea: "- User need: high\n- Architecture fit: high\n- Comparable-system fit: medium\n- Sequencing confidence: high", recommendedRoute: "planning", }, slices: [ { sliceId: "S01", title: "Tool wiring", risk: "medium", depends: [], demo: "The tool writes roadmap state.", goal: "Wire the handler.", successCriteria: "Handler persists state and renders markdown.", proofLevel: "integration", integrationClosure: "Downstream callers read rendered roadmap output.", observabilityImpact: "Tests expose render and validation failures.", }, { sliceId: "S02", title: "Prompt migration", risk: "low", depends: ["S01"], demo: "Prompts call the tool.", goal: "Migrate prompts to DB-backed path.", successCriteria: "Prompt contracts reference the new tool.", proofLevel: "integration", integrationClosure: "Prompt tests cover the new planning route.", observabilityImpact: "Prompt and rogue-write failures become explicit.", }, ], }; } test("handlePlanMilestone writes milestone and slice planning state and renders roadmap", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const result = await handlePlanMilestone(validParams(), base); assert.ok( !("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`, ); const milestone = getMilestone("M001"); assert.ok(milestone, "milestone should exist"); assert.equal( milestone?.vision, "Make planning write through the database.", ); assert.deepEqual(milestone?.success_criteria, [ "Planning persists", "Roadmap renders from DB", ]); assert.equal( milestone?.verification_contract, "Contract verification text", ); assert.equal(milestone?.vision_meeting?.recommendedRoute, "planning"); assert.match( milestone?.vision_meeting?.customerPanel ?? "", /Power users care about fidelity/, ); const slices = getMilestoneSlices("M001"); assert.equal(slices.length, 2); assert.equal(slices[0]?.id, "S01"); assert.equal(slices[0]?.goal, "Wire the handler."); assert.equal(slices[1]?.depends[0], "S01"); const roadmapPath = join( base, ".sf", "milestones", "M001", "M001-ROADMAP.md", ); assert.ok(existsSync(roadmapPath), "roadmap should be rendered to disk"); const roadmap = readFileSync(roadmapPath, "utf-8"); assert.match(roadmap, /# M001: DB-backed planning/); assert.match(roadmap, /## Vision/); assert.match(roadmap, /Make planning write through the database\./); assert.match(roadmap, /## Vision Alignment Meeting/); assert.match(roadmap, /### Customer Panel/); assert.match(roadmap, /### Weighted Synthesis/); assert.match(roadmap, /## Slice Overview/); assert.match(roadmap, /\| S01 \| Tool wiring \| medium \|/); assert.match(roadmap, /\| S02 \| Prompt migration \| low \| S01 \|/); } finally { cleanup(base); } }); test("handlePlanMilestone persists weighted roadmap draft even when the meeting routes back to research", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const baseVisionMeeting = validParams().visionMeeting!; const result = await handlePlanMilestone( { ...validParams(), visionMeeting: { ...baseVisionMeeting, moderator: "Weighted synthesis says comparable-product expectations are still too fuzzy for a final roadmap.", confidenceByArea: "- User need: high\n- Architecture fit: medium\n- Comparable-system fit: low\n- Sequencing confidence: low", recommendedRoute: "researching", }, }, base, ); assert.ok( !("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`, ); const milestone = getMilestone("M001"); assert.equal(milestone?.vision_meeting?.recommendedRoute, "researching"); const roadmapPath = join( base, ".sf", "milestones", "M001", "M001-ROADMAP.md", ); const roadmap = readFileSync(roadmapPath, "utf-8"); assert.match(roadmap, /### Recommended Route/); assert.match(roadmap, /researching/); } finally { cleanup(base); } }); test("handlePlanMilestone rejects invalid payloads", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const params = validParams(); const result = await handlePlanMilestone({ ...params, slices: [] }, base); assert.ok("error" in result); assert.match( result.error, /validation failed: slices must be a non-empty array/, ); } finally { cleanup(base); } }); test("handlePlanMilestone rejects leaked JSON fields in nested planning text", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const params = validParams(); const result = await handlePlanMilestone( { ...params, slices: [ { ...params.slices![0]!, proofLevel: 'Contract proof.", "integrationClosure": "leaked field"', }, ], }, base, ); assert.ok("error" in result); assert.match( result.error, /validation failed: slices\[0\]\.proofLevel appears to contain leaked JSON fields/, ); } finally { cleanup(base); } }); test("handlePlanMilestone normalizes escaped newlines in planning arrays", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const result = await handlePlanMilestone( { ...validParams(), successCriteria: ["First criterion\\nSecond criterion"], }, base, ); assert.ok( !("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`, ); const milestone = getMilestone("M001"); assert.deepEqual(milestone?.success_criteria, [ "First criterion\nSecond criterion", ]); } finally { cleanup(base); } }); test("handlePlanMilestone scaffolds common milestone slices from templateId", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const params = validParams(); const result = await handlePlanMilestone( { milestoneId: params.milestoneId, title: params.title, vision: params.vision, templateId: "bugfix", successCriteria: params.successCriteria, }, base, ); assert.ok( !("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`, ); const slices = getMilestoneSlices("M001"); assert.equal(slices.length, 3); assert.equal(slices[0]?.id, "S01"); assert.equal(slices[1]?.depends[0], "S01"); assert.match(slices[0]?.goal ?? "", /Capture the failing boundary/i); const roadmapPath = join( base, ".sf", "milestones", "M001", "M001-ROADMAP.md", ); const roadmap = readFileSync(roadmapPath, "utf-8"); assert.match(roadmap, /Reproduce and bound the failure/); assert.match(roadmap, /Verify and guard against regression/); } finally { cleanup(base); } }); test("handlePlanMilestone surfaces render failures and does not clear parse-visible state on failure", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const fallbackRoadmapPath = join( base, ".sf", "milestones", "MISSING", "MISSING-ROADMAP.md", ); mkdirSync(fallbackRoadmapPath, { recursive: true }); const result = await handlePlanMilestone( { ...validParams(), milestoneId: "MISSING" }, base, ); assert.ok("error" in result); assert.match(result.error, /render failed:/); const existingRoadmapPath = join( base, ".sf", "milestones", "M001", "M001-ROADMAP.md", ); writeFileSync( existingRoadmapPath, "# M001: Cached roadmap\n\n**Vision:** old value\n\n## Slices\n\n", "utf-8", ); const cachedAfter = parseRoadmap( readFileSync(existingRoadmapPath, "utf-8"), ); assert.equal(cachedAfter.vision, "old value"); } finally { cleanup(base); } }); test("handlePlanMilestone clears parse-visible roadmap state after successful render", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const roadmapPath = join( base, ".sf", "milestones", "M001", "M001-ROADMAP.md", ); writeFileSync( roadmapPath, "# M001: Cached roadmap\n\n**Vision:** old value\n\n## Slices\n\n", "utf-8", ); const cachedBefore = parseRoadmap(readFileSync(roadmapPath, "utf-8")); assert.equal(cachedBefore.vision, "old value"); const result = await handlePlanMilestone(validParams(), base); assert.ok(!("error" in result)); const contentAfter = readFileSync(roadmapPath, "utf-8"); assert.match(contentAfter, /Make planning write through the database\./); assert.match(contentAfter, /S01/); assert.match(contentAfter, /S02/); } finally { cleanup(base); } }); test("handlePlanMilestone reruns idempotently and updates existing planning state", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { const first = await handlePlanMilestone(validParams(), base); assert.ok(!("error" in first)); const baseParams = validParams(); const second = await handlePlanMilestone( { ...baseParams, vision: "Updated vision", slices: [ { ...baseParams.slices![0]!, goal: "Updated goal", observabilityImpact: "Updated observability", }, baseParams.slices![1]!, ], }, base, ); assert.ok(!("error" in second)); const milestone = getMilestone("M001"); assert.equal(milestone?.vision, "Updated vision"); const slices = getMilestoneSlices("M001"); assert.equal(slices.length, 2); assert.equal(slices[0]?.goal, "Updated goal"); assert.equal(slices[0]?.observability_impact, "Updated observability"); } finally { cleanup(base); } }); test("handlePlanMilestone preserves completed slice status on re-plan (#2558)", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { // Initial plan — both slices start as "pending" const first = await handlePlanMilestone(validParams(), base); assert.ok( !("error" in first), `unexpected error: ${"error" in first ? first.error : ""}`, ); const _baseParams = validParams(); // Mark S01 as complete (simulates work done in a worktree) updateSliceStatus("M001", "S01", "complete", new Date().toISOString()); const s01Before = getSlice("M001", "S01"); assert.equal( s01Before?.status, "complete", "S01 should be complete before re-plan", ); // Re-plan the same milestone — S01 must stay "complete", S02 stays "pending" const second = await handlePlanMilestone(validParams(), base); assert.ok( !("error" in second), `unexpected error: ${"error" in second ? second.error : ""}`, ); const s01After = getSlice("M001", "S01"); assert.equal( s01After?.status, "complete", "S01 status must be preserved as complete after re-plan", ); const s02After = getSlice("M001", "S02"); assert.equal(s02After?.status, "pending", "S02 should remain pending"); } finally { cleanup(base); } }); test("plan-milestone re-plan preserves completed status and updates slice fields (#2558)", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { // Initial plan — both slices start as "pending" const first = await handlePlanMilestone(validParams(), base); assert.ok( !("error" in first), `unexpected error: ${"error" in first ? first.error : ""}`, ); const baseParams = validParams(); // Mark S01 as complete (simulates work done in worktree, then reconciled) updateSliceStatus("M001", "S01", "complete", new Date().toISOString()); assert.equal(getSlice("M001", "S01")?.status, "complete"); // Re-plan with updated title for S01. // The handler must: // 1. NOT downgrade S01 from "complete" to "pending" // 2. Update S01's non-status fields (title, risk, depends, demo) // 3. Keep S02 as "pending" const updatedParams = { ...baseParams, slices: [ { ...baseParams.slices![0]!, title: "Updated S01 title", risk: "high" }, baseParams.slices![1]!, ], }; const second = await handlePlanMilestone(updatedParams, base); assert.ok( !("error" in second), `unexpected error: ${"error" in second ? second.error : ""}`, ); const s01After = getSlice("M001", "S01"); assert.equal( s01After?.status, "complete", "completed slice status must survive re-plan", ); assert.equal( s01After?.title, "Updated S01 title", "title should update on re-plan", ); assert.equal(s01After?.risk, "high", "risk should update on re-plan"); const s02After = getSlice("M001", "S02"); assert.equal(s02After?.status, "pending", "pending slice stays pending"); } finally { cleanup(base); } }); test("handlePlanMilestone promotes pre-existing queued milestone to active (#3022)", async () => { const base = makeTmpBase(); const dbPath = join(base, ".sf", "sf.db"); openDatabase(dbPath); try { // Simulate ensureMilestoneDbRow: pre-create row with status "queued" // (this is what sf_milestone_generate_id does) insertMilestone({ id: "M001", status: "queued" }); const before = getMilestone("M001"); assert.equal( before?.status, "queued", "pre-condition: milestone should start as queued", ); // Now plan the milestone — status should be promoted to "active" const result = await handlePlanMilestone(validParams(), base); assert.ok( !("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`, ); const after = getMilestone("M001"); assert.equal( after?.status, "active", "milestone status should be promoted from queued to active", ); assert.equal( after?.title, "DB-backed planning", "milestone title should be set", ); } finally { cleanup(base); } });