gsd-pi 2.80.0-dev.e146beb20 → 2.80.0-dev.e51d2c88c

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 (197) hide show
  1. package/README.md +4 -2
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +29 -15
  4. package/dist/resources/extensions/gsd/auto/resolve.js +17 -0
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +13 -1
  6. package/dist/resources/extensions/gsd/auto-prompts.js +13 -1
  7. package/dist/resources/extensions/gsd/auto-recovery.js +43 -1
  8. package/dist/resources/extensions/gsd/auto-supervisor.js +8 -1
  9. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +2 -2
  10. package/dist/resources/extensions/gsd/auto.js +66 -4
  11. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +21 -2
  12. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +27 -20
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +21 -0
  14. package/dist/resources/extensions/gsd/context-budget.js +37 -2
  15. package/dist/resources/extensions/gsd/db/unit-dispatches.js +39 -0
  16. package/dist/resources/extensions/gsd/db-base-schema.js +4 -2
  17. package/dist/resources/extensions/gsd/db-migration-steps.js +6 -0
  18. package/dist/resources/extensions/gsd/gsd-db.js +46 -13
  19. package/dist/resources/extensions/gsd/guided-flow.js +33 -4
  20. package/dist/resources/extensions/gsd/memory-store.js +69 -12
  21. package/dist/resources/extensions/gsd/migrate/command.js +40 -1
  22. package/dist/resources/extensions/gsd/migration-auto-check.js +87 -0
  23. package/dist/resources/extensions/gsd/prompt-loader.js +28 -2
  24. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  25. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  26. package/dist/resources/extensions/gsd/prompts/quick-task.md +1 -5
  27. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  28. package/dist/resources/extensions/gsd/quick.js +34 -2
  29. package/dist/resources/extensions/gsd/tools/context-mode-tool-result.js +15 -0
  30. package/dist/resources/extensions/gsd/tools/exec-search-tool.js +5 -0
  31. package/dist/resources/extensions/gsd/tools/exec-tool.js +3 -15
  32. package/dist/resources/extensions/gsd/tools/memory-tools.js +1 -0
  33. package/dist/resources/extensions/gsd/tools/resume-tool.js +5 -0
  34. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +1 -1
  35. package/dist/resources/extensions/gsd/unit-context-composer.js +12 -3
  36. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  37. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  38. package/dist/web/standalone/.next/BUILD_ID +1 -1
  39. package/dist/web/standalone/.next/app-path-routes-manifest.json +18 -18
  40. package/dist/web/standalone/.next/build-manifest.json +2 -2
  41. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  42. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.html +1 -1
  59. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app-paths-manifest.json +18 -18
  66. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  68. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  69. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  70. package/package.json +3 -3
  71. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  72. package/packages/mcp-server/dist/workflow-tools.js +22 -17
  73. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  74. package/packages/mcp-server/src/workflow-tools.test.ts +75 -2
  75. package/packages/mcp-server/src/workflow-tools.ts +30 -16
  76. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  77. package/packages/native/tsconfig.tsbuildinfo +1 -1
  78. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  79. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/agent-session.js +8 -0
  82. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +3 -1
  84. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts +11 -0
  86. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/compaction/compaction.js +9 -0
  88. package/packages/pi-coding-agent/dist/core/compaction/compaction.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts +2 -0
  90. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts.map +1 -0
  91. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js +103 -0
  92. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js.map +1 -0
  93. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +1 -0
  94. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/extensions/runner.js +3 -0
  96. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  97. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js +2 -0
  98. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +7 -0
  100. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +20 -0
  103. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  104. package/packages/pi-coding-agent/dist/core/settings-manager.js +25 -0
  105. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  106. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +1 -0
  107. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +3 -0
  109. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +13 -5
  112. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js +53 -0
  114. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +3 -0
  117. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  118. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  119. package/packages/pi-coding-agent/src/core/agent-session.ts +8 -0
  120. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +3 -1
  121. package/packages/pi-coding-agent/src/core/compaction/compaction.ts +18 -0
  122. package/packages/pi-coding-agent/src/core/compaction-threshold.test.ts +121 -0
  123. package/packages/pi-coding-agent/src/core/extensions/runner.test.ts +2 -0
  124. package/packages/pi-coding-agent/src/core/extensions/runner.ts +3 -0
  125. package/packages/pi-coding-agent/src/core/extensions/types.ts +7 -0
  126. package/packages/pi-coding-agent/src/core/settings-manager.ts +39 -1
  127. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +4 -0
  128. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts +56 -0
  129. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +22 -7
  130. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +3 -0
  131. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  132. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  133. package/packages/pi-tui/dist/tui.js +18 -8
  134. package/packages/pi-tui/dist/tui.js.map +1 -1
  135. package/packages/pi-tui/src/tui.ts +20 -8
  136. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  137. package/src/resources/extensions/gsd/auto/phases.ts +35 -20
  138. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  139. package/src/resources/extensions/gsd/auto/run-unit.ts +18 -1
  140. package/src/resources/extensions/gsd/auto-prompts.ts +17 -1
  141. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  142. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  143. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  144. package/src/resources/extensions/gsd/auto.ts +78 -3
  145. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  146. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +27 -19
  147. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +22 -0
  148. package/src/resources/extensions/gsd/context-budget.ts +44 -2
  149. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  150. package/src/resources/extensions/gsd/db-base-schema.ts +4 -2
  151. package/src/resources/extensions/gsd/db-migration-steps.ts +8 -0
  152. package/src/resources/extensions/gsd/gsd-db.ts +50 -13
  153. package/src/resources/extensions/gsd/guided-flow.ts +49 -4
  154. package/src/resources/extensions/gsd/memory-store.ts +77 -12
  155. package/src/resources/extensions/gsd/migrate/command.ts +47 -1
  156. package/src/resources/extensions/gsd/migration-auto-check.ts +129 -0
  157. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  158. package/src/resources/extensions/gsd/prompt-loader.ts +27 -2
  159. package/src/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  160. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  161. package/src/resources/extensions/gsd/prompts/quick-task.md +1 -5
  162. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  163. package/src/resources/extensions/gsd/quick.ts +37 -2
  164. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +71 -0
  165. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +56 -13
  166. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  167. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +14 -1
  168. package/src/resources/extensions/gsd/tests/context-budget.test.ts +10 -1
  169. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +313 -0
  170. package/src/resources/extensions/gsd/tests/exec-history.test.ts +15 -0
  171. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +65 -0
  172. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +234 -0
  173. package/src/resources/extensions/gsd/tests/memory-decay-factor.test.ts +90 -0
  174. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +48 -0
  175. package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +127 -0
  176. package/src/resources/extensions/gsd/tests/prompt-path-audit.test.ts +40 -0
  177. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +19 -0
  178. package/src/resources/extensions/gsd/tests/quick-external-gsd.test.ts +40 -0
  179. package/src/resources/extensions/gsd/tests/schema-v27-v28-sequence.test.ts +156 -0
  180. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  181. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  182. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +55 -0
  183. package/src/resources/extensions/gsd/tests/status-db-open.test.ts +9 -0
  184. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +136 -4
  185. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  186. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  187. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +3 -0
  188. package/src/resources/extensions/gsd/tools/context-mode-tool-result.ts +25 -0
  189. package/src/resources/extensions/gsd/tools/exec-search-tool.ts +7 -7
  190. package/src/resources/extensions/gsd/tools/exec-tool.ts +4 -23
  191. package/src/resources/extensions/gsd/tools/memory-tools.ts +1 -0
  192. package/src/resources/extensions/gsd/tools/resume-tool.ts +7 -7
  193. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +1 -1
  194. package/src/resources/extensions/gsd/unit-context-composer.ts +19 -4
  195. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  196. /package/dist/web/standalone/.next/static/{y73quA-XdLo9n41nxphjW → 8F5YpnZNBaooIWGF4GBV3}/_buildManifest.js +0 -0
  197. /package/dist/web/standalone/.next/static/{y73quA-XdLo9n41nxphjW → 8F5YpnZNBaooIWGF4GBV3}/_ssgManifest.js +0 -0
