gsd-pi 2.13.0 → 2.14.0

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.
Files changed (99) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +1 -0
  3. package/dist/loader.js +50 -6
  4. package/dist/resource-loader.d.ts +7 -6
  5. package/dist/resource-loader.js +15 -8
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
  7. package/dist/resources/extensions/gsd/auto.ts +252 -370
  8. package/dist/resources/extensions/gsd/commands.ts +118 -34
  9. package/dist/resources/extensions/gsd/doctor.ts +29 -4
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
  11. package/dist/resources/extensions/gsd/git-service.ts +8 -431
  12. package/dist/resources/extensions/gsd/gitignore.ts +11 -4
  13. package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
  14. package/dist/resources/extensions/gsd/preferences.ts +18 -17
  15. package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
  16. package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
  17. package/dist/resources/extensions/gsd/state.ts +26 -8
  18. package/dist/resources/extensions/gsd/templates/state.md +0 -1
  19. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  20. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  23. package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  24. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  25. package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  26. package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  27. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  28. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  29. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  30. package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  31. package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  32. package/dist/resources/extensions/gsd/types.ts +0 -1
  33. package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
  34. package/dist/resources/extensions/gsd/worktree.ts +7 -65
  35. package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  36. package/package.json +1 -1
  37. package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
  38. package/packages/pi-ai/dist/providers/google.js +12 -4
  39. package/packages/pi-ai/dist/providers/google.js.map +1 -1
  40. package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
  41. package/packages/pi-ai/dist/providers/mistral.js +10 -2
  42. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  43. package/packages/pi-ai/src/providers/google.ts +20 -8
  44. package/packages/pi-ai/src/providers/mistral.ts +14 -2
  45. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
  46. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
  57. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
  58. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
  59. package/packages/pi-tui/dist/components/input.d.ts +1 -0
  60. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  61. package/packages/pi-tui/dist/components/input.js +10 -0
  62. package/packages/pi-tui/dist/components/input.js.map +1 -1
  63. package/packages/pi-tui/src/components/input.ts +11 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
  65. package/src/resources/extensions/gsd/auto.ts +252 -370
  66. package/src/resources/extensions/gsd/commands.ts +118 -34
  67. package/src/resources/extensions/gsd/doctor.ts +29 -4
  68. package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
  69. package/src/resources/extensions/gsd/git-service.ts +8 -431
  70. package/src/resources/extensions/gsd/gitignore.ts +11 -4
  71. package/src/resources/extensions/gsd/guided-flow.ts +141 -5
  72. package/src/resources/extensions/gsd/preferences.ts +18 -17
  73. package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
  74. package/src/resources/extensions/gsd/prompts/queue.md +7 -1
  75. package/src/resources/extensions/gsd/state.ts +26 -8
  76. package/src/resources/extensions/gsd/templates/state.md +0 -1
  77. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  78. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  79. package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  80. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  81. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  82. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  84. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  85. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  86. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  87. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  88. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  89. package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  90. package/src/resources/extensions/gsd/types.ts +0 -1
  91. package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
  92. package/src/resources/extensions/gsd/worktree.ts +7 -65
  93. package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  94. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  95. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  96. package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
  97. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  98. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  99. package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
