fix(sf): stop on contradictory roadmap slice counts
This commit is contained in:
parent
8133ba9003
commit
b9375656ca
2 changed files with 115 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue