diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index c68f6c94a..2bd8b05c2 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -269,6 +269,7 @@ export async function startAuto( stepMode = requestedStepMode; cmdCtx = ctx; basePath = base; + unitDispatchCount.clear(); // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); @@ -1005,105 +1006,105 @@ async function dispatchNextUnit( unitId = `${mid}/${sid}`; prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath); } else { - // ── Adaptive Replanning: check if last completed slice needs reassessment ── - // Computed here (after summarizing guard) so complete-slice always runs first. - const needsReassess = await checkNeedsReassessment(basePath, mid, state); - if (needsRunUat) { - const { sliceId, uatType } = needsRunUat; - unitType = "run-uat"; - unitId = `${mid}/${sliceId}`; - const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; - const uatContent = await loadFile(uatFile); - prompt = await buildRunUatPrompt( - mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, - ); - // For non-artifact-driven UAT types, pause after the prompt is dispatched. - // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT, - // then auto-mode pauses for human execution. On resume, result file exists → skip. - if (uatType !== "artifact-driven") { - pauseAfterUatDispatch = true; - } - } else if (needsReassess) { - unitType = "reassess-roadmap"; - unitId = `${mid}/${needsReassess.sliceId}`; - prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); - } else if (state.phase === "pre-planning") { - // Need roadmap — check if context exists - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); + // ── Adaptive Replanning: check if last completed slice needs reassessment ── + // Computed here (after summarizing guard) so complete-slice always runs first. + const needsReassess = await checkNeedsReassessment(basePath, mid, state); + if (needsRunUat) { + const { sliceId, uatType } = needsRunUat; + unitType = "run-uat"; + unitId = `${mid}/${sliceId}`; + const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!; + const uatContent = await loadFile(uatFile); + prompt = await buildRunUatPrompt( + mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath, + ); + // For non-artifact-driven UAT types, pause after the prompt is dispatched. + // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT, + // then auto-mode pauses for human execution. On resume, result file exists → skip. + if (uatType !== "artifact-driven") { + pauseAfterUatDispatch = true; + } + } else if (needsReassess) { + unitType = "reassess-roadmap"; + unitId = `${mid}/${needsReassess.sliceId}`; + prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); + } else if (state.phase === "pre-planning") { + // Need roadmap — check if context exists + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); - if (!hasContext) { + if (!hasContext) { + await stopAuto(ctx, pi); + ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning"); + return; + } + + // Research before roadmap if no research exists + const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-milestone"; + unitId = mid; + prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath); + } else { + unitType = "plan-milestone"; + unitId = mid; + prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath); + } + + } else if (state.phase === "planning") { + // Slice needs planning — but research first if no research exists + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); + const hasResearch = !!(researchFile && await loadFile(researchFile)); + + if (!hasResearch) { + unitType = "research-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } else { + unitType = "plan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + } + + } else if (state.phase === "replanning-slice") { + // Blocker discovered — replan the slice before continuing + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + unitType = "replan-slice"; + unitId = `${mid}/${sid}`; + prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); + + } else if (state.phase === "executing" && state.activeTask) { + // Execute next task + const sid = state.activeSlice!.id; + const sTitle = state.activeSlice!.title; + const tid = state.activeTask.id; + const tTitle = state.activeTask.title; + unitType = "execute-task"; + unitId = `${mid}/${sid}/${tid}`; + prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath); + + } else if (state.phase === "completing-milestone") { + // All slices done — complete the milestone + unitType = "complete-milestone"; + unitId = mid; + prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath); + + } else { + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } await stopAuto(ctx, pi); - ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning"); + ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); return; } - - // Research before roadmap if no research exists - const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH"); - const hasResearch = !!(researchFile && await loadFile(researchFile)); - - if (!hasResearch) { - unitType = "research-milestone"; - unitId = mid; - prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath); - } else { - unitType = "plan-milestone"; - unitId = mid; - prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath); - } - - } else if (state.phase === "planning") { - // Slice needs planning — but research first if no research exists - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); - const hasResearch = !!(researchFile && await loadFile(researchFile)); - - if (!hasResearch) { - unitType = "research-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - } else { - unitType = "plan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - } - - } else if (state.phase === "replanning-slice") { - // Blocker discovered — replan the slice before continuing - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - unitType = "replan-slice"; - unitId = `${mid}/${sid}`; - prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath); - - } else if (state.phase === "executing" && state.activeTask) { - // Execute next task - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; - const tid = state.activeTask.id; - const tTitle = state.activeTask.title; - unitType = "execute-task"; - unitId = `${mid}/${sid}/${tid}`; - prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath); - - } else if (state.phase === "completing-milestone") { - // All slices done — complete the milestone - unitType = "complete-milestone"; - unitId = mid; - prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath); - - } else { - if (currentUnit) { - const modelId = ctx.model?.id ?? "unknown"; - snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); - saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); - } - await stopAuto(ctx, pi); - ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning"); - return; } - } // close outer else block (summarizing guard) await emitObservabilityWarnings(ctx, unitType, unitId); diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index 0e774ddcb..570b737a8 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -1248,6 +1248,100 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts == assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum'); } +// ═══════════════════════════════════════════════════════════════════════════ +// parseSummary: bare scalar frontmatter fields (regression test for #91) +// ═══════════════════════════════════════════════════════════════════════════ + +console.log('\n=== parseSummary: bare scalar "none" coerced to string array (#91) ==='); +{ + const content = `--- +id: T04 +parent: S03 +milestone: M001 +provides: + - iOS rules +key_files: + - .claude/rules/swift-style.md +key_decisions: none +patterns_established: none +drill_down_paths: none +observability_surfaces: none — static reference files +affects: single-value +--- + +# T04: iOS Rules + +**Created iOS-specific rules.** + +## What Happened + +Added rules. + +## Deviations + +None. +`; + + const s = parseSummary(content); + + // Array fields should remain arrays + assertEq(s.frontmatter.provides.length, 1, '#91: provides array preserved'); + assertEq(s.frontmatter.provides[0], 'iOS rules', '#91: provides value'); + assertEq(s.frontmatter.key_files.length, 1, '#91: key_files array preserved'); + + // Bare scalar "none" must be coerced to ["none"], not crash + assertEq(Array.isArray(s.frontmatter.key_decisions), true, '#91: key_decisions is array'); + assertEq(s.frontmatter.key_decisions.length, 1, '#91: key_decisions has 1 element'); + assertEq(s.frontmatter.key_decisions[0], 'none', '#91: key_decisions[0] is "none"'); + + assertEq(Array.isArray(s.frontmatter.patterns_established), true, '#91: patterns_established is array'); + assertEq(s.frontmatter.patterns_established.length, 1, '#91: patterns_established coerced'); + + assertEq(Array.isArray(s.frontmatter.drill_down_paths), true, '#91: drill_down_paths is array'); + assertEq(s.frontmatter.drill_down_paths.length, 1, '#91: drill_down_paths coerced'); + + // Scalar with spaces: "none — static reference files" + assertEq(Array.isArray(s.frontmatter.observability_surfaces), true, '#91: observability_surfaces is array'); + assertEq(s.frontmatter.observability_surfaces.length, 1, '#91: observability_surfaces coerced'); + assertEq(s.frontmatter.observability_surfaces[0], 'none — static reference files', '#91: full scalar preserved'); + + // Single value (not "none") also coerced + assertEq(Array.isArray(s.frontmatter.affects), true, '#91: affects is array'); + assertEq(s.frontmatter.affects.length, 1, '#91: affects single value coerced'); + assertEq(s.frontmatter.affects[0], 'single-value', '#91: affects value'); + + // .slice().join() must not crash (the original bug) + const decisions = s.frontmatter.key_decisions.slice(0, 2).join('; '); + assertEq(decisions, 'none', '#91: .slice().join() works on coerced array'); +} + +console.log('\n=== parseSummary: missing/empty frontmatter fields yield empty arrays ==='); +{ + const content = `--- +id: T05 +parent: S04 +milestone: M001 +--- + +# T05: Minimal Summary + +**Minimal.** + +## What Happened + +Nothing. +`; + + const s = parseSummary(content); + assertEq(s.frontmatter.provides.length, 0, 'missing provides = empty array'); + assertEq(s.frontmatter.key_decisions.length, 0, 'missing key_decisions = empty array'); + assertEq(s.frontmatter.affects.length, 0, 'missing affects = empty array'); + assertEq(s.frontmatter.key_files.length, 0, 'missing key_files = empty array'); + assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array'); + assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array'); + assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array'); +} + // ═══════════════════════════════════════════════════════════════════════════ // Results // ═══════════════════════════════════════════════════════════════════════════