gsd-pi 2.25.0 → 2.26.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 (122) hide show
  1. package/README.md +11 -2
  2. package/dist/headless.js +24 -4
  3. package/dist/resources/extensions/async-jobs/index.ts +9 -1
  4. package/dist/resources/extensions/bg-shell/index.ts +3 -2
  5. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -4
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +14 -3
  7. package/dist/resources/extensions/gsd/auto.ts +81 -12
  8. package/dist/resources/extensions/gsd/doctor-proactive.ts +7 -6
  9. package/dist/resources/extensions/gsd/doctor.ts +24 -1
  10. package/dist/resources/extensions/gsd/files.ts +13 -2
  11. package/dist/resources/extensions/gsd/guided-flow.ts +19 -9
  12. package/dist/resources/extensions/gsd/index.ts +48 -7
  13. package/dist/resources/extensions/gsd/migrate/writer.ts +39 -0
  14. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +122 -4
  15. package/dist/resources/extensions/gsd/preferences.ts +2 -1
  16. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  17. package/dist/resources/extensions/gsd/prompts/discuss.md +1 -1
  18. package/dist/resources/extensions/gsd/prompts/queue.md +2 -2
  19. package/dist/resources/extensions/gsd/roadmap-slices.ts +45 -1
  20. package/dist/resources/extensions/gsd/state.ts +17 -6
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +70 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +23 -3
  23. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +13 -7
  24. package/dist/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +171 -0
  25. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +8 -4
  26. package/dist/resources/extensions/gsd/types.ts +2 -0
  27. package/dist/resources/extensions/search-the-web/native-search.ts +4 -0
  28. package/dist/resources/extensions/shared/path-display.ts +19 -0
  29. package/package.json +1 -6
  30. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  31. package/packages/pi-ai/dist/providers/anthropic.js +25 -0
  32. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  33. package/packages/pi-ai/src/providers/anthropic.ts +27 -0
  34. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
  35. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  36. package/packages/pi-coding-agent/dist/core/agent-session.js +32 -0
  37. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  38. package/packages/pi-coding-agent/dist/core/keybindings.js +1 -1
  39. package/packages/pi-coding-agent/dist/core/keybindings.js.map +1 -1
  40. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  41. package/packages/pi-coding-agent/dist/core/lsp/client.js +12 -1
  42. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  43. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/core/lsp/index.js +7 -0
  45. package/packages/pi-coding-agent/dist/core/lsp/index.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/core/sdk.d.ts +2 -2
  47. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/sdk.js +8 -3
  49. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  51. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/settings-manager.js +8 -0
  53. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  56. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/core/system-prompt.js +2 -1
  59. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/index.d.ts +2 -1
  61. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/index.js +5 -1
  63. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts +41 -3
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +301 -62
  67. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  69. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +63 -30
  71. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/tests/path-display.test.d.ts +8 -0
  73. package/packages/pi-coding-agent/dist/tests/path-display.test.d.ts.map +1 -0
  74. package/packages/pi-coding-agent/dist/tests/path-display.test.js +60 -0
  75. package/packages/pi-coding-agent/dist/tests/path-display.test.js.map +1 -0
  76. package/packages/pi-coding-agent/dist/utils/clipboard-image.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/utils/clipboard-image.js +32 -6
  78. package/packages/pi-coding-agent/dist/utils/clipboard-image.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/utils/path-display.d.ts +34 -0
  80. package/packages/pi-coding-agent/dist/utils/path-display.d.ts.map +1 -0
  81. package/packages/pi-coding-agent/dist/utils/path-display.js +36 -0
  82. package/packages/pi-coding-agent/dist/utils/path-display.js.map +1 -0
  83. package/packages/pi-coding-agent/src/core/agent-session.ts +36 -0
  84. package/packages/pi-coding-agent/src/core/keybindings.ts +1 -1
  85. package/packages/pi-coding-agent/src/core/lsp/client.ts +11 -1
  86. package/packages/pi-coding-agent/src/core/lsp/index.ts +7 -0
  87. package/packages/pi-coding-agent/src/core/sdk.ts +17 -1
  88. package/packages/pi-coding-agent/src/core/settings-manager.ts +11 -0
  89. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  90. package/packages/pi-coding-agent/src/core/system-prompt.ts +2 -1
  91. package/packages/pi-coding-agent/src/index.ts +15 -0
  92. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +347 -62
  93. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +40 -4
  94. package/packages/pi-coding-agent/src/tests/path-display.test.ts +85 -0
  95. package/packages/pi-coding-agent/src/utils/clipboard-image.ts +33 -6
  96. package/packages/pi-coding-agent/src/utils/path-display.ts +36 -0
  97. package/src/resources/extensions/async-jobs/index.ts +9 -1
  98. package/src/resources/extensions/bg-shell/index.ts +3 -2
  99. package/src/resources/extensions/gsd/auto-recovery.ts +7 -4
  100. package/src/resources/extensions/gsd/auto-worktree.ts +14 -3
  101. package/src/resources/extensions/gsd/auto.ts +81 -12
  102. package/src/resources/extensions/gsd/doctor-proactive.ts +7 -6
  103. package/src/resources/extensions/gsd/doctor.ts +24 -1
  104. package/src/resources/extensions/gsd/files.ts +13 -2
  105. package/src/resources/extensions/gsd/guided-flow.ts +19 -9
  106. package/src/resources/extensions/gsd/index.ts +48 -7
  107. package/src/resources/extensions/gsd/migrate/writer.ts +39 -0
  108. package/src/resources/extensions/gsd/parallel-orchestrator.ts +122 -4
  109. package/src/resources/extensions/gsd/preferences.ts +2 -1
  110. package/src/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  111. package/src/resources/extensions/gsd/prompts/discuss.md +1 -1
  112. package/src/resources/extensions/gsd/prompts/queue.md +2 -2
  113. package/src/resources/extensions/gsd/roadmap-slices.ts +45 -1
  114. package/src/resources/extensions/gsd/state.ts +17 -6
  115. package/src/resources/extensions/gsd/tests/derive-state.test.ts +70 -0
  116. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +23 -3
  117. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +13 -7
  118. package/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +171 -0
  119. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +8 -4
  120. package/src/resources/extensions/gsd/types.ts +2 -0
  121. package/src/resources/extensions/search-the-web/native-search.ts +4 -0
  122. package/src/resources/extensions/shared/path-display.ts +19 -0
