From b9375656ca2a3dce59e051963a61b707fdf5111e Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 17:13:06 +0200 Subject: [PATCH] fix(sf): stop on contradictory roadmap slice counts --- src/resources/extensions/sf/auto-dispatch.ts | 67 +++++++++++++++++++ .../tests/parallel-research-dispatch.test.ts | 48 +++++++++++++ 2 files changed, 115 insertions(+) diff --git a/src/resources/extensions/sf/auto-dispatch.ts b/src/resources/extensions/sf/auto-dispatch.ts index daaca018b..5144e09eb 100644 --- a/src/resources/extensions/sf/auto-dispatch.ts +++ b/src/resources/extensions/sf/auto-dispatch.ts @@ -170,6 +170,50 @@ function hasPriorParallelResearchFailure(basePath: string, mid: string): boolean } } +const ROADMAP_COUNT_WORDS = new Map([ + ["one", 1], + ["two", 2], + ["three", 3], + ["four", 4], + ["five", 5], + ["six", 6], + ["seven", 7], + ["eight", 8], + ["nine", 9], + ["ten", 10], +]); + +function parseSliceCountToken(token: string): number | null { + const normalized = token.toLowerCase(); + const wordCount = ROADMAP_COUNT_WORDS.get(normalized); + if (wordCount !== undefined) return wordCount; + const numeric = Number.parseInt(normalized, 10); + return Number.isFinite(numeric) && numeric > 0 ? numeric : null; +} + +function findRoadmapSliceCountContradiction( + roadmapContent: string, + actualSliceCount: number, +): string | null { + const narrative = roadmapContent.split(/\n##\s+(?:Slice Overview|Slices)\b/i)[0]; + const countToken = "(one|two|three|four|five|six|seven|eight|nine|ten|\\d+)"; + const claimPatterns = [ + new RegExp(`\\b${countToken}\\s+slices\\s*:`, "i"), + new RegExp(`\\b${countToken}[-\\s]+slice\\s+structure\\b`, "i"), + new RegExp(`\\btotal:\\s*${countToken}\\s+slices\\b`, "i"), + ]; + + for (const pattern of claimPatterns) { + const matched = narrative.match(pattern); + const declared = matched?.[1] ? parseSliceCountToken(matched[1]) : null; + if (declared !== null && declared !== actualSliceCount) { + return `roadmap narrative declares ${declared} slice${declared === 1 ? "" : "s"}, but the parsed Slice Overview contains ${actualSliceCount}`; + } + } + + return null; +} + export function formatTaskCompleteFailurePrompt(reason: string): string { return `sf_task_complete failed: ${reason}. Try the call again, or investigate the write path.`; } @@ -788,6 +832,29 @@ export const DISPATCH_RULES: DispatchRule[] = [ }; }, }, + { + name: "planning (roadmap contradiction) → stop", + match: async ({ state, mid, basePath }) => { + if (state.phase !== "planning") return null; + + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + if (!roadmapContent) return null; + + const roadmap = parseRoadmap(roadmapContent); + const contradiction = findRoadmapSliceCountContradiction( + roadmapContent, + roadmap.slices.length, + ); + if (!contradiction) return null; + + return { + action: "stop", + reason: `${mid}: ${contradiction}. Repair ROADMAP.md before dispatching auto-mode work.`, + level: "error", + }; + }, + }, { // Keep this rule before the single-slice research rule so the multi-slice // path wins whenever 2+ slices are ready. diff --git a/src/resources/extensions/sf/tests/parallel-research-dispatch.test.ts b/src/resources/extensions/sf/tests/parallel-research-dispatch.test.ts index cb11d8085..0db0b0caa 100644 --- a/src/resources/extensions/sf/tests/parallel-research-dispatch.test.ts +++ b/src/resources/extensions/sf/tests/parallel-research-dispatch.test.ts @@ -217,6 +217,54 @@ test("resolveDispatch prefers parallel research when multiple slices are ready", } }); +test("resolveDispatch stops when roadmap narrative contradicts slice table", async () => { + const base = makeTmpProject(); + writeFileSync( + join(base, ".sf", "milestones", "M001", "M001-ROADMAP.md"), + [ + "# M001: Contradictory Roadmap", + "", + "## Vision", + "Two slices: S01 validates the current behavior and S02 ships the build.", + "", + "## Slice Overview", + "| ID | Slice | Risk | Depends | Done | After this |", + "|----|-------|------|---------|------|------------|", + "| S01 | Validate | low | — | ⬜ | Evidence exists. |", + "| S02 | Build | high | — | ⬜ | Feature ships. |", + "| S03 | Stale duplicate | high | — | ⬜ | Should not be dispatched. |", + "", + ].join("\n"), + "utf-8", + ); + + const action = await resolveDispatch({ + basePath: base, + mid: "M001", + midTitle: "Contradictory Roadmap", + state: { + phase: "planning", + activeMilestone: { + id: "M001", + title: "Contradictory Roadmap", + status: "active", + }, + activeSlice: { id: "S01", title: "Validate" }, + activeTask: null, + registry: [], + blockers: [], + } as any, + prefs: undefined, + }); + + assert.equal(action.action, "stop"); + if (action.action === "stop") { + assert.equal(action.level, "error"); + assert.match(action.reason, /declares 2 slices/); + assert.match(action.reason, /contains 3/); + } +}); + test("resolveDispatch skips parallel research when blocker artifact exists", async () => { const base = makeTmpProject(); writeFileSync(