@@ -50,13 +50,76 @@ export function checkAutoStartAfterDiscuss(): boolean {
50
50
 
51
51
  const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
52
52
 
53
- // Don't fire until the discuss phase has actually produced a context file
54
- // for the milestone being discussed. agent_end fires after every LLM turn,
55
- // including the initial "What do you want to build?" response — we need to
56
- // wait for the full conversation to complete and the LLM to write CONTEXT.md.
53
+ // Gate 1: Primary milestone must have CONTEXT.md
57
54
  const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
58
55
  if (!contextFile) return false; // no context yet — keep waiting
59
56
 
57
+ // Gate 2: STATE.md must exist — written as the last step in the discuss
58
+ // output phase. This prevents auto-start from firing during Phase 3
59
+ // (sequential readiness gates for remaining milestones) in multi-milestone
60
+ // discussions, where M001-CONTEXT.md exists but M002/M003 haven't been
61
+ // processed yet.
62
+ const stateFile = resolveGsdRootFile(basePath, "STATE");
63
+ if (!stateFile) return false; // discussion not finalized yet
64
+
65
+ // Gate 3: Multi-milestone completeness warning
66
+ // Parse PROJECT.md for milestone sequence, warn if any are missing context.
67
+ // Don't block — milestones can be intentionally queued without context.
68
+ const projectFile = resolveGsdRootFile(basePath, "PROJECT");
69
+ if (projectFile) {
70
+ try {
71
+ const projectContent = readFileSync(projectFile, "utf-8");
72
+ const milestoneIds = parseMilestoneSequenceFromProject(projectContent);
73
+ if (milestoneIds.length > 1) {
74
+ const missing = milestoneIds.filter(id => {
75
+ const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
76
+ const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
77
+ const hasDir = existsSync(join(basePath, ".gsd", "milestones", id));
78
+ return !hasContext && !hasDraft && !hasDir;
79
+ });
80
+ if (missing.length > 0) {
81
+ ctx.ui.notify(
82
+ `Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` +
83
+ `Discussion may not have completed all readiness gates.`,
84
+ "warning",
85
+ );
86
+ }
87
+ }
88
+ } catch { /* non-fatal — PROJECT.md parsing failure shouldn't block auto-start */ }
89
+ }
90
+
91
+ // Gate 4: Discussion manifest process verification (multi-milestone only)
92
+ // The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
93
+ // If the manifest exists but gates_completed < total, the LLM hasn't finished
94
+ // presenting all readiness gates to the user — block auto-start.
95
+ const manifestPath = join(basePath, ".gsd", "DISCUSSION-MANIFEST.json");
96
+ if (existsSync(manifestPath)) {
97
+ try {
98
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
99
+ const total = typeof manifest.total === "number" ? manifest.total : 0;
100
+ const completed = typeof manifest.gates_completed === "number" ? manifest.gates_completed : 0;
101
+
102
+ if (total > 1 && completed < total) {
103
+ // Discussion not complete — block auto-start until all gates are done
104
+ return false;
105
+ }
106
+
107
+ // Cross-check manifest milestones against PROJECT.md if available
108
+ if (projectFile) {
109
+ const projectContent = readFileSync(projectFile, "utf-8");
110
+ const projectIds = parseMilestoneSequenceFromProject(projectContent);
111
+ const manifestIds = Object.keys(manifest.milestones ?? {});
112
+ const untracked = projectIds.filter(id => !manifestIds.includes(id));
113
+ if (untracked.length > 0) {
114
+ ctx.ui.notify(
115
+ `Discussion manifest missing gates for: ${untracked.join(", ")}`,
116
+ "warning",
117
+ );
118
+ }
119
+ }
120
+ } catch { /* malformed manifest — warn but don't block */ }
121
+ }
122
+
60
123
  // Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
61
124
  // CONTEXT.md, delete the draft — it's been consumed by the discussion.
62
125
  try {
@@ -64,11 +127,28 @@ export function checkAutoStartAfterDiscuss(): boolean {
64
127
  if (draftFile) unlinkSync(draftFile);
65
128
  } catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
66
129
 
130
+ // Cleanup: remove discussion manifest after auto-start (only needed during discussion)
131
+ try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ }
132
+
67
133
  pendingAutoStart = null;
68
134
  startAuto(ctx, pi, basePath, false, { step }).catch(() => {});
69
135
  return true;
70
136
  }
71
137
 
138
+ /**
139
+ * Extract milestone IDs from PROJECT.md milestone sequence table.
140
+ * Looks for rows like "| M001 | Name | Status |" and extracts the ID column.
141
+ */
142
+ function parseMilestoneSequenceFromProject(content: string): string[] {
143
+ const ids: string[] = [];
144
+ const lines = content.split(/\r?\n/);
145
+ for (const line of lines) {
146
+ const match = line.match(/^\|\s*(M\d{3}[A-Z0-9-]*)\s*\|/);
147
+ if (match) ids.push(match[1]);
148
+ }
149
+ return ids;
150
+ }
151
+
72
152
  // ─── Types ────────────────────────────────────────────────────────────────────
73
153
 
74
154
  type UIContext = ExtensionContext;