@@ -483,6 +483,45 @@ export async function writeGSDDirectory(
483
483
  counts.research++;
484
484
  }
485
485
 
486
+ // For fully-completed milestones (all slices done), write a pass-through
487
+ // validation file so deriveState() doesn't enter validating-milestone
488
+ // phase for historical milestones that predate the validation gate (#819).
489
+ const allSlicesDone = milestone.slices.length > 0 && milestone.slices.every(s => s.done);
490
+ if (allSlicesDone) {
491
+ const validationPath = join(mDir, `${milestone.id}-VALIDATION.md`);
492
+ const validationContent = [
493
+ `---`,
494
+ `verdict: pass`,
495
+ `migrated: true`,
496
+ `---`,
497
+ ``,
498
+ `# ${milestone.id} Validation`,
499
+ ``,
500
+ `Migrated milestone — all slices were completed in the original project.`,
501
+ ``,
502
+ ].join('\n');
503
+ await saveFile(validationPath, validationContent);
504
+ paths.push(validationPath);
505
+ counts.other++;
506
+
507
+ // Also write a milestone summary if one doesn't exist
508
+ const summaryPath = join(mDir, `${milestone.id}-SUMMARY.md`);
509
+ const summaryContent = [
510
+ `---`,
511
+ `status: done`,
512
+ `migrated: true`,
513
+ `---`,
514
+ ``,
515
+ `# ${milestone.id}: ${milestone.title}`,
516
+ ``,
517
+ `Migrated from .planning — ${milestone.slices.length} slices completed.`,
518
+ ``,
519
+ ].join('\n');
520
+ await saveFile(summaryPath, summaryContent);
521
+ paths.push(summaryPath);
522
+ counts.other++;
523
+ }
524
+
486
525
  // Slices
