diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index e22ec2f2f..97ef90276 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -845,13 +845,15 @@ async function showStepWizard( /** * Describe what the next unit will be, based on current state. */ -function describeNextUnit(state: GSDState): { label: string; description: string } { +export function describeNextUnit(state: GSDState): { label: string; description: string } { const sid = state.activeSlice?.id; const sTitle = state.activeSlice?.title; const tid = state.activeTask?.id; const tTitle = state.activeTask?.title; switch (state.phase) { + case "needs-discussion": + return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." }; case "pre-planning": return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." }; case "planning": @@ -1528,6 +1530,19 @@ async function dispatchNextUnit( unitType = "reassess-roadmap"; unitId = `${mid}/${needsReassess.sliceId}`; prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath); + } else if (state.phase === "needs-discussion") { + // Draft milestone — pause auto-mode and notify user. + // This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion + // where the user chose "Needs own discussion". Auto-mode cannot proceed because + // the draft is seed material, not a finalized context — planning requires a + // dedicated discussion first. + await stopAuto(ctx, pi); + ctx.ui.notify( + `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`, + "warning", + ); + return; + } else if (state.phase === "pre-planning") { // Need roadmap — check if context exists const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 22f7080b6..b6614b13b 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -20,7 +20,7 @@ import { } from "./paths.js"; import { randomInt } from "node:crypto"; import { join } from "node:path"; -import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; @@ -55,6 +55,13 @@ export function checkAutoStartAfterDiscuss(): boolean { const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT"); if (!contextFile) return false; // no context yet — keep waiting + // 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 { + const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT"); + if (draftFile) unlinkSync(draftFile); + } catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ } + pendingAutoStart = null; startAuto(ctx, pi, basePath, false, { step }).catch(() => {}); return true; @@ -248,7 +255,7 @@ export async function showQueue( * Build a context block describing all existing milestones for the queue prompt. * Gives the LLM enough information to dedup, sequence, and dependency-check. */ -async function buildExistingMilestonesContext( +export async function buildExistingMilestonesContext( basePath: string, milestoneIds: string[], state: import("./types.js").GSDState, @@ -289,6 +296,15 @@ async function buildExistingMilestonesContext( if (content) { parts.push(`\n**Context:**\n${content.trim()}`); } + } else { + // No full CONTEXT.md — check for CONTEXT-DRAFT.md (draft seed from prior discussion) + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + if (draftFile) { + const draftContent = await loadFile(draftFile); + if (draftContent) { + parts.push(`\n**Draft context available:**\n${draftContent.trim()}`); + } + } } // For completed milestones, include the summary if it exists @@ -637,6 +653,62 @@ export async function showSmartEntry( return; } + // ── Draft milestone — needs discussion before planning ──────────────── + if (state.phase === "needs-discussion") { + const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT"); + const draftContent = draftFile ? await loadFile(draftFile) : null; + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${milestoneId}: ${milestoneTitle}`, + summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."], + actions: [ + { + id: "discuss_draft", + label: "Discuss from draft", + description: "Continue where the prior discussion left off — seed material is loaded automatically.", + recommended: true, + }, + { + id: "discuss_fresh", + label: "Start fresh discussion", + description: "Discard the draft and start a new discussion from scratch.", + }, + { + id: "skip_milestone", + label: "Skip — create new milestone", + description: "Leave this milestone as-is and start something new.", + }, + ], + notYetMessage: "Run /gsd when ready to discuss this milestone.", + }); + + if (choice === "discuss_draft") { + const basePrompt = loadPrompt("guided-discuss-milestone", { + milestoneId, milestoneTitle, + }); + const seed = draftContent + ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` + : basePrompt; + pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; + dispatchWorkflow(pi, seed, "gsd-discuss"); + } else if (choice === "discuss_fresh") { + pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; + dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + milestoneId, milestoneTitle, + }), "gsd-discuss"); + } else if (choice === "skip_milestone") { + const milestoneIds = findMilestoneIds(basePath); + const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; + dispatchWorkflow(pi, buildDiscussPrompt(nextId, + `New milestone ${nextId}.`, + basePath + )); + } + return; + } + // ── No active slice ────────────────────────────────────────────────── if (!state.activeSlice) { const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index 0d5690940..da26b814c 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -201,13 +201,34 @@ After writing the files and committing, say exactly: "Milestone {{milestoneId}} ### Multi-Milestone -Once the user confirms the milestone split, in a single pass: +Once the user confirms the milestone split: + +#### Phase 1: Shared artifacts + 1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone 2. Write `.gsd/PROJECT.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/project.md` first. 3. Write `.gsd/REQUIREMENTS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/requirements.md` first. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet. -4. Write a `CONTEXT.md` for **every** milestone — capture the intent, scope, risks, constraints, user-visible outcome, completion class, final integrated acceptance, and relevant requirements for each. Each future milestone's CONTEXT.md should be rich enough that a planning 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. -5. Write a `ROADMAP.md` for **only the first milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. -6. Seed `.gsd/DECISIONS.md`. +4. Seed `.gsd/DECISIONS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/decisions.md` first. + +#### Phase 2: Primary milestone + +5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth). +6. Write a `ROADMAP.md` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. + +#### Phase 3: Sequential readiness gate for remaining milestones + +For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options: + +- **"Discuss now"** — The user wants to conduct a focused discussion for this milestone in the current session, while the context from the broader discussion is still fresh. Proceed with a focused discussion for this milestone (reflection → investigation → questioning → depth verification). When the discussion concludes, write a full `CONTEXT.md`. Then move to the gate for the next milestone. +- **"Write draft for later"** — This milestone has seed material from the current conversation but needs its own dedicated discussion in a future session. Write a `CONTEXT-DRAFT.md` capturing the seed material (what was discussed, key ideas, provisional scope, open questions). Mark it clearly as a draft, not a finalized context. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user: "M00x has draft context — needs discussion. Run /gsd." The `/gsd` wizard shows a "Discuss from draft" option that seeds the new discussion with this draft, so nothing from the current conversation is lost. After the dedicated discussion produces a full CONTEXT.md, the draft file is automatically deleted. +- **"Just queue it"** — This milestone is identified but intentionally left without context. No context file is written — the directory already exists from Phase 1. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user to run /gsd. The wizard starts a full discussion from scratch. + +**Why sequential, not batch:** After writing the primary milestone's context and roadmap, the agent still has context window capacity. Asking one milestone at a time lets the user decide per-milestone whether to invest that remaining capacity in a focused discussion now, or defer to a future session. A batch question ("Ready/Draft/Queue for M002, M003, M004?") forces the user to decide everything upfront without knowing how much session capacity remains. + +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. + +#### Phase 4: Finalize + 7. Update `.gsd/STATE.md` 8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count) diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md index 407b59cdd..f309c2eb2 100644 --- a/src/resources/extensions/gsd/prompts/queue.md +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -1,5 +1,18 @@ {{preamble}} +## Draft Awareness + +Drafts are milestones that were identified during a prior multi-milestone discussion where the user chose "Needs own discussion" instead of "Ready for auto-planning." A `CONTEXT-DRAFT.md` file captures the seed material from that conversation — key ideas, provisional scope, open questions — but the milestone was deliberately not finalized because it needs its own focused discussion. + +Before asking "What do you want to add?", check the existing milestones context below. If any milestone is marked **"Draft context available"**, surface these drafts to the user first: + +1. Tell the user which milestones have draft contexts and briefly summarize what each draft contains (read the draft file). +2. Use `ask_user_questions` to ask per-draft milestone: + - **"Discuss now"** — Treat this draft as the primary topic. Read the draft content, use it as seed material, and conduct a focused discussion following the standard discussion flow (reflection → investigation → questioning → depth verification → requirements → roadmap). After the discussion, write the full CONTEXT.md and delete the `CONTEXT-DRAFT.md` file. The milestone is then ready for auto-planning. + - **"Leave for later"** — Keep the draft as-is. The user will discuss it in a future session. Auto-mode will continue to pause when it reaches this milestone. +3. Handle all draft discussions before proceeding to new queue work. +4. If no drafts exist in the context, skip this section entirely and proceed to "What do you want to add?" + Say exactly: "What do you want to add?" — nothing else. Wait for the user's answer. ## Discussion Phase diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 954b96aea..c50bd9320 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -65,6 +65,8 @@ export async function getActiveMilestoneId(basePath: string): Promise { let activeMilestone: ActiveRef | null = null; let activeRoadmap: Roadmap | null = null; let activeMilestoneFound = false; + let activeMilestoneHasDraft = false; for (const mid of milestoneIds) { const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); @@ -138,6 +141,13 @@ export async function deriveState(basePath: string): Promise { } // No roadmap and no summary — treat as incomplete/active if (!activeMilestoneFound) { + // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. + // A draft seed means the milestone has discussion material but no full context yet. + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + if (!contextFile) { + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + if (draftFile) activeMilestoneHasDraft = true; + } activeMilestone = { id: mid, title: mid }; activeMilestoneFound = true; registry.push({ id: mid, title: mid, status: 'active' }); @@ -235,15 +245,21 @@ export async function deriveState(basePath: string): Promise { } if (!activeRoadmap) { - // Active milestone exists but has no roadmap yet — needs planning + // Active milestone exists but has no roadmap yet. + // If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning. + // Otherwise, it's a blank milestone ready for initial planning. + const phase = activeMilestoneHasDraft ? 'needs-discussion' as const : 'pre-planning' as const; + const nextAction = activeMilestoneHasDraft + ? `Discuss draft context for milestone ${activeMilestone.id}.` + : `Plan milestone ${activeMilestone.id}.`; return { activeMilestone, activeSlice: null, activeTask: null, - phase: 'pre-planning', + phase, recentDecisions: [], blockers: [], - nextAction: `Plan milestone ${activeMilestone.id}.`, + nextAction, registry, requirements, progress: { diff --git a/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts new file mode 100644 index 000000000..01811aabe --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-draft-pause.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describeNextUnit } from "../auto.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +// ─── Test describeNextUnit with 'needs-discussion' phase ────────────────── + +const ndState = { + phase: "needs-discussion" as const, + activeMilestone: { id: "M007", title: "Future Milestone" }, + activeSlice: undefined, + activeTask: undefined, + milestoneRegistry: [], + nextAction: "", +}; + +const ndResult = describeNextUnit(ndState as any); +assert( + ndResult.label !== "Continue", + `needs-discussion label should not be default "Continue", got: "${ndResult.label}"`, +); +assert( + ndResult.label.toLowerCase().includes("draft") || ndResult.label.toLowerCase().includes("discuss"), + `needs-discussion label should mention "draft" or "discuss", got: "${ndResult.label}"`, +); +assert( + ndResult.description.toLowerCase().includes("discussion") || ndResult.description.toLowerCase().includes("draft"), + `needs-discussion description should mention "discussion" or "draft", got: "${ndResult.description}"`, +); + +// ─── Backward compatibility: pre-planning still works ────────────────────── + +const ppState = { + phase: "pre-planning" as const, + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: undefined, + activeTask: undefined, + milestoneRegistry: [], + nextAction: "", +}; + +const ppResult = describeNextUnit(ppState as any); +assert( + ppResult.label === "Research & plan milestone", + `pre-planning label should be "Research & plan milestone", got: "${ppResult.label}"`, +); + +// ─── Backward compatibility: executing still works ────────────────────────── + +const exState = { + phase: "executing" as const, + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: { id: "S01", title: "Test Slice" }, + activeTask: { id: "T01", title: "Test Task" }, + milestoneRegistry: [], + nextAction: "", +}; + +const exResult = describeNextUnit(exState as any); +assert( + exResult.label.includes("T01"), + `executing label should include task ID, got: "${exResult.label}"`, +); + +// ─── Static verification: needs-discussion in dispatchNextUnit ────────────── + +const autoSource = readFileSync( + join(import.meta.dirname, "..", "auto.ts"), + "utf-8", +); + +// Check describeNextUnit has the case +const hasDescribeCase = autoSource.includes('case "needs-discussion"'); +assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case"); + +// Check dispatchNextUnit has the branch +const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"'); +assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch"); + +// Check the dispatch branch calls stopAuto +const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"'); +const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600); +assert( + nextChunk.includes("stopAuto"), + "needs-discussion dispatch branch should call stopAuto", +); + +// Check notification includes /gsd guidance +assert( + nextChunk.includes("/gsd"), + "needs-discussion notification should tell user to run /gsd", +); + +// ─── Results ────────────────────────────────────────────────────────────── + +console.log(`\nauto-draft-pause: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/src/resources/extensions/gsd/tests/derive-state-draft.test.ts b/src/resources/extensions/gsd/tests/derive-state-draft.test.ts new file mode 100644 index 000000000..72b980a93 --- /dev/null +++ b/src/resources/extensions/gsd/tests/derive-state-draft.test.ts @@ -0,0 +1,299 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { deriveState } from '../state.js'; + +let passed = 0; +let failed = 0; + +function assertEq(actual: T, expected: T, message: string): void { + if (JSON.stringify(actual) === JSON.stringify(expected)) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-draft-test-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeContextDraft(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content); +} + +function writeContext(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-CONTEXT.md`), content); +} + +function writeRoadmap(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-ROADMAP.md`), content); +} + +function writePlan(base: string, mid: string, sid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid); + mkdirSync(join(dir, 'tasks'), { recursive: true }); + writeFileSync(join(dir, `${sid}-PLAN.md`), content); +} + +function writeMilestoneSummary(base: string, mid: string, content: string): void { + const dir = join(base, '.gsd', 'milestones', mid); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${mid}-SUMMARY.md`), content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Groups +// ═══════════════════════════════════════════════════════════════════════════ + +async function main(): Promise { + + // ─── Test 1: CONTEXT-DRAFT.md only → needs-discussion ────────────────── + console.log('\n=== CONTEXT-DRAFT.md only → needs-discussion ==='); + { + const base = createFixtureBase(); + try { + // M001 directory with only CONTEXT-DRAFT.md — no CONTEXT.md, no ROADMAP.md + writeContextDraft(base, 'M001', '# Draft Context\n\nSeed discussion material.'); + + const state = await deriveState(base); + + assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001'); + assertEq(state.activeSlice, null, 'activeSlice is null'); + assertEq(state.activeTask, null, 'activeTask is null'); + assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active'); + assertEq( + state.nextAction.includes('Discuss'), + true, + 'nextAction mentions Discuss' + ); + } finally { + cleanup(base); + } + } + + // ─── Test 2: CONTEXT.md only → pre-planning (unchanged) ─────────────── + console.log('\n=== CONTEXT.md only → pre-planning (unchanged) ==='); + { + const base = createFixtureBase(); + try { + // M001 directory with CONTEXT.md but no ROADMAP.md + writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady for planning.'); + + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning with CONTEXT.md'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001'); + assertEq(state.activeSlice, null, 'activeSlice is null'); + assertEq(state.activeTask, null, 'activeTask is null'); + assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active'); + } finally { + cleanup(base); + } + } + + // ─── Test 3: Both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ────── + console.log('\n=== both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ==='); + { + const base = createFixtureBase(); + try { + // M001 has both files — CONTEXT.md should take precedence + writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady.'); + writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored.'); + + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning when CONTEXT.md exists'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001'); + assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active'); + } finally { + cleanup(base); + } + } + + // ─── Test 4: M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ── + console.log('\n=== M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ==='); + { + const base = createFixtureBase(); + try { + // M001: complete (roadmap with all slices done + summary) + writeRoadmap(base, 'M001', `# M001: First Milestone + +**Vision:** Already done. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone complete.'); + + // M002: only CONTEXT-DRAFT.md + writeContextDraft(base, 'M002', '# Draft for M002\n\nSeed material.'); + + const state = await deriveState(base); + + assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002'); + assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone id is M002'); + assertEq(state.activeSlice, null, 'activeSlice is null'); + assertEq(state.registry.length, 2, 'registry has 2 entries'); + assertEq(state.registry[0]?.status, 'complete', 'M001 is complete'); + assertEq(state.registry[1]?.status, 'active', 'M002 is active'); + assertEq(state.progress?.milestones?.done, 1, 'milestones done = 1'); + assertEq(state.progress?.milestones?.total, 2, 'milestones total = 2'); + } finally { + cleanup(base); + } + } + + // ─── Test 5: Multi-milestone: M001 complete, M002 CONTEXT-DRAFT, M003 pending ── + console.log('\n=== multi-milestone: M001 complete, M002 draft, M003 pending ==='); + { + const base = createFixtureBase(); + try { + // M001: complete + writeRoadmap(base, 'M001', `# M001: First + +**Vision:** Done. + +## Slices + +- [x] **S01: Done** \`risk:low\` \`depends:[]\` + > After this: Done. +`); + writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nComplete.'); + + // M002: draft only — should become active with needs-discussion + writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.'); + + // M003: blank milestone directory — should be pending + mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true }); + + const state = await deriveState(base); + + assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002'); + assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone is M002'); + assertEq(state.registry.length, 3, 'registry has 3 entries'); + assertEq(state.registry[0]?.status, 'complete', 'M001 is complete'); + assertEq(state.registry[1]?.status, 'active', 'M002 is active'); + assertEq(state.registry[2]?.status, 'pending', 'M003 is pending'); + } finally { + cleanup(base); + } + } + + // ─── Test 6: Milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence ── + console.log('\n=== milestone with ROADMAP + CONTEXT-DRAFT → normal execution ==='); + { + const base = createFixtureBase(); + try { + // M001 has ROADMAP.md (active slice, incomplete tasks) and CONTEXT-DRAFT.md + // The ROADMAP should take precedence — we're past the draft phase + writeRoadmap(base, 'M001', `# M001: Active Milestone + +**Vision:** In progress. + +## Slices + +- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\` + > After this: First slice done. +`); + writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored — roadmap exists.'); + + // Add a plan so it goes to executing phase + writePlan(base, 'M001', 'S01', `# S01: First Slice + +**Goal:** Do something. + +## Tasks + +- [ ] **T01: First Task** \`est:30m\` +`); + + const state = await deriveState(base); + + assertEq(state.phase, 'executing', 'phase is executing (ROADMAP takes precedence over CONTEXT-DRAFT)'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001'); + assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01'); + assertEq(state.activeTask?.id, 'T01', 'activeTask is T01'); + } finally { + cleanup(base); + } + } + + // ─── Test 7: Empty milestone dir (no files at all) → pre-planning ───── + console.log('\n=== empty milestone dir (no files) → pre-planning ==='); + { + const base = createFixtureBase(); + try { + // M001: just a directory, no files at all + mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning for blank milestone'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001'); + assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active'); + } finally { + cleanup(base); + } + } + + // ─── Test 8: CONTEXT-DRAFT on non-first active milestone ────────────── + // M001 has no summary and no roadmap (active), M002 has CONTEXT-DRAFT + // M001 should be active (pre-planning), M002 should be pending + console.log('\n=== CONTEXT-DRAFT on non-active milestone → pending ==='); + { + const base = createFixtureBase(); + try { + // M001: blank (no roadmap, no summary) → becomes active first + mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + + // M002: has CONTEXT-DRAFT but isn't active (M001 is first) + writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.'); + + const state = await deriveState(base); + + assertEq(state.phase, 'pre-planning', 'phase is pre-planning (M001 is active, not M002)'); + assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001'); + assertEq(state.registry[0]?.status, 'active', 'M001 is active'); + assertEq(state.registry[1]?.status, 'pending', 'M002 is pending'); + } finally { + cleanup(base); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Summary + // ═══════════════════════════════════════════════════════════════════════════ + + console.log(`\n${'═'.repeat(60)}`); + console.log(`Draft-aware state derivation tests: ${passed} passed, ${failed} failed`); + console.log('═'.repeat(60)); + + if (failed > 0) { + process.exit(1); + } +} + +main().catch(err => { + console.error('Test suite error:', err); + process.exit(1); +}); diff --git a/src/resources/extensions/gsd/tests/draft-promotion.test.ts b/src/resources/extensions/gsd/tests/draft-promotion.test.ts new file mode 100644 index 000000000..0fb7160cd --- /dev/null +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -0,0 +1,165 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { deriveState } from "../state.js"; +import { resolveMilestoneFile } from "../paths.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +// ─── Full state transition: needs-discussion → pre-planning ───────────── + +console.log("=== Draft promotion: full state transition ==="); + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-test-")); +const gsd = join(tmpBase, ".gsd"); + +mkdirSync(join(gsd, "milestones", "M001"), { recursive: true }); + +// Step 1: Create CONTEXT-DRAFT.md only → needs-discussion +const draftPath = join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"); +writeFileSync(draftPath, "# M001: Draft\n\nSeed material.\n"); + +const state1 = await deriveState(tmpBase); +assert( + state1.phase === "needs-discussion", + `draft-only should be 'needs-discussion', got: "${state1.phase}"`, +); + +// Step 2: Write CONTEXT.md (simulating discussion output) → pre-planning +const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); +writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); + +const state2 = await deriveState(tmpBase); +assert( + state2.phase === "pre-planning", + `after CONTEXT.md written, should be 'pre-planning', got: "${state2.phase}"`, +); + +// Step 3: Simulate draft cleanup (what checkAutoStartAfterDiscuss does) +const resolvedDraft = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT"); +assert( + resolvedDraft !== null && resolvedDraft !== undefined, + "CONTEXT-DRAFT.md should still exist before cleanup", +); + +// Delete the draft (simulating the cleanup in checkAutoStartAfterDiscuss) +const { unlinkSync } = await import("node:fs"); +try { + if (resolvedDraft) unlinkSync(resolvedDraft); +} catch { /* non-fatal */ } + +assert( + !existsSync(draftPath), + "CONTEXT-DRAFT.md should be deleted after promotion cleanup", +); + +// Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) +const state3 = await deriveState(tmpBase); +assert( + state3.phase === "pre-planning", + `after cleanup, should still be 'pre-planning', got: "${state3.phase}"`, +); + +// ─── No-draft case: cleanup is a no-op ────────────────────────────────── + +console.log("=== No-draft cleanup: no-op ==="); + +const tmpBase2 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-noop-")); +const gsd2 = join(tmpBase2, ".gsd"); + +mkdirSync(join(gsd2, "milestones", "M001"), { recursive: true }); +writeFileSync( + join(gsd2, "milestones", "M001", "M001-CONTEXT.md"), + "# M001: Normal\n\nStandard discussion output.\n", +); + +// No CONTEXT-DRAFT.md exists — cleanup should be a no-op +const noDraft = resolveMilestoneFile(tmpBase2, "M001", "CONTEXT-DRAFT"); +assert( + noDraft === null || noDraft === undefined, + "no CONTEXT-DRAFT.md should exist for standard discussion milestone", +); + +// deriveState should return pre-planning normally +const state4 = await deriveState(tmpBase2); +assert( + state4.phase === "pre-planning", + `standard discussion milestone should be 'pre-planning', got: "${state4.phase}"`, +); + +// ─── Both files exist → CONTEXT.md wins, draft cleanup works ─────────── + +console.log("=== Both files: CONTEXT wins, draft cleanable ==="); + +const tmpBase3 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-both-")); +const gsd3 = join(tmpBase3, ".gsd"); + +mkdirSync(join(gsd3, "milestones", "M001"), { recursive: true }); +writeFileSync( + join(gsd3, "milestones", "M001", "M001-CONTEXT.md"), + "# M001: Full\n\nFull context.\n", +); +const bothDraftPath = join(gsd3, "milestones", "M001", "M001-CONTEXT-DRAFT.md"); +writeFileSync(bothDraftPath, "# M001: Draft\n\nStale draft.\n"); + +const state5 = await deriveState(tmpBase3); +assert( + state5.phase === "pre-planning", + `both files: CONTEXT.md wins, should be 'pre-planning', got: "${state5.phase}"`, +); + +// Cleanup the stale draft +const bothDraft = resolveMilestoneFile(tmpBase3, "M001", "CONTEXT-DRAFT"); +try { + if (bothDraft) unlinkSync(bothDraft); +} catch { /* non-fatal */ } + +assert( + !existsSync(bothDraftPath), + "stale CONTEXT-DRAFT.md should be deleted in both-files case", +); + +// ─── Static: guided-flow.ts has cleanup code ─────────────────────────── + +console.log("=== Static: cleanup code in guided-flow.ts ==="); + +const { readFileSync } = await import("node:fs"); +const guidedFlowSource = readFileSync( + join(import.meta.dirname, "..", "guided-flow.ts"), + "utf-8", +); + +const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss"); +const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200); + +assert( + checkFnChunk.includes("CONTEXT-DRAFT"), + "checkAutoStartAfterDiscuss should reference CONTEXT-DRAFT for cleanup", +); + +assert( + checkFnChunk.includes("unlinkSync"), + "checkAutoStartAfterDiscuss should use unlinkSync to delete the draft", +); + +// ─── Cleanup ────────────────────────────────────────────────────────── + +rmSync(tmpBase, { recursive: true, force: true }); +rmSync(tmpBase2, { recursive: true, force: true }); +rmSync(tmpBase3, { recursive: true, force: true }); + +// ─── Results ────────────────────────────────────────────────────────── + +console.log(`\ndraft-promotion: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts b/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts new file mode 100644 index 000000000..ff065c5e7 --- /dev/null +++ b/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { deriveState } from "../state.js"; +import { buildExistingMilestonesContext } from "../guided-flow.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +// ─── Fixture setup ────────────────────────────────────────────────────── + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-")); +const gsd = join(tmpBase, ".gsd"); + +// M001: has only CONTEXT-DRAFT.md (draft milestone) +mkdirSync(join(gsd, "milestones", "M001"), { recursive: true }); +writeFileSync( + join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + "# M001: Draft Milestone\n\nSeed material from prior discussion.\n", +); + +// M002: has full CONTEXT.md (ready milestone) +mkdirSync(join(gsd, "milestones", "M002"), { recursive: true }); +writeFileSync( + join(gsd, "milestones", "M002", "M002-CONTEXT.md"), + "# M002: Ready Milestone\n\nFull context from deep discussion.\n", +); + +// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins) +mkdirSync(join(gsd, "milestones", "M003"), { recursive: true }); +writeFileSync( + join(gsd, "milestones", "M003", "M003-CONTEXT.md"), + "# M003: Full Context\n\nThis is the real context.\n", +); +writeFileSync( + join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"), + "# M003: Draft\n\nThis should be ignored.\n", +); + +// M004: has neither (empty milestone dir) +mkdirSync(join(gsd, "milestones", "M004"), { recursive: true }); + +// ─── Build context ────────────────────────────────────────────────────── + +const state = await deriveState(tmpBase); +const milestoneIds = ["M001", "M002", "M003", "M004"]; +const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state); + +// ─── Test: draft-only milestone includes "Draft context available" ────── + +assert( + context.includes("Draft context available"), + "M001 (draft-only) should include 'Draft context available' label", +); + +assert( + context.includes("Seed material from prior discussion"), + "M001 draft content should be included in context output", +); + +// ─── Test: full-context milestone uses "Context:" label ──────────────── + +assert( + context.includes("**Context:**"), + "M002 (full context) should use 'Context:' label", +); + +assert( + context.includes("Full context from deep discussion"), + "M002 context content should be included", +); + +// ─── Test: both files → CONTEXT.md wins, no draft label ──────────────── + +// Find M003's section and check it has Context: but not Draft +const m003Idx = context.indexOf("M003:"); +const m003Section = context.slice(m003Idx, m003Idx + 500); + +assert( + m003Section.includes("**Context:**"), + "M003 (both files) should use 'Context:' label (CONTEXT.md wins)", +); + +assert( + !m003Section.includes("Draft context available"), + "M003 (both files) should NOT show draft label — CONTEXT.md takes precedence", +); + +assert( + m003Section.includes("This is the real context"), + "M003 should show CONTEXT.md content, not draft content", +); + +// ─── Test: neither file → no context section ─────────────────────────── + +const m004Idx = context.indexOf("M004:"); +const m004Section = context.slice(m004Idx, m004Idx + 500); + +assert( + !m004Section.includes("**Context:**"), + "M004 (neither file) should not have Context: label", +); + +assert( + !m004Section.includes("Draft context available"), + "M004 (neither file) should not have Draft label", +); + +// ─── Cleanup ────────────────────────────────────────────────────────── + +rmSync(tmpBase, { recursive: true, force: true }); + +// ─── Results ────────────────────────────────────────────────────────── + +console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts new file mode 100644 index 000000000..904819a9a --- /dev/null +++ b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { deriveState } from "../state.js"; +import { resolveMilestoneFile } from "../paths.js"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) { + passed++; + } else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +// ─── Fixture: milestone with only CONTEXT-DRAFT.md ────────────────────── + +const tmpBase = mkdtempSync(join(tmpdir(), "gsd-smart-entry-draft-test-")); +const gsd = join(tmpBase, ".gsd"); + +mkdirSync(join(gsd, "milestones", "M001"), { recursive: true }); + +const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`; +writeFileSync( + join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + draftContent, +); + +// ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ─── + +const state = await deriveState(tmpBase); + +assert( + state.phase === "needs-discussion", + `phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`, +); + +assert( + state.activeMilestone?.id === "M001", + `active milestone should be M001, got: "${state.activeMilestone?.id}"`, +); + +// ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ───────────────────── + +const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT"); + +assert( + draftFile !== null && draftFile !== undefined, + `resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`, +); + +assert( + draftFile!.endsWith("M001-CONTEXT-DRAFT.md"), + `resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`, +); + +// ─── Test: CONTEXT.md is NOT resolved (only draft exists) ────────────────── + +const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT"); + +assert( + contextFile === null || contextFile === undefined, + `resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`, +); + +// ─── Static: guided-flow.ts has 'needs-discussion' branch ───────────────── + +const guidedFlowSource = readFileSync( + join(import.meta.dirname, "..", "guided-flow.ts"), + "utf-8", +); + +assert( + guidedFlowSource.includes('state.phase === "needs-discussion"'), + "guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry", +); + +// Check the branch has draft-aware menu options +const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"'); +const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 3000); + +assert( + branchChunk.includes("discuss_draft"), + "needs-discussion branch should have 'discuss_draft' option", +); + +assert( + branchChunk.includes("discuss_fresh"), + "needs-discussion branch should have 'discuss_fresh' option", +); + +assert( + branchChunk.includes("skip_milestone"), + "needs-discussion branch should have 'skip_milestone' option", +); + +assert( + branchChunk.includes("CONTEXT-DRAFT"), + "needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile", +); + +assert( + branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"), + "discuss_draft path should include draft content as seed in the dispatched prompt", +); + +assert( + branchChunk.includes("return"), + "needs-discussion branch should return early (not fall through to generic no-roadmap menu)", +); + +// ─── Cleanup ────────────────────────────────────────────────────────────── + +rmSync(tmpBase, { recursive: true, force: true }); + +// ─── Results ────────────────────────────────────────────────────────────── + +console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 6ed114844..1985545c3 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -5,7 +5,7 @@ // ─── Enums & Literal Unions ──────────────────────────────────────────────── export type RiskLevel = 'low' | 'medium' | 'high'; -export type Phase = 'pre-planning' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked'; +export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked'; export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted'; // ─── Roadmap (Milestone-level) ─────────────────────────────────────────────