@@ -7,6 +7,7 @@ import { join } from 'node:path';
7
7
  import { EXEC_DEFAULTS, runExecSandbox, type ExecSandboxOptions } from '../exec-sandbox.ts';
8
8
  import { buildExecOptions, executeGsdExec } from '../tools/exec-tool.ts';
9
9
  import { isContextModeEnabled } from '../preferences-types.ts';
10
+ import { validatePreferences } from '../preferences-validation.ts';
10
11
 
11
12
  function freshBase(): string {
12
13
  return mkdtempSync(join(tmpdir(), 'gsd-exec-test-'));
@@ -174,6 +175,52 @@ test('executeGsdExec: runs when enabled explicitly set to true', async () => {
174
175
  }
175
176
  });
176
177
 
178
+ test('executeGsdExec: forwards custom exec_env_allowlist from preferences', async () => {
179
+ const base = freshBase();
180
+ try {
181
+ const result = await executeGsdExec(
182
+ {
183
+ runtime: 'bash',
184
+ script: 'printf "allowed=%s blocked=%s\\n" "$GSD_ALLOWED" "$GSD_BLOCKED"',
185
+ },
186
+ {
187
+ baseDir: base,
188
+ preferences: {
189
+ context_mode: {
190
+ enabled: true,
191
+ exec_env_allowlist: ['GSD_ALLOWED'],
192
+ },
193
+ },
194
+ env: {
195
+ PATH: '/usr/bin:/bin',
196
+ HOME: '/tmp',
197
+ GSD_ALLOWED: 'yes',
198
+ GSD_BLOCKED: 'no',
199
+ },
200
+ },
201
+ );
202
+ assert.ok(!result.isError);
203
+ assert.match(result.content[0].text, /allowed=yes blocked=/);
204
+ assert.doesNotMatch(result.content[0].text, /blocked=no/);
205
+ } finally {
206
+ cleanup(base);
207
+ }
208
+ });
209
+
210
+ test('executeGsdExec: enforces per-call timeout override end-to-end', async () => {
211
+ const base = freshBase();
212
+ try {
213
+ const result = await executeGsdExec(
214
+ { runtime: 'bash', script: 'sleep 2', timeout_ms: 1 },
215
+ { baseDir: base, preferences: { context_mode: { enabled: true, exec_timeout_ms: 10_000 } } },
216
+ );
217
+ assert.equal(result.details.timed_out, true);
218
+ assert.equal(result.isError, true);
219
+ } finally {
220
+ cleanup(base);
221
+ }
222
+ });
223
+
177
224
  test('executeGsdExec: rejects empty script', async () => {
178
225
  const base = freshBase();
179
226
  try {
@@ -188,6 +235,24 @@ test('executeGsdExec: rejects empty script', async () => {
188
235
  }
189
236
  });
190
237
 
238
+ test('validatePreferences: rejects invalid context_mode preference values', () => {
239
+ const result = validatePreferences({
240
+ context_mode: {
241
+ enabled: 'false',
242
+ exec_timeout_ms: 999,
243
+ exec_stdout_cap_bytes: 1,
244
+ exec_digest_chars: -1,
245
+ exec_env_allowlist: ['GOOD_NAME', 'bad-name'],
246
+ },
247
+ } as any);
248
+ assert.ok(result.errors.length > 0);
249
+ assert.ok(result.errors.includes('context_mode.enabled must be a boolean'));
250
+ assert.ok(result.errors.includes('context_mode.exec_timeout_ms must be a number between 1000 and 600000'));
251
+ assert.ok(result.errors.includes('context_mode.exec_stdout_cap_bytes must be a number between 4096 and 16777216'));
252
+ assert.ok(result.errors.includes('context_mode.exec_digest_chars must be a number between 0 and 4000'));
253
+ assert.ok(result.errors.includes('context_mode.exec_env_allowlist must be an array of valid env var names'));
254
+ });
255
+
191
256
  test('isContextModeEnabled: defaults to true; only explicit false disables', () => {
192
257
  assert.equal(isContextModeEnabled(undefined), true, 'undefined prefs → on');
193
258
  assert.equal(isContextModeEnabled(null), true, 'null prefs → on');
@@ -399,6 +399,240 @@ test("runDispatch pauses when complete-milestone summary exists on disk but the
399
399
  assert.equal(stopCalls, 0, "mismatch pause should not hard-stop the loop");
400
400
  });
401
401
 
402
+ test("runDispatch pauses when execute-task artifacts exist but DB status is still open", async (t) => {
403
+ const capture = createEventCapture();
404
+ let pauseCalls = 0;
405
+ let stopCalls = 0;
406
+ let invalidateCalls = 0;
407
+ const base = join(tmpdir(), `gsd-stuck-execute-task-${randomUUID()}`);
408
+ t.after(() => {
409
+ closeDatabase();
410
+ rmSync(base, { recursive: true, force: true });
411
+ });
412
+
413
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
414
+ const tasksDir = join(sliceDir, "tasks");
415
+ mkdirSync(tasksDir, { recursive: true });
416
+ openDatabase(join(base, ".gsd", "gsd.db"));
417
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
418
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
419
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "pending" });
420
+ writeFileSync(
421
+ join(sliceDir, "S01-PLAN.md"),
422
+ [
423
+ "# S01",
424
+ "",
425
+ "## Tasks",
426
+ "",
427
+ "- [x] **T01: First task** `est:1h`",
428
+ "",
429
+ ].join("\n"),
430
+ );
431
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
432
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
433
+
434
+ const deps = makeMockDeps(capture, {
435
+ pauseAuto: async () => { pauseCalls++; },
436
+ stopAuto: async () => { stopCalls++; },
437
+ invalidateAllCaches: () => { invalidateCalls++; },
438
+ resolveDispatch: async () => ({
439
+ action: "dispatch" as const,
440
+ unitType: "execute-task",
441
+ unitId: "M001/S01/T01",
442
+ prompt: "execute the task",
443
+ matchedRule: "executing → execute-task",
444
+ }),
445
+ });
446
+ const ic = makeIC(deps, {
447
+ s: {
448
+ ...makeSession(),
449
+ basePath: base,
450
+ originalBasePath: base,
451
+ } as any,
452
+ });
453
+ const preData: PreDispatchData = {
454
+ state: {
455
+ phase: "executing",
456
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
457
+ activeSlice: { id: "S01", title: "Slice" },
458
+ activeTask: { id: "T01", title: "First task" },
459
+ registry: [{ id: "M001", status: "active" }],
460
+ blockers: [],
461
+ } as any,
462
+ mid: "M001",
463
+ midTitle: "Test Milestone",
464
+ };
465
+ const loopState: LoopState = {
466
+ recentUnits: [
467
+ { key: "execute-task/M001/S01/T01" },
468
+ { key: "execute-task/M001/S01/T01" },
469
+ ],
470
+ stuckRecoveryAttempts: 0,
471
+ consecutiveFinalizeTimeouts: 0,
472
+ };
473
+
474
+ const result = await runDispatch(ic, preData, loopState);
475
+
476
+ assert.equal(result.action, "break");
477
+ assert.equal((result as any).reason, "execute-task-artifact-db-mismatch");
478
+ assert.equal(pauseCalls, 1, "execute-task disk/db mismatch should pause auto-mode");
479
+ assert.equal(stopCalls, 0, "execute-task disk/db mismatch should not hard-stop the loop");
480
+ assert.equal(invalidateCalls, 0, "mismatch should not clear caches and continue toward redispatch");
481
+ assert.equal(loopState.recentUnits.length, 3, "mismatch should keep the stuck window intact");
482
+ assert.equal(loopState.stuckRecoveryAttempts, 1, "mismatch should not reset the recovery counter");
483
+ });
484
+
485
+ test("runDispatch pauses at Level 2 when execute-task artifacts exist but DB status is still open", async (t) => {
486
+ const capture = createEventCapture();
487
+ let pauseCalls = 0;
488
+ let stopCalls = 0;
489
+ let invalidateCalls = 0;
490
+ const base = join(tmpdir(), `gsd-stuck-execute-task-l2-${randomUUID()}`);
491
+ t.after(() => {
492
+ closeDatabase();
493
+ rmSync(base, { recursive: true, force: true });
494
+ });
495
+
496
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
497
+ const tasksDir = join(sliceDir, "tasks");
498
+ mkdirSync(tasksDir, { recursive: true });
499
+ openDatabase(join(base, ".gsd", "gsd.db"));
500
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
501
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
502
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "pending" });
503
+ writeFileSync(
504
+ join(sliceDir, "S01-PLAN.md"),
505
+ "# S01\n\n## Tasks\n\n- [x] **T01: First task** `est:1h`\n",
506
+ );
507
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
508
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
509
+
510
+ const deps = makeMockDeps(capture, {
511
+ pauseAuto: async () => { pauseCalls++; },
512
+ stopAuto: async () => { stopCalls++; },
513
+ invalidateAllCaches: () => { invalidateCalls++; },
514
+ resolveDispatch: async () => ({
515
+ action: "dispatch" as const,
516
+ unitType: "execute-task",
517
+ unitId: "M001/S01/T01",
518
+ prompt: "execute the task",
519
+ matchedRule: "executing execute-task",
520
+ }),
521
+ });
522
+ const ic = makeIC(deps, {
523
+ s: {
524
+ ...makeSession(),
525
+ basePath: base,
526
+ originalBasePath: base,
527
+ } as any,
528
+ });
529
+ const preData: PreDispatchData = {
530
+ state: {
531
+ phase: "executing",
532
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
533
+ activeSlice: { id: "S01", title: "Slice" },
534
+ activeTask: { id: "T01", title: "First task" },
535
+ registry: [{ id: "M001", status: "active" }],
536
+ blockers: [],
537
+ } as any,
538
+ mid: "M001",
539
+ midTitle: "Test Milestone",
540
+ };
541
+ const loopState: LoopState = {
542
+ recentUnits: [
543
+ { key: "execute-task/M001/S01/T01" },
544
+ { key: "execute-task/M001/S01/T01" },
545
+ ],
546
+ stuckRecoveryAttempts: 1,
547
+ consecutiveFinalizeTimeouts: 0,
548
+ };
549
+
550
+ const result = await runDispatch(ic, preData, loopState);
551
+
552
+ assert.equal(result.action, "break");
553
+ assert.equal((result as any).reason, "execute-task-artifact-db-mismatch");
554
+ assert.equal(pauseCalls, 1, "Level 2 execute-task disk/db mismatch should pause auto-mode");
555
+ assert.equal(stopCalls, 0, "Level 2 execute-task disk/db mismatch should not hard-stop the loop");
556
+ assert.equal(invalidateCalls, 1, "Level 2 should invalidate caches before the final artifact recheck");
557
+ assert.equal(loopState.recentUnits.length, 3, "Level 2 mismatch should keep the stuck window intact");
558
+ assert.equal(loopState.stuckRecoveryAttempts, 1, "Level 2 mismatch should not reset the recovery counter");
559
+ });
560
+
561
+ test("runDispatch clears execute-task stuck state when artifacts and DB status are complete", async (t) => {
562
+ const capture = createEventCapture();
563
+ let pauseCalls = 0;
564
+ let stopCalls = 0;
565
+ let invalidateCalls = 0;
566
+ const base = join(tmpdir(), `gsd-stuck-execute-task-complete-${randomUUID()}`);
567
+ t.after(() => {
568
+ closeDatabase();
569
+ rmSync(base, { recursive: true, force: true });
570
+ });
571
+
572
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
573
+ const tasksDir = join(sliceDir, "tasks");
574
+ mkdirSync(tasksDir, { recursive: true });
575
+ openDatabase(join(base, ".gsd", "gsd.db"));
576
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
577
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
578
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "First task", status: "complete" });
579
+ writeFileSync(
580
+ join(sliceDir, "S01-PLAN.md"),
581
+ "# S01\n\n## Tasks\n\n- [x] **T01: First task** `est:1h`\n",
582
+ );
583
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n");
584
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone on disk.\n");
585
+
586
+ const deps = makeMockDeps(capture, {
587
+ pauseAuto: async () => { pauseCalls++; },
588
+ stopAuto: async () => { stopCalls++; },
589
+ invalidateAllCaches: () => { invalidateCalls++; },
590
+ resolveDispatch: async () => ({
591
+ action: "dispatch" as const,
592
+ unitType: "execute-task",
593
+ unitId: "M001/S01/T01",
594
+ prompt: "execute the task",
595
+ matchedRule: "executing execute-task",
596
+ }),
597
+ });
598
+ const ic = makeIC(deps, {
599
+ s: {
600
+ ...makeSession(),
601
+ basePath: base,
602
+ originalBasePath: base,
603
+ } as any,
604
+ });
605
+ const preData: PreDispatchData = {
606
+ state: {
607
+ phase: "executing",
608
+ activeMilestone: { id: "M001", title: "Test", status: "active" },
609
+ activeSlice: { id: "S01", title: "Slice" },
610
+ activeTask: { id: "T01", title: "First task" },
611
+ registry: [{ id: "M001", status: "active" }],
612
+ blockers: [],
613
+ } as any,
614
+ mid: "M001",
615
+ midTitle: "Test Milestone",
616
+ };
617
+ const loopState: LoopState = {
618
+ recentUnits: [
619
+ { key: "execute-task/M001/S01/T01" },
620
+ { key: "execute-task/M001/S01/T01" },
621
+ ],
622
+ stuckRecoveryAttempts: 0,
623
+ consecutiveFinalizeTimeouts: 0,
624
+ };
625
+
626
+ const result = await runDispatch(ic, preData, loopState);
627
+
628
+ assert.equal(result.action, "continue");
629
+ assert.equal(pauseCalls, 0, "closed DB task should not pause auto-mode");
630
+ assert.equal(stopCalls, 0, "closed DB task should not hard-stop the loop");
631
+ assert.equal(invalidateCalls, 1, "closed DB task recovery should invalidate caches once");
632
+ assert.deepEqual(loopState.recentUnits, [], "closed DB task recovery should clear the stuck window");
633
+ assert.equal(loopState.stuckRecoveryAttempts, 0, "closed DB task recovery should reset the recovery counter");
634
+ });
635
+
402
636
  test("runDispatch clears stuck state after Level 1 artifact recovery", async (t) => {
403
637
  const capture = createEventCapture();
404
638
  let invalidateCalls = 0;
@@ -0,0 +1,90 @@
1
+ // gsd-2 / memoryDecayFactor unit tests
2
+ //
3
+ // Pure-function boundary tests for the V28 time-decay scoring helper.
4
+ // The function maps last_hit_at → multiplier in [0.7, 1.0] used by
5
+ // queryMemoriesRanked to down-weight stale memories without fully suppressing
6
+ // them. These tests pin the contract:
7
+ //
8
+ // - null / invalid / future timestamps → 1.0 (no decay penalty)
9
+ // - 0 days ago → 1.0
10
+ // - linear decay between 0 and 90 days
11
+ // - 90+ days ago → 0.7 floor
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import { memoryDecayFactor } from "../memory-store.ts";
17
+
18
+ const DAY_MS = 86_400_000;
19
+
20
+ function isoDaysAgo(days: number): string {
21
+ return new Date(Date.now() - days * DAY_MS).toISOString();
22
+ }
23
+
24
+ test("memoryDecayFactor: null lastHitAt returns 1.0 (never-hit = no decay)", () => {
25
+ assert.equal(memoryDecayFactor(null), 1.0);
26
+ });
27
+
28
+ test("memoryDecayFactor: invalid timestamp string returns 1.0 (defensive)", () => {
29
+ assert.equal(memoryDecayFactor("not-a-date"), 1.0);
30
+ assert.equal(memoryDecayFactor(""), 1.0);
31
+ });
32
+
33
+ test("memoryDecayFactor: future timestamp clamps to daysAgo=0 → 1.0", () => {
34
+ // Clock skew or manual DB edits can yield future last_hit_at values.
35
+ // The factor must stay within [0.7, 1.0] regardless.
36
+ const future = new Date(Date.now() + 30 * DAY_MS).toISOString();
37
+ const factor = memoryDecayFactor(future);
38
+ assert.ok(factor <= 1.0, `factor must not exceed 1.0, got ${factor}`);
39
+ assert.ok(factor >= 0.7, `factor must not fall below 0.7, got ${factor}`);
40
+ assert.equal(factor, 1.0);
41
+ });
42
+
43
+ test("memoryDecayFactor: 0 days ago returns 1.0", () => {
44
+ const factor = memoryDecayFactor(new Date().toISOString());
45
+ // Tiny clock drift between now-string and Date.now() inside the function;
46
+ // assert it's effectively 1.0 within float tolerance.
47
+ assert.ok(Math.abs(factor - 1.0) < 1e-6, `expected ≈1.0, got ${factor}`);
48
+ });
49
+
50
+ test("memoryDecayFactor: 30 days ago returns ~0.90 (linear midpoint)", () => {
51
+ // Formula: 1.0 - 0.3 * (30/90) = 1.0 - 0.1 = 0.90
52
+ const factor = memoryDecayFactor(isoDaysAgo(30));
53
+ assert.ok(Math.abs(factor - 0.90) < 1e-3, `expected ≈0.90, got ${factor}`);
54
+ });
55
+
56
+ test("memoryDecayFactor: 60 days ago returns ~0.80", () => {
57
+ // Formula: 1.0 - 0.3 * (60/90) = 1.0 - 0.2 = 0.80
58
+ const factor = memoryDecayFactor(isoDaysAgo(60));
59
+ assert.ok(Math.abs(factor - 0.80) < 1e-3, `expected ≈0.80, got ${factor}`);
60
+ });
61
+
62
+ test("memoryDecayFactor: 90 days ago returns 0.70 (floor)", () => {
63
+ const factor = memoryDecayFactor(isoDaysAgo(90));
64
+ assert.ok(Math.abs(factor - 0.70) < 1e-3, `expected ≈0.70, got ${factor}`);
65
+ });
66
+
67
+ test("memoryDecayFactor: 180 days ago stays at 0.70 floor", () => {
68
+ const factor = memoryDecayFactor(isoDaysAgo(180));
69
+ assert.equal(factor, 0.70);
70
+ });
71
+
72
+ test("memoryDecayFactor: result always in [0.7, 1.0] for any input", () => {
73
+ const samples: (string | null)[] = [
74
+ null,
75
+ "",
76
+ "garbage",
77
+ new Date(0).toISOString(),
78
+ isoDaysAgo(0),
79
+ isoDaysAgo(15),
80
+ isoDaysAgo(45),
81
+ isoDaysAgo(89),
82
+ isoDaysAgo(91),
83
+ isoDaysAgo(365),
84
+ new Date(Date.now() + 365 * DAY_MS).toISOString(),
85
+ ];
86
+ for (const s of samples) {
87
+ const f = memoryDecayFactor(s);
88
+ assert.ok(f >= 0.7 && f <= 1.0, `factor out of [0.7, 1.0] for ${s}: ${f}`);
89
+ }
90
+ });
@@ -13,6 +13,9 @@ import { parseRoadmap, parsePlan } from '../parsers-legacy.ts';
13
13
  import { parseSummary } from '../files.ts';
14
14
  import { deriveState } from '../state.ts';
15
15
  import { invalidateAllCaches } from '../cache.ts';
16
+ import { ensureDbOpen } from '../bootstrap/dynamic-tools.ts';
17
+ import { closeDatabase, getAllMilestones } from '../gsd-db.ts';
18
+ import { importWrittenMigrationToDb } from '../migrate/command.ts';
16
19
  import type {
17
20
  GSDProject,
18
21
  GSDMilestone,
@@ -250,6 +253,51 @@ test('Scenario 1: Incomplete project — write, parse, deriveState', async () =>
250
253
  }
251
254
  });
252
255
 
256
+ test('Scenario 1b: written migration imports into authoritative DB state', async () => {
257
+ const base = mkdtempSync(join(tmpdir(), 'gsd-writer-db-import-'));
258
+ try {
259
+ const project = buildIncompleteProject();
260
+ await writeGSDDirectory(project, base);
261
+
262
+ assert.equal(await ensureDbOpen(base), true, 'db import: ensureDbOpen creates authoritative DB');
263
+
264
+ invalidateAllCaches();
265
+ const before = await deriveState(base);
266
+ assert.equal(before.activeMilestone, null, 'db import: markdown-only migration is invisible before DB import');
267
+
268
+ const imported = await importWrittenMigrationToDb(base);
269
+ assert.deepStrictEqual(imported.hierarchy, { milestones: 1, slices: 2, tasks: 3 }, 'db import: hierarchy counts');
270
+
271
+ invalidateAllCaches();
272
+ const after = await deriveState(base);
273
+ assert.deepStrictEqual(after.phase, 'executing', 'db import: deriveState sees imported DB hierarchy');
274
+ assert.deepStrictEqual(after.activeMilestone?.id, 'M001', 'db import: active milestone');
275
+ assert.deepStrictEqual(after.activeSlice?.id, 'S02', 'db import: active slice');
276
+ assert.deepStrictEqual(after.activeTask?.id, 'T03', 'db import: active task');
277
+ } finally {
278
+ closeDatabase();
279
+ rmSync(base, { recursive: true, force: true });
280
+ }
281
+ });
282
+
283
+ test('Scenario 1c: DB import verification fails when preview counts do not match', async () => {
284
+ const base = mkdtempSync(join(tmpdir(), 'gsd-writer-db-check-'));
285
+ try {
286
+ const project = buildIncompleteProject();
287
+ await writeGSDDirectory(project, base);
288
+
289
+ const preview = generatePreview(project);
290
+ await assert.rejects(
291
+ () => importWrittenMigrationToDb(base, { ...preview, totalTasks: preview.totalTasks + 1 }),
292
+ /migration DB import verification failed: tasks 3\/4/,
293
+ );
294
+ assert.deepStrictEqual(getAllMilestones(), [], 'db import: failed verification rolls back hierarchy rewrite');
295
+ } finally {
296
+ closeDatabase();
297
+ rmSync(base, { recursive: true, force: true });
298
+ }
299
+ });
300
+
253
301
  // ─── Scenario 2: Fully complete project ────────────────────────────────
254
302
 
255
303
  test('Scenario 2: Fully complete project — deriveState phase', async () => {
@@ -0,0 +1,127 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+
7
+ import { ensureDbOpen } from "../bootstrap/dynamic-tools.ts";
8
+ import {
9
+ _getAdapter,
10
+ closeDatabase,
11
+ getAllMilestones,
12
+ getSliceTasks,
13
+ } from "../gsd-db.ts";
14
+ import {
15
+ autoImportMarkdownHierarchyIfDbMismatch,
16
+ countMarkdownHierarchy,
17
+ } from "../migration-auto-check.ts";
18
+ import { writeGSDDirectory } from "../migrate/writer.ts";
19
+ import type { GSDProject } from "../migrate/types.ts";
20
+
21
+ function makeBase(): string {
22
+ return mkdtempSync(join(tmpdir(), "gsd-migration-auto-check-"));
23
+ }
24
+
25
+ function cleanup(base: string): void {
26
+ closeDatabase();
27
+ rmSync(base, { recursive: true, force: true });
28
+ }
29
+
30
+ function projectFixture(): GSDProject {
31
+ return {
32
+ projectContent: "# Legacy Project\n",
33
+ decisionsContent: "",
34
+ requirements: [],
35
+ milestones: [
36
+ {
37
+ id: "M001",
38
+ title: "Legacy Milestone",
39
+ vision: "Carry forward previous work",
40
+ successCriteria: ["Existing task is visible"],
41
+ research: null,
42
+ boundaryMap: [],
43
+ slices: [
44
+ {
45
+ id: "S01",
46
+ title: "Legacy Slice",
47
+ risk: "medium",
48
+ depends: [],
49
+ done: false,
50
+ demo: "Legacy slice demo",
51
+ goal: "Legacy slice demo",
52
+ research: null,
53
+ summary: null,
54
+ tasks: [
55
+ {
56
+ id: "T01",
57
+ title: "Legacy Task",
58
+ description: "Task carried from markdown",
59
+ done: false,
60
+ estimate: "",
61
+ files: ["src/index.ts"],
62
+ mustHaves: [],
63
+ summary: null,
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ };
71
+ }
72
+
73
+ test("migration auto-check imports markdown hierarchy when DB is empty", async () => {
74
+ const base = makeBase();
75
+ try {
76
+ await writeGSDDirectory(projectFixture(), base);
77
+ assert.deepEqual(countMarkdownHierarchy(base), { milestones: 1, slices: 1, tasks: 1 });
78
+
79
+ assert.equal(await ensureDbOpen(base), true);
80
+ assert.equal(getAllMilestones().length, 0, "fresh authoritative DB starts empty");
81
+
82
+ const result = await autoImportMarkdownHierarchyIfDbMismatch(base);
83
+ assert.equal(result.action, "imported");
84
+ assert.equal(result.reason, "db-empty");
85
+ assert.deepEqual(result.afterDb, { milestones: 1, slices: 1, tasks: 1 });
86
+ assert.equal(getAllMilestones().length, 1);
87
+ assert.equal(getSliceTasks("M001", "S01").length, 1);
88
+ } finally {
89
+ cleanup(base);
90
+ }
91
+ });
92
+
93
+ test("migration auto-check repairs DB hierarchy count mismatch", async () => {
94
+ const base = makeBase();
95
+ try {
96
+ await writeGSDDirectory(projectFixture(), base);
97
+ await autoImportMarkdownHierarchyIfDbMismatch(base);
98
+
99
+ _getAdapter()!.prepare("DELETE FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?").run("M001", "S01", "T01");
100
+ assert.equal(getSliceTasks("M001", "S01").length, 0, "test fixture simulates stale DB task count");
101
+
102
+ const result = await autoImportMarkdownHierarchyIfDbMismatch(base);
103
+ assert.equal(result.action, "imported");
104
+ assert.equal(result.reason, "count-mismatch");
105
+ assert.deepEqual(result.beforeDb, { milestones: 1, slices: 1, tasks: 0 });
106
+ assert.deepEqual(result.afterDb, { milestones: 1, slices: 1, tasks: 1 });
107
+ assert.equal(getSliceTasks("M001", "S01").length, 1);
108
+ } finally {
109
+ cleanup(base);
110
+ }
111
+ });
112
+
113
+ test("migration auto-check leaves matching DB hierarchy alone", async () => {
114
+ const base = makeBase();
115
+ try {
116
+ await writeGSDDirectory(projectFixture(), base);
117
+ await autoImportMarkdownHierarchyIfDbMismatch(base);
118
+
119
+ const result = await autoImportMarkdownHierarchyIfDbMismatch(base);
120
+ assert.equal(result.action, "none");
121
+ assert.equal(result.reason, "in-sync");
122
+ assert.deepEqual(result.markdown, { milestones: 1, slices: 1, tasks: 1 });
123
+ assert.deepEqual(result.afterDb, { milestones: 1, slices: 1, tasks: 1 });
124
+ } finally {
125
+ cleanup(base);
126
+ }
127
+ });
@@ -0,0 +1,40 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname } from "node:path";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const promptsDir = join(__dirname, "..", "prompts");
11
+
12
+ test("prompt templates do not reference legacy milestone-root .gsd paths", () => {
13
+ const offenders: string[] = [];
14
+ for (const file of readdirSync(promptsDir)) {
15
+ if (!file.endsWith(".md")) continue;
16
+ const content = readFileSync(join(promptsDir, file), "utf-8");
17
+ const legacyPatterns = [
18
+ /\.gsd\/\{\{(?:milestoneId|mid)\}\}\//g,
19
+ /\.gsd\/<milestone-id>\//g,
20
+ /\.gsd\/<ID>\//g,
21
+ ];
22
+ for (const pattern of legacyPatterns) {
23
+ if (pattern.test(content)) {
24
+ offenders.push(`${file}: ${pattern.source}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ assert.deepEqual(
30
+ offenders,
31
+ [],
32
+ "Milestone artifacts must use .gsd/milestones/<MID>/..., not legacy .gsd/<MID>/...",
33
+ );
34
+ });
35
+
36
+ test("quick task prompt delegates commit policy to quick.ts", () => {
37
+ const content = readFileSync(join(promptsDir, "quick-task.md"), "utf-8");
38
+ assert.match(content, /\{\{commitInstruction\}\}/);
39
+ assert.doesNotMatch(content, /Stage only relevant files/);
40
+ });
@@ -65,6 +65,25 @@ describe('prompt step ordering (#3696)', () => {
65
65
  assert.ok(learningsIdx < completeMilestoneIdx, 'learnings extraction must happen before gsd_complete_milestone');
66
66
  });
67
67
 
68
+ test('complete-milestone duplicate guard checks milestone status before durable writes', () => {
69
+ const guardMatch = completeMilestoneMd.match(/^\d+\.\s.*gsd_milestone_status/m);
70
+ const reqUpdateMatch = completeMilestoneMd.match(/^\d+\.\s.*gsd_requirement_update/m);
71
+ assert.ok(guardMatch, 'complete-milestone must start with a gsd_milestone_status duplicate guard');
72
+ assert.ok(reqUpdateMatch, 'gsd_requirement_update should appear in a numbered step');
73
+
74
+ const guardIdx = completeMilestoneMd.indexOf(guardMatch![0]);
75
+ const reqUpdateIdx = completeMilestoneMd.indexOf(reqUpdateMatch![0]);
76
+ assert.ok(
77
+ guardIdx < reqUpdateIdx,
78
+ 'duplicate guard must run before requirement/project/learnings writes',
79
+ );
80
+ assert.match(
81
+ completeMilestoneMd,
82
+ /status(?:`|\*\*)?\s+(?:is\s+)?(?:`complete`|"complete")/i,
83
+ 'duplicate guard must tell the agent to stop when status is complete',
84
+ );
85
+ });
86
+
68
87
  test('complete-slice.md uses gsd_requirement_update', () => {
69
88
  assert.match(completeSliceMd, /gsd_requirement_update/,
70
89
  'complete-slice.md should reference gsd_requirement_update');