gsd-pi 2.32.0 → 2.33.0-dev.bafba33

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 (91) hide show
  1. package/README.md +27 -20
  2. package/dist/resource-loader.js +13 -3
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
  4. package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
  5. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  6. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  7. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  8. package/dist/resources/extensions/gsd/auto-prompts.ts +46 -44
  9. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  10. package/dist/resources/extensions/gsd/auto-start.ts +8 -6
  11. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  12. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  13. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  14. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  15. package/dist/resources/extensions/gsd/auto-worktree.ts +5 -4
  16. package/dist/resources/extensions/gsd/auto.ts +108 -182
  17. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  18. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  19. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  20. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  21. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  22. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  23. package/dist/resources/extensions/gsd/export.ts +2 -1
  24. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  25. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  26. package/dist/resources/extensions/gsd/index.ts +12 -5
  27. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  28. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  29. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  30. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  31. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  33. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  35. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  36. package/dist/resources/extensions/gsd/quick.ts +58 -3
  37. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  38. package/dist/resources/extensions/gsd/session-lock.ts +12 -1
  39. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  40. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  41. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  42. package/dist/resources/extensions/gsd/undo.ts +5 -7
  43. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  44. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  45. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  46. package/package.json +3 -2
  47. package/packages/pi-coding-agent/package.json +1 -1
  48. package/pkg/package.json +1 -1
  49. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  50. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  51. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  52. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  53. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  54. package/src/resources/extensions/gsd/auto-prompts.ts +46 -44
  55. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  56. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  57. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  58. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  59. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  60. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  61. package/src/resources/extensions/gsd/auto-worktree.ts +5 -4
  62. package/src/resources/extensions/gsd/auto.ts +108 -182
  63. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  64. package/src/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  65. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  66. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  67. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  68. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  69. package/src/resources/extensions/gsd/export.ts +2 -1
  70. package/src/resources/extensions/gsd/git-service.ts +3 -2
  71. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  72. package/src/resources/extensions/gsd/index.ts +12 -5
  73. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  74. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  75. package/src/resources/extensions/gsd/metrics.ts +3 -3
  76. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  77. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  78. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  79. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  80. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  81. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  82. package/src/resources/extensions/gsd/quick.ts +58 -3
  83. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  84. package/src/resources/extensions/gsd/session-lock.ts +12 -1
  85. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  86. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  87. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  88. package/src/resources/extensions/gsd/undo.ts +5 -7
  89. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  90. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  91. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
@@ -0,0 +1,839 @@
1
+ /**
2
+ * Regression test suite for the auto-mode dispatch loop.
3
+ * Covers phase transitions, dispatch rule matching, state derivation edge cases,
4
+ * and every fix from the #1308 issue catalog.
5
+ *
6
+ * Run: node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \
7
+ * --experimental-strip-types --test src/resources/extensions/gsd/tests/loop-regression.test.ts
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { deriveState } from "../state.ts";
16
+ import { resolveDispatch, getDispatchRuleNames } from "../auto-dispatch.ts";
17
+ import type { GSDState } from "../types.ts";
18
+
19
+ // ─── Helpers ──────────────────────────────────────────────────────────────
20
+
21
+ function makeTmp(name: string): string {
22
+ const dir = join(tmpdir(), `loop-regression-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
23
+ mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+
27
+ function writeGsdFile(base: string, ...pathParts: string[]): void {
28
+ const fullPath = join(base, ".gsd", ...pathParts);
29
+ mkdirSync(join(fullPath, ".."), { recursive: true });
30
+ // Default to empty content; callers use writeGsdFileContent for real content
31
+ }
32
+
33
+ function writeGsdFileContent(base: string, relativePath: string, content: string): void {
34
+ const fullPath = join(base, ".gsd", relativePath);
35
+ mkdirSync(join(fullPath, ".."), { recursive: true });
36
+ writeFileSync(fullPath, content, "utf-8");
37
+ }
38
+
39
+ function buildMinimalRoadmap(slices: Array<{ id: string; title: string; done: boolean; depends?: string[] }>): string {
40
+ const lines = ["# M001: Test Milestone", "", "## Slices", ""];
41
+ for (const s of slices) {
42
+ const cb = s.done ? "x" : " ";
43
+ const deps = s.depends?.length ? ` \`depends:[${s.depends.join(",")}]\`` : " `depends:[]`";
44
+ lines.push(`- [${cb}] **${s.id}: ${s.title}** \`risk:low\`${deps}`);
45
+ lines.push(` > Demo text for ${s.id}`);
46
+ lines.push("");
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ function buildMinimalPlan(tasks: Array<{ id: string; title: string; done: boolean }>): string {
52
+ const lines = ["# S01: Test Slice", "", "**Goal:** test", "", "## Tasks", ""];
53
+ for (const t of tasks) {
54
+ const cb = t.done ? "x" : " ";
55
+ lines.push(`- [${cb}] **${t.id}: ${t.title}** \`est:5m\``);
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+
60
+ function buildMinimalSummary(id: string): string {
61
+ return [
62
+ "---",
63
+ `id: ${id}`,
64
+ "parent: S01",
65
+ "milestone: M001",
66
+ "duration: 5m",
67
+ "verification_result: passed",
68
+ `completed_at: ${new Date().toISOString()}`,
69
+ "---",
70
+ "",
71
+ `# ${id}: Done`,
72
+ "",
73
+ "Completed.",
74
+ ].join("\n");
75
+ }
76
+
77
+ // ─── Phase 1: Dispatch Rule Ordering ──────────────────────────────────────
78
+
79
+ test("dispatch rules are in the expected order", () => {
80
+ const names = getDispatchRuleNames();
81
+ assert.ok(names.length >= 15, `expected ≥15 rules, got ${names.length}`);
82
+
83
+ // Verify critical ordering: override gate first, complete-slice before UAT,
84
+ // needs-discussion before pre-planning, executing last
85
+ const overrideIdx = names.indexOf("rewrite-docs (override gate)");
86
+ const completeSliceIdx = names.indexOf("summarizing → complete-slice");
87
+ const uatGateIdx = names.indexOf("uat-verdict-gate (non-PASS blocks progression)");
88
+ const needsDiscussIdx = names.indexOf("needs-discussion → stop");
89
+ const prePlanNoCtxIdx = names.indexOf("pre-planning (no context) → stop");
90
+ const executeIdx = names.indexOf("executing → execute-task");
91
+
92
+ assert.ok(overrideIdx === 0, "override gate should be first rule");
93
+ assert.ok(completeSliceIdx < uatGateIdx, "complete-slice should fire before UAT gate");
94
+ assert.ok(needsDiscussIdx < prePlanNoCtxIdx, "needs-discussion should fire before pre-planning");
95
+ assert.ok(executeIdx > prePlanNoCtxIdx, "execute-task should fire after pre-planning rules");
96
+ });
97
+
98
+ // ─── Phase 2: State Derivation — Phase Transitions ───────────────────────
99
+
100
+ test("deriveState: empty project → pre-planning with no milestones", async () => {
101
+ const tmp = makeTmp("empty");
102
+ try {
103
+ mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true });
104
+ const state = await deriveState(tmp);
105
+ assert.equal(state.phase, "pre-planning");
106
+ assert.equal(state.activeMilestone, null);
107
+ assert.deepEqual(state.registry, []);
108
+ } finally {
109
+ rmSync(tmp, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("deriveState: milestone with context but no roadmap → pre-planning", async () => {
114
+ const tmp = makeTmp("no-roadmap");
115
+ try {
116
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
117
+ mkdirSync(mDir, { recursive: true });
118
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\nContext here.");
119
+ const state = await deriveState(tmp);
120
+ assert.equal(state.phase, "pre-planning");
121
+ assert.equal(state.activeMilestone?.id, "M001");
122
+ } finally {
123
+ rmSync(tmp, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test("deriveState: milestone with CONTEXT-DRAFT.md → needs-discussion", async () => {
128
+ const tmp = makeTmp("draft");
129
+ try {
130
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
131
+ mkdirSync(mDir, { recursive: true });
132
+ writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nSome ideas.");
133
+ const state = await deriveState(tmp);
134
+ assert.equal(state.phase, "needs-discussion");
135
+ assert.equal(state.activeMilestone?.id, "M001");
136
+ } finally {
137
+ rmSync(tmp, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("deriveState: roadmap with no plan → planning", async () => {
142
+ const tmp = makeTmp("planning");
143
+ try {
144
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
145
+ mkdirSync(join(mDir, "slices", "S01"), { recursive: true });
146
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
147
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
148
+ { id: "S01", title: "First Slice", done: false },
149
+ ]));
150
+ const state = await deriveState(tmp);
151
+ assert.equal(state.phase, "planning");
152
+ assert.equal(state.activeSlice?.id, "S01");
153
+ } finally {
154
+ rmSync(tmp, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("deriveState: plan with incomplete tasks → executing", async () => {
159
+ const tmp = makeTmp("executing");
160
+ try {
161
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
162
+ const sDir = join(mDir, "slices", "S01");
163
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
164
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
165
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
166
+ { id: "S01", title: "First Slice", done: false },
167
+ ]));
168
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
169
+ { id: "T01", title: "Task One", done: false },
170
+ { id: "T02", title: "Task Two", done: false },
171
+ ]));
172
+ writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01 Plan\n\nDo stuff.");
173
+ writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02 Plan\n\nDo more.");
174
+ const state = await deriveState(tmp);
175
+ assert.equal(state.phase, "executing");
176
+ assert.equal(state.activeTask?.id, "T01");
177
+ } finally {
178
+ rmSync(tmp, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("deriveState: all tasks done → summarizing", async () => {
183
+ const tmp = makeTmp("summarizing");
184
+ try {
185
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
186
+ const sDir = join(mDir, "slices", "S01");
187
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
188
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
189
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
190
+ { id: "S01", title: "First Slice", done: false },
191
+ ]));
192
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
193
+ { id: "T01", title: "Task One", done: true },
194
+ ]));
195
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
196
+ const state = await deriveState(tmp);
197
+ assert.equal(state.phase, "summarizing");
198
+ assert.equal(state.activeSlice?.id, "S01");
199
+ assert.equal(state.activeTask, null);
200
+ } finally {
201
+ rmSync(tmp, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test("deriveState: all slices done → validating-milestone", async () => {
206
+ const tmp = makeTmp("validating");
207
+ try {
208
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
209
+ const sDir = join(mDir, "slices", "S01");
210
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
211
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
212
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
213
+ { id: "S01", title: "First Slice", done: true },
214
+ ]));
215
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
216
+ { id: "T01", title: "Task One", done: true },
217
+ ]));
218
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
219
+ writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone.");
220
+ const state = await deriveState(tmp);
221
+ assert.equal(state.phase, "validating-milestone");
222
+ } finally {
223
+ rmSync(tmp, { recursive: true, force: true });
224
+ }
225
+ });
226
+
227
+ test("deriveState: validation terminal → completing-milestone", async () => {
228
+ const tmp = makeTmp("completing");
229
+ try {
230
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
231
+ const sDir = join(mDir, "slices", "S01");
232
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
233
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
234
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
235
+ { id: "S01", title: "First Slice", done: true },
236
+ ]));
237
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
238
+ { id: "T01", title: "Task One", done: true },
239
+ ]));
240
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
241
+ writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone.");
242
+ writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\n\nAll good.");
243
+ const state = await deriveState(tmp);
244
+ assert.equal(state.phase, "completing-milestone");
245
+ } finally {
246
+ rmSync(tmp, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test("deriveState: milestone with summary → complete", async () => {
251
+ const tmp = makeTmp("complete");
252
+ try {
253
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
254
+ mkdirSync(mDir, { recursive: true });
255
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
256
+ { id: "S01", title: "First Slice", done: true },
257
+ ]));
258
+ writeFileSync(join(mDir, "M001-SUMMARY.md"), "# M001 Summary\n\nMilestone complete.");
259
+ const state = await deriveState(tmp);
260
+ assert.equal(state.phase, "complete");
261
+ } finally {
262
+ rmSync(tmp, { recursive: true, force: true });
263
+ }
264
+ });
265
+
266
+ // ─── Phase 3: Regression Tests for Specific Bug Fixes ────────────────────
267
+
268
+ test("#1155: completion-transition codes should NOT be fixable at task level", async () => {
269
+ // Verify COMPLETION_TRANSITION_CODES exists and contains expected codes
270
+ const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts");
271
+ assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_summary"));
272
+ assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_uat"));
273
+ assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_roadmap_not_checked"));
274
+ });
275
+
276
+ test("#1170: needs-discussion phase is correctly derived from CONTEXT-DRAFT.md", async () => {
277
+ const tmp = makeTmp("needs-discussion");
278
+ try {
279
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
280
+ mkdirSync(mDir, { recursive: true });
281
+ writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nDraft context.");
282
+ const state = await deriveState(tmp);
283
+ assert.equal(state.phase, "needs-discussion");
284
+ // Verify the dispatch table returns stop for needs-discussion
285
+ const result = await resolveDispatch({
286
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
287
+ });
288
+ assert.equal(result.action, "stop");
289
+ } finally {
290
+ rmSync(tmp, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test("#1176: state.registry is always an array even with corrupt/missing state", async () => {
295
+ const tmp = makeTmp("empty-registry");
296
+ try {
297
+ mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true });
298
+ const state = await deriveState(tmp);
299
+ assert.ok(Array.isArray(state.registry), "registry should be an array");
300
+ assert.equal(state.registry.length, 0);
301
+ } finally {
302
+ rmSync(tmp, { recursive: true, force: true });
303
+ }
304
+ });
305
+
306
+ test("#1243: prose H3 slice headers are parsed correctly", async () => {
307
+ const { parseRoadmapSlices } = await import("../roadmap-slices.ts");
308
+ const content = `# M001: Test
309
+
310
+ ## Roadmap
311
+
312
+ ### S01: First Feature
313
+ Depends on: none
314
+
315
+ ### S02: Second Feature
316
+ Depends on: S01
317
+
318
+ ### S03: Third Feature
319
+ `;
320
+ const slices = parseRoadmapSlices(content);
321
+ assert.equal(slices.length, 3, "should parse 3 H3 slices");
322
+ assert.equal(slices[0]!.id, "S01");
323
+ assert.equal(slices[1]!.id, "S02");
324
+ assert.equal(slices[2]!.id, "S03");
325
+ assert.deepEqual(slices[1]!.depends, ["S01"]);
326
+ });
327
+
328
+ test("#1243: bold-wrapped and dot-separator slice headers are parsed", async () => {
329
+ const { parseRoadmapSlices } = await import("../roadmap-slices.ts");
330
+ const content = `# M001
331
+
332
+ ## **S01: Bold Wrapped**
333
+ > Demo
334
+
335
+ ## S02. Dot Separator Title
336
+ > Demo
337
+ `;
338
+ const slices = parseRoadmapSlices(content);
339
+ assert.equal(slices.length, 2);
340
+ assert.equal(slices[0]!.id, "S01");
341
+ assert.ok(slices[0]!.title.includes("Bold"), `title should contain Bold, got: ${slices[0]!.title}`);
342
+ assert.equal(slices[1]!.id, "S02");
343
+ });
344
+
345
+ test("slice dependency blocking → phase: blocked", async () => {
346
+ const tmp = makeTmp("dep-blocked");
347
+ try {
348
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
349
+ mkdirSync(join(mDir, "slices"), { recursive: true });
350
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
351
+ // S01 depends on S02 and S02 depends on S01 — circular!
352
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
353
+ { id: "S01", title: "Slice A", done: false, depends: ["S02"] },
354
+ { id: "S02", title: "Slice B", done: false, depends: ["S01"] },
355
+ ]));
356
+ const state = await deriveState(tmp);
357
+ assert.equal(state.phase, "blocked");
358
+ assert.ok(state.blockers.length > 0, "should have blockers");
359
+ } finally {
360
+ rmSync(tmp, { recursive: true, force: true });
361
+ }
362
+ });
363
+
364
+ test("multi-milestone: M001 complete, M002 active", async () => {
365
+ const tmp = makeTmp("multi-milestone");
366
+ try {
367
+ // M001 — complete
368
+ const m1Dir = join(tmp, ".gsd", "milestones", "M001");
369
+ mkdirSync(m1Dir, { recursive: true });
370
+ writeFileSync(join(m1Dir, "M001-ROADMAP.md"), buildMinimalRoadmap([
371
+ { id: "S01", title: "Done", done: true },
372
+ ]));
373
+ writeFileSync(join(m1Dir, "M001-SUMMARY.md"), "# M001 Summary\n\nComplete.");
374
+
375
+ // M002 — active, needs planning
376
+ const m2Dir = join(tmp, ".gsd", "milestones", "M002");
377
+ mkdirSync(m2Dir, { recursive: true });
378
+ writeFileSync(join(m2Dir, "M002-CONTEXT.md"), "# M002\n\nNew work.");
379
+
380
+ const state = await deriveState(tmp);
381
+ assert.equal(state.activeMilestone?.id, "M002");
382
+ assert.equal(state.phase, "pre-planning");
383
+ assert.equal(state.registry.length, 2);
384
+ assert.equal(state.registry[0]!.status, "complete");
385
+ assert.equal(state.registry[1]!.status, "active");
386
+ } finally {
387
+ rmSync(tmp, { recursive: true, force: true });
388
+ }
389
+ });
390
+
391
+ test("blocker_discovered in task summary → replanning-slice", async () => {
392
+ const tmp = makeTmp("replan");
393
+ try {
394
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
395
+ const sDir = join(mDir, "slices", "S01");
396
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
397
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
398
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
399
+ { id: "S01", title: "Test", done: false },
400
+ ]));
401
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
402
+ { id: "T01", title: "Done", done: true },
403
+ { id: "T02", title: "Todo", done: false },
404
+ ]));
405
+ writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nPlan.");
406
+ writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02\nPlan.");
407
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), [
408
+ "---",
409
+ "id: T01",
410
+ "parent: S01",
411
+ "milestone: M001",
412
+ "blocker_discovered: true",
413
+ "---",
414
+ "",
415
+ "# T01: Blocker found",
416
+ "",
417
+ "API doesn't support this.",
418
+ ].join("\n"));
419
+
420
+ const state = await deriveState(tmp);
421
+ assert.equal(state.phase, "replanning-slice");
422
+ assert.ok(state.blockers[0]!.includes("T01"), "blocker should reference T01");
423
+ } finally {
424
+ rmSync(tmp, { recursive: true, force: true });
425
+ }
426
+ });
427
+
428
+ // ─── Phase 4: Edge Cases ─────────────────────────────────────────────────
429
+
430
+ test("empty plan file (0 tasks) → stays in planning", async () => {
431
+ const tmp = makeTmp("empty-plan");
432
+ try {
433
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
434
+ const sDir = join(mDir, "slices", "S01");
435
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
436
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
437
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
438
+ { id: "S01", title: "Test", done: false },
439
+ ]));
440
+ // Plan file exists but has no task entries
441
+ writeFileSync(join(sDir, "S01-PLAN.md"), "# S01: Test\n\n**Goal:** test\n\n## Tasks\n");
442
+
443
+ const state = await deriveState(tmp);
444
+ assert.equal(state.phase, "planning");
445
+ } finally {
446
+ rmSync(tmp, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test("parked milestone is not treated as active or complete", async () => {
451
+ const tmp = makeTmp("parked");
452
+ try {
453
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
454
+ mkdirSync(mDir, { recursive: true });
455
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
456
+ { id: "S01", title: "Test", done: false },
457
+ ]));
458
+ writeFileSync(join(mDir, "M001-PARKED.md"), "Parked for later.");
459
+
460
+ const state = await deriveState(tmp);
461
+ assert.equal(state.registry[0]!.status, "parked");
462
+ assert.equal(state.activeMilestone, null);
463
+ // Phase should be pre-planning (all milestones parked, not complete)
464
+ assert.equal(state.phase, "pre-planning");
465
+ } finally {
466
+ rmSync(tmp, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ // ─── Phase 5: Defensive Guards ───────────────────────────────────────────
471
+
472
+ test("dispatch returns stop when phase=summarizing but activeSlice is null (corrupt state)", async () => {
473
+ const corruptState: GSDState = {
474
+ activeMilestone: { id: "M001", title: "Test" },
475
+ activeSlice: null, // BUG: summarizing should always have activeSlice
476
+ activeTask: null,
477
+ phase: "summarizing",
478
+ recentDecisions: [],
479
+ blockers: [],
480
+ nextAction: "",
481
+ registry: [{ id: "M001", title: "Test", status: "active" }],
482
+ requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
483
+ progress: { milestones: { done: 0, total: 1 } },
484
+ };
485
+ const result = await resolveDispatch({
486
+ basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined,
487
+ });
488
+ assert.equal(result.action, "stop", "should stop instead of crashing");
489
+ assert.ok((result as any).reason.includes("no active slice"), `reason should mention missing slice: ${(result as any).reason}`);
490
+ });
491
+
492
+ test("dispatch returns stop when phase=executing but activeSlice is null (corrupt state)", async () => {
493
+ const corruptState: GSDState = {
494
+ activeMilestone: { id: "M001", title: "Test" },
495
+ activeSlice: null,
496
+ activeTask: { id: "T01", title: "Task" },
497
+ phase: "executing",
498
+ recentDecisions: [],
499
+ blockers: [],
500
+ nextAction: "",
501
+ registry: [{ id: "M001", title: "Test", status: "active" }],
502
+ requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
503
+ progress: { milestones: { done: 0, total: 1 } },
504
+ };
505
+ const result = await resolveDispatch({
506
+ basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined,
507
+ });
508
+ assert.equal(result.action, "stop", "should stop instead of crashing");
509
+ });
510
+
511
+ // ─── Phase 6: Worktree & Lock Consistency ────────────────────────────────
512
+
513
+ test("repoIdentity returns same hash for main repo and worktree", async () => {
514
+ // This test verifies the fix for #1288 — identity hash must be stable
515
+ // across worktree and non-worktree contexts.
516
+ const { repoIdentity } = await import("../repo-identity.ts");
517
+ // Call from the current directory (main repo)
518
+ const hash = repoIdentity(process.cwd());
519
+ assert.ok(hash.length === 12, `hash should be 12 hex chars, got: ${hash}`);
520
+ assert.match(hash, /^[a-f0-9]{12}$/, `hash should be hex, got: ${hash}`);
521
+ });
522
+
523
+ test("session lock settings: retry path matches primary stale timeout", async () => {
524
+ // Verify the fix for #1304 — retry lock must use same settings as primary
525
+ const lockSource = (await import("node:fs")).readFileSync(
526
+ "src/resources/extensions/gsd/session-lock.ts", "utf-8"
527
+ );
528
+ // Find all stale: settings
529
+ const staleMatches = [...lockSource.matchAll(/stale:\s*(\d[\d_]*)/g)];
530
+ const staleValues = staleMatches.map(m => parseInt(m[1]!.replace(/_/g, ""), 10));
531
+ // All stale values should be the same (primary and retry aligned)
532
+ const uniqueStale = [...new Set(staleValues)];
533
+ assert.equal(uniqueStale.length, 1, `all stale timeouts should be identical, got: ${staleValues.join(", ")}`);
534
+ });
535
+
536
+ test("COMPLETION_TRANSITION_CODES are a subset of DoctorIssueCode", async () => {
537
+ const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts");
538
+ // Just verify the set is non-empty and contains expected codes
539
+ assert.ok(COMPLETION_TRANSITION_CODES.size >= 3, "should have at least 3 transition codes");
540
+ for (const code of COMPLETION_TRANSITION_CODES) {
541
+ assert.ok(typeof code === "string", `code should be string: ${code}`);
542
+ assert.ok(code.startsWith("all_tasks_done_"), `code should start with all_tasks_done_: ${code}`);
543
+ }
544
+ });
545
+
546
+ // ─── Scope 2: State Derivation — Array Safety ────────────────────────────
547
+
548
+ test("deriveState: registry is always an array with malformed roadmap", async () => {
549
+ const tmp = makeTmp("malformed-roadmap");
550
+ try {
551
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
552
+ mkdirSync(mDir, { recursive: true });
553
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
554
+ // Roadmap exists but is completely empty
555
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), "");
556
+ const state = await deriveState(tmp);
557
+ assert.ok(Array.isArray(state.registry), "registry must be array");
558
+ assert.equal(state.activeMilestone?.id, "M001");
559
+ } finally {
560
+ rmSync(tmp, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ test("deriveState: plan with garbled content still returns valid state", async () => {
565
+ const tmp = makeTmp("garbled-plan");
566
+ try {
567
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
568
+ const sDir = join(mDir, "slices", "S01");
569
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
570
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
571
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
572
+ { id: "S01", title: "Test", done: false },
573
+ ]));
574
+ // Plan file exists but contains garbage
575
+ writeFileSync(join(sDir, "S01-PLAN.md"), "just some random text\nno tasks here\n!!!");
576
+ const state = await deriveState(tmp);
577
+ // Should fall back to planning since no tasks parsed
578
+ assert.equal(state.phase, "planning");
579
+ assert.equal(state.activeSlice?.id, "S01");
580
+ } finally {
581
+ rmSync(tmp, { recursive: true, force: true });
582
+ }
583
+ });
584
+
585
+ // ─── Scope 4: Lock Management — Exit Handler Verification ────────────────
586
+
587
+ test("session lock: releaseSessionLock removes auto.lock file", async () => {
588
+ const tmp = makeTmp("lock-release");
589
+ try {
590
+ const gsd = join(tmp, ".gsd");
591
+ mkdirSync(gsd, { recursive: true });
592
+ const lockFile = join(gsd, "auto.lock");
593
+ writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
594
+ assert.ok(existsSync(lockFile), "lock file should exist before release");
595
+
596
+ const { releaseSessionLock } = await import("../session-lock.ts");
597
+ releaseSessionLock(tmp);
598
+
599
+ assert.ok(!existsSync(lockFile), "lock file should be removed after release");
600
+ } finally {
601
+ rmSync(tmp, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ test("session lock: onCompromised handler exists in both primary and retry paths", async () => {
606
+ const lockSource = readFileSync(
607
+ "src/resources/extensions/gsd/session-lock.ts", "utf-8"
608
+ );
609
+ const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)];
610
+ // Should have at least 2 onCompromised handlers (primary + retry)
611
+ // plus the flag declaration and the check in validateSessionLock
612
+ assert.ok(compromisedMatches.length >= 3,
613
+ `expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`);
614
+ });
615
+
616
+ // ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ────────────
617
+
618
+ test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => {
619
+ const { formatCrashInfo } = await import("../crash-recovery.ts");
620
+ const info = formatCrashInfo({
621
+ pid: 12345,
622
+ startedAt: new Date().toISOString(),
623
+ unitType: "starting",
624
+ unitId: "bootstrap",
625
+ unitStartedAt: new Date().toISOString(),
626
+ completedUnits: 0,
627
+ });
628
+ assert.ok(info.includes("bootstrap"), "should mention bootstrap");
629
+ assert.ok(info.includes("No work was lost") || info.includes("/gsd auto"),
630
+ "should include recovery guidance for bootstrap crash");
631
+ });
632
+
633
+ test("crash recovery: formatCrashInfo includes guidance for execute-task crash", async () => {
634
+ const { formatCrashInfo } = await import("../crash-recovery.ts");
635
+ const info = formatCrashInfo({
636
+ pid: 12345,
637
+ startedAt: new Date().toISOString(),
638
+ unitType: "execute-task",
639
+ unitId: "M001/S01/T02",
640
+ unitStartedAt: new Date().toISOString(),
641
+ completedUnits: 5,
642
+ });
643
+ assert.ok(info.includes("execute"), "should mention execute");
644
+ assert.ok(info.includes("resume") || info.includes("preserved") || info.includes("/gsd auto"),
645
+ "should include recovery guidance for task crash");
646
+ });
647
+
648
+ test("crash recovery: formatCrashInfo includes guidance for complete-slice crash", async () => {
649
+ const { formatCrashInfo } = await import("../crash-recovery.ts");
650
+ const info = formatCrashInfo({
651
+ pid: 12345,
652
+ startedAt: new Date().toISOString(),
653
+ unitType: "complete-slice",
654
+ unitId: "M001/S01",
655
+ unitStartedAt: new Date().toISOString(),
656
+ completedUnits: 10,
657
+ });
658
+ assert.ok(info.includes("complete"), "should mention complete");
659
+ assert.ok(info.includes("finish") || info.includes("/gsd auto"),
660
+ "should include recovery guidance for completion crash");
661
+ });
662
+
663
+ test("crash recovery: formatCrashInfo includes guidance for research crash", async () => {
664
+ const { formatCrashInfo } = await import("../crash-recovery.ts");
665
+ const info = formatCrashInfo({
666
+ pid: 12345,
667
+ startedAt: new Date().toISOString(),
668
+ unitType: "research-milestone",
669
+ unitId: "M001",
670
+ unitStartedAt: new Date().toISOString(),
671
+ completedUnits: 1,
672
+ });
673
+ assert.ok(info.includes("research"), "should mention research");
674
+ assert.ok(info.includes("incomplete") || info.includes("re-run") || info.includes("/gsd auto"),
675
+ "should include recovery guidance for research crash");
676
+ });
677
+
678
+ // ─── Scope 6: Milestone Transitions — Dispatch Flow ─────────────────────
679
+
680
+ test("dispatch: needs-discussion stops with discussion guidance", async () => {
681
+ const tmp = makeTmp("dispatch-discussion");
682
+ try {
683
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
684
+ mkdirSync(mDir, { recursive: true });
685
+ writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nIdeas.");
686
+ const state = await deriveState(tmp);
687
+ const result = await resolveDispatch({
688
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
689
+ });
690
+ assert.equal(result.action, "stop");
691
+ assert.ok((result as any).reason.includes("discussion") || (result as any).reason.includes("discuss"),
692
+ "stop reason should mention discussion");
693
+ } finally {
694
+ rmSync(tmp, { recursive: true, force: true });
695
+ }
696
+ });
697
+
698
+ test("dispatch: pre-planning without context stops with guidance", async () => {
699
+ const tmp = makeTmp("dispatch-no-context");
700
+ try {
701
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
702
+ mkdirSync(mDir, { recursive: true });
703
+ // No context, no roadmap — just a bare milestone directory
704
+ const state = await deriveState(tmp);
705
+ const result = await resolveDispatch({
706
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
707
+ });
708
+ assert.equal(result.action, "stop");
709
+ assert.ok((result as any).reason.includes("context") || (result as any).reason.includes("discuss"),
710
+ "stop reason should mention missing context");
711
+ } finally {
712
+ rmSync(tmp, { recursive: true, force: true });
713
+ }
714
+ });
715
+
716
+ test("dispatch: pre-planning with context dispatches research-milestone", async () => {
717
+ const tmp = makeTmp("dispatch-research");
718
+ try {
719
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
720
+ mkdirSync(mDir, { recursive: true });
721
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nBuild a thing.");
722
+ const state = await deriveState(tmp);
723
+ const result = await resolveDispatch({
724
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
725
+ });
726
+ assert.equal(result.action, "dispatch");
727
+ assert.equal((result as any).unitType, "research-milestone");
728
+ } finally {
729
+ rmSync(tmp, { recursive: true, force: true });
730
+ }
731
+ });
732
+
733
+ test("dispatch: executing phase dispatches execute-task", async () => {
734
+ const tmp = makeTmp("dispatch-execute");
735
+ try {
736
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
737
+ const sDir = join(mDir, "slices", "S01");
738
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
739
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
740
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
741
+ { id: "S01", title: "Test", done: false },
742
+ ]));
743
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
744
+ { id: "T01", title: "Do work", done: false },
745
+ ]));
746
+ writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nDo the thing.");
747
+ const state = await deriveState(tmp);
748
+ assert.equal(state.phase, "executing");
749
+ const result = await resolveDispatch({
750
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
751
+ });
752
+ assert.equal(result.action, "dispatch");
753
+ assert.equal((result as any).unitType, "execute-task");
754
+ assert.equal((result as any).unitId, "M001/S01/T01");
755
+ } finally {
756
+ rmSync(tmp, { recursive: true, force: true });
757
+ }
758
+ });
759
+
760
+ test("dispatch: summarizing phase dispatches complete-slice", async () => {
761
+ const tmp = makeTmp("dispatch-complete-slice");
762
+ try {
763
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
764
+ const sDir = join(mDir, "slices", "S01");
765
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
766
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
767
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
768
+ { id: "S01", title: "Test", done: false },
769
+ ]));
770
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
771
+ { id: "T01", title: "Done task", done: true },
772
+ ]));
773
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
774
+ const state = await deriveState(tmp);
775
+ assert.equal(state.phase, "summarizing");
776
+ const result = await resolveDispatch({
777
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
778
+ });
779
+ assert.equal(result.action, "dispatch");
780
+ assert.equal((result as any).unitType, "complete-slice");
781
+ } finally {
782
+ rmSync(tmp, { recursive: true, force: true });
783
+ }
784
+ });
785
+
786
+ test("dispatch: validating-milestone dispatches validate-milestone", async () => {
787
+ const tmp = makeTmp("dispatch-validate");
788
+ try {
789
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
790
+ const sDir = join(mDir, "slices", "S01");
791
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
792
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
793
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
794
+ { id: "S01", title: "Test", done: true },
795
+ ]));
796
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
797
+ { id: "T01", title: "Done", done: true },
798
+ ]));
799
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
800
+ writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone.");
801
+ const state = await deriveState(tmp);
802
+ assert.equal(state.phase, "validating-milestone");
803
+ const result = await resolveDispatch({
804
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
805
+ });
806
+ assert.equal(result.action, "dispatch");
807
+ assert.equal((result as any).unitType, "validate-milestone");
808
+ } finally {
809
+ rmSync(tmp, { recursive: true, force: true });
810
+ }
811
+ });
812
+
813
+ test("dispatch: completing-milestone dispatches complete-milestone", async () => {
814
+ const tmp = makeTmp("dispatch-complete-ms");
815
+ try {
816
+ const mDir = join(tmp, ".gsd", "milestones", "M001");
817
+ const sDir = join(mDir, "slices", "S01");
818
+ mkdirSync(join(sDir, "tasks"), { recursive: true });
819
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext.");
820
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([
821
+ { id: "S01", title: "Test", done: true },
822
+ ]));
823
+ writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([
824
+ { id: "T01", title: "Done", done: true },
825
+ ]));
826
+ writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01"));
827
+ writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone.");
828
+ writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nPassed.");
829
+ const state = await deriveState(tmp);
830
+ assert.equal(state.phase, "completing-milestone");
831
+ const result = await resolveDispatch({
832
+ basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined,
833
+ });
834
+ assert.equal(result.action, "dispatch");
835
+ assert.equal((result as any).unitType, "complete-milestone");
836
+ } finally {
837
+ rmSync(tmp, { recursive: true, force: true });
838
+ }
839
+ });