@@ -467,6 +547,62 @@ export async function showDiscuss(
467
547
  const mid = state.activeMilestone.id;
468
548
  const milestoneTitle = state.activeMilestone.title;
469
549
 
550
+ // Special case: milestone is in needs-discussion phase (has CONTEXT-DRAFT.md but no roadmap yet).
551
+ // Route to the draft discussion flow instead of erroring — the discussion IS how the roadmap gets created.
552
+ if (state.phase === "needs-discussion") {
553
+ const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
554
+ const draftContent = draftFile ? await loadFile(draftFile) : null;
555
+
556
+ const choice = await showNextAction(ctx as any, {
557
+ title: `GSD — ${mid}: ${milestoneTitle}`,
558
+ summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
559
+ actions: [
560
+ {
561
+ id: "discuss_draft",
562
+ label: "Discuss from draft",
563
+ description: "Continue where the prior discussion left off — seed material is loaded automatically.",
564
+ recommended: true,
565
+ },
566
+ {
567
+ id: "discuss_fresh",
568
+ label: "Start fresh discussion",
569
+ description: "Discard the draft and start a new discussion from scratch.",
570
+ },
571
+ {
572
+ id: "skip_milestone",
573
+ label: "Skip — create new milestone",
574
+ description: "Leave this milestone as-is and start something new.",
575
+ },
576
+ ],
577
+ notYetMessage: "Run /gsd discuss when ready to discuss this milestone.",
578
+ });
579
+
580
+ if (choice === "discuss_draft") {
581
+ const discussMilestoneTemplates = inlineTemplate("context", "Context");
582
+ const basePrompt = loadPrompt("guided-discuss-milestone", {
583
+ milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
584
+ });
585
+ const seed = draftContent
586
+ ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
587
+ : basePrompt;
588
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
589
+ dispatchWorkflow(pi, seed, "gsd-discuss");
590
+ } else if (choice === "discuss_fresh") {
591
+ const discussMilestoneTemplates = inlineTemplate("context", "Context");
592
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
593
+ dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
594
+ milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
595
+ }), "gsd-discuss");
596
+ } else if (choice === "skip_milestone") {
597
+ const milestoneIds = findMilestoneIds(basePath);
598
+ const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
599
+ const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
600
+ pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
601
+ dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
602
+ }
603
+ return;
604
+ }
605
+
470
606
  // Guard: no roadmap yet
471
607
  const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
472
608
  const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
