fix: add fallback parser for prose-style roadmaps without ## Slices section (#807) (#835)

When the LLM writes freeform prose roadmaps with `## Slice S01: Title`
headers instead of the machine-readable `## Slices` checklist,
parseRoadmapSlices() returned zero slices, causing deriveState() to
permanently block with 'No slice eligible'.

Add a fallback parser that detects prose-style `## Slice SXX:` headers
(and variants like `## S01:`, `## S01 —`) and extracts slice IDs,
titles, and dependencies from the prose. Also parses `Depends on:`
text patterns. All fallback slices default to risk:medium and done:false.
This commit is contained in:
Tom Boucher 2026-03-17 09:49:50 -04:00 committed by GitHub
parent 2f459b5d03
commit a7453719f5

View file

@ -53,7 +53,12 @@ function extractSlicesSection(content: string): string {
export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
const slicesSection = extractSlicesSection(content);
const slices: RoadmapSliceEntry[] = [];
if (!slicesSection) return slices;
if (!slicesSection) {
// Fallback: detect prose-style slice headers (## Slice S01: Title)
// when the LLM writes freeform prose instead of the ## Slices checklist.
// This prevents a permanent "No slice eligible" block (#807).
return parseProseSliceHeaders(content);
}
const checkboxItems = slicesSection.split("\n");
let currentSlice: RoadmapSliceEntry | null = null;
@ -88,3 +93,42 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
if (currentSlice) slices.push(currentSlice);
return slices;
}
/**
* Fallback parser for prose-style roadmaps where the LLM wrote
* `## Slice S01: Title` headers instead of the machine-readable
* `## Slices` checklist. Extracts slice IDs and titles so auto-mode
* can at least identify slices and plan them.
*
* Also handles `## S01: Title` and `## S01 — Title` variants.
*/
function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
const slices: RoadmapSliceEntry[] = [];
const headerPattern = /^##\s+(?:Slice\s+)?(S\d+)[:\s—-]+\s*(.+)/gm;
let match: RegExpExecArray | null;
while ((match = headerPattern.exec(content)) !== null) {
const id = match[1]!;
const title = match[2]!.trim();
// Try to extract depends from prose: "Depends on: S01" or "**Depends on:** S01, S02"
const afterHeader = content.slice(match.index + match[0].length);
const nextHeader = afterHeader.search(/^##\s/m);
const section = nextHeader !== -1 ? afterHeader.slice(0, nextHeader) : afterHeader.slice(0, 500);
const depsMatch = section.match(/\*{0,2}Depends\s+on:?\*{0,2}\s*(.+)/i);
let depends: string[] = [];
if (depsMatch) {
const rawDeps = depsMatch[1]!.replace(/none/i, "").trim();
if (rawDeps) {
depends = expandDependencies(
rawDeps.split(/[,;]/).map(s => s.trim().replace(/[^A-Za-z0-9]/g, "")).filter(Boolean)
);
}
}
slices.push({ id, title, risk: "medium" as RiskLevel, depends, done: false, demo: "" });
}
return slices;
}