feat: multi-milestone readiness flow with per-milestone discussion gate (#377)

This commit is contained in:
deseltrus 2026-03-14 18:26:30 +01:00 committed by GitHub
parent a4a909aaf5
commit 8a64ac054c
11 changed files with 970 additions and 11 deletions

View file

@ -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");

View file

@ -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");

View file

@ -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)

View file

@ -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

View file

@ -65,6 +65,8 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
if (summaryFile) continue; // completed milestone, skip
return mid; // No roadmap and no summary — milestone is incomplete
// Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here.
// A draft milestone is still "active" — this function only determines which milestone is current.
}
const roadmap = parseRoadmap(content);
if (!isMilestoneComplete(roadmap)) return mid;
@ -120,6 +122,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
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<GSDState> {
}
// 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<GSDState> {
}
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: {

View file

@ -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);

View file

@ -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<T>(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<void> {
// ─── 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);
});

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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) ─────────────────────────────────────────────