487
526
  for (const slice of milestone.slices) {
488
527
  const sDir = join(mDir, 'slices', slice.id);
@@ -124,6 +124,12 @@ export async function startParallel(
124
124
  const toStart = milestoneIds.slice(0, config.max_workers);
125
125
 
126
126
  for (const mid of toStart) {
127
+ // Check budget ceiling before each spawn
128
+ if (isBudgetExceeded()) {
129
+ errors.push({ mid, error: `Budget ceiling ($${config.budget_ceiling}) reached — skipping` });
130
+ continue;
131
+ }
132
+
127
133
  try {
128
134
  // Create the worktree (without chdir — coordinator stays in project root)
129
135
  let wtPath: string;
@@ -233,7 +239,7 @@ export function spawnWorker(
233
239
 
234
240
  let child: ChildProcess;
235
241
  try {
236
- child = spawn(process.execPath, [binPath, "--print", "/gsd auto"], {
242
+ child = spawn(process.execPath, [binPath, "--mode", "json", "--print", "/gsd auto"], {
237
243
  cwd: worker.worktreePath,
238
244
  env: {
239
245
  ...process.env,
@@ -267,6 +273,28 @@ export function spawnWorker(
267
273
  return false;
268
274
  }
269
275
 
276
+ // ── NDJSON stdout monitoring ────────────────────────────────────────
277
+ // Workers run with --mode json, emitting one JSON event per line.
278
+ // We parse message_end events to extract cost/token usage, keeping
279
+ // the coordinator's cost tracking in sync with actual API spend.
280
+ if (child.stdout) {
281
+ let stdoutBuffer = "";
282
+ child.stdout.on("data", (data: Buffer) => {
283
+ stdoutBuffer += data.toString();
284
+ const lines = stdoutBuffer.split("\n");
285
+ stdoutBuffer = lines.pop() || "";
286
+ for (const line of lines) {
287
+ processWorkerLine(basePath, milestoneId, line);
288
+ }
289
+ });
290
+ // Flush remaining buffer on close
291
+ child.stdout.on("close", () => {
292
+ if (stdoutBuffer.trim()) {
293
+ processWorkerLine(basePath, milestoneId, stdoutBuffer);
294
+ }
295
+ });
296
+ }
297
+
270
298
  // Update session status with real PID
271
299
  writeSessionStatus(basePath, {
272
300
  milestoneId,
@@ -343,6 +371,90 @@ function resolveGsdBin(): string | null {
343
371
  return null;
344
372
  }
345
373
 
374
+ // ─── NDJSON Processing ──────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * Process a single NDJSON line from a worker's stdout.
378
+ * Extracts cost and token usage from message_end events and updates
379
+ * the worker's tracking state + session status file.
380
+ */
381
+ function processWorkerLine(basePath: string, milestoneId: string, line: string): void {
382
+ if (!line.trim() || !state) return;
383
+
384
+ let event: Record<string, unknown>;
385
+ try {
386
+ event = JSON.parse(line);
387
+ } catch {
388
+ return; // Not valid JSON — skip (stderr leakage, debug output, etc.)
389
+ }
390
+
391
+ const type = String(event.type ?? "");
392
+
393
+ // message_end carries usage data with cost
394
+ if (type === "message_end" && event.message) {
395
+ const msg = event.message as Record<string, unknown>;
396
+ const usage = msg.usage as Record<string, unknown> | undefined;
397
+
398
+ if (usage) {
399
+ const cost = (usage.cost as Record<string, unknown>)?.total;
400
+ if (typeof cost === "number") {
401
+ const worker = state.workers.get(milestoneId);
402
+ if (worker) {
403
+ worker.cost += cost;
404
+ // Update aggregate
405
+ state.totalCost = 0;
406
+ for (const w of state.workers.values()) {
407
+ state.totalCost += w.cost;
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ // Track completed units (each message_end from assistant = progress)
414
+ if (msg.role === "assistant") {
415
+ const worker = state.workers.get(milestoneId);
416
+ if (worker) {
417
+ worker.completedUnits++;
418
+ }
419
+ }
420
+
421
+ // Update session status file so dashboard sees live cost
422
+ const worker = state.workers.get(milestoneId);
423
+ if (worker) {
424
+ writeSessionStatus(basePath, {
425
+ milestoneId,
426
+ pid: worker.pid,
427
+ state: worker.state,
428
+ currentUnit: null,
429
+ completedUnits: worker.completedUnits,
430
+ cost: worker.cost,
431
+ lastHeartbeat: Date.now(),
432
+ startedAt: worker.startedAt,
433
+ worktreePath: worker.worktreePath,
434
+ });
435
+ }
436
+ }
437
+
438
+ // tool_execution_start can track current unit
439
+ if (type === "extension_ui_request" && event.method === "notify") {
440
+ // GSD auto-mode sends notifications about current unit
441
+ const worker = state.workers.get(milestoneId);
442
+ if (worker) {
443
+ writeSessionStatus(basePath, {
444
+ milestoneId,
445
+ pid: worker.pid,
446
+ state: worker.state,
447
+ currentUnit: null,
448
+ completedUnits: worker.completedUnits,
449
+ cost: worker.cost,
450
+ lastHeartbeat: Date.now(),
451
+ startedAt: worker.startedAt,
452
+ worktreePath: worker.worktreePath,
453
+ });
454
+ }
455
+ }
456
+ }
457
+
346
458
  // ─── Stop ──────────────────────────────────────────────────────────────────
347
459
 
348
460
  /**
@@ -366,10 +478,16 @@ export async function stopParallel(
366
478
  // Send stop signal via file-based IPC (worker checks on next dispatch)
367
479
  sendSignal(basePath, mid, "stop");
368
480
 
369
- // Also send SIGTERM to the process for immediate response
370
- if (worker.process && worker.pid > 0) {
481
+ // Send SIGTERM to the process for immediate response.
482
+ // Use process handle when available, fall back to PID-based kill
483
+ // (handles are null after coordinator restart / deserialization).
484
+ if (worker.pid > 0) {
371
485
  try {
372
- worker.process.kill("SIGTERM");
486
+ if (worker.process) {
487
+ worker.process.kill("SIGTERM");
488
+ } else {
489
+ process.kill(worker.pid, "SIGTERM");
490
+ }
373
491
  } catch { /* process may already be dead */ }
374
492
  }
375
493
 
@@ -916,8 +916,9 @@ export function validatePreferences(preferences: GSDPreferences): {
916
916
  if (p.skip_reassess !== undefined) validatedPhases.skip_reassess = !!p.skip_reassess;
917
917
  if (p.skip_slice_research !== undefined) validatedPhases.skip_slice_research = !!p.skip_slice_research;
918
918
  if (p.skip_milestone_validation !== undefined) validatedPhases.skip_milestone_validation = !!p.skip_milestone_validation;
919
+ if ((p as any).require_slice_discussion !== undefined) (validatedPhases as any).require_slice_discussion = !!(p as any).require_slice_discussion;
919
920
  // Warn on unknown phase keys
920
- const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research", "skip_milestone_validation"]);
921
+ const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research", "skip_milestone_validation", "require_slice_discussion"]);
921
922
  for (const key of Object.keys(p)) {
922
923
  if (!knownPhaseKeys.has(key)) {
923
924
  warnings.push(`unknown phases key "${key}" — ignored`);
@@ -55,7 +55,7 @@ Use these templates exactly:
55
55
  9. Say exactly: "Milestone {{milestoneId}} ready."
56
56
 
57
57
  **For multi-milestone**, write in this order:
58
- 1. Create all milestone directories: `mkdir -p .gsd/milestones/{M###}/slices` for each
58
+ 1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones/<ID>/slices` for each.
59
59
  2. Write `.gsd/PROJECT.md` — full vision across ALL milestones (using Project template)
60
60
  3. Write `.gsd/REQUIREMENTS.md` — full capability contract (using Requirements template)
61
61
  4. Seed `.gsd/DECISIONS.md` (using Decisions template)
@@ -82,5 +82,5 @@ Use these templates exactly:
82
82
  - **Investigate before writing** — always scout the codebase first
83
83
  - **Use depends_on frontmatter** for multi-milestone sequences (the state machine reads this field to determine execution order)
84
84
  - **Anti-reduction rule** — if the spec describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or reduce scope. Phase complex/risky work into later milestones — do not cut it.
85
- - **Naming convention** — directories use bare IDs (`M001/`, `S01/`), files use ID-SUFFIX format (`M001-CONTEXT.md`, `M001-ROADMAP.md`)
85
+ - **Naming convention** — always use `gsd_generate_milestone_id` to get milestone IDs. Directories use bare IDs (e.g. `M001/` or `M001-r5jzab/`), files use ID-SUFFIX format (e.g. `M001-CONTEXT.md` or `M001-r5jzab-CONTEXT.md`). Never invent milestone IDs manually.
86
86
  - **End with "Milestone {{milestoneId}} ready."** — this triggers auto-start detection
@@ -211,7 +211,7 @@ Once the user confirms the milestone split:
211
211
 
212
212
  #### Phase 1: Shared artifacts
213
213
 
214
- 1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` for each milestone
214
+ 1. For each milestone, call `gsd_generate_milestone_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones/<ID>/slices`.
215
215
  2. Write `.gsd/PROJECT.md` — use the **Project** output template below.
216
216
  3. Write `.gsd/REQUIREMENTS.md` — use the **Requirements** output template below. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet.
217
217
  4. Seed `.gsd/DECISIONS.md` — use the **Decisions** output template below.
@@ -79,9 +79,9 @@ Determine where the new milestones should go in the overall sequence. Consider d
79
79
 
80
80
  ## Output Phase
81
81
 
82
- Once the user is satisfied, in a single pass for **each** new milestone (starting from {{nextId}}):
82
+ Once the user is satisfied, in a single pass for **each** new milestone:
83
83
 
84
- 1. `mkdir -p .gsd/milestones/<ID>/slices`
84
+ 1. Call `gsd_generate_milestone_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones/<ID>/slices`.
85
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
86
  ```yaml
87
87
  ---
@@ -53,7 +53,12 @@ function extractSlicesSection(content: string): string {
53
53
  export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
54
54
  const slicesSection = extractSlicesSection(content);
55
55
  const slices: RoadmapSliceEntry[] = [];
56
- if (!slicesSection) return slices;
56
+ if (!slicesSection) {
57
+ // Fallback: detect prose-style slice headers (## Slice S01: Title)
58
+ // when the LLM writes freeform prose instead of the ## Slices checklist.
59
+ // This prevents a permanent "No slice eligible" block (#807).
60
+ return parseProseSliceHeaders(content);
61
+ }
57
62
 
58
63
  const checkboxItems = slicesSection.split("\n");
59
64
  let currentSlice: RoadmapSliceEntry | null = null;
@@ -88,3 +93,42 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
88
93
  if (currentSlice) slices.push(currentSlice);
89
94
  return slices;
90
95
  }
96
+
97
+ /**
98
+ * Fallback parser for prose-style roadmaps where the LLM wrote
99
+ * `## Slice S01: Title` headers instead of the machine-readable
100
+ * `## Slices` checklist. Extracts slice IDs and titles so auto-mode
101
+ * can at least identify slices and plan them.
102
+ *
103
+ * Also handles `## S01: Title` and `## S01 — Title` variants.
104
+ */
105
+ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
106
+ const slices: RoadmapSliceEntry[] = [];
107
+ const headerPattern = /^##\s+(?:Slice\s+)?(S\d+)[:\s—–-]+\s*(.+)/gm;
108
+ let match: RegExpExecArray | null;
109
+
110
+ while ((match = headerPattern.exec(content)) !== null) {
111
+ const id = match[1]!;
112
+ const title = match[2]!.trim();
113
+
114
+ // Try to extract depends from prose: "Depends on: S01" or "**Depends on:** S01, S02"
115
+ const afterHeader = content.slice(match.index + match[0].length);
116
+ const nextHeader = afterHeader.search(/^##\s/m);
117
+ const section = nextHeader !== -1 ? afterHeader.slice(0, nextHeader) : afterHeader.slice(0, 500);
118
+
119
+ const depsMatch = section.match(/\*{0,2}Depends\s+on:?\*{0,2}\s*(.+)/i);
120
+ let depends: string[] = [];
121
+ if (depsMatch) {
122
+ const rawDeps = depsMatch[1]!.replace(/none/i, "").trim();
123
+ if (rawDeps) {
124
+ depends = expandDependencies(
125
+ rawDeps.split(/[,;]/).map(s => s.trim().replace(/[^A-Za-z0-9]/g, "")).filter(Boolean)
126
+ );
127
+ }
128
+ }
129
+
130
+ slices.push({ id, title, risk: "medium" as RiskLevel, depends, done: false, demo: "" });
131
+ }
132
+
133
+ return slices;
134
+ }
@@ -62,7 +62,11 @@ export function isValidationTerminal(validationContent: string): boolean {
62
62
  if (!match) return false;
63
63
  const verdict = match[1].match(/verdict:\s*(\S+)/);
64
64
  if (!verdict) return false;
65
- return verdict[1] === 'pass' || verdict[1] === 'needs-attention';
65
+ // 'pass' and 'needs-attention' are always terminal.
66
+ // 'needs-remediation' is treated as terminal to prevent infinite loops
67
+ // when no remediation slices exist in the roadmap (#832). The validation
68
+ // report is preserved on disk for manual review.
69
+ return verdict[1] === 'pass' || verdict[1] === 'needs-attention' || verdict[1] === 'needs-remediation';
66
70
  }
67
71
 
68
72
  // ─── State Derivation ──────────────────────────────────────────────────────
@@ -290,19 +294,26 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
290
294
 
291
295
  if (complete) {
292
296
  // All slices done — check validation and summary state
297
+ const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
293
298
  const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION");
294
299
  const validationContent = validationFile ? await cachedLoadFile(validationFile) : null;
295
300
  const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false;
296
- const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY");
297
301
 
298
- if (!validationTerminal && !activeMilestoneFound) {
299
- // No terminal validation yet validating-milestone
302
+ if (summaryFile) {
303
+ // Summary exists milestone is complete regardless of validation state.
304
+ // The summary is the terminal artifact (#864).
305
+ registry.push({ id: mid, title, status: 'complete' });
306
+ } else if (!validationTerminal && !activeMilestoneFound) {
307
+ // No summary and no terminal validation → validating-milestone
300
308
  activeMilestone = { id: mid, title };
301
309
  activeRoadmap = roadmap;
302
310
  activeMilestoneFound = true;
303
311
  registry.push({ id: mid, title, status: 'active' });
304
- } else if (!summaryFile && !activeMilestoneFound) {
305
- // Validated but no summary written yet completing-milestone
312
+ } else if (!validationTerminal && activeMilestoneFound) {
313
+ // No summary and no terminal validation, but another milestone is already active
314
+ registry.push({ id: mid, title, status: 'pending' });
315
+ } else if (!activeMilestoneFound) {
316
+ // Terminal validation but no summary → completing-milestone
306
317
  activeMilestone = { id: mid, title };
307
318
  activeRoadmap = roadmap;
308
319
  activeMilestoneFound = true;
@@ -700,6 +700,76 @@ slice: S01
700
700
  }
701
701
  }
702
702
 
703
+ // ─── Test: completed M001 (summary, no validation) skipped for active M003 (#864) ────
704
+ console.log('\n=== completed milestone with summary but no validation is not active (#864) ===');
705
+ {
706
+ const base = createFixtureBase();
707
+ try {
708
+ // M001: all slices done, has summary, no validation
709
+ writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Done.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
710
+ writeMilestoneSummary(base, 'M001', '---\nid: M001\n---\n\n# M001: First Milestone\n\n**Completed.**');
711
+ // M003: incomplete, should be active
712
+ writeRoadmap(base, 'M003', `# M003: Active Milestone\n\n**Vision:** Do stuff.\n\n## Slices\n\n- [ ] **S01: Work slice** \`risk:low\` \`depends:[]\`\n > Needs work.\n`);
713
+
714
+ const state = await deriveState(base);
715
+ assertEq(state.activeMilestone?.id, 'M003', 'active milestone is M003, not completed M001');
716
+ const m001Entry = state.registry.find(e => e.id === 'M001');
717
+ assertEq(m001Entry?.status, 'complete', 'M001 is marked complete despite no validation');
718
+ } finally {
719
+ cleanup(base);
720
+ }
721
+ }
722
+
723
+ // ─── Test: completed M001 with summary AND validation is complete (#864) ────
724
+ console.log('\n=== completed milestone with summary and validation is complete ===');
725
+ {
726
+ const base = createFixtureBase();
727
+ try {
728
+ writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Done.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
729
+ writeMilestoneSummary(base, 'M001', '---\nid: M001\n---\n\n# M001: First Milestone\n\n**Completed.**');
730
+ writeMilestoneValidation(base, 'M001', 'pass');
731
+ writeRoadmap(base, 'M003', `# M003: Active Milestone\n\n**Vision:** Do stuff.\n\n## Slices\n\n- [ ] **S01: Work slice** \`risk:low\` \`depends:[]\`\n > Needs work.\n`);
732
+
733
+ const state = await deriveState(base);
734
+ assertEq(state.activeMilestone?.id, 'M003', 'active milestone is M003');
735
+ const m001Entry = state.registry.find(e => e.id === 'M001');
736
+ assertEq(m001Entry?.status, 'complete', 'M001 with both summary and validation is complete');
737
+ } finally {
738
+ cleanup(base);
739
+ }
740
+ }
741
+
742
+ // ─── Test: all slices done, no summary, no validation → needs validation (#864) ────
743
+ console.log('\n=== all slices done, no summary, no validation → validating-milestone ===');
744
+ {
745
+ const base = createFixtureBase();
746
+ try {
747
+ writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Validate me.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
748
+ // No summary, no validation — this should be active for validation
749
+
750
+ const state = await deriveState(base);
751
+ assertEq(state.activeMilestone?.id, 'M001', 'M001 is active for validation');
752
+ } finally {
753
+ cleanup(base);
754
+ }
755
+ }
756
+
757
+ // ─── Test: all slices done, validation pass, no summary → needs completion (#864) ────
758
+ console.log('\n=== all slices done, validation pass, no summary → completing-milestone ===');
759
+ {
760
+ const base = createFixtureBase();
761
+ try {
762
+ writeRoadmap(base, 'M001', `# M001: First Milestone\n\n**Vision:** Complete me.\n\n## Slices\n\n- [x] **S01: Done slice** \`risk:low\` \`depends:[]\`\n > Completed.\n`);
763
+ writeMilestoneValidation(base, 'M001', 'pass');
764
+ // No summary — validated but not yet completed
765
+
766
+ const state = await deriveState(base);
767
+ assertEq(state.activeMilestone?.id, 'M001', 'M001 is active for completion');
768
+ } finally {
769
+ cleanup(base);
770
+ }
771
+ }
772
+
703
773
  report();
704
774
  }
705
775
 
@@ -188,7 +188,7 @@ async function main(): Promise<void> {
188
188
  cleanups.push(dir);
189
189
  mkdirSync(join(dir, ".gsd"), { recursive: true });
190
190
 
191
- const result = preDispatchHealthGate(dir);
191
+ const result = await preDispatchHealthGate(dir);
192
192
  assertTrue(result.proceed, "gate passes on clean state");
193
193
  assertEq(result.issues.length, 0, "no issues on clean state");
194
194
  }
@@ -206,7 +206,7 @@ async function main(): Promise<void> {
206
206
  unitStartedAt: "2026-03-10T00:01:00Z", completedUnits: 3,
207
207
  }));
208
208
 
209
- const result = preDispatchHealthGate(dir);
209
+ const result = await preDispatchHealthGate(dir);
210
210
  assertTrue(result.proceed, "gate passes after auto-clearing stale lock");
211
211
  assertTrue(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared");
212
212
  assertTrue(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed");
@@ -222,7 +222,7 @@ async function main(): Promise<void> {
222
222
  const headHash = run("git rev-parse HEAD", dir);
223
223
  writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
224
224
 
225
- const result = preDispatchHealthGate(dir);
225
+ const result = await preDispatchHealthGate(dir);
226
226
  assertTrue(result.proceed, "gate passes after auto-healing merge state");
227
227
  assertTrue(result.fixesApplied.some(f => f.includes("cleaned merge state")), "reports merge state cleaned");
228
228
  assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed");
@@ -231,6 +231,26 @@ async function main(): Promise<void> {
231
231
  console.log(" (skipped on Windows)");
232
232
  }
233
233
 
234
+ console.log("\n=== health gate: STATE.md missing — auto-healed ===");
235
+ {
236
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
237
+ cleanups.push(dir);
238
+ // Minimal .gsd structure: milestones dir exists but no STATE.md
239
+ mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
240
+
241
+ const stateFile = join(dir, ".gsd", "STATE.md");
242
+ assertTrue(!existsSync(stateFile), "STATE.md does not exist before gate");
243
+
244
+ const result = await preDispatchHealthGate(dir);
245
+ assertTrue(result.proceed, "gate passes after rebuilding STATE.md");
246
+ assertTrue(
247
+ result.fixesApplied.some(f => f.includes("rebuilt missing STATE.md")),
248
+ "reports STATE.md rebuilt",
249
+ );
250
+ assertTrue(existsSync(stateFile), "STATE.md created by auto-heal");
251
+ assertTrue(result.issues.length === 0, "no blocking issues after heal");
252
+ }
253
+
234
254
  } finally {
235
255
  resetProactiveHealing();
236
256
  for (const dir of cleanups) {
@@ -11,6 +11,7 @@ import { writeGSDDirectory } from '../migrate/writer.ts';
11
11
  import { generatePreview } from '../migrate/preview.ts';
12
12
  import { parseRoadmap, parsePlan, parseSummary } from '../files.ts';
13
13
  import { deriveState } from '../state.ts';
14
+ import { invalidateAllCaches } from '../cache.ts';
14
15
  import type {
15
16
  GSDProject,
16
17
  GSDMilestone,
@@ -207,6 +208,7 @@ async function main(): Promise<void> {
207
208
 
208
209
  // (e) deriveState
209
210
  console.log(' --- deriveState ---');
211
+ invalidateAllCaches();
210
212
  const state = await deriveState(base);
211
213
  assertEq(state.phase, 'executing', 'incomplete: deriveState phase is executing');
212
214
  assertTrue(state.activeMilestone !== null, 'incomplete: deriveState has activeMilestone');
@@ -262,14 +264,18 @@ async function main(): Promise<void> {
262
264
  assertTrue(!existsSync(join(m, 'M001-RESEARCH.md')), 'complete: M001-RESEARCH.md NOT written (null)');
263
265
  // No REQUIREMENTS.md since empty requirements
264
266
  assertTrue(!existsSync(join(base, '.gsd', 'REQUIREMENTS.md')), 'complete: REQUIREMENTS.md NOT written (empty)');
265
-
266
- // deriveState: all slices done, all tasks done needs validation then milestone summary
267
- // Without VALIDATION file, it should be 'validating-milestone'
267
+ // Completed milestone should have VALIDATION and SUMMARY from migration (#819)
268
+ assertTrue(existsSync(join(m, 'M001-VALIDATION.md')), 'complete: M001-VALIDATION.md written for completed milestone');
269
+ assertTrue(existsSync(join(m, 'M001-SUMMARY.md')), 'complete: M001-SUMMARY.md written for completed milestone');
270
+
271
+ // deriveState: all slices done, all tasks done — migration now writes
272
+ // VALIDATION.md and SUMMARY.md for completed milestones (#819),
273
+ // so the milestone should be fully complete.
274
+ invalidateAllCaches();
268
275
  const state = await deriveState(base);
269
- // All slices are done in roadmap. No VALIDATION or SUMMARY exists.
270
- // deriveState should return 'validating-milestone' since validation gate precedes completion.
271
- assertEq(state.phase, 'validating-milestone', 'complete: deriveState phase is validating-milestone');
272
- assertTrue(state.activeMilestone !== null, 'complete: deriveState has activeMilestone');
276
+ assertEq(state.phase, 'complete', 'complete: deriveState phase is complete (validation + summary written by migration)');
277
+ // When all milestones are complete, activeMilestone points to the last entry (for display)
278
+ assertTrue(state.activeMilestone !== null, 'complete: deriveState has activeMilestone (last entry)');
273
279
  assertEq(state.activeMilestone!.id, 'M001', 'complete: deriveState activeMilestone is M001');
274
280
 
275
281
  // generatePreview for complete project