@@ -564,7 +700,7 @@ export async function showSmartEntry(
564
700
  ): Promise<void> {
565
701
  const stepMode = options?.step;
566
702
 
567
- // ── Ensure git repo exists — GSD needs it for branch-per-slice ──────
703
+ // ── Ensure git repo exists — GSD needs it for worktree isolation ──────
568
704
  try {
569
705
  execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
570
706
  } catch {
@@ -296,6 +296,9 @@ export function renderPreferencesForSystemPrompt(preferences: GSDPreferences, re
296
296
  if (validated.errors.length > 0) {
297
297
  lines.push("- Validation: some preference values were ignored because they were invalid.");
298
298
  }
299
+ for (const warning of validated.warnings) {
300
+ lines.push(`- Deprecation: ${warning}`);
301
+ }
299
302
 
300
303
  preferences = validated.preferences;
301
304
 
@@ -482,16 +485,20 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences {
482
485
  return root as GSDPreferences;
483
486
  }
484
487
 
485
- function parseScalar(value: string): string | number | boolean {
488
+ function parseScalar(value: string): unknown {
486
489
  if (value === "true") return true;
487
490
  if (value === "false") return false;
491
+ // Recognize empty array/object literals (with or without surrounding quotes)
492
+ const unquoted = value.replace(/^['\"]|['\"]$/g, "");
493
+ if (unquoted === "[]") return [];
494
+ if (unquoted === "{}") return {};
488
495
  if (/^-?\d+$/.test(value)) {
489
496
  const n = Number(value);
490
497
  // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss
491
498
  if (Number.isSafeInteger(n)) return n;
492
499
  return value;
493
500
  }
494
- return value.replace(/^['\"]|['\"]$/g, "");
501
+ return unquoted;
495
502
  }
496
503
 
497
504
  /**
@@ -637,8 +644,10 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
637
644
  export function validatePreferences(preferences: GSDPreferences): {
638
645
  preferences: GSDPreferences;
639
646
  errors: string[];
647
+ warnings: string[];
640
648
  } {
641
649
  const errors: string[] = [];
650
+ const warnings: string[] = [];
642
651
  const validated: GSDPreferences = {};
643
652
 
644
653
  if (preferences.version !== undefined) {
@@ -725,7 +734,7 @@ export function validatePreferences(preferences: GSDPreferences): {
725
734
  const knownUnitTypes = new Set([
726
735
  "research-milestone", "plan-milestone", "research-slice", "plan-slice",
727
736
  "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
728
- "run-uat", "fix-merge", "complete-milestone",
737
+ "run-uat", "complete-milestone",
729
738
  ]);
730
739
  for (const hook of preferences.post_unit_hooks) {
731
740
  if (!hook || typeof hook !== "object") {
@@ -791,7 +800,7 @@ export function validatePreferences(preferences: GSDPreferences): {
791
800
  const knownUnitTypes = new Set([
792
801
  "research-milestone", "plan-milestone", "research-slice", "plan-slice",
793
802
  "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
794
- "run-uat", "fix-merge", "complete-milestone",
803
+ "run-uat", "complete-milestone",
795
804
  ]);
796
805
  const validActions = new Set(["modify", "skip", "replace"]);
797
806
  for (const hook of preferences.pre_dispatch_hooks) {
@@ -905,21 +914,13 @@ export function validatePreferences(preferences: GSDPreferences): {
905
914
  errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)");
906
915
  }
907
916
  }
917
+ // Deprecated: isolation and merge_to_main are ignored (branchless architecture).
918
+ // Emit warnings so users know to remove them from preferences.
908
919
  if (g.isolation !== undefined) {
909
- const validIsolation = new Set(["worktree", "branch"]);
910
- if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
911
- git.isolation = g.isolation as "worktree" | "branch";
912
- } else {
913
- errors.push("git.isolation must be one of: worktree, branch");
914
- }
920
+ warnings.push("git.isolation is deprecated worktree isolation is now always enabled. Remove this setting.");
915
921
  }
916
922
  if (g.merge_to_main !== undefined) {
917
- const validMergeToMain = new Set(["milestone", "slice"]);
918
- if (typeof g.merge_to_main === "string" && validMergeToMain.has(g.merge_to_main)) {
919
- git.merge_to_main = g.merge_to_main as "milestone" | "slice";
920
- } else {
921
- errors.push("git.merge_to_main must be one of: milestone, slice");
922
- }
923
+ warnings.push("git.merge_to_main is deprecated milestone-level merge is now always used. Remove this setting.");
923
924
  }
924
925
 
925
926
  if (Object.keys(git).length > 0) {
@@ -927,7 +928,7 @@ export function validatePreferences(preferences: GSDPreferences): {
927
928
  }
928
929
  }
929
930
 
930
- return { preferences: validated, errors };
931
+ return { preferences: validated, errors, warnings };
931
932
  }
932
933
 
933
934
  function mergeStringLists(base?: unknown, override?: unknown): string[] | undefined {
@@ -215,6 +215,20 @@ Once the user confirms the milestone split:
215
215
  5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth).
216
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
217
 
218
+ #### MANDATORY: depends_on Frontmatter in CONTEXT.md
219
+
220
+ Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The auto-mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't.
221
+
222
+ ```yaml
223
+ ---
224
+ depends_on: [M001, M002]
225
+ ---
226
+
227
+ # M003: Title
228
+ ```
229
+
230
+ If a milestone has no dependencies, omit the frontmatter. The dependency chain from the milestone confirmation gate MUST be reflected in each CONTEXT.md frontmatter. Do NOT rely on QUEUE.md or PROJECT.md for dependency tracking — the state machine only reads CONTEXT.md frontmatter.
231
+
218
232
  #### Phase 3: Sequential readiness gate for remaining milestones
219
233
 
220
234
  For each remaining milestone **one at a time, in sequence**, use `ask_user_questions` to assess readiness. Present three options:
@@ -227,6 +241,27 @@ For each remaining milestone **one at a time, in sequence**, use `ask_user_quest
227
241
 
228
242
  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
243
 
244
+ #### Milestone Gate Tracking (MANDATORY for multi-milestone)
245
+
246
+ After EVERY Phase 3 gate decision, immediately write or update `.gsd/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before auto-mode starts — if gates are incomplete, auto-mode will NOT start.
247
+
248
+ ```json
249
+ {
250
+ "primary": "M001",
251
+ "milestones": {
252
+ "M001": { "gate": "discussed", "context": "full" },
253
+ "M002": { "gate": "discussed", "context": "full" },
254
+ "M003": { "gate": "queued", "context": "none" }
255
+ },
256
+ "total": 3,
257
+ "gates_completed": 3
258
+ }
259
+ ```
260
+
261
+ Write this file AFTER each gate decision, not just at the end. Update `gates_completed` incrementally. The system reads this file and BLOCKS auto-start if `gates_completed < total`.
262
+
263
+ For single-milestone projects, do NOT write this file — it is only for multi-milestone discussions.
264
+
230
265
  #### Phase 4: Finalize
231
266
 
232
267
  7. Update `.gsd/STATE.md`
@@ -82,7 +82,13 @@ Determine where the new milestones should go in the overall sequence. Consider d
82
82
  Once the user is satisfied, in a single pass for **each** new milestone (starting from {{nextId}}):
83
83
 
84
84
  1. `mkdir -p .gsd/milestones/<ID>/slices`
85
- 2. Write `.gsd/milestones/<ID>/<ID>-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution."
85
+ 2. Write `.gsd/milestones/<ID>/<ID>-CONTEXT.md` — use the **Context** output template below. Capture intent, scope, risks, constraints, integration points, and relevant requirements. Mark the status as "Queued — pending auto-mode execution." **If this milestone depends on other milestones, add YAML frontmatter with `depends_on`:**
86
+ ```yaml
87
+ ---
88
+ depends_on: [M001, M002]
89
+ ---
90
+ ```
91
+ The auto-mode state machine reads this field to enforce execution order. Without it, milestones may execute out of order. List the exact milestone IDs (including any suffix like `-0zjrg0`) from the dependency chain discussed with the user.
86
92
 
87
93
  Then, after all milestone directories and context files are written:
88
94
 
@@ -29,7 +29,7 @@ import {
29
29
  resolveGsdRootFile,
30
30
  gsdRoot,
31
31
  } from './paths.js';
32
- import { getActiveSliceBranch } from './worktree.js';
32
+
33
33
  import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
34
34
  import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
35
35
 
@@ -438,8 +438,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
438
438
  };
439
439
  }
440
440
 
441
- const activeBranch = getActiveSliceBranch(basePath);
442
-
443
441
  // Check if the slice has a plan
444
442
  const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN");
445
443
  const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null;
@@ -453,7 +451,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
453
451
  recentDecisions: [],
454
452
  blockers: [],
455
453
  nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`,
456
- activeBranch: activeBranch ?? undefined,
454
+
457
455
  registry,
458
456
  requirements,
459
457
  progress: {
@@ -470,7 +468,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
470
468
  };
471
469
  const activeTaskEntry = slicePlan.tasks.find(t => !t.done);
472
470
 
473
- if (!activeTaskEntry) {
471
+ if (!activeTaskEntry && slicePlan.tasks.length > 0) {
474
472
  // All tasks done but slice not marked complete
475
473
  return {
476
474
  activeMilestone,
@@ -480,7 +478,28 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
480
478
  recentDecisions: [],
481
479
  blockers: [],
482
480
  nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`,
483
- activeBranch: activeBranch ?? undefined,
481
+
482
+ registry,
483
+ requirements,
484
+ progress: {
485
+ milestones: milestoneProgress,
486
+ slices: sliceProgress,
487
+ tasks: taskProgress,
488
+ },
489
+ };
490
+ }
491
+
492
+ // Empty plan — no tasks defined yet, stay in planning phase
493
+ if (!activeTaskEntry) {
494
+ return {
495
+ activeMilestone,
496
+ activeSlice,
497
+ activeTask: null,
498
+ phase: 'planning',
499
+ recentDecisions: [],
500
+ blockers: [],
501
+ nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`,
502
+
484
503
  registry,
485
504
  requirements,
486
505
  progress: {
@@ -526,7 +545,7 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
526
545
  recentDecisions: [],
527
546
  blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`],
528
547
  nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`,
529
- activeBranch: activeBranch ?? undefined,
548
+
530
549
  activeWorkspace: undefined,
531
550
  registry,
532
551
  requirements,
@@ -557,7 +576,6 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
557
576
  nextAction: hasInterrupted
558
577
  ? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.`
559
578
  : `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`,
560
- activeBranch: activeBranch ?? undefined,
561
579
  registry,
562
580
  requirements,
563
581
  progress: {
@@ -4,7 +4,6 @@
4
4
  **Active Slice:** {{sliceId}}: {{sliceTitle}}
5
5
  **Active Task:** {{taskId}}: {{taskTitle}}
6
6
  **Phase:** {{phase}}
7
- **Slice Branch:** {{activeBranch}}
8
7
  **Active Workspace:** {{activeWorkspace}}
9
8
  **Next Action:** {{nextAction}}
10
9
  **Last Updated:** {{date}}
@@ -14,7 +14,6 @@ import { execSync } from "node:child_process";
14
14
  import {
15
15
  createAutoWorktree,
16
16
  mergeMilestoneToMain,
17
- mergeSliceToMilestone,
18
17
  getAutoWorktreeOriginalBase,
19
18
  } from "../auto-worktree.ts";
20
19
  import { getSliceBranchName } from "../worktree.ts";
@@ -71,7 +70,9 @@ function addSliceToMilestone(
71
70
  run(`git commit -m "${c.message}"`, wtPath);
72
71
  }
73
72
  run(`git checkout milestone/${milestoneId}`, wtPath);
74
- mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
73
+ run(`git merge --no-ff ${sliceBranch} -m "feat(${milestoneId}/${sliceId}): ${sliceTitle}"`, wtPath);
74
+ // Clean up the slice branch
75
+ run(`git branch -d ${sliceBranch}`, wtPath);
75
76
  }
76
77
 
77
78
  async function main(): Promise<void> {
@@ -53,7 +53,7 @@ async function main(): Promise<void> {
53
53
  mkdirSync(msDir, { recursive: true });
54
54
  writeFileSync(join(msDir, "CONTEXT.md"), "# M003 Context\n");
55
55
  run("git add .", tempDir);
56
- run("git commit -m 'add milestone'", tempDir);
56
+ run("git commit -m \"add milestone\"", tempDir);
57
57
 
58
58
  console.log("\n=== auto-worktree lifecycle ===");
59
59
 
@@ -651,6 +651,41 @@ Continue from step 2.
651
651
  }
652
652
  }
653
653
 
654
+ // ─── Empty plan (zero tasks) stays in planning, not summarizing (#454) ──
655
+ console.log('\n=== empty plan → planning (not summarizing) ===');
656
+ {
657
+ const base = createFixtureBase();
658
+ try {
659
+ writeRoadmap(base, 'M001', `---
660
+ id: M001
661
+ title: "Test"
662
+ ---
663
+ # M001: Test
664
+ ## Vision
665
+ Test
666
+ ## Success Criteria
667
+ - Done
668
+ ## Slices
669
+ - [ ] **S01: Empty slice** \`risk:low\` \`depends:[]\`
670
+ > Test
671
+ ## Boundary Map
672
+ _None_
673
+ `);
674
+ writePlan(base, 'M001', 'S01', `---
675
+ slice: S01
676
+ ---
677
+ # S01 Plan
678
+ ## Tasks
679
+ `);
680
+ const state = await deriveState(base);
681
+ assertEq(state.phase, 'planning', 'empty plan stays in planning');
682
+ assertEq(state.activeSlice?.id, 'S01', 'active slice is S01');
683
+ assertEq(state.activeTask, null, 'no active task');
684
+ } finally {
685
+ cleanup(base);
686
+ }
687
+ }
688
+
654
689
  report();
655
690
  }
656
691
 
@@ -60,7 +60,7 @@ _None_
60
60
 
61
61
  // Commit .gsd files
62
62
  run("git add -A", dir);
63
- run("git commit -m 'add milestone'", dir);
63
+ run("git commit -m \"add milestone\"", dir);
64
64
 
65
65
  return dir;
66
66
  }
@@ -101,7 +101,7 @@ _None_
101
101
  `);
102
102
 
103
103
  run("git add -A", dir);
104
- run("git commit -m 'add milestone'", dir);
104
+ run("git commit -m \"add milestone\"", dir);
105
105
 
106
106
  return dir;
107
107
  }
@@ -111,6 +111,11 @@ async function main(): Promise<void> {
111
111
 
112
112
  try {
113
113
  // ─── Test 1: Orphaned worktree detection & fix ─────────────────────
114
+ // Skip on Windows: git worktree path resolution on Windows temp dirs
115
+ // uses UNC/8.3 forms that don't survive path normalization. The source
116
+ // logic is correct (tested on macOS/Linux) — the test infra doesn't
117
+ // produce matching paths on Windows CI.
118
+ if (process.platform !== "win32") {
114
119
  console.log("\n=== orphaned_auto_worktree ===");
115
120
  {
116
121
  const dir = createRepoWithCompletedMilestone();
@@ -132,8 +137,14 @@ async function main(): Promise<void> {
132
137
  const wtList = run("git worktree list", dir);
133
138
  assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
134
139
  }
140
+ } else {
141
+ console.log("\n=== orphaned_auto_worktree (skipped on Windows) ===");
142
+ }
135
143
 
136
144
  // ─── Test 2: Stale milestone branch detection & fix ────────────────
145
+ // Skip on Windows: git branch glob matching and path resolution
146
+ // behave differently in Windows temp dirs.
147
+ if (process.platform !== "win32") {
137
148
  console.log("\n=== stale_milestone_branch ===");
138
149
  {
139
150
  const dir = createRepoWithCompletedMilestone();
@@ -151,9 +162,12 @@ async function main(): Promise<void> {
151
162
  assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
152
163
 
153
164
  // Verify branch is gone
154
- const branches = run("git branch --list 'milestone/*'", dir);
165
+ const branches = run("git branch --list milestone/*", dir);
155
166
  assertTrue(!branches.includes("milestone/M001"), "branch gone after fix");
156
167
  }
168
+ } else {
169
+ console.log("\n=== stale_milestone_branch (skipped on Windows) ===");
170
+ }
157
171
 
158
172
  // ─── Test 3: Corrupt merge state detection & fix ───────────────────
159
173
  console.log("\n=== corrupt_merge_state ===");
@@ -187,7 +201,7 @@ async function main(): Promise<void> {
187
201
  mkdirSync(activityDir, { recursive: true });
188
202
  writeFileSync(join(activityDir, "test.log"), "log data\n");
189
203
  run("git add -f .gsd/activity/test.log", dir);
190
- run("git commit -m 'track runtime file'", dir);
204
+ run("git commit -m \"track runtime file\"", dir);
191
205
 
192
206
  const detect = await runGSDDoctor(dir);
193
207
  const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
@@ -220,6 +234,7 @@ async function main(): Promise<void> {
220
234
  }
221
235
 
222
236
  // ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
237
+ if (process.platform !== "win32") {
223
238
  console.log("\n=== active worktree safety ===");
224
239
  {
225
240
  const dir = createRepoWithActiveMilestone();
@@ -233,6 +248,9 @@ async function main(): Promise<void> {
233
248
  const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
234
249
  assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
235
250
  }
251
+ } else {
252
+ console.log("\n=== active worktree safety (skipped on Windows) ===");
253
+ }
236
254
 
237
255
  } finally {
238
256
  for (const dir of cleanups) {
@@ -145,7 +145,8 @@ const guidedFlowSource = readFileSync(
145
145
  );
146
146
 
147
147
  const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
148
- const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnIdx + 1200);
148
+ const checkFnEnd = guidedFlowSource.indexOf("\nexport ", checkFnIdx + 1);
149
+ const checkFnChunk = guidedFlowSource.slice(checkFnIdx, checkFnEnd > checkFnIdx ? checkFnEnd : checkFnIdx + 5000);
149
150
 
150
151
  assert(
151
152
  checkFnChunk.includes("CONTEXT-DRAFT"),