diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 3d6a52c74..8626bc6af 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -23,6 +23,7 @@ const BASELINE_PATTERNS = [ ".gsd/metrics.json", ".gsd/completed-units.json", ".gsd/STATE.md", + ".gsd/DISCUSSION-MANIFEST.json", // ── OS junk ── ".DS_Store", diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index d85c31862..fddd76d6f 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -50,13 +50,76 @@ export function checkAutoStartAfterDiscuss(): boolean { const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart; - // Don't fire until the discuss phase has actually produced a context file - // for the milestone being discussed. agent_end fires after every LLM turn, - // including the initial "What do you want to build?" response — we need to - // wait for the full conversation to complete and the LLM to write CONTEXT.md. + // Gate 1: Primary milestone must have CONTEXT.md const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT"); if (!contextFile) return false; // no context yet — keep waiting + // Gate 2: STATE.md must exist — written as the last step in the discuss + // output phase. This prevents auto-start from firing during Phase 3 + // (sequential readiness gates for remaining milestones) in multi-milestone + // discussions, where M001-CONTEXT.md exists but M002/M003 haven't been + // processed yet. + const stateFile = resolveGsdRootFile(basePath, "STATE"); + if (!stateFile) return false; // discussion not finalized yet + + // Gate 3: Multi-milestone completeness warning + // Parse PROJECT.md for milestone sequence, warn if any are missing context. + // Don't block — milestones can be intentionally queued without context. + const projectFile = resolveGsdRootFile(basePath, "PROJECT"); + if (projectFile) { + try { + const projectContent = readFileSync(projectFile, "utf-8"); + const milestoneIds = parseMilestoneSequenceFromProject(projectContent); + if (milestoneIds.length > 1) { + const missing = milestoneIds.filter(id => { + const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT"); + const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT"); + const hasDir = existsSync(join(basePath, ".gsd", "milestones", id)); + return !hasContext && !hasDraft && !hasDir; + }); + if (missing.length > 0) { + ctx.ui.notify( + `Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` + + `Discussion may not have completed all readiness gates.`, + "warning", + ); + } + } + } catch { /* non-fatal — PROJECT.md parsing failure shouldn't block auto-start */ } + } + + // Gate 4: Discussion manifest process verification (multi-milestone only) + // The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision. + // If the manifest exists but gates_completed < total, the LLM hasn't finished + // presenting all readiness gates to the user — block auto-start. + const manifestPath = join(basePath, ".gsd", "DISCUSSION-MANIFEST.json"); + if (existsSync(manifestPath)) { + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + const total = typeof manifest.total === "number" ? manifest.total : 0; + const completed = typeof manifest.gates_completed === "number" ? manifest.gates_completed : 0; + + if (total > 1 && completed < total) { + // Discussion not complete — block auto-start until all gates are done + return false; + } + + // Cross-check manifest milestones against PROJECT.md if available + if (projectFile) { + const projectContent = readFileSync(projectFile, "utf-8"); + const projectIds = parseMilestoneSequenceFromProject(projectContent); + const manifestIds = Object.keys(manifest.milestones ?? {}); + const untracked = projectIds.filter(id => !manifestIds.includes(id)); + if (untracked.length > 0) { + ctx.ui.notify( + `Discussion manifest missing gates for: ${untracked.join(", ")}`, + "warning", + ); + } + } + } catch { /* malformed manifest — warn but don't block */ } + } + // Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new // CONTEXT.md, delete the draft — it's been consumed by the discussion. try { @@ -64,11 +127,28 @@ export function checkAutoStartAfterDiscuss(): boolean { if (draftFile) unlinkSync(draftFile); } catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ } + // Cleanup: remove discussion manifest after auto-start (only needed during discussion) + try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ } + pendingAutoStart = null; startAuto(ctx, pi, basePath, false, { step }).catch(() => {}); return true; } +/** + * Extract milestone IDs from PROJECT.md milestone sequence table. + * Looks for rows like "| M001 | Name | Status |" and extracts the ID column. + */ +function parseMilestoneSequenceFromProject(content: string): string[] { + const ids: string[] = []; + const lines = content.split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^\|\s*(M\d{3}[A-Z0-9-]*)\s*\|/); + if (match) ids.push(match[1]); + } + return ids; +} + // ─── Types ──────────────────────────────────────────────────────────────────── type UIContext = ExtensionContext; diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index accdbc8ce..fef9176b8 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -227,6 +227,27 @@ For each remaining milestone **one at a time, in sequence**, use `ask_user_quest Each context file (full or draft) should be rich enough that a future agent encountering it fresh — with no memory of this conversation — can understand the intent, constraints, dependencies, what this milestone unlocks, and what "done" looks like. +#### Milestone Gate Tracking (MANDATORY for multi-milestone) + +After EVERY Phase 3 gate decision, immediately write or update `.gsd/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before auto-mode starts — if gates are incomplete, auto-mode will NOT start. + +```json +{ + "primary": "M001", + "milestones": { + "M001": { "gate": "discussed", "context": "full" }, + "M002": { "gate": "discussed", "context": "full" }, + "M003": { "gate": "queued", "context": "none" } + }, + "total": 3, + "gates_completed": 3 +} +``` + +Write this file AFTER each gate decision, not just at the end. Update `gates_completed` incrementally. The system reads this file and BLOCKS auto-start if `gates_completed < total`. + +For single-milestone projects, do NOT write this file — it is only for multi-milestone discussions. + #### Phase 4: Finalize 7. Update `.gsd/STATE.md` diff --git a/src/resources/extensions/gsd/tests/draft-promotion.test.ts b/src/resources/extensions/gsd/tests/draft-promotion.test.ts index 4ea6f976c..0ce24ed50 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -145,7 +145,8 @@ const guidedFlowSource = readFileSync( ); const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss"); -const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200); +const checkFnEnd = guidedFlowSource.indexOf("\nexport ", checkFnIdx + 1); +const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnEnd > checkFnIdx ? checkFnEnd : checkFnIdx + 5000); assert( checkFnChunk.includes("CONTEXT-DRAFT"),