gsd-pi 2.10.11 → 2.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loader.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, resolve, join } from 'path';
4
- import { existsSync, readFileSync } from 'fs';
4
+ import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs';
5
5
  import { agentDir, appRoot } from './app-paths.js';
6
6
  import { renderLogo } from './logo.js';
7
7
  // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
@@ -90,5 +90,25 @@ process.env.GSD_BUNDLED_EXTENSION_PATHS = [
90
90
  // must set it here before any SDK clients are created.
91
91
  import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
92
92
  setGlobalDispatcher(new EnvHttpProxyAgent());
93
+ // Ensure workspace packages are linked before importing cli.js (which imports @gsd/*).
94
+ // npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.
95
+ const gsdScopeDir = join(gsdNodeModules, '@gsd');
96
+ const packagesDir = join(gsdRoot, 'packages');
97
+ const wsPackages = ['native', 'pi-agent-core', 'pi-ai', 'pi-coding-agent', 'pi-tui'];
98
+ try {
99
+ if (!existsSync(gsdScopeDir))
100
+ mkdirSync(gsdScopeDir, { recursive: true });
101
+ for (const pkg of wsPackages) {
102
+ const target = join(gsdScopeDir, pkg);
103
+ const source = join(packagesDir, pkg);
104
+ if (existsSync(source) && !existsSync(target)) {
105
+ try {
106
+ symlinkSync(source, target, 'junction');
107
+ }
108
+ catch { /* non-fatal */ }
109
+ }
110
+ }
111
+ }
112
+ catch { /* non-fatal */ }
93
113
  // Dynamic import defers ESM evaluation — config.js will see PI_PACKAGE_DIR above
94
114
  await import('./cli.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.10.11",
3
+ "version": "2.10.12",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -845,13 +845,15 @@ async function showStepWizard(
845
845
  /**
846
846
  * Describe what the next unit will be, based on current state.
847
847
  */
848
- function describeNextUnit(state: GSDState): { label: string; description: string } {
848
+ export function describeNextUnit(state: GSDState): { label: string; description: string } {
849
849
  const sid = state.activeSlice?.id;
850
850
  const sTitle = state.activeSlice?.title;
851
851
  const tid = state.activeTask?.id;
852
852
  const tTitle = state.activeTask?.title;
853
853
 
854
854
  switch (state.phase) {
855
+ case "needs-discussion":
856
+ return { label: "Discuss milestone draft", description: "Milestone has a draft context — needs discussion before planning." };
855
857
  case "pre-planning":
856
858
  return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
857
859
  case "planning":
@@ -1528,6 +1530,19 @@ async function dispatchNextUnit(
1528
1530
  unitType = "reassess-roadmap";
1529
1531
  unitId = `${mid}/${needsReassess.sliceId}`;
1530
1532
  prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
1533
+ } else if (state.phase === "needs-discussion") {
1534
+ // Draft milestone — pause auto-mode and notify user.
1535
+ // This milestone has a CONTEXT-DRAFT.md from a prior multi-milestone discussion
1536
+ // where the user chose "Needs own discussion". Auto-mode cannot proceed because
1537
+ // the draft is seed material, not a finalized context — planning requires a
1538
+ // dedicated discussion first.
1539
+ await stopAuto(ctx, pi);
1540
+ ctx.ui.notify(
1541
+ `${mid}: ${midTitle} has draft context from a prior discussion — needs its own discussion before planning.\nRun /gsd to discuss.`,
1542
+ "warning",
1543
+ );
1544
+ return;
1545
+
1531
1546
  } else if (state.phase === "pre-planning") {
1532
1547
  // Need roadmap — check if context exists
1533
1548
  const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
@@ -20,7 +20,7 @@ import {
20
20
  } from "./paths.js";
21
21
  import { randomInt } from "node:crypto";
22
22
  import { join } from "node:path";
23
- import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
23
+ import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
24
24
  import { execSync, execFileSync } from "node:child_process";
25
25
  import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
26
26
  import { loadEffectiveGSDPreferences } from "./preferences.js";
@@ -55,6 +55,13 @@ export function checkAutoStartAfterDiscuss(): boolean {
55
55
  const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
56
56
  if (!contextFile) return false; // no context yet — keep waiting
57
57
 
58
+ // Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
59
+ // CONTEXT.md, delete the draft — it's been consumed by the discussion.
60
+ try {
61
+ const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
62
+ if (draftFile) unlinkSync(draftFile);
63
+ } catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
64
+
58
65
  pendingAutoStart = null;
59
66
  startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
60
67
  return true;
@@ -248,7 +255,7 @@ export async function showQueue(
248
255
  * Build a context block describing all existing milestones for the queue prompt.
249
256
  * Gives the LLM enough information to dedup, sequence, and dependency-check.
250
257
  */
251
- async function buildExistingMilestonesContext(
258
+ export async function buildExistingMilestonesContext(
252
259
  basePath: string,
253
260
  milestoneIds: string[],
254
261
  state: import("./types.js").GSDState,
@@ -289,6 +296,15 @@ async function buildExistingMilestonesContext(
289
296
  if (content) {
290
297
  parts.push(`\n**Context:**\n${content.trim()}`);
291
298
  }
299
+ } else {
300
+ // No full CONTEXT.md — check for CONTEXT-DRAFT.md (draft seed from prior discussion)
301
+ const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
302
+ if (draftFile) {
303
+ const draftContent = await loadFile(draftFile);
304
+ if (draftContent) {
305
+ parts.push(`\n**Draft context available:**\n${draftContent.trim()}`);
306
+ }
307
+ }
292
308
  }
293
309
 
294
310
  // For completed milestones, include the summary if it exists
@@ -637,6 +653,62 @@ export async function showSmartEntry(
637
653
  return;
638
654
  }
639
655
 
656
+ // ── Draft milestone — needs discussion before planning ────────────────
657
+ if (state.phase === "needs-discussion") {
658
+ const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
659
+ const draftContent = draftFile ? await loadFile(draftFile) : null;
660
+
661
+ const choice = await showNextAction(ctx as any, {
662
+ title: `GSD — ${milestoneId}: ${milestoneTitle}`,
663
+ summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
664
+ actions: [
665
+ {
666
+ id: "discuss_draft",
667
+ label: "Discuss from draft",
668
+ description: "Continue where the prior discussion left off — seed material is loaded automatically.",
669
+ recommended: true,
670
+ },
671
+ {
672
+ id: "discuss_fresh",
673
+ label: "Start fresh discussion",
674
+ description: "Discard the draft and start a new discussion from scratch.",
675
+ },
676
+ {
677
+ id: "skip_milestone",
678
+ label: "Skip — create new milestone",
679
+ description: "Leave this milestone as-is and start something new.",
680
+ },
681
+ ],
682
+ notYetMessage: "Run /gsd when ready to discuss this milestone.",
683
+ });
684
+
685
+ if (choice === "discuss_draft") {
686
+ const basePrompt = loadPrompt("guided-discuss-milestone", {
687
+ milestoneId, milestoneTitle,
688
+ });
689
+ const seed = draftContent
690
+ ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
691
+ : basePrompt;
692
+ pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
693
+ dispatchWorkflow(pi, seed, "gsd-discuss");
694
+ } else if (choice === "discuss_fresh") {
695
+ pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
696
+ dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
697
+ milestoneId, milestoneTitle,
698
+ }), "gsd-discuss");
699
+ } else if (choice === "skip_milestone") {
700
+ const milestoneIds = findMilestoneIds(basePath);
701
+ const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
702
+ const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
703
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
704
+ dispatchWorkflow(pi, buildDiscussPrompt(nextId,
705
+ `New milestone ${nextId}.`,
706
+ basePath
707
+ ));
708
+ }
709
+ return;
710
+ }
711
+
640
712
  // ── No active slice ──────────────────────────────────────────────────
641
713
  if (!state.activeSlice) {
642
714
  const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
@@ -201,13 +201,34 @@ After writing the files and committing, say exactly: "Milestone {{milestoneId}}
201
201
 
202
202
  ### Multi-Milestone
203
203
 
204
- Once the user confirms the milestone split, in a single pass:
204
+ Once the user confirms the milestone split:
205
+
206
+ #### Phase 1: Shared artifacts
207
+
205
208
  1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone
206
209
  2. Write `.gsd/PROJECT.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/project.md` first.
207
210
  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.
208
- 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.
209
- 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.
210
- 6. Seed `.gsd/DECISIONS.md`.
211
+ 4. Seed `.gsd/DECISIONS.md` — read the template at `~/.gsd/agent/extensions/gsd/templates/decisions.md` first.
212
+
213
+ #### Phase 2: Primary milestone
214
+
215
+ 5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth).
216
+ 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.
217
+
218
+ #### Phase 3: Sequential readiness gate for remaining milestones
219
+
220
+ For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options:
221
+
222
+ - **"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.
223
+ - **"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.
224
+ - **"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.
225
+
226
+ **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.
227
+
228
+ 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.
229
+
230
+ #### Phase 4: Finalize
231
+
211
232
  7. Update `.gsd/STATE.md`
212
233
  8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count)
213
234
 
@@ -1,5 +1,18 @@
1
1
  {{preamble}}
2
2
 
3
+ ## Draft Awareness
4
+
5
+ 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.
6
+
7
+ 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:
8
+
9
+ 1. Tell the user which milestones have draft contexts and briefly summarize what each draft contains (read the draft file).
10
+ 2. Use `ask_user_questions` to ask per-draft milestone:
11
+ - **"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.
12
+ - **"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.
13
+ 3. Handle all draft discussions before proceeding to new queue work.
14
+ 4. If no drafts exist in the context, skip this section entirely and proceed to "What do you want to add?"
15
+
3
16
  Say exactly: "What do you want to add?" — nothing else. Wait for the user's answer.
4
17
 
5
18
  ## Discussion Phase
@@ -65,6 +65,8 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
65
65
  const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
66
66
  if (summaryFile) continue; // completed milestone, skip
67
67
  return mid; // No roadmap and no summary — milestone is incomplete
68
+ // Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here.
69
+ // A draft milestone is still "active" — this function only determines which milestone is current.
68
70
  }
69
71
  const roadmap = parseRoadmap(content);
70
72
  if (!isMilestoneComplete(roadmap)) return mid;
@@ -120,6 +122,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
120
122
  let activeMilestone: ActiveRef | null = null;
121
123
  let activeRoadmap: Roadmap | null = null;
122
124
  let activeMilestoneFound = false;
125
+ let activeMilestoneHasDraft = false;
123
126
 
124
127
  for (const mid of milestoneIds) {
125
128
  const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
@@ -138,6 +141,13 @@ export async function deriveState(basePath: string): Promise<GSDState> {
138
141
  }
139
142
  // No roadmap and no summary — treat as incomplete/active
140
143
  if (!activeMilestoneFound) {
144
+ // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones.
145
+ // A draft seed means the milestone has discussion material but no full context yet.
146
+ const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
147
+ if (!contextFile) {
148
+ const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
149
+ if (draftFile) activeMilestoneHasDraft = true;
150
+ }
141
151
  activeMilestone = { id: mid, title: mid };
142
152
  activeMilestoneFound = true;
143
153
  registry.push({ id: mid, title: mid, status: 'active' });
@@ -235,15 +245,21 @@ export async function deriveState(basePath: string): Promise<GSDState> {
235
245
  }
236
246
 
237
247
  if (!activeRoadmap) {
238
- // Active milestone exists but has no roadmap yet — needs planning
248
+ // Active milestone exists but has no roadmap yet.
249
+ // If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning.
250
+ // Otherwise, it's a blank milestone ready for initial planning.
251
+ const phase = activeMilestoneHasDraft ? 'needs-discussion' as const : 'pre-planning' as const;
252
+ const nextAction = activeMilestoneHasDraft
253
+ ? `Discuss draft context for milestone ${activeMilestone.id}.`
254
+ : `Plan milestone ${activeMilestone.id}.`;
239
255
  return {
240
256
  activeMilestone,
241
257
  activeSlice: null,
242
258
  activeTask: null,
243
- phase: 'pre-planning',
259
+ phase,
244
260
  recentDecisions: [],
245
261
  blockers: [],
246
- nextAction: `Plan milestone ${activeMilestone.id}.`,
262
+ nextAction,
247
263
  registry,
248
264
  requirements,
249
265
  progress: {
@@ -0,0 +1,109 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { describeNextUnit } from "../auto.js";
5
+
6
+ let passed = 0;
7
+ let failed = 0;
8
+
9
+ function assert(condition: boolean, message: string): void {
10
+ if (condition) {
11
+ passed++;
12
+ } else {
13
+ failed++;
14
+ console.error(` FAIL: ${message}`);
15
+ }
16
+ }
17
+
18
+ // ─── Test describeNextUnit with 'needs-discussion' phase ──────────────────
19
+
20
+ const ndState = {
21
+ phase: "needs-discussion" as const,
22
+ activeMilestone: { id: "M007", title: "Future Milestone" },
23
+ activeSlice: undefined,
24
+ activeTask: undefined,
25
+ milestoneRegistry: [],
26
+ nextAction: "",
27
+ };
28
+
29
+ const ndResult = describeNextUnit(ndState as any);
30
+ assert(
31
+ ndResult.label !== "Continue",
32
+ `needs-discussion label should not be default "Continue", got: "${ndResult.label}"`,
33
+ );
34
+ assert(
35
+ ndResult.label.toLowerCase().includes("draft") || ndResult.label.toLowerCase().includes("discuss"),
36
+ `needs-discussion label should mention "draft" or "discuss", got: "${ndResult.label}"`,
37
+ );
38
+ assert(
39
+ ndResult.description.toLowerCase().includes("discussion") || ndResult.description.toLowerCase().includes("draft"),
40
+ `needs-discussion description should mention "discussion" or "draft", got: "${ndResult.description}"`,
41
+ );
42
+
43
+ // ─── Backward compatibility: pre-planning still works ──────────────────────
44
+
45
+ const ppState = {
46
+ phase: "pre-planning" as const,
47
+ activeMilestone: { id: "M001", title: "Test" },
48
+ activeSlice: undefined,
49
+ activeTask: undefined,
50
+ milestoneRegistry: [],
51
+ nextAction: "",
52
+ };
53
+
54
+ const ppResult = describeNextUnit(ppState as any);
55
+ assert(
56
+ ppResult.label === "Research & plan milestone",
57
+ `pre-planning label should be "Research & plan milestone", got: "${ppResult.label}"`,
58
+ );
59
+
60
+ // ─── Backward compatibility: executing still works ──────────────────────────
61
+
62
+ const exState = {
63
+ phase: "executing" as const,
64
+ activeMilestone: { id: "M001", title: "Test" },
65
+ activeSlice: { id: "S01", title: "Test Slice" },
66
+ activeTask: { id: "T01", title: "Test Task" },
67
+ milestoneRegistry: [],
68
+ nextAction: "",
69
+ };
70
+
71
+ const exResult = describeNextUnit(exState as any);
72
+ assert(
73
+ exResult.label.includes("T01"),
74
+ `executing label should include task ID, got: "${exResult.label}"`,
75
+ );
76
+
77
+ // ─── Static verification: needs-discussion in dispatchNextUnit ──────────────
78
+
79
+ const autoSource = readFileSync(
80
+ join(import.meta.dirname, "..", "auto.ts"),
81
+ "utf-8",
82
+ );
83
+
84
+ // Check describeNextUnit has the case
85
+ const hasDescribeCase = autoSource.includes('case "needs-discussion"');
86
+ assert(hasDescribeCase, "auto.ts describeNextUnit should have 'needs-discussion' case");
87
+
88
+ // Check dispatchNextUnit has the branch
89
+ const hasDispatchBranch = autoSource.includes('state.phase === "needs-discussion"');
90
+ assert(hasDispatchBranch, "auto.ts dispatchNextUnit should have 'needs-discussion' branch");
91
+
92
+ // Check the dispatch branch calls stopAuto
93
+ const dispatchIdx = autoSource.indexOf('state.phase === "needs-discussion"');
94
+ const nextChunk = autoSource.slice(dispatchIdx, dispatchIdx + 600);
95
+ assert(
96
+ nextChunk.includes("stopAuto"),
97
+ "needs-discussion dispatch branch should call stopAuto",
98
+ );
99
+
100
+ // Check notification includes /gsd guidance
101
+ assert(
102
+ nextChunk.includes("/gsd"),
103
+ "needs-discussion notification should tell user to run /gsd",
104
+ );
105
+
106
+ // ─── Results ──────────────────────────────────────────────────────────────
107
+
108
+ console.log(`\nauto-draft-pause: ${passed} passed, ${failed} failed`);
109
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,299 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+
5
+ import { deriveState } from '../state.js';
6
+
7
+ let passed = 0;
8
+ let failed = 0;
9
+
10
+ function assertEq<T>(actual: T, expected: T, message: string): void {
11
+ if (JSON.stringify(actual) === JSON.stringify(expected)) {
12
+ passed++;
13
+ } else {
14
+ failed++;
15
+ console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
16
+ }
17
+ }
18
+
19
+ // ─── Fixture Helpers ───────────────────────────────────────────────────────
20
+
21
+ function createFixtureBase(): string {
22
+ const base = mkdtempSync(join(tmpdir(), 'gsd-draft-test-'));
23
+ mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
24
+ return base;
25
+ }
26
+
27
+ function writeContextDraft(base: string, mid: string, content: string): void {
28
+ const dir = join(base, '.gsd', 'milestones', mid);
29
+ mkdirSync(dir, { recursive: true });
30
+ writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content);
31
+ }
32
+
33
+ function writeContext(base: string, mid: string, content: string): void {
34
+ const dir = join(base, '.gsd', 'milestones', mid);
35
+ mkdirSync(dir, { recursive: true });
36
+ writeFileSync(join(dir, `${mid}-CONTEXT.md`), content);
37
+ }
38
+
39
+ function writeRoadmap(base: string, mid: string, content: string): void {
40
+ const dir = join(base, '.gsd', 'milestones', mid);
41
+ mkdirSync(dir, { recursive: true });
42
+ writeFileSync(join(dir, `${mid}-ROADMAP.md`), content);
43
+ }
44
+
45
+ function writePlan(base: string, mid: string, sid: string, content: string): void {
46
+ const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
47
+ mkdirSync(join(dir, 'tasks'), { recursive: true });
48
+ writeFileSync(join(dir, `${sid}-PLAN.md`), content);
49
+ }
50
+
51
+ function writeMilestoneSummary(base: string, mid: string, content: string): void {
52
+ const dir = join(base, '.gsd', 'milestones', mid);
53
+ mkdirSync(dir, { recursive: true });
54
+ writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
55
+ }
56
+
57
+ function cleanup(base: string): void {
58
+ rmSync(base, { recursive: true, force: true });
59
+ }
60
+
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+ // Test Groups
63
+ // ═══════════════════════════════════════════════════════════════════════════
64
+
65
+ async function main(): Promise<void> {
66
+
67
+ // ─── Test 1: CONTEXT-DRAFT.md only → needs-discussion ──────────────────
68
+ console.log('\n=== CONTEXT-DRAFT.md only → needs-discussion ===');
69
+ {
70
+ const base = createFixtureBase();
71
+ try {
72
+ // M001 directory with only CONTEXT-DRAFT.md — no CONTEXT.md, no ROADMAP.md
73
+ writeContextDraft(base, 'M001', '# Draft Context\n\nSeed discussion material.');
74
+
75
+ const state = await deriveState(base);
76
+
77
+ assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion');
78
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
79
+ assertEq(state.activeSlice, null, 'activeSlice is null');
80
+ assertEq(state.activeTask, null, 'activeTask is null');
81
+ assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
82
+ assertEq(
83
+ state.nextAction.includes('Discuss'),
84
+ true,
85
+ 'nextAction mentions Discuss'
86
+ );
87
+ } finally {
88
+ cleanup(base);
89
+ }
90
+ }
91
+
92
+ // ─── Test 2: CONTEXT.md only → pre-planning (unchanged) ───────────────
93
+ console.log('\n=== CONTEXT.md only → pre-planning (unchanged) ===');
94
+ {
95
+ const base = createFixtureBase();
96
+ try {
97
+ // M001 directory with CONTEXT.md but no ROADMAP.md
98
+ writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady for planning.');
99
+
100
+ const state = await deriveState(base);
101
+
102
+ assertEq(state.phase, 'pre-planning', 'phase is pre-planning with CONTEXT.md');
103
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
104
+ assertEq(state.activeSlice, null, 'activeSlice is null');
105
+ assertEq(state.activeTask, null, 'activeTask is null');
106
+ assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
107
+ } finally {
108
+ cleanup(base);
109
+ }
110
+ }
111
+
112
+ // ─── Test 3: Both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ──────
113
+ console.log('\n=== both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ===');
114
+ {
115
+ const base = createFixtureBase();
116
+ try {
117
+ // M001 has both files — CONTEXT.md should take precedence
118
+ writeContext(base, 'M001', '---\ntitle: Full Context\n---\n\n# Full Context\n\nReady.');
119
+ writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored.');
120
+
121
+ const state = await deriveState(base);
122
+
123
+ assertEq(state.phase, 'pre-planning', 'phase is pre-planning when CONTEXT.md exists');
124
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone id is M001');
125
+ assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
126
+ } finally {
127
+ cleanup(base);
128
+ }
129
+ }
130
+
131
+ // ─── Test 4: M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ──
132
+ console.log('\n=== M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ===');
133
+ {
134
+ const base = createFixtureBase();
135
+ try {
136
+ // M001: complete (roadmap with all slices done + summary)
137
+ writeRoadmap(base, 'M001', `# M001: First Milestone
138
+
139
+ **Vision:** Already done.
140
+
141
+ ## Slices
142
+
143
+ - [x] **S01: Done** \`risk:low\` \`depends:[]\`
144
+ > After this: Done.
145
+ `);
146
+ writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nFirst milestone complete.');
147
+
148
+ // M002: only CONTEXT-DRAFT.md
149
+ writeContextDraft(base, 'M002', '# Draft for M002\n\nSeed material.');
150
+
151
+ const state = await deriveState(base);
152
+
153
+ assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
154
+ assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone id is M002');
155
+ assertEq(state.activeSlice, null, 'activeSlice is null');
156
+ assertEq(state.registry.length, 2, 'registry has 2 entries');
157
+ assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
158
+ assertEq(state.registry[1]?.status, 'active', 'M002 is active');
159
+ assertEq(state.progress?.milestones?.done, 1, 'milestones done = 1');
160
+ assertEq(state.progress?.milestones?.total, 2, 'milestones total = 2');
161
+ } finally {
162
+ cleanup(base);
163
+ }
164
+ }
165
+
166
+ // ─── Test 5: Multi-milestone: M001 complete, M002 CONTEXT-DRAFT, M003 pending ──
167
+ console.log('\n=== multi-milestone: M001 complete, M002 draft, M003 pending ===');
168
+ {
169
+ const base = createFixtureBase();
170
+ try {
171
+ // M001: complete
172
+ writeRoadmap(base, 'M001', `# M001: First
173
+
174
+ **Vision:** Done.
175
+
176
+ ## Slices
177
+
178
+ - [x] **S01: Done** \`risk:low\` \`depends:[]\`
179
+ > After this: Done.
180
+ `);
181
+ writeMilestoneSummary(base, 'M001', '# M001 Summary\n\nComplete.');
182
+
183
+ // M002: draft only — should become active with needs-discussion
184
+ writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
185
+
186
+ // M003: blank milestone directory — should be pending
187
+ mkdirSync(join(base, '.gsd', 'milestones', 'M003'), { recursive: true });
188
+
189
+ const state = await deriveState(base);
190
+
191
+ assertEq(state.phase, 'needs-discussion', 'phase is needs-discussion for M002');
192
+ assertEq(state.activeMilestone?.id, 'M002', 'activeMilestone is M002');
193
+ assertEq(state.registry.length, 3, 'registry has 3 entries');
194
+ assertEq(state.registry[0]?.status, 'complete', 'M001 is complete');
195
+ assertEq(state.registry[1]?.status, 'active', 'M002 is active');
196
+ assertEq(state.registry[2]?.status, 'pending', 'M003 is pending');
197
+ } finally {
198
+ cleanup(base);
199
+ }
200
+ }
201
+
202
+ // ─── Test 6: Milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence ──
203
+ console.log('\n=== milestone with ROADMAP + CONTEXT-DRAFT → normal execution ===');
204
+ {
205
+ const base = createFixtureBase();
206
+ try {
207
+ // M001 has ROADMAP.md (active slice, incomplete tasks) and CONTEXT-DRAFT.md
208
+ // The ROADMAP should take precedence — we're past the draft phase
209
+ writeRoadmap(base, 'M001', `# M001: Active Milestone
210
+
211
+ **Vision:** In progress.
212
+
213
+ ## Slices
214
+
215
+ - [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
216
+ > After this: First slice done.
217
+ `);
218
+ writeContextDraft(base, 'M001', '# Draft\n\nThis should be ignored — roadmap exists.');
219
+
220
+ // Add a plan so it goes to executing phase
221
+ writePlan(base, 'M001', 'S01', `# S01: First Slice
222
+
223
+ **Goal:** Do something.
224
+
225
+ ## Tasks
226
+
227
+ - [ ] **T01: First Task** \`est:30m\`
228
+ `);
229
+
230
+ const state = await deriveState(base);
231
+
232
+ assertEq(state.phase, 'executing', 'phase is executing (ROADMAP takes precedence over CONTEXT-DRAFT)');
233
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
234
+ assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01');
235
+ assertEq(state.activeTask?.id, 'T01', 'activeTask is T01');
236
+ } finally {
237
+ cleanup(base);
238
+ }
239
+ }
240
+
241
+ // ─── Test 7: Empty milestone dir (no files at all) → pre-planning ─────
242
+ console.log('\n=== empty milestone dir (no files) → pre-planning ===');
243
+ {
244
+ const base = createFixtureBase();
245
+ try {
246
+ // M001: just a directory, no files at all
247
+ mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
248
+
249
+ const state = await deriveState(base);
250
+
251
+ assertEq(state.phase, 'pre-planning', 'phase is pre-planning for blank milestone');
252
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
253
+ assertEq(state.registry[0]?.status, 'active', 'registry[0] status is active');
254
+ } finally {
255
+ cleanup(base);
256
+ }
257
+ }
258
+
259
+ // ─── Test 8: CONTEXT-DRAFT on non-first active milestone ──────────────
260
+ // M001 has no summary and no roadmap (active), M002 has CONTEXT-DRAFT
261
+ // M001 should be active (pre-planning), M002 should be pending
262
+ console.log('\n=== CONTEXT-DRAFT on non-active milestone → pending ===');
263
+ {
264
+ const base = createFixtureBase();
265
+ try {
266
+ // M001: blank (no roadmap, no summary) → becomes active first
267
+ mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
268
+
269
+ // M002: has CONTEXT-DRAFT but isn't active (M001 is first)
270
+ writeContextDraft(base, 'M002', '# M002 Draft\n\nSeed.');
271
+
272
+ const state = await deriveState(base);
273
+
274
+ assertEq(state.phase, 'pre-planning', 'phase is pre-planning (M001 is active, not M002)');
275
+ assertEq(state.activeMilestone?.id, 'M001', 'activeMilestone is M001');
276
+ assertEq(state.registry[0]?.status, 'active', 'M001 is active');
277
+ assertEq(state.registry[1]?.status, 'pending', 'M002 is pending');
278
+ } finally {
279
+ cleanup(base);
280
+ }
281
+ }
282
+
283
+ // ═══════════════════════════════════════════════════════════════════════════
284
+ // Summary
285
+ // ═══════════════════════════════════════════════════════════════════════════
286
+
287
+ console.log(`\n${'═'.repeat(60)}`);
288
+ console.log(`Draft-aware state derivation tests: ${passed} passed, ${failed} failed`);
289
+ console.log('═'.repeat(60));
290
+
291
+ if (failed > 0) {
292
+ process.exit(1);
293
+ }
294
+ }
295
+
296
+ main().catch(err => {
297
+ console.error('Test suite error:', err);
298
+ process.exit(1);
299
+ });
@@ -0,0 +1,165 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import { deriveState } from "../state.js";
6
+ import { resolveMilestoneFile } from "../paths.js";
7
+
8
+ let passed = 0;
9
+ let failed = 0;
10
+
11
+ function assert(condition: boolean, message: string): void {
12
+ if (condition) {
13
+ passed++;
14
+ } else {
15
+ failed++;
16
+ console.error(` FAIL: ${message}`);
17
+ }
18
+ }
19
+
20
+ // ─── Full state transition: needs-discussion → pre-planning ─────────────
21
+
22
+ console.log("=== Draft promotion: full state transition ===");
23
+
24
+ const tmpBase = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-test-"));
25
+ const gsd = join(tmpBase, ".gsd");
26
+
27
+ mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
28
+
29
+ // Step 1: Create CONTEXT-DRAFT.md only → needs-discussion
30
+ const draftPath = join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
31
+ writeFileSync(draftPath, "# M001: Draft\n\nSeed material.\n");
32
+
33
+ const state1 = await deriveState(tmpBase);
34
+ assert(
35
+ state1.phase === "needs-discussion",
36
+ `draft-only should be 'needs-discussion', got: "${state1.phase}"`,
37
+ );
38
+
39
+ // Step 2: Write CONTEXT.md (simulating discussion output) → pre-planning
40
+ const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md");
41
+ writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n");
42
+
43
+ const state2 = await deriveState(tmpBase);
44
+ assert(
45
+ state2.phase === "pre-planning",
46
+ `after CONTEXT.md written, should be 'pre-planning', got: "${state2.phase}"`,
47
+ );
48
+
49
+ // Step 3: Simulate draft cleanup (what checkAutoStartAfterDiscuss does)
50
+ const resolvedDraft = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
51
+ assert(
52
+ resolvedDraft !== null && resolvedDraft !== undefined,
53
+ "CONTEXT-DRAFT.md should still exist before cleanup",
54
+ );
55
+
56
+ // Delete the draft (simulating the cleanup in checkAutoStartAfterDiscuss)
57
+ const { unlinkSync } = await import("node:fs");
58
+ try {
59
+ if (resolvedDraft) unlinkSync(resolvedDraft);
60
+ } catch { /* non-fatal */ }
61
+
62
+ assert(
63
+ !existsSync(draftPath),
64
+ "CONTEXT-DRAFT.md should be deleted after promotion cleanup",
65
+ );
66
+
67
+ // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists)
68
+ const state3 = await deriveState(tmpBase);
69
+ assert(
70
+ state3.phase === "pre-planning",
71
+ `after cleanup, should still be 'pre-planning', got: "${state3.phase}"`,
72
+ );
73
+
74
+ // ─── No-draft case: cleanup is a no-op ──────────────────────────────────
75
+
76
+ console.log("=== No-draft cleanup: no-op ===");
77
+
78
+ const tmpBase2 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-noop-"));
79
+ const gsd2 = join(tmpBase2, ".gsd");
80
+
81
+ mkdirSync(join(gsd2, "milestones", "M001"), { recursive: true });
82
+ writeFileSync(
83
+ join(gsd2, "milestones", "M001", "M001-CONTEXT.md"),
84
+ "# M001: Normal\n\nStandard discussion output.\n",
85
+ );
86
+
87
+ // No CONTEXT-DRAFT.md exists — cleanup should be a no-op
88
+ const noDraft = resolveMilestoneFile(tmpBase2, "M001", "CONTEXT-DRAFT");
89
+ assert(
90
+ noDraft === null || noDraft === undefined,
91
+ "no CONTEXT-DRAFT.md should exist for standard discussion milestone",
92
+ );
93
+
94
+ // deriveState should return pre-planning normally
95
+ const state4 = await deriveState(tmpBase2);
96
+ assert(
97
+ state4.phase === "pre-planning",
98
+ `standard discussion milestone should be 'pre-planning', got: "${state4.phase}"`,
99
+ );
100
+
101
+ // ─── Both files exist → CONTEXT.md wins, draft cleanup works ───────────
102
+
103
+ console.log("=== Both files: CONTEXT wins, draft cleanable ===");
104
+
105
+ const tmpBase3 = mkdtempSync(join(tmpdir(), "gsd-draft-promotion-both-"));
106
+ const gsd3 = join(tmpBase3, ".gsd");
107
+
108
+ mkdirSync(join(gsd3, "milestones", "M001"), { recursive: true });
109
+ writeFileSync(
110
+ join(gsd3, "milestones", "M001", "M001-CONTEXT.md"),
111
+ "# M001: Full\n\nFull context.\n",
112
+ );
113
+ const bothDraftPath = join(gsd3, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
114
+ writeFileSync(bothDraftPath, "# M001: Draft\n\nStale draft.\n");
115
+
116
+ const state5 = await deriveState(tmpBase3);
117
+ assert(
118
+ state5.phase === "pre-planning",
119
+ `both files: CONTEXT.md wins, should be 'pre-planning', got: "${state5.phase}"`,
120
+ );
121
+
122
+ // Cleanup the stale draft
123
+ const bothDraft = resolveMilestoneFile(tmpBase3, "M001", "CONTEXT-DRAFT");
124
+ try {
125
+ if (bothDraft) unlinkSync(bothDraft);
126
+ } catch { /* non-fatal */ }
127
+
128
+ assert(
129
+ !existsSync(bothDraftPath),
130
+ "stale CONTEXT-DRAFT.md should be deleted in both-files case",
131
+ );
132
+
133
+ // ─── Static: guided-flow.ts has cleanup code ───────────────────────────
134
+
135
+ console.log("=== Static: cleanup code in guided-flow.ts ===");
136
+
137
+ const { readFileSync } = await import("node:fs");
138
+ const guidedFlowSource = readFileSync(
139
+ join(import.meta.dirname, "..", "guided-flow.ts"),
140
+ "utf-8",
141
+ );
142
+
143
+ const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
144
+ const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200);
145
+
146
+ assert(
147
+ checkFnChunk.includes("CONTEXT-DRAFT"),
148
+ "checkAutoStartAfterDiscuss should reference CONTEXT-DRAFT for cleanup",
149
+ );
150
+
151
+ assert(
152
+ checkFnChunk.includes("unlinkSync"),
153
+ "checkAutoStartAfterDiscuss should use unlinkSync to delete the draft",
154
+ );
155
+
156
+ // ─── Cleanup ──────────────────────────────────────────────────────────
157
+
158
+ rmSync(tmpBase, { recursive: true, force: true });
159
+ rmSync(tmpBase2, { recursive: true, force: true });
160
+ rmSync(tmpBase3, { recursive: true, force: true });
161
+
162
+ // ─── Results ──────────────────────────────────────────────────────────
163
+
164
+ console.log(`\ndraft-promotion: ${passed} passed, ${failed} failed`);
165
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,126 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import { deriveState } from "../state.js";
6
+ import { buildExistingMilestonesContext } from "../guided-flow.js";
7
+
8
+ let passed = 0;
9
+ let failed = 0;
10
+
11
+ function assert(condition: boolean, message: string): void {
12
+ if (condition) {
13
+ passed++;
14
+ } else {
15
+ failed++;
16
+ console.error(` FAIL: ${message}`);
17
+ }
18
+ }
19
+
20
+ // ─── Fixture setup ──────────────────────────────────────────────────────
21
+
22
+ const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-"));
23
+ const gsd = join(tmpBase, ".gsd");
24
+
25
+ // M001: has only CONTEXT-DRAFT.md (draft milestone)
26
+ mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
27
+ writeFileSync(
28
+ join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
29
+ "# M001: Draft Milestone\n\nSeed material from prior discussion.\n",
30
+ );
31
+
32
+ // M002: has full CONTEXT.md (ready milestone)
33
+ mkdirSync(join(gsd, "milestones", "M002"), { recursive: true });
34
+ writeFileSync(
35
+ join(gsd, "milestones", "M002", "M002-CONTEXT.md"),
36
+ "# M002: Ready Milestone\n\nFull context from deep discussion.\n",
37
+ );
38
+
39
+ // M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins)
40
+ mkdirSync(join(gsd, "milestones", "M003"), { recursive: true });
41
+ writeFileSync(
42
+ join(gsd, "milestones", "M003", "M003-CONTEXT.md"),
43
+ "# M003: Full Context\n\nThis is the real context.\n",
44
+ );
45
+ writeFileSync(
46
+ join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"),
47
+ "# M003: Draft\n\nThis should be ignored.\n",
48
+ );
49
+
50
+ // M004: has neither (empty milestone dir)
51
+ mkdirSync(join(gsd, "milestones", "M004"), { recursive: true });
52
+
53
+ // ─── Build context ──────────────────────────────────────────────────────
54
+
55
+ const state = await deriveState(tmpBase);
56
+ const milestoneIds = ["M001", "M002", "M003", "M004"];
57
+ const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state);
58
+
59
+ // ─── Test: draft-only milestone includes "Draft context available" ──────
60
+
61
+ assert(
62
+ context.includes("Draft context available"),
63
+ "M001 (draft-only) should include 'Draft context available' label",
64
+ );
65
+
66
+ assert(
67
+ context.includes("Seed material from prior discussion"),
68
+ "M001 draft content should be included in context output",
69
+ );
70
+
71
+ // ─── Test: full-context milestone uses "Context:" label ────────────────
72
+
73
+ assert(
74
+ context.includes("**Context:**"),
75
+ "M002 (full context) should use 'Context:' label",
76
+ );
77
+
78
+ assert(
79
+ context.includes("Full context from deep discussion"),
80
+ "M002 context content should be included",
81
+ );
82
+
83
+ // ─── Test: both files → CONTEXT.md wins, no draft label ────────────────
84
+
85
+ // Find M003's section and check it has Context: but not Draft
86
+ const m003Idx = context.indexOf("M003:");
87
+ const m003Section = context.slice(m003Idx, m003Idx + 500);
88
+
89
+ assert(
90
+ m003Section.includes("**Context:**"),
91
+ "M003 (both files) should use 'Context:' label (CONTEXT.md wins)",
92
+ );
93
+
94
+ assert(
95
+ !m003Section.includes("Draft context available"),
96
+ "M003 (both files) should NOT show draft label — CONTEXT.md takes precedence",
97
+ );
98
+
99
+ assert(
100
+ m003Section.includes("This is the real context"),
101
+ "M003 should show CONTEXT.md content, not draft content",
102
+ );
103
+
104
+ // ─── Test: neither file → no context section ───────────────────────────
105
+
106
+ const m004Idx = context.indexOf("M004:");
107
+ const m004Section = context.slice(m004Idx, m004Idx + 500);
108
+
109
+ assert(
110
+ !m004Section.includes("**Context:**"),
111
+ "M004 (neither file) should not have Context: label",
112
+ );
113
+
114
+ assert(
115
+ !m004Section.includes("Draft context available"),
116
+ "M004 (neither file) should not have Draft label",
117
+ );
118
+
119
+ // ─── Cleanup ──────────────────────────────────────────────────────────
120
+
121
+ rmSync(tmpBase, { recursive: true, force: true });
122
+
123
+ // ─── Results ──────────────────────────────────────────────────────────
124
+
125
+ console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`);
126
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,123 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+
5
+ import { deriveState } from "../state.js";
6
+ import { resolveMilestoneFile } from "../paths.js";
7
+
8
+ let passed = 0;
9
+ let failed = 0;
10
+
11
+ function assert(condition: boolean, message: string): void {
12
+ if (condition) {
13
+ passed++;
14
+ } else {
15
+ failed++;
16
+ console.error(` FAIL: ${message}`);
17
+ }
18
+ }
19
+
20
+ // ─── Fixture: milestone with only CONTEXT-DRAFT.md ──────────────────────
21
+
22
+ const tmpBase = mkdtempSync(join(tmpdir(), "gsd-smart-entry-draft-test-"));
23
+ const gsd = join(tmpBase, ".gsd");
24
+
25
+ mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
26
+
27
+ const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`;
28
+ writeFileSync(
29
+ join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
30
+ draftContent,
31
+ );
32
+
33
+ // ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ───
34
+
35
+ const state = await deriveState(tmpBase);
36
+
37
+ assert(
38
+ state.phase === "needs-discussion",
39
+ `phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`,
40
+ );
41
+
42
+ assert(
43
+ state.activeMilestone?.id === "M001",
44
+ `active milestone should be M001, got: "${state.activeMilestone?.id}"`,
45
+ );
46
+
47
+ // ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ─────────────────────
48
+
49
+ const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
50
+
51
+ assert(
52
+ draftFile !== null && draftFile !== undefined,
53
+ `resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`,
54
+ );
55
+
56
+ assert(
57
+ draftFile!.endsWith("M001-CONTEXT-DRAFT.md"),
58
+ `resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`,
59
+ );
60
+
61
+ // ─── Test: CONTEXT.md is NOT resolved (only draft exists) ──────────────────
62
+
63
+ const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT");
64
+
65
+ assert(
66
+ contextFile === null || contextFile === undefined,
67
+ `resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`,
68
+ );
69
+
70
+ // ─── Static: guided-flow.ts has 'needs-discussion' branch ─────────────────
71
+
72
+ const guidedFlowSource = readFileSync(
73
+ join(import.meta.dirname, "..", "guided-flow.ts"),
74
+ "utf-8",
75
+ );
76
+
77
+ assert(
78
+ guidedFlowSource.includes('state.phase === "needs-discussion"'),
79
+ "guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry",
80
+ );
81
+
82
+ // Check the branch has draft-aware menu options
83
+ const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"');
84
+ const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 3000);
85
+
86
+ assert(
87
+ branchChunk.includes("discuss_draft"),
88
+ "needs-discussion branch should have 'discuss_draft' option",
89
+ );
90
+
91
+ assert(
92
+ branchChunk.includes("discuss_fresh"),
93
+ "needs-discussion branch should have 'discuss_fresh' option",
94
+ );
95
+
96
+ assert(
97
+ branchChunk.includes("skip_milestone"),
98
+ "needs-discussion branch should have 'skip_milestone' option",
99
+ );
100
+
101
+ assert(
102
+ branchChunk.includes("CONTEXT-DRAFT"),
103
+ "needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile",
104
+ );
105
+
106
+ assert(
107
+ branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"),
108
+ "discuss_draft path should include draft content as seed in the dispatched prompt",
109
+ );
110
+
111
+ assert(
112
+ branchChunk.includes("return"),
113
+ "needs-discussion branch should return early (not fall through to generic no-roadmap menu)",
114
+ );
115
+
116
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
117
+
118
+ rmSync(tmpBase, { recursive: true, force: true });
119
+
120
+ // ─── Results ──────────────────────────────────────────────────────────────
121
+
122
+ console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`);
123
+ if (failed > 0) process.exit(1);
@@ -5,7 +5,7 @@
5
5
  // ─── Enums & Literal Unions ────────────────────────────────────────────────
6
6
 
7
7
  export type RiskLevel = 'low' | 'medium' | 'high';
8
- export type Phase = 'pre-planning' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
8
+ export type Phase = 'pre-planning' | 'needs-discussion' | 'discussing' | 'researching' | 'planning' | 'executing' | 'verifying' | 'summarizing' | 'advancing' | 'completing-milestone' | 'replanning-slice' | 'complete' | 'paused' | 'blocked';
9
9
  export type ContinueStatus = 'in_progress' | 'interrupted' | 'compacted';
10
10
 
11
11
  // ─── Roadmap (Milestone-level) ─────────────────────────────────────────────