gsd-pi 2.59.0-dev.d77b3dd → 2.60.0-dev.2580e65

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 (198) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +7 -4
  2. package/dist/resources/extensions/gsd/auto/phases.js +15 -7
  3. package/dist/resources/extensions/gsd/auto-dashboard.js +21 -8
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +6 -3
  5. package/dist/resources/extensions/gsd/auto-model-selection.js +58 -9
  6. package/dist/resources/extensions/gsd/auto-post-unit.js +3 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.js +36 -20
  8. package/dist/resources/extensions/gsd/auto-recovery.js +37 -18
  9. package/dist/resources/extensions/gsd/auto-start.js +9 -5
  10. package/dist/resources/extensions/gsd/auto-timers.js +11 -5
  11. package/dist/resources/extensions/gsd/auto-unit-closeout.js +5 -3
  12. package/dist/resources/extensions/gsd/auto-verification.js +3 -2
  13. package/dist/resources/extensions/gsd/auto-worktree.js +120 -55
  14. package/dist/resources/extensions/gsd/auto.js +39 -17
  15. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +6 -3
  16. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -2
  17. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +4 -10
  18. package/dist/resources/extensions/gsd/bootstrap/journal-tools.js +2 -1
  19. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -0
  20. package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -10
  21. package/dist/resources/extensions/gsd/commands/catalog.js +2 -0
  22. package/dist/resources/extensions/gsd/commands-codebase.js +48 -21
  23. package/dist/resources/extensions/gsd/commands-inspect.js +2 -1
  24. package/dist/resources/extensions/gsd/commands-maintenance.js +32 -19
  25. package/dist/resources/extensions/gsd/complexity-classifier.js +8 -4
  26. package/dist/resources/extensions/gsd/custom-verification.js +3 -2
  27. package/dist/resources/extensions/gsd/gsd-db.js +33 -13
  28. package/dist/resources/extensions/gsd/guided-flow.js +19 -9
  29. package/dist/resources/extensions/gsd/init-wizard.js +12 -0
  30. package/dist/resources/extensions/gsd/markdown-renderer.js +11 -9
  31. package/dist/resources/extensions/gsd/md-importer.js +5 -4
  32. package/dist/resources/extensions/gsd/milestone-actions.js +3 -2
  33. package/dist/resources/extensions/gsd/milestone-ids.js +2 -1
  34. package/dist/resources/extensions/gsd/model-router.js +156 -121
  35. package/dist/resources/extensions/gsd/parallel-merge.js +5 -3
  36. package/dist/resources/extensions/gsd/parallel-orchestrator.js +26 -14
  37. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  38. package/dist/resources/extensions/gsd/preferences-validation.js +45 -0
  39. package/dist/resources/extensions/gsd/preferences.js +15 -3
  40. package/dist/resources/extensions/gsd/prompt-loader.js +3 -2
  41. package/dist/resources/extensions/gsd/prompts/rethink.md +1 -1
  42. package/dist/resources/extensions/gsd/rule-registry.js +7 -6
  43. package/dist/resources/extensions/gsd/safe-fs.js +6 -8
  44. package/dist/resources/extensions/gsd/tools/complete-milestone.js +3 -2
  45. package/dist/resources/extensions/gsd/tools/complete-slice.js +3 -2
  46. package/dist/resources/extensions/gsd/tools/complete-task.js +3 -2
  47. package/dist/resources/extensions/gsd/tools/plan-milestone.js +3 -2
  48. package/dist/resources/extensions/gsd/tools/plan-slice.js +3 -2
  49. package/dist/resources/extensions/gsd/tools/plan-task.js +2 -1
  50. package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +4 -4
  51. package/dist/resources/extensions/gsd/tools/reopen-slice.js +2 -1
  52. package/dist/resources/extensions/gsd/tools/reopen-task.js +2 -1
  53. package/dist/resources/extensions/gsd/tools/replan-slice.js +2 -1
  54. package/dist/resources/extensions/gsd/tools/validate-milestone.js +2 -1
  55. package/dist/resources/extensions/gsd/triage-resolution.js +11 -4
  56. package/dist/resources/extensions/gsd/workflow-events.js +2 -1
  57. package/dist/resources/extensions/gsd/workflow-logger.js +37 -4
  58. package/dist/resources/extensions/gsd/workflow-migration.js +14 -12
  59. package/dist/resources/extensions/gsd/workflow-projections.js +2 -2
  60. package/dist/resources/extensions/gsd/workflow-reconcile.js +2 -2
  61. package/dist/resources/extensions/gsd/worktree-manager.js +26 -14
  62. package/dist/resources/extensions/shared/interview-ui.js +3 -1
  63. package/dist/web/standalone/.next/BUILD_ID +1 -1
  64. package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
  65. package/dist/web/standalone/.next/build-manifest.json +2 -2
  66. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  67. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  68. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  76. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  78. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  79. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  80. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  81. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  82. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  83. package/dist/web/standalone/.next/server/app/index.html +1 -1
  84. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  85. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  86. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  87. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  88. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  89. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  90. package/dist/web/standalone/.next/server/app-paths-manifest.json +19 -19
  91. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  92. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  93. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  94. package/package.json +1 -1
  95. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
  97. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  98. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -1
  99. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  100. package/packages/pi-coding-agent/dist/core/extensions/runner.js +16 -0
  101. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +26 -0
  103. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  104. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  106. package/packages/pi-coding-agent/dist/core/lsp/config.js +6 -1
  107. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  108. package/packages/pi-coding-agent/dist/core/lsp/defaults.json +2 -2
  109. package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts +2 -0
  110. package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts.map +1 -0
  111. package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js +47 -0
  112. package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js.map +1 -0
  113. package/packages/pi-coding-agent/package.json +1 -1
  114. package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
  115. package/packages/pi-coding-agent/src/core/extensions/runner.ts +19 -0
  116. package/packages/pi-coding-agent/src/core/extensions/types.ts +26 -0
  117. package/packages/pi-coding-agent/src/core/lsp/config.ts +7 -1
  118. package/packages/pi-coding-agent/src/core/lsp/defaults.json +2 -2
  119. package/packages/pi-coding-agent/src/core/lsp/lsp-legacy-alias.test.ts +70 -0
  120. package/pkg/package.json +1 -1
  121. package/src/resources/extensions/ask-user-questions.ts +7 -3
  122. package/src/resources/extensions/gsd/auto/phases.ts +17 -7
  123. package/src/resources/extensions/gsd/auto-dashboard.ts +22 -8
  124. package/src/resources/extensions/gsd/auto-dispatch.ts +7 -3
  125. package/src/resources/extensions/gsd/auto-model-selection.ts +77 -15
  126. package/src/resources/extensions/gsd/auto-post-unit.ts +4 -4
  127. package/src/resources/extensions/gsd/auto-prompts.ts +37 -20
  128. package/src/resources/extensions/gsd/auto-recovery.ts +38 -18
  129. package/src/resources/extensions/gsd/auto-start.ts +10 -9
  130. package/src/resources/extensions/gsd/auto-timers.ts +12 -5
  131. package/src/resources/extensions/gsd/auto-unit-closeout.ts +6 -2
  132. package/src/resources/extensions/gsd/auto-verification.ts +3 -6
  133. package/src/resources/extensions/gsd/auto-worktree.ts +121 -55
  134. package/src/resources/extensions/gsd/auto.ts +40 -17
  135. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -3
  136. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -2
  137. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +4 -16
  138. package/src/resources/extensions/gsd/bootstrap/journal-tools.ts +2 -1
  139. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +8 -0
  140. package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -10
  141. package/src/resources/extensions/gsd/commands/catalog.ts +2 -0
  142. package/src/resources/extensions/gsd/commands-codebase.ts +52 -20
  143. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  144. package/src/resources/extensions/gsd/commands-maintenance.ts +28 -19
  145. package/src/resources/extensions/gsd/complexity-classifier.ts +9 -4
  146. package/src/resources/extensions/gsd/custom-verification.ts +3 -2
  147. package/src/resources/extensions/gsd/gsd-db.ts +12 -14
  148. package/src/resources/extensions/gsd/guided-flow.ts +9 -8
  149. package/src/resources/extensions/gsd/init-wizard.ts +12 -0
  150. package/src/resources/extensions/gsd/markdown-renderer.ts +11 -17
  151. package/src/resources/extensions/gsd/md-importer.ts +5 -4
  152. package/src/resources/extensions/gsd/milestone-actions.ts +3 -2
  153. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  154. package/src/resources/extensions/gsd/model-router.ts +199 -173
  155. package/src/resources/extensions/gsd/parallel-merge.ts +5 -3
  156. package/src/resources/extensions/gsd/parallel-orchestrator.ts +18 -14
  157. package/src/resources/extensions/gsd/preferences-types.ts +13 -0
  158. package/src/resources/extensions/gsd/preferences-validation.ts +45 -0
  159. package/src/resources/extensions/gsd/preferences.ts +16 -3
  160. package/src/resources/extensions/gsd/prompt-loader.ts +3 -2
  161. package/src/resources/extensions/gsd/prompts/rethink.md +1 -1
  162. package/src/resources/extensions/gsd/rule-registry.ts +7 -6
  163. package/src/resources/extensions/gsd/safe-fs.ts +6 -5
  164. package/src/resources/extensions/gsd/tests/capability-router.test.ts +347 -0
  165. package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +63 -0
  166. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +27 -2
  167. package/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts +4 -4
  168. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +1188 -0
  169. package/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts +841 -0
  170. package/src/resources/extensions/gsd/tests/model-router.test.ts +403 -3
  171. package/src/resources/extensions/gsd/tests/preferences.test.ts +62 -0
  172. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +21 -0
  173. package/src/resources/extensions/gsd/tests/silent-catch-diagnostics.test.ts +284 -0
  174. package/src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts +120 -0
  175. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +6 -6
  176. package/src/resources/extensions/gsd/tools/complete-milestone.ts +3 -6
  177. package/src/resources/extensions/gsd/tools/complete-slice.ts +3 -6
  178. package/src/resources/extensions/gsd/tools/complete-task.ts +3 -6
  179. package/src/resources/extensions/gsd/tools/plan-milestone.ts +3 -6
  180. package/src/resources/extensions/gsd/tools/plan-slice.ts +3 -6
  181. package/src/resources/extensions/gsd/tools/plan-task.ts +2 -3
  182. package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +4 -6
  183. package/src/resources/extensions/gsd/tools/reopen-slice.ts +2 -3
  184. package/src/resources/extensions/gsd/tools/reopen-task.ts +2 -3
  185. package/src/resources/extensions/gsd/tools/replan-slice.ts +2 -3
  186. package/src/resources/extensions/gsd/tools/validate-milestone.ts +2 -3
  187. package/src/resources/extensions/gsd/triage-resolution.ts +11 -4
  188. package/src/resources/extensions/gsd/types.ts +1 -0
  189. package/src/resources/extensions/gsd/workflow-events.ts +2 -1
  190. package/src/resources/extensions/gsd/workflow-logger.ts +52 -5
  191. package/src/resources/extensions/gsd/workflow-migration.ts +14 -12
  192. package/src/resources/extensions/gsd/workflow-projections.ts +2 -2
  193. package/src/resources/extensions/gsd/workflow-reconcile.ts +2 -2
  194. package/src/resources/extensions/gsd/worktree-manager.ts +16 -14
  195. package/src/resources/extensions/shared/interview-ui.ts +3 -1
  196. package/src/resources/extensions/shared/tests/interview-notes-loop.test.ts +144 -0
  197. /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_buildManifest.js +0 -0
  198. /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_ssgManifest.js +0 -0
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * state-machine-edge-cases.test.ts — Gap-filling tests for the GSD state
3
+ * machine covering failure modes, boundary conditions, and edge cases NOT
4
+ * covered by the existing state-machine-live-validation.test.ts suite.
5
+ *
6
+ * Coverage gaps filled:
7
+ * 1. State derivation failures (file deletion races, partial DB, cache staleness,
8
+ * corrupt files, 0-slice ROADMAP)
9
+ * 2. Transition boundary failures (mid-transition mutation, cascading blockers,
10
+ * multi-level milestone deps, blocked→unblocked recovery)
11
+ * 3. Dispatch failures (null activeSlice, evaluating-gates without config,
12
+ * unhandled phase, missing task plan recovery)
13
+ * 4. Completion & verification failures (unparseable verdict, needs-remediation
14
+ * blocks completion, missing SUMMARY blocks validation, UAT verdict gate,
15
+ * replan loop cap)
16
+ */
17
+
18
+ // GSD State Machine Edge Case Tests
19
+
20
+ import { describe, test, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import {
23
+ mkdtempSync,
24
+ mkdirSync,
25
+ writeFileSync,
26
+ readFileSync,
27
+ rmSync,
28
+ existsSync,
29
+ unlinkSync,
30
+ } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+
34
+ // ── DB layer ──────────────────────────────────────────────────────────────
35
+ import {
36
+ openDatabase,
37
+ closeDatabase,
38
+ insertMilestone,
39
+ insertSlice,
40
+ insertTask,
41
+ getTask,
42
+ getSlice,
43
+ getMilestone,
44
+ getSliceTasks,
45
+ getMilestoneSlices,
46
+ updateTaskStatus,
47
+ updateSliceStatus,
48
+ updateMilestoneStatus,
49
+ insertReplanHistory,
50
+ getReplanHistory,
51
+ insertGateRow,
52
+ getPendingGates,
53
+ } from "../../gsd-db.ts";
54
+
55
+ // ── State derivation ──────────────────────────────────────────────────────
56
+ import {
57
+ deriveState,
58
+ deriveStateFromDb,
59
+ invalidateStateCache,
60
+ isGhostMilestone,
61
+ isValidationTerminal,
62
+ } from "../../state.ts";
63
+
64
+ // ── Status guards ─────────────────────────────────────────────────────────
65
+ import { isClosedStatus } from "../../status-guards.ts";
66
+
67
+ // ── Cache invalidation ───────────────────────────────────────────────────
68
+ import { invalidateAllCaches } from "../../cache.ts";
69
+
70
+ // ── Dispatch ─────────────────────────────────────────────────────────────
71
+ import {
72
+ resolveDispatch,
73
+ DISPATCH_RULES,
74
+ getDispatchRuleNames,
75
+ } from "../../auto-dispatch.ts";
76
+ import type { DispatchContext, DispatchAction } from "../../auto-dispatch.ts";
77
+
78
+ // ── Verdict parser ──────────────────────────────────────────────────────
79
+ import {
80
+ extractVerdict,
81
+ isAcceptableUatVerdict,
82
+ isValidMilestoneVerdict,
83
+ } from "../../verdict-parser.ts";
84
+
85
+ // ── Path helpers ─────────────────────────────────────────────────────────
86
+ import { clearPathCache } from "../../paths.ts";
87
+
88
+ // ═══════════════════════════════════════════════════════════════════════════
89
+ // Fixture Helpers
90
+ // ═══════════════════════════════════════════════════════════════════════════
91
+
92
+ function makeTempDir(): string {
93
+ return mkdtempSync(join(tmpdir(), "gsd-edge-cases-"));
94
+ }
95
+
96
+ /**
97
+ * Create a standard .gsd/ fixture with M001 containing S01 (2 tasks) and S02 (1 task).
98
+ * Same structure as state-machine-live-validation.test.ts for consistency.
99
+ */
100
+ function createFullFixture(): string {
101
+ const base = makeTempDir();
102
+ const gsdDir = join(base, ".gsd");
103
+ const m001Dir = join(gsdDir, "milestones", "M001");
104
+ const s01Dir = join(m001Dir, "slices", "S01");
105
+ const s01Tasks = join(s01Dir, "tasks");
106
+ const s02Dir = join(m001Dir, "slices", "S02");
107
+ const s02Tasks = join(s02Dir, "tasks");
108
+
109
+ mkdirSync(s01Tasks, { recursive: true });
110
+ mkdirSync(s02Tasks, { recursive: true });
111
+
112
+ writeFileSync(
113
+ join(m001Dir, "M001-CONTEXT.md"),
114
+ [
115
+ "# M001: Edge Case Milestone",
116
+ "",
117
+ "## Purpose",
118
+ "Test state machine edge cases.",
119
+ ].join("\n"),
120
+ );
121
+
122
+ writeFileSync(
123
+ join(m001Dir, "M001-ROADMAP.md"),
124
+ [
125
+ "# M001: Edge Case Milestone",
126
+ "",
127
+ "## Vision",
128
+ "Prove edge case correctness.",
129
+ "",
130
+ "## Success Criteria",
131
+ "- All edge cases handled",
132
+ "",
133
+ "## Slices",
134
+ "",
135
+ "- [ ] **S01: First Feature** `risk:low` `depends:[]`",
136
+ " - After this: First feature proven.",
137
+ "",
138
+ "- [ ] **S02: Second Feature** `risk:low` `depends:[]`",
139
+ " - After this: Second feature proven.",
140
+ "",
141
+ "## Boundary Map",
142
+ "",
143
+ "| From | To | Produces | Consumes |",
144
+ "|------|----|----------|----------|",
145
+ "| S01 | terminal | feature-a | nothing |",
146
+ "| S02 | terminal | feature-b | nothing |",
147
+ ].join("\n"),
148
+ );
149
+
150
+ writeFileSync(
151
+ join(s01Dir, "S01-PLAN.md"),
152
+ [
153
+ "# S01: First Feature",
154
+ "",
155
+ "**Goal:** Implement first feature.",
156
+ "",
157
+ "## Tasks",
158
+ "",
159
+ "- [ ] **T01: Implementation** `est:30m`",
160
+ " - Do: Build it",
161
+ " - Verify: Run tests",
162
+ "",
163
+ "- [ ] **T02: Testing** `est:30m`",
164
+ " - Do: Write tests",
165
+ " - Verify: Run tests",
166
+ ].join("\n"),
167
+ );
168
+
169
+ writeFileSync(join(s01Tasks, "T01-PLAN.md"), "# T01 Plan\nImplement.\n");
170
+ writeFileSync(join(s01Tasks, "T02-PLAN.md"), "# T02 Plan\nTest.\n");
171
+
172
+ writeFileSync(
173
+ join(s02Dir, "S02-PLAN.md"),
174
+ [
175
+ "# S02: Second Feature",
176
+ "",
177
+ "**Goal:** Implement second feature.",
178
+ "",
179
+ "## Tasks",
180
+ "",
181
+ "- [ ] **T01: Implementation** `est:30m`",
182
+ " - Do: Build it",
183
+ " - Verify: Run tests",
184
+ ].join("\n"),
185
+ );
186
+
187
+ writeFileSync(join(s02Tasks, "T01-PLAN.md"), "# T01 Plan\nBuild.\n");
188
+
189
+ return base;
190
+ }
191
+
192
+ /**
193
+ * Create a multi-milestone fixture with M001 → M002 → M003 dependency chain.
194
+ */
195
+ function createMultiMilestoneFixture(): string {
196
+ const base = makeTempDir();
197
+ const gsdDir = join(base, ".gsd");
198
+
199
+ for (const mid of ["M001", "M002", "M003"]) {
200
+ const mDir = join(gsdDir, "milestones", mid);
201
+ const sDir = join(mDir, "slices", "S01", "tasks");
202
+ mkdirSync(sDir, { recursive: true });
203
+
204
+ writeFileSync(
205
+ join(mDir, `${mid}-CONTEXT.md`),
206
+ `# ${mid}: Milestone ${mid.slice(-1)}\n\n## Purpose\nTest deps.\n`,
207
+ );
208
+
209
+ writeFileSync(
210
+ join(mDir, `${mid}-ROADMAP.md`),
211
+ [
212
+ `# ${mid}: Milestone ${mid.slice(-1)}`,
213
+ "",
214
+ "## Vision",
215
+ "Test dependency chains.",
216
+ "",
217
+ "## Success Criteria",
218
+ "- Works",
219
+ "",
220
+ "## Slices",
221
+ "",
222
+ "- [ ] **S01: Only Slice** `risk:low` `depends:[]`",
223
+ " - After this: Done.",
224
+ "",
225
+ "## Boundary Map",
226
+ "",
227
+ "| From | To | Produces | Consumes |",
228
+ "|------|----|----------|----------|",
229
+ "| S01 | terminal | output | nothing |",
230
+ ].join("\n"),
231
+ );
232
+
233
+ writeFileSync(
234
+ join(mDir, "slices", "S01", "S01-PLAN.md"),
235
+ [
236
+ "# S01: Only Slice",
237
+ "",
238
+ "**Goal:** Do the thing.",
239
+ "",
240
+ "## Tasks",
241
+ "",
242
+ "- [ ] **T01: Task** `est:30m`",
243
+ " - Do: Implement",
244
+ " - Verify: Run tests",
245
+ ].join("\n"),
246
+ );
247
+
248
+ writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
249
+ }
250
+
251
+ return base;
252
+ }
253
+
254
+ function buildDispatchCtx(
255
+ base: string,
256
+ mid: string,
257
+ stateOverrides: Partial<import("../../types.ts").GSDState> = {},
258
+ ): DispatchContext {
259
+ return {
260
+ basePath: base,
261
+ mid,
262
+ midTitle: `${mid} Test`,
263
+ state: {
264
+ activeMilestone: { id: mid, title: `${mid} Test` },
265
+ activeSlice: null,
266
+ activeTask: null,
267
+ phase: "executing",
268
+ recentDecisions: [],
269
+ blockers: [],
270
+ nextAction: "",
271
+ registry: [],
272
+ requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
273
+ progress: { milestones: { done: 0, total: 1 } },
274
+ ...stateOverrides,
275
+ },
276
+ prefs: undefined,
277
+ };
278
+ }
279
+
280
+ // ═══════════════════════════════════════════════════════════════════════════
281
+ // Test Suite
282
+ // ═══════════════════════════════════════════════════════════════════════════
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+ // SECTION 1: State Derivation Failure Modes
286
+ // ─────────────────────────────────────────────────────────────────────────
287
+
288
+ describe("state derivation failures", () => {
289
+ let base: string;
290
+
291
+ afterEach(() => {
292
+ try { closeDatabase(); } catch { /* may not be open */ }
293
+ if (base) rmSync(base, { recursive: true, force: true });
294
+ });
295
+
296
+ test("file deleted between deriveState calls produces consistent result", async () => {
297
+ // Simulates race condition: PLAN file exists on first derive, deleted before second
298
+ base = createFullFixture();
299
+ openDatabase(join(base, ".gsd", "gsd.db"));
300
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
301
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
302
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
303
+
304
+ invalidateAllCaches();
305
+ const stateBefore = await deriveStateFromDb(base);
306
+ assert.equal(stateBefore.phase, "executing");
307
+
308
+ // Delete the task plan file mid-flow
309
+ const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md");
310
+ if (existsSync(planPath)) unlinkSync(planPath);
311
+
312
+ invalidateAllCaches();
313
+ const stateAfter = await deriveStateFromDb(base);
314
+ // State machine should still function — either executing (DB says task exists)
315
+ // or planning (missing plan file triggers replan). Should NOT throw.
316
+ assert.ok(
317
+ ["executing", "planning"].includes(stateAfter.phase),
318
+ `expected executing or planning after plan deletion, got: ${stateAfter.phase}`,
319
+ );
320
+ });
321
+
322
+ test("partial DB write: milestone inserted but no slices → pre-planning", async () => {
323
+ base = makeTempDir();
324
+ const mDir = join(base, ".gsd", "milestones", "M001");
325
+ mkdirSync(mDir, { recursive: true });
326
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\n## Purpose\nTest.\n");
327
+
328
+ openDatabase(join(base, ".gsd", "gsd.db"));
329
+ // Only insert milestone — no slices, no roadmap
330
+ insertMilestone({ id: "M001", title: "Partial", status: "active" });
331
+
332
+ invalidateAllCaches();
333
+ const state = await deriveStateFromDb(base);
334
+ // No roadmap → pre-planning (milestone exists but no structure yet)
335
+ assert.equal(state.phase, "pre-planning");
336
+ assert.equal(state.activeMilestone?.id, "M001");
337
+ });
338
+
339
+ test("cache staleness: derive within TTL returns same result after DB mutation", async () => {
340
+ base = createFullFixture();
341
+ openDatabase(join(base, ".gsd", "gsd.db"));
342
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
343
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
344
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
345
+
346
+ // First call populates cache
347
+ invalidateStateCache();
348
+ const state1 = await deriveState(base);
349
+ assert.equal(state1.phase, "executing");
350
+
351
+ // Mutate DB WITHOUT invalidating cache
352
+ updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());
353
+
354
+ // Second call within 100ms TTL should return cached (stale) result
355
+ const state2 = await deriveState(base);
356
+ assert.equal(state2.phase, "executing", "cached result should still show executing");
357
+
358
+ // After explicit invalidation, should reflect the DB mutation
359
+ invalidateStateCache();
360
+ const state3 = await deriveState(base);
361
+ assert.equal(state3.phase, "summarizing", "after cache invalidation should show summarizing");
362
+ });
363
+
364
+ test("corrupt ROADMAP: binary content does not crash deriveState", async () => {
365
+ base = makeTempDir();
366
+ const mDir = join(base, ".gsd", "milestones", "M001");
367
+ mkdirSync(mDir, { recursive: true });
368
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Corrupt\n\n## Purpose\nTest.\n");
369
+ // Write binary garbage as ROADMAP
370
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), Buffer.from([0x00, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]));
371
+
372
+ openDatabase(join(base, ".gsd", "gsd.db"));
373
+ insertMilestone({ id: "M001", title: "Corrupt", status: "active" });
374
+
375
+ invalidateAllCaches();
376
+ // Should NOT throw — should degrade gracefully
377
+ const state = await deriveStateFromDb(base);
378
+ assert.ok(state.phase, "should produce a valid phase even with corrupt ROADMAP");
379
+ });
380
+
381
+ test("0-byte ROADMAP file is treated as no roadmap (pre-planning)", async () => {
382
+ base = makeTempDir();
383
+ const mDir = join(base, ".gsd", "milestones", "M001");
384
+ mkdirSync(mDir, { recursive: true });
385
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Empty\n\n## Purpose\nTest.\n");
386
+ writeFileSync(join(mDir, "M001-ROADMAP.md"), "");
387
+
388
+ openDatabase(join(base, ".gsd", "gsd.db"));
389
+ insertMilestone({ id: "M001", title: "Empty", status: "active" });
390
+
391
+ invalidateAllCaches();
392
+ const state = await deriveStateFromDb(base);
393
+ assert.equal(state.phase, "pre-planning", "empty ROADMAP should result in pre-planning");
394
+ });
395
+
396
+ test("ROADMAP with no ## Slices section derives pre-planning", async () => {
397
+ base = makeTempDir();
398
+ const mDir = join(base, ".gsd", "milestones", "M001");
399
+ mkdirSync(mDir, { recursive: true });
400
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: No Slices\n\n## Purpose\nTest.\n");
401
+ writeFileSync(
402
+ join(mDir, "M001-ROADMAP.md"),
403
+ [
404
+ "# M001: No Slices",
405
+ "",
406
+ "## Vision",
407
+ "Test zero slices.",
408
+ "",
409
+ "## Success Criteria",
410
+ "- Works",
411
+ "",
412
+ "## Slices",
413
+ "",
414
+ "## Boundary Map",
415
+ "",
416
+ "| From | To | Produces | Consumes |",
417
+ "|------|----|----------|----------|",
418
+ ].join("\n"),
419
+ );
420
+
421
+ openDatabase(join(base, ".gsd", "gsd.db"));
422
+ insertMilestone({ id: "M001", title: "No Slices", status: "active" });
423
+
424
+ invalidateAllCaches();
425
+ const state = await deriveStateFromDb(base);
426
+ // 0-slice ROADMAP guard: should NOT derive validating-milestone (#2667)
427
+ assert.notEqual(
428
+ state.phase,
429
+ "validating-milestone",
430
+ "0-slice ROADMAP must NOT produce validating-milestone",
431
+ );
432
+ });
433
+
434
+ test("corrupt VALIDATION frontmatter: extractVerdict returns undefined", () => {
435
+ // Test the verdict parser directly with malformed content
436
+ assert.equal(extractVerdict(""), undefined, "empty string → undefined");
437
+ assert.equal(extractVerdict("---\n\n---\n# No verdict"), undefined, "empty frontmatter → undefined");
438
+ assert.equal(extractVerdict("---\nverdict:\n---"), undefined, "verdict with no value → undefined");
439
+ assert.equal(
440
+ extractVerdict("random text without frontmatter"),
441
+ undefined,
442
+ "no frontmatter → undefined",
443
+ );
444
+ });
445
+
446
+ test("VALIDATION with binary/garbage content: isValidationTerminal returns false", () => {
447
+ assert.equal(isValidationTerminal(""), false, "empty → not terminal");
448
+ assert.equal(isValidationTerminal("\x00\xFF\xFE"), false, "binary → not terminal");
449
+ assert.equal(
450
+ isValidationTerminal("---\ngarbage: yes\n---\nNo verdict here."),
451
+ false,
452
+ "no verdict field → not terminal",
453
+ );
454
+ });
455
+ });
456
+
457
+ // ─────────────────────────────────────────────────────────────────────────
458
+ // SECTION 2: Transition Boundary Failures
459
+ // ─────────────────────────────────────────────────────────────────────────
460
+
461
+ describe("transition boundary failures", () => {
462
+ let base: string;
463
+
464
+ afterEach(() => {
465
+ try { closeDatabase(); } catch { /* may not be open */ }
466
+ if (base) rmSync(base, { recursive: true, force: true });
467
+ });
468
+
469
+ test("mid-transition: CONTEXT.md created between derives transitions needs-discussion → pre-planning correctly", async () => {
470
+ base = makeTempDir();
471
+ const mDir = join(base, ".gsd", "milestones", "M001");
472
+ mkdirSync(mDir, { recursive: true });
473
+
474
+ // Start with only CONTEXT-DRAFT → needs-discussion
475
+ writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\nSome draft.\n");
476
+
477
+ openDatabase(join(base, ".gsd", "gsd.db"));
478
+ invalidateAllCaches();
479
+ const state1 = await deriveState(base);
480
+ assert.equal(state1.phase, "needs-discussion");
481
+
482
+ // Now write the full CONTEXT (simulates discussion completion)
483
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Resolved\n\n## Purpose\nDone.\n");
484
+
485
+ invalidateAllCaches();
486
+ const state2 = await deriveState(base);
487
+ // Should advance to pre-planning (has context but no roadmap yet)
488
+ assert.equal(state2.phase, "pre-planning");
489
+ });
490
+
491
+ test("cascading slice dependencies: S02 depends S01, S03 depends S02 — only S01 eligible", async () => {
492
+ base = makeTempDir();
493
+ const mDir = join(base, ".gsd", "milestones", "M001");
494
+
495
+ // Create 3 slices with chain deps
496
+ for (const sid of ["S01", "S02", "S03"]) {
497
+ const sDir = join(mDir, "slices", sid, "tasks");
498
+ mkdirSync(sDir, { recursive: true });
499
+ writeFileSync(
500
+ join(mDir, "slices", sid, `${sid}-PLAN.md`),
501
+ [
502
+ `# ${sid}: Feature`,
503
+ "",
504
+ "**Goal:** Do the thing.",
505
+ "",
506
+ "## Tasks",
507
+ "",
508
+ "- [ ] **T01: Task** `est:30m`",
509
+ " - Do: Implement",
510
+ " - Verify: Run tests",
511
+ ].join("\n"),
512
+ );
513
+ writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
514
+ }
515
+
516
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest deps.\n");
517
+ writeFileSync(
518
+ join(mDir, "M001-ROADMAP.md"),
519
+ [
520
+ "# M001: Chain Deps",
521
+ "",
522
+ "## Vision",
523
+ "Test cascading.",
524
+ "",
525
+ "## Success Criteria",
526
+ "- Works",
527
+ "",
528
+ "## Slices",
529
+ "",
530
+ "- [ ] **S01: Base** `risk:low` `depends:[]`",
531
+ " - After this: Base done.",
532
+ "",
533
+ "- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
534
+ " - After this: Middle done.",
535
+ "",
536
+ "- [ ] **S03: Top** `risk:low` `depends:[S02]`",
537
+ " - After this: Top done.",
538
+ "",
539
+ "## Boundary Map",
540
+ "",
541
+ "| From | To | Produces | Consumes |",
542
+ "|------|----|----------|----------|",
543
+ "| S01 | S02 | base | nothing |",
544
+ "| S02 | S03 | middle | base |",
545
+ "| S03 | terminal | top | middle |",
546
+ ].join("\n"),
547
+ );
548
+
549
+ openDatabase(join(base, ".gsd", "gsd.db"));
550
+ insertMilestone({ id: "M001", title: "Chain", status: "active" });
551
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "pending", depends: [] });
552
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
553
+ insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
554
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
555
+ insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
556
+ insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
557
+
558
+ invalidateAllCaches();
559
+ const state = await deriveStateFromDb(base);
560
+
561
+ // Only S01 should be active — S02 and S03 are dep-blocked
562
+ assert.equal(state.activeSlice?.id, "S01", "S01 should be the active slice (no deps)");
563
+ assert.equal(state.phase, "executing", "should be executing S01");
564
+ });
565
+
566
+ test("cascading deps: completing S01 unblocks S02 (not S03)", async () => {
567
+ base = makeTempDir();
568
+ const mDir = join(base, ".gsd", "milestones", "M001");
569
+ for (const sid of ["S01", "S02", "S03"]) {
570
+ const sDir = join(mDir, "slices", sid, "tasks");
571
+ mkdirSync(sDir, { recursive: true });
572
+ writeFileSync(
573
+ join(mDir, "slices", sid, `${sid}-PLAN.md`),
574
+ `# ${sid}\n\n**Goal:** Do.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:30m\`\n - Do: Impl\n - Verify: Test\n`,
575
+ );
576
+ writeFileSync(join(sDir, "T01-PLAN.md"), `# T01 Plan\nDo it.\n`);
577
+ }
578
+ // Write slice SUMMARY for S01
579
+ writeFileSync(
580
+ join(mDir, "slices", "S01", "S01-SUMMARY.md"),
581
+ "---\n---\n# S01 Summary\nDone.\n",
582
+ );
583
+
584
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest.\n");
585
+ writeFileSync(
586
+ join(mDir, "M001-ROADMAP.md"),
587
+ [
588
+ "# M001: Chain",
589
+ "",
590
+ "## Vision",
591
+ "Test.",
592
+ "",
593
+ "## Success Criteria",
594
+ "- Works",
595
+ "",
596
+ "## Slices",
597
+ "",
598
+ "- [x] **S01: Base** `risk:low` `depends:[]`",
599
+ " - After this: Done.",
600
+ "",
601
+ "- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
602
+ " - After this: Done.",
603
+ "",
604
+ "- [ ] **S03: Top** `risk:low` `depends:[S02]`",
605
+ " - After this: Done.",
606
+ "",
607
+ "## Boundary Map",
608
+ "",
609
+ "| From | To | Produces | Consumes |",
610
+ "|------|----|----------|----------|",
611
+ "| S01 | S02 | x | nothing |",
612
+ "| S02 | S03 | y | x |",
613
+ "| S03 | terminal | z | y |",
614
+ ].join("\n"),
615
+ );
616
+
617
+ openDatabase(join(base, ".gsd", "gsd.db"));
618
+ insertMilestone({ id: "M001", title: "Chain", status: "active" });
619
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "complete", depends: [] });
620
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
621
+ insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
622
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
623
+ insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
624
+ insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
625
+
626
+ invalidateAllCaches();
627
+ const state = await deriveStateFromDb(base);
628
+
629
+ // S01 complete → S02 unblocked → S02 should be active
630
+ assert.equal(state.activeSlice?.id, "S02", "S02 should be active after S01 completes");
631
+ assert.equal(state.phase, "executing");
632
+ });
633
+
634
+ test("multi-milestone deps: M002 depends M001, M003 depends M002 — blocked correctly", async () => {
635
+ base = createMultiMilestoneFixture();
636
+ openDatabase(join(base, ".gsd", "gsd.db"));
637
+ insertMilestone({ id: "M001", title: "First", status: "active" });
638
+ insertMilestone({ id: "M002", title: "Second", status: "active", depends_on: ["M001"] });
639
+ insertMilestone({ id: "M003", title: "Third", status: "active", depends_on: ["M002"] });
640
+
641
+ insertSlice({ id: "S01", milestoneId: "M001", title: "S01", status: "pending" });
642
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
643
+ insertSlice({ id: "S01", milestoneId: "M002", title: "S01", status: "pending" });
644
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M002", status: "pending" });
645
+ insertSlice({ id: "S01", milestoneId: "M003", title: "S01", status: "pending" });
646
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M003", status: "pending" });
647
+
648
+ invalidateAllCaches();
649
+ const state = await deriveStateFromDb(base);
650
+
651
+ // Only M001 should be active — M002 and M003 are blocked
652
+ assert.equal(state.activeMilestone?.id, "M001", "M001 should be active (no deps)");
653
+ });
654
+
655
+ test("blocker_discovered in task transitions to replanning-slice", async () => {
656
+ base = createFullFixture();
657
+ openDatabase(join(base, ".gsd", "gsd.db"));
658
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
659
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
660
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
661
+ insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
662
+
663
+ invalidateAllCaches();
664
+ const state = await deriveStateFromDb(base);
665
+ assert.equal(state.phase, "replanning-slice", "blocker_discovered should trigger replanning");
666
+ assert.ok(state.blockers.length > 0, "should report blocker");
667
+ });
668
+
669
+ test("replan loop protection: replan already done skips replanning-slice", async () => {
670
+ base = createFullFixture();
671
+ openDatabase(join(base, ".gsd", "gsd.db"));
672
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
673
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
674
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
675
+ insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
676
+
677
+ // Record that a replan was already done for this slice
678
+ insertReplanHistory({
679
+ milestoneId: "M001",
680
+ sliceId: "S01",
681
+ summary: "Already replanned once",
682
+ });
683
+
684
+ invalidateAllCaches();
685
+ const state = await deriveStateFromDb(base);
686
+ // With replan history, should NOT re-enter replanning-slice
687
+ assert.notEqual(
688
+ state.phase,
689
+ "replanning-slice",
690
+ "replan loop protection: should not re-enter replanning after replan was done",
691
+ );
692
+ });
693
+
694
+ test("blocked state: all slices have unmet deps → blocked phase", async () => {
695
+ base = makeTempDir();
696
+ const mDir = join(base, ".gsd", "milestones", "M001");
697
+ mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
698
+ mkdirSync(join(mDir, "slices", "S02", "tasks"), { recursive: true });
699
+
700
+ writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\n## Purpose\nTest.\n");
701
+ writeFileSync(
702
+ join(mDir, "M001-ROADMAP.md"),
703
+ [
704
+ "# M001: Blocked",
705
+ "",
706
+ "## Vision",
707
+ "Test blocked.",
708
+ "",
709
+ "## Success Criteria",
710
+ "- Works",
711
+ "",
712
+ "## Slices",
713
+ "",
714
+ "- [ ] **S01: A** `risk:low` `depends:[S02]`",
715
+ " - After this: Done.",
716
+ "",
717
+ "- [ ] **S02: B** `risk:low` `depends:[S01]`",
718
+ " - After this: Done.",
719
+ "",
720
+ "## Boundary Map",
721
+ "",
722
+ "| From | To | Produces | Consumes |",
723
+ "|------|----|----------|----------|",
724
+ "| S01 | S02 | a | b |",
725
+ "| S02 | S01 | b | a |",
726
+ ].join("\n"),
727
+ );
728
+
729
+ openDatabase(join(base, ".gsd", "gsd.db"));
730
+ insertMilestone({ id: "M001", title: "Blocked", status: "active" });
731
+ // Circular deps: S01→S02 and S02→S01 — both blocked
732
+ insertSlice({ id: "S01", milestoneId: "M001", title: "A", status: "pending", depends: ["S02"] });
733
+ insertSlice({ id: "S02", milestoneId: "M001", title: "B", status: "pending", depends: ["S01"] });
734
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
735
+ insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
736
+
737
+ invalidateAllCaches();
738
+ const state = await deriveStateFromDb(base);
739
+ assert.equal(state.phase, "blocked", "circular deps should produce blocked phase");
740
+ });
741
+ });
742
+
743
+ // ─────────────────────────────────────────────────────────────────────────
744
+ // SECTION 3: Dispatch Failure Modes
745
+ // ─────────────────────────────────────────────────────────────────────────
746
+
747
+ describe("dispatch failure modes", () => {
748
+ let base: string;
749
+
750
+ afterEach(() => {
751
+ try { closeDatabase(); } catch { /* may not be open */ }
752
+ if (base) rmSync(base, { recursive: true, force: true });
753
+ });
754
+
755
+ test("dispatch with null activeSlice in executing phase → stop (error)", async () => {
756
+ base = createFullFixture();
757
+ openDatabase(join(base, ".gsd", "gsd.db"));
758
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
759
+
760
+ const ctx = buildDispatchCtx(base, "M001", {
761
+ phase: "executing",
762
+ activeSlice: null,
763
+ activeTask: { id: "T01", title: "Task" },
764
+ });
765
+
766
+ // The "executing → execute-task (recover missing task plan)" rule checks activeSlice
767
+ // and returns missingSliceStop when null
768
+ const result = await resolveDispatch(ctx);
769
+ assert.equal(result.action, "stop", "null activeSlice in executing should stop");
770
+ });
771
+
772
+ test("dispatch for unhandled phase → stop with diagnostic", async () => {
773
+ base = createFullFixture();
774
+ openDatabase(join(base, ".gsd", "gsd.db"));
775
+
776
+ const ctx = buildDispatchCtx(base, "M001", {
777
+ phase: "paused" as any,
778
+ activeSlice: null,
779
+ activeTask: null,
780
+ });
781
+
782
+ const result = await resolveDispatch(ctx);
783
+ assert.equal(result.action, "stop", "unhandled phase should produce stop action");
784
+ });
785
+
786
+ test("dispatch: summarizing with null activeSlice → stop (error)", async () => {
787
+ base = createFullFixture();
788
+ openDatabase(join(base, ".gsd", "gsd.db"));
789
+
790
+ const ctx = buildDispatchCtx(base, "M001", {
791
+ phase: "summarizing",
792
+ activeSlice: null,
793
+ activeTask: null,
794
+ });
795
+
796
+ const result = await resolveDispatch(ctx);
797
+ assert.equal(result.action, "stop", "summarizing without activeSlice should stop");
798
+ assert.ok(
799
+ (result as any).reason?.includes("no active slice"),
800
+ "stop reason should mention missing slice",
801
+ );
802
+ });
803
+
804
+ test("dispatch: evaluating-gates without gate config → skip (gates omitted)", async () => {
805
+ base = createFullFixture();
806
+ openDatabase(join(base, ".gsd", "gsd.db"));
807
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
808
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
809
+
810
+ const ctx = buildDispatchCtx(base, "M001", {
811
+ phase: "evaluating-gates",
812
+ activeSlice: { id: "S01", title: "First" },
813
+ activeTask: null,
814
+ });
815
+ ctx.prefs = undefined; // No prefs → gate_evaluation not enabled
816
+
817
+ const result = await resolveDispatch(ctx);
818
+ // Without gate config, the rule should skip (gates omitted)
819
+ assert.ok(
820
+ result.action === "skip" || result.action === "stop",
821
+ `evaluating-gates without config should skip or stop, got: ${result.action}`,
822
+ );
823
+ });
824
+
825
+ test("dispatch: needs-discussion → discuss-milestone dispatch", async () => {
826
+ base = createFullFixture();
827
+ openDatabase(join(base, ".gsd", "gsd.db"));
828
+
829
+ const ctx = buildDispatchCtx(base, "M001", {
830
+ phase: "needs-discussion",
831
+ activeSlice: null,
832
+ activeTask: null,
833
+ });
834
+
835
+ const result = await resolveDispatch(ctx);
836
+ assert.equal(result.action, "dispatch");
837
+ assert.equal((result as any).unitType, "discuss-milestone");
838
+ });
839
+
840
+ test("dispatch: complete phase → stop with info level", async () => {
841
+ base = createFullFixture();
842
+ openDatabase(join(base, ".gsd", "gsd.db"));
843
+
844
+ const ctx = buildDispatchCtx(base, "M001", {
845
+ phase: "complete",
846
+ activeSlice: null,
847
+ activeTask: null,
848
+ });
849
+
850
+ const result = await resolveDispatch(ctx);
851
+ assert.equal(result.action, "stop");
852
+ assert.equal((result as any).level, "info");
853
+ assert.ok((result as any).reason?.includes("complete"), "reason should mention completion");
854
+ });
855
+
856
+ test("dispatch rule order: first match wins for overlapping rules", () => {
857
+ const ruleNames = getDispatchRuleNames();
858
+ // Verify critical ordering constraints
859
+ const summarizeIdx = ruleNames.indexOf("summarizing → complete-slice");
860
+ const runUatIdx = ruleNames.indexOf("run-uat (post-completion)");
861
+ const uatGateIdx = ruleNames.indexOf("uat-verdict-gate (non-PASS blocks progression)");
862
+ const executeIdx = ruleNames.indexOf("executing → execute-task");
863
+
864
+ // summarizing should come before execute-task
865
+ assert.ok(summarizeIdx < executeIdx, "summarizing rule should precede execute-task");
866
+ // run-uat should come before uat-verdict-gate
867
+ assert.ok(runUatIdx < uatGateIdx, "run-uat should precede uat-verdict-gate");
868
+ });
869
+ });
870
+
871
+ // ─────────────────────────────────────────────────────────────────────────
872
+ // SECTION 4: Completion & Verification Failures
873
+ // ─────────────────────────────────────────────────────────────────────────
874
+
875
+ describe("completion and verification failures", () => {
876
+ let base: string;
877
+
878
+ afterEach(() => {
879
+ try { closeDatabase(); } catch { /* may not be open */ }
880
+ if (base) rmSync(base, { recursive: true, force: true });
881
+ });
882
+
883
+ test("needs-remediation VALIDATION blocks milestone completion dispatch", async () => {
884
+ base = createFullFixture();
885
+ const mDir = join(base, ".gsd", "milestones", "M001");
886
+ writeFileSync(
887
+ join(mDir, "M001-VALIDATION.md"),
888
+ [
889
+ "---",
890
+ "verdict: needs-remediation",
891
+ "remediation_round: 1",
892
+ "---",
893
+ "",
894
+ "# Validation",
895
+ "",
896
+ "Needs remediation work.",
897
+ ].join("\n"),
898
+ );
899
+
900
+ openDatabase(join(base, ".gsd", "gsd.db"));
901
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
902
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
903
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
904
+
905
+ const ctx = buildDispatchCtx(base, "M001", {
906
+ phase: "completing-milestone",
907
+ activeSlice: null,
908
+ activeTask: null,
909
+ });
910
+
911
+ const result = await resolveDispatch(ctx);
912
+ assert.equal(result.action, "stop", "needs-remediation should block completion");
913
+ assert.ok(
914
+ (result as any).reason?.includes("needs-remediation"),
915
+ "stop reason should mention needs-remediation",
916
+ );
917
+ });
918
+
919
+ test("missing slice SUMMARY blocks milestone validation dispatch", async () => {
920
+ base = createFullFixture();
921
+ openDatabase(join(base, ".gsd", "gsd.db"));
922
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
923
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
924
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
925
+ // No S01-SUMMARY.md or S02-SUMMARY.md on disk
926
+
927
+ const ctx = buildDispatchCtx(base, "M001", {
928
+ phase: "validating-milestone",
929
+ activeSlice: null,
930
+ activeTask: null,
931
+ });
932
+
933
+ const result = await resolveDispatch(ctx);
934
+ assert.equal(result.action, "stop", "missing SUMMARY files should block validation");
935
+ assert.ok(
936
+ (result as any).reason?.includes("missing SUMMARY"),
937
+ "stop reason should mention missing SUMMARY",
938
+ );
939
+ });
940
+
941
+ test("VALIDATION with pass verdict: isValidationTerminal returns true", () => {
942
+ const content = "---\nverdict: pass\nremediation_round: 0\n---\n# Pass\n";
943
+ assert.equal(isValidationTerminal(content), true);
944
+ });
945
+
946
+ test("VALIDATION with needs-attention: isValidationTerminal returns true", () => {
947
+ const content = "---\nverdict: needs-attention\n---\n# Attention\n";
948
+ assert.equal(isValidationTerminal(content), true);
949
+ });
950
+
951
+ test("VALIDATION with needs-remediation: isValidationTerminal returns true (terminal for loop prevention)", () => {
952
+ // Per #832: needs-remediation IS terminal to prevent validate-milestone loops
953
+ const content = "---\nverdict: needs-remediation\nremediation_round: 1\n---\n# Remediate\n";
954
+ assert.equal(isValidationTerminal(content), true);
955
+ });
956
+
957
+ test("UAT verdict gate: non-PASS verdict blocks progression", () => {
958
+ assert.equal(isAcceptableUatVerdict("pass", undefined), true);
959
+ assert.equal(isAcceptableUatVerdict("passed", undefined), true);
960
+ assert.equal(isAcceptableUatVerdict("fail", undefined), false);
961
+ assert.equal(isAcceptableUatVerdict("needs-remediation", undefined), false);
962
+ assert.equal(isAcceptableUatVerdict("partial", undefined), false, "partial without eligible type → not acceptable");
963
+ assert.equal(isAcceptableUatVerdict("partial", "mixed"), true, "partial with mixed type → acceptable");
964
+ assert.equal(isAcceptableUatVerdict("partial", "human-experience"), true, "partial with human-experience → acceptable");
965
+ assert.equal(isAcceptableUatVerdict("partial", "artifact-driven"), false, "partial with artifact-driven → not acceptable");
966
+ });
967
+
968
+ test("milestone validation verdict schema validation", () => {
969
+ assert.equal(isValidMilestoneVerdict("pass"), true);
970
+ assert.equal(isValidMilestoneVerdict("needs-attention"), true);
971
+ assert.equal(isValidMilestoneVerdict("needs-remediation"), true);
972
+ assert.equal(isValidMilestoneVerdict("fail"), false, "fail is not a valid milestone verdict");
973
+ assert.equal(isValidMilestoneVerdict(""), false);
974
+ assert.equal(isValidMilestoneVerdict("unknown"), false);
975
+ });
976
+
977
+ test("all slices done + no VALIDATION → validating-milestone (not completing)", async () => {
978
+ base = createFullFixture();
979
+ openDatabase(join(base, ".gsd", "gsd.db"));
980
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
981
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
982
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
983
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
984
+ insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
985
+ insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
986
+
987
+ invalidateAllCaches();
988
+ const state = await deriveStateFromDb(base);
989
+ assert.equal(
990
+ state.phase,
991
+ "validating-milestone",
992
+ "all slices done without VALIDATION should be validating-milestone",
993
+ );
994
+ });
995
+
996
+ test("all slices done + terminal VALIDATION + no SUMMARY → completing-milestone", async () => {
997
+ base = createFullFixture();
998
+ writeFileSync(
999
+ join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md"),
1000
+ "---\nverdict: pass\n---\n# Validation\nPassed.\n",
1001
+ );
1002
+
1003
+ openDatabase(join(base, ".gsd", "gsd.db"));
1004
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
1005
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
1006
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
1007
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
1008
+ insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
1009
+ insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
1010
+
1011
+ invalidateAllCaches();
1012
+ const state = await deriveStateFromDb(base);
1013
+ assert.equal(
1014
+ state.phase,
1015
+ "completing-milestone",
1016
+ "terminal VALIDATION without SUMMARY should be completing-milestone",
1017
+ );
1018
+ });
1019
+
1020
+ test("extractVerdict: markdown body fallback works", () => {
1021
+ // When LLM writes verdict in body instead of frontmatter (#2960)
1022
+ assert.equal(extractVerdict("# Validation\n\n**Verdict:** PASS"), "pass");
1023
+ assert.equal(extractVerdict("# Validation\n\n**Verdict:** ✅ PASS"), "pass");
1024
+ assert.equal(extractVerdict("# Validation\n\n**Verdict** needs-remediation"), "needs-remediation");
1025
+ });
1026
+
1027
+ test("extractVerdict: normalizes 'passed' to 'pass'", () => {
1028
+ assert.equal(extractVerdict("---\nverdict: passed\n---"), "pass");
1029
+ assert.equal(extractVerdict("**Verdict:** passed"), "pass");
1030
+ });
1031
+
1032
+ test("isClosedStatus: boundary values", () => {
1033
+ assert.equal(isClosedStatus("complete"), true);
1034
+ assert.equal(isClosedStatus("done"), true);
1035
+ assert.equal(isClosedStatus("skipped"), true);
1036
+ assert.equal(isClosedStatus("active"), false);
1037
+ assert.equal(isClosedStatus("pending"), false);
1038
+ assert.equal(isClosedStatus("in_progress"), false);
1039
+ assert.equal(isClosedStatus(""), false);
1040
+ assert.equal(isClosedStatus("COMPLETE"), false, "case-sensitive: uppercase should be false");
1041
+ });
1042
+ });
1043
+
1044
+ // ─────────────────────────────────────────────────────────────────────────
1045
+ // SECTION 5: Ghost Milestone Edge Cases
1046
+ // ─────────────────────────────────────────────────────────────────────────
1047
+
1048
+ describe("ghost milestone edge cases", () => {
1049
+ let base: string;
1050
+
1051
+ afterEach(() => {
1052
+ try { closeDatabase(); } catch { /* may not be open */ }
1053
+ if (base) rmSync(base, { recursive: true, force: true });
1054
+ });
1055
+
1056
+ test("empty directory with DB row is NOT a ghost (#2921)", () => {
1057
+ base = makeTempDir();
1058
+ const mDir = join(base, ".gsd", "milestones", "M001");
1059
+ mkdirSync(mDir, { recursive: true });
1060
+
1061
+ openDatabase(join(base, ".gsd", "gsd.db"));
1062
+ insertMilestone({ id: "M001", title: "Queued", status: "active" });
1063
+
1064
+ assert.equal(isGhostMilestone(base, "M001"), false, "DB row means not a ghost");
1065
+ });
1066
+
1067
+ test("empty directory with worktree is NOT a ghost (#2921)", () => {
1068
+ base = makeTempDir();
1069
+ const mDir = join(base, ".gsd", "milestones", "M001");
1070
+ mkdirSync(mDir, { recursive: true });
1071
+ // Simulate worktree existence
1072
+ mkdirSync(join(base, ".gsd", "worktrees", "M001"), { recursive: true });
1073
+
1074
+ assert.equal(isGhostMilestone(base, "M001"), false, "worktree means not a ghost");
1075
+ });
1076
+
1077
+ test("empty directory without DB or worktree IS a ghost", () => {
1078
+ base = makeTempDir();
1079
+ const mDir = join(base, ".gsd", "milestones", "M001");
1080
+ mkdirSync(mDir, { recursive: true });
1081
+
1082
+ assert.equal(isGhostMilestone(base, "M001"), true, "no DB, no worktree, no files → ghost");
1083
+ });
1084
+
1085
+ test("directory with only META.json is still a ghost", () => {
1086
+ base = makeTempDir();
1087
+ const mDir = join(base, ".gsd", "milestones", "M001");
1088
+ mkdirSync(mDir, { recursive: true });
1089
+ writeFileSync(join(mDir, "META.json"), '{"created":"2026-01-01"}');
1090
+
1091
+ assert.equal(isGhostMilestone(base, "M001"), true, "META.json alone → ghost");
1092
+ });
1093
+
1094
+ test("ghost milestones are skipped in state derivation", async () => {
1095
+ base = makeTempDir();
1096
+ const gsdDir = join(base, ".gsd", "milestones");
1097
+
1098
+ // M001 is ghost — empty dir
1099
+ mkdirSync(join(gsdDir, "M001"), { recursive: true });
1100
+
1101
+ // M002 is real — has CONTEXT-DRAFT
1102
+ mkdirSync(join(gsdDir, "M002"), { recursive: true });
1103
+ writeFileSync(join(gsdDir, "M002", "M002-CONTEXT-DRAFT.md"), "# Draft\nContent.\n");
1104
+
1105
+ invalidateAllCaches();
1106
+ const state = await deriveState(base);
1107
+ assert.equal(state.activeMilestone?.id, "M002", "ghost M001 skipped, M002 is active");
1108
+ });
1109
+ });
1110
+
1111
+ // ─────────────────────────────────────────────────────────────────────────
1112
+ // SECTION 6: Dispatch Guard Integration
1113
+ // ─────────────────────────────────────────────────────────────────────────
1114
+
1115
+ describe("dispatch guard integration", () => {
1116
+ let base: string;
1117
+
1118
+ afterEach(() => {
1119
+ try { closeDatabase(); } catch { /* may not be open */ }
1120
+ if (base) rmSync(base, { recursive: true, force: true });
1121
+ });
1122
+
1123
+ test("skip_milestone_validation preference writes pass-through VALIDATION", async () => {
1124
+ base = createFullFixture();
1125
+ openDatabase(join(base, ".gsd", "gsd.db"));
1126
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
1127
+ insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
1128
+ insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
1129
+ // Write slice SUMMARYs so the missing SUMMARY guard doesn't fire
1130
+ writeFileSync(
1131
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
1132
+ "# S01 Summary\nDone.\n",
1133
+ );
1134
+ writeFileSync(
1135
+ join(base, ".gsd", "milestones", "M001", "slices", "S02", "S02-SUMMARY.md"),
1136
+ "# S02 Summary\nDone.\n",
1137
+ );
1138
+
1139
+ const ctx = buildDispatchCtx(base, "M001", {
1140
+ phase: "validating-milestone",
1141
+ activeSlice: null,
1142
+ activeTask: null,
1143
+ });
1144
+ ctx.prefs = { phases: { skip_milestone_validation: true } } as any;
1145
+
1146
+ const result = await resolveDispatch(ctx);
1147
+ assert.equal(result.action, "skip", "skip_milestone_validation should produce skip action");
1148
+
1149
+ // Should have written a pass-through VALIDATION file
1150
+ const validationPath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
1151
+ assert.ok(existsSync(validationPath), "VALIDATION file should be written");
1152
+ const content = readFileSync(validationPath, "utf-8");
1153
+ assert.ok(content.includes("verdict: pass"), "should contain pass verdict");
1154
+ assert.ok(content.includes("skipped by preference"), "should note it was skipped");
1155
+ });
1156
+
1157
+ test("rewrite-docs circuit breaker: exceeding MAX attempts resolves all overrides", async () => {
1158
+ base = createFullFixture();
1159
+ openDatabase(join(base, ".gsd", "gsd.db"));
1160
+ insertMilestone({ id: "M001", title: "Active", status: "active" });
1161
+
1162
+ // Write a rewrite count at the max
1163
+ const runtimeDir = join(base, ".gsd", "runtime");
1164
+ mkdirSync(runtimeDir, { recursive: true });
1165
+ writeFileSync(
1166
+ join(runtimeDir, "rewrite-count.json"),
1167
+ JSON.stringify({ count: 3, updatedAt: new Date().toISOString() }),
1168
+ );
1169
+
1170
+ // Import and check
1171
+ const { getRewriteCount } = await import("../../auto-dispatch.ts");
1172
+ assert.equal(getRewriteCount(base), 3, "rewrite count should be 3");
1173
+ });
1174
+
1175
+ test("replanning-slice with null activeSlice → stop (error)", async () => {
1176
+ base = createFullFixture();
1177
+ openDatabase(join(base, ".gsd", "gsd.db"));
1178
+
1179
+ const ctx = buildDispatchCtx(base, "M001", {
1180
+ phase: "replanning-slice",
1181
+ activeSlice: null,
1182
+ activeTask: null,
1183
+ });
1184
+
1185
+ const result = await resolveDispatch(ctx);
1186
+ assert.equal(result.action, "stop", "replanning without activeSlice should stop");
1187
+ });
1188
+ });