fix(sf): stop on contradictory roadmap slice counts

This commit is contained in:
Mikael Hugo 2026-05-02 17:13:06 +02:00
parent 8133ba9003
commit b9375656ca
2 changed files with 115 additions and 0 deletions

View file

@ -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.

View file

@ -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(