gsd-pi 2.80.0-dev.0aaa4fc17 → 2.80.0-dev.1486c1899

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 (166) 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 +22 -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 +52 -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/db/unit-dispatches.js +39 -0
  15. package/dist/resources/extensions/gsd/guided-flow.js +19 -4
  16. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  17. package/dist/resources/extensions/gsd/prompts/quick-task.md +1 -5
  18. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  19. package/dist/resources/extensions/gsd/quick.js +34 -2
  20. package/dist/resources/extensions/gsd/tools/context-mode-tool-result.js +15 -0
  21. package/dist/resources/extensions/gsd/tools/exec-search-tool.js +5 -0
  22. package/dist/resources/extensions/gsd/tools/exec-tool.js +3 -15
  23. package/dist/resources/extensions/gsd/tools/resume-tool.js +5 -0
  24. package/dist/resources/extensions/gsd/unit-context-composer.js +12 -3
  25. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  26. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  27. package/dist/web/standalone/.next/BUILD_ID +1 -1
  28. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  29. package/dist/web/standalone/.next/build-manifest.json +2 -2
  30. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  31. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.html +1 -1
  48. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  55. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  57. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  58. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  59. package/package.json +3 -3
  60. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  61. package/packages/mcp-server/dist/workflow-tools.js +22 -17
  62. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  63. package/packages/mcp-server/src/workflow-tools.test.ts +75 -2
  64. package/packages/mcp-server/src/workflow-tools.ts +30 -16
  65. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  66. package/packages/native/tsconfig.tsbuildinfo +1 -1
  67. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  68. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/agent-session.js +8 -0
  71. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +3 -1
  73. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts +11 -0
  75. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts.map +1 -1
  76. package/packages/pi-coding-agent/dist/core/compaction/compaction.js +9 -0
  77. package/packages/pi-coding-agent/dist/core/compaction/compaction.js.map +1 -1
  78. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts +2 -0
  79. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts.map +1 -0
  80. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js +103 -0
  81. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js.map +1 -0
  82. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +1 -0
  83. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/extensions/runner.js +3 -0
  85. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js +2 -0
  87. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +7 -0
  89. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +20 -0
  92. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/settings-manager.js +25 -0
  94. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +1 -0
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +3 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  99. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  100. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +13 -5
  101. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  102. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js +53 -0
  103. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js.map +1 -1
  104. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +3 -0
  106. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  107. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  108. package/packages/pi-coding-agent/src/core/agent-session.ts +8 -0
  109. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +3 -1
  110. package/packages/pi-coding-agent/src/core/compaction/compaction.ts +18 -0
  111. package/packages/pi-coding-agent/src/core/compaction-threshold.test.ts +121 -0
  112. package/packages/pi-coding-agent/src/core/extensions/runner.test.ts +2 -0
  113. package/packages/pi-coding-agent/src/core/extensions/runner.ts +3 -0
  114. package/packages/pi-coding-agent/src/core/extensions/types.ts +7 -0
  115. package/packages/pi-coding-agent/src/core/settings-manager.ts +39 -1
  116. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +4 -0
  117. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts +56 -0
  118. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +22 -7
  119. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +3 -0
  120. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  121. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  122. package/packages/pi-tui/dist/tui.js +18 -8
  123. package/packages/pi-tui/dist/tui.js.map +1 -1
  124. package/packages/pi-tui/src/tui.ts +20 -8
  125. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  126. package/src/resources/extensions/gsd/auto/phases.ts +28 -20
  127. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  128. package/src/resources/extensions/gsd/auto/run-unit.ts +18 -1
  129. package/src/resources/extensions/gsd/auto-prompts.ts +17 -1
  130. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  131. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  132. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  133. package/src/resources/extensions/gsd/auto.ts +60 -3
  134. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  135. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +27 -19
  136. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +22 -0
  137. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  138. package/src/resources/extensions/gsd/guided-flow.ts +32 -4
  139. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  140. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  141. package/src/resources/extensions/gsd/prompts/quick-task.md +1 -5
  142. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  143. package/src/resources/extensions/gsd/quick.ts +37 -2
  144. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +71 -0
  145. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  146. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +11 -0
  147. package/src/resources/extensions/gsd/tests/exec-history.test.ts +15 -0
  148. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +65 -0
  149. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +234 -0
  150. package/src/resources/extensions/gsd/tests/prompt-path-audit.test.ts +40 -0
  151. package/src/resources/extensions/gsd/tests/quick-external-gsd.test.ts +40 -0
  152. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  153. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  154. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +55 -0
  155. package/src/resources/extensions/gsd/tests/status-db-open.test.ts +9 -0
  156. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +136 -4
  157. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  158. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  159. package/src/resources/extensions/gsd/tools/context-mode-tool-result.ts +25 -0
  160. package/src/resources/extensions/gsd/tools/exec-search-tool.ts +7 -7
  161. package/src/resources/extensions/gsd/tools/exec-tool.ts +4 -23
  162. package/src/resources/extensions/gsd/tools/resume-tool.ts +7 -7
  163. package/src/resources/extensions/gsd/unit-context-composer.ts +19 -4
  164. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  165. /package/dist/web/standalone/.next/static/{YRK7-Es93MwpIn3VL35K0 → O-2rnxSJki_W-2fkK3zIj}/_buildManifest.js +0 -0
  166. /package/dist/web/standalone/.next/static/{YRK7-Es93MwpIn3VL35K0 → O-2rnxSJki_W-2fkK3zIj}/_ssgManifest.js +0 -0
@@ -108,6 +108,21 @@ test('executeExecSearch: returns helpful empty-state message when no matches', (
108
108
  }
109
109
  });
110
110
 
111
+ test('executeExecSearch: returns disabled error when context_mode.enabled=false', () => {
112
+ const base = freshBase();
113
+ try {
114
+ writeRun(base, 'should-not-surface', { stdout: 'hidden\n' });
115
+ const result = executeExecSearch(
116
+ { query: 'hidden' },
117
+ { baseDir: base, preferences: { context_mode: { enabled: false } } },
118
+ );
119
+ assert.equal(result.isError, true);
120
+ assert.equal((result.details as { error?: string }).error, 'context_mode_disabled');
121
+ } finally {
122
+ cleanup(base);
123
+ }
124
+ });
125
+
111
126
  test('executeExecSearch: includes stdout_path and preview in details', () => {
112
127
  const base = freshBase();
113
128
  try {
@@ -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,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
+ });
@@ -0,0 +1,40 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { buildQuickCommitInstruction } from "../quick.ts";
8
+
9
+ function git(cwd: string, args: string[]): void {
10
+ const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
11
+ assert.equal(result.status, 0, result.stderr || result.stdout);
12
+ }
13
+
14
+ test("quick task commit instruction does not ask agents to stage external .gsd quick files", { skip: process.platform === "win32" }, () => {
15
+ const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), "gsd-quick-ext-")));
16
+ const repo = join(tempRoot, "repo");
17
+ const externalGsd = join(tempRoot, "state");
18
+ mkdirSync(repo);
19
+ mkdirSync(externalGsd);
20
+
21
+ const previousCwd = process.cwd();
22
+ try {
23
+ git(repo, ["init"]);
24
+ git(repo, ["config", "user.email", "test@example.com"]);
25
+ git(repo, ["config", "user.name", "Test User"]);
26
+ writeFileSync(join(repo, "README.md"), "# Test\n", "utf-8");
27
+ git(repo, ["add", "README.md"]);
28
+ git(repo, ["commit", "-m", "init"]);
29
+ symlinkSync(externalGsd, join(repo, ".gsd"), "dir");
30
+
31
+ const instruction = buildQuickCommitInstruction(repo, join(repo, ".gsd"));
32
+
33
+ assert.match(instruction, /do not stage or commit `\.gsd\/quick\/\.\.\.`/);
34
+ assert.match(instruction, /nothing in the project repo to commit/);
35
+ assert.match(instruction, /Write the quick summary file directly/);
36
+ } finally {
37
+ process.chdir(previousCwd);
38
+ rmSync(tempRoot, { recursive: true, force: true });
39
+ }
40
+ });
@@ -101,3 +101,30 @@ test("registerSigtermHandler deregisters previous handler from all signals", ()
101
101
  // Clean up
102
102
  deregisterSigtermHandler(handler2);
103
103
  });
104
+
105
+ test("registered signal handler runs best-effort cleanup before exiting", () => {
106
+ let cleanupCalled = false;
107
+ let exitCode: number | string | null | undefined;
108
+ const originalExit = process.exit;
109
+ const handler = registerSigtermHandler(
110
+ "/tmp/test-signal-cleanup",
111
+ null,
112
+ () => {
113
+ cleanupCalled = true;
114
+ },
115
+ );
116
+
117
+ (process as any).exit = ((code?: number | string | null) => {
118
+ exitCode = code;
119
+ throw new Error("process.exit intercepted");
120
+ }) as never;
121
+
122
+ try {
123
+ assert.throws(() => handler(), /process\.exit intercepted/);
124
+ assert.equal(cleanupCalled, true);
125
+ assert.equal(exitCode, 0);
126
+ } finally {
127
+ (process as any).exit = originalExit;
128
+ deregisterSigtermHandler(handler);
129
+ }
130
+ });
@@ -15,10 +15,11 @@
15
15
  * (the fix) and verifies it does not crash.
16
16
  */
17
17
 
18
- import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
18
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { tmpdir } from "node:os";
21
21
  import { recoverTimedOutUnit, type RecoveryContext } from "../auto-timeout-recovery.ts";
22
+ import { closeDatabase, insertMilestone, insertSlice, insertTask, openDatabase } from "../gsd-db.ts";
22
23
  import { test } from 'node:test';
23
24
  import assert from 'node:assert/strict';
24
25
 
@@ -39,6 +40,14 @@ function makeMockPi() {
39
40
  } as any;
40
41
  }
41
42
 
43
+ function makeRecordingPi() {
44
+ const messages: unknown[] = [];
45
+ return {
46
+ messages,
47
+ sendMessage: (message: unknown) => { messages.push(message); },
48
+ } as any;
49
+ }
50
+
42
51
  // ═══ #1855: empty RecoveryContext (basePath undefined) crashes ════════════════
43
52
 
44
53
  {
@@ -63,6 +72,45 @@ function makeMockPi() {
63
72
  assert.ok(crashed, "should crash when basePath is undefined (reproduces #1855)");
64
73
  }
65
74
 
75
+ // ═══ DB-complete execute-task recovery advances without steering ═════════════
76
+
77
+ {
78
+ console.log("\n=== execute-task timeout recovery trusts closed DB status ===");
79
+ const base = mkdtempSync(join(tmpdir(), "gsd-timeout-db-complete-"));
80
+ mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
81
+
82
+ try {
83
+ openDatabase(join(base, ".gsd", "gsd.db"));
84
+ insertMilestone({ id: "M001", title: "Milestone", status: "active" });
85
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "in_progress" });
86
+ insertTask({ id: "T01", milestoneId: "M001", sliceId: "S01", title: "Task", status: "complete" });
87
+ writeFileSync(
88
+ join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
89
+ "# S01\n\n## Tasks\n\n- [ ] **T01: Task** `est:10m`\n",
90
+ "utf-8",
91
+ );
92
+ writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S01: Task\n", "utf-8");
93
+
94
+ const ctx = makeMockCtx();
95
+ const pi = makeRecordingPi();
96
+ const result = await recoverTimedOutUnit(ctx, pi, "execute-task", "M001/S01/T01", "idle", {
97
+ basePath: base,
98
+ verbose: false,
99
+ currentUnitStartedAt: Date.now(),
100
+ unitRecoveryCount: new Map(),
101
+ });
102
+
103
+ assert.equal(result, "recovered", "db-complete task should recover immediately");
104
+ assert.equal(pi.messages.length, 0, "db-complete task should not send steering recovery");
105
+ const runtime = JSON.parse(readFileSync(join(base, ".gsd", "runtime", "units", "execute-task-M001-S01-T01.json"), "utf-8"));
106
+ assert.equal(runtime.phase, "finalized", "db-complete task should be finalized");
107
+ assert.equal(runtime.recovery.dbComplete, true, "runtime recovery should record DB completion");
108
+ } finally {
109
+ closeDatabase();
110
+ rmSync(base, { recursive: true, force: true });
111
+ }
112
+ }
113
+
66
114
  // ═══ #1855: valid RecoveryContext does not crash ═════════════════════════════
67
115
 
68
116
  {
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
 
6
6
  import { _withDetachedAutoKeepaliveForTest } from "../auto.ts";
7
+ import { _scheduleAutoStartAfterIdleForTest } from "../guided-flow.ts";
7
8
 
8
9
  const gsdDir = resolve(import.meta.dirname, "..");
9
10
 
@@ -96,6 +97,23 @@ test("auto bootstrap validates blocked directories before touching .gsd migratio
96
97
  );
97
98
  });
98
99
 
100
+ test("fresh start registers the auto worker before bootstrap enters worktree flow (#5405)", () => {
101
+ const autoSrc = readGsdFile("auto.ts");
102
+ const startAutoIdx = autoSrc.indexOf("export async function startAuto(");
103
+ const startAutoBody = autoSrc.slice(startAutoIdx);
104
+
105
+ const preBootstrapRegisterIdx = startAutoBody.indexOf("registerAutoWorkerForSession(s, base);");
106
+ const bootstrapCallIdx = startAutoBody.indexOf("const ready = await bootstrapAutoSession(");
107
+
108
+ assert.ok(startAutoIdx > -1, "startAuto should exist");
109
+ assert.ok(preBootstrapRegisterIdx > -1, "startAuto should register worker before bootstrap");
110
+ assert.ok(bootstrapCallIdx > -1, "startAuto should call bootstrapAutoSession");
111
+ assert.ok(
112
+ preBootstrapRegisterIdx < bootstrapCallIdx,
113
+ "worker registration must happen before bootstrap so enterMilestone can claim milestone leases on first entry",
114
+ );
115
+ });
116
+
99
117
  test("startAutoDetached reports failures asynchronously (#3733)", () => {
100
118
  const autoSrc = readGsdFile("auto.ts");
101
119
 
@@ -185,3 +203,40 @@ test("detached auto-start preserves milestone lock across pause/stop cleanup (#3
185
203
  "AutoSession should track the detached milestone lock explicitly",
186
204
  );
187
205
  });
206
+
207
+ test("discussion auto-start waits for the current command context to become idle", async () => {
208
+ let releaseIdle!: () => void;
209
+ const idle = new Promise<void>((resolveIdle) => {
210
+ releaseIdle = resolveIdle;
211
+ });
212
+ const launches: unknown[][] = [];
213
+ const ctx = {
214
+ waitForIdle: () => idle,
215
+ ui: {
216
+ notify: () => {},
217
+ },
218
+ } as any;
219
+
220
+ _scheduleAutoStartAfterIdleForTest(
221
+ ctx,
222
+ {} as any,
223
+ "/tmp/gsd-auto-start-idle-test",
224
+ false,
225
+ { step: true },
226
+ (...args: unknown[]) => {
227
+ launches.push(args);
228
+ },
229
+ );
230
+
231
+ await Promise.resolve();
232
+ assert.equal(launches.length, 0, "auto-start must not launch before waitForIdle resolves");
233
+
234
+ releaseIdle();
235
+ await Promise.resolve();
236
+ assert.equal(launches.length, 0, "auto-start should defer launch to the next timer turn");
237
+
238
+ await new Promise((resolveTimer) => setTimeout(resolveTimer, 0));
239
+ assert.equal(launches.length, 1);
240
+ assert.equal(launches[0][2], "/tmp/gsd-auto-start-idle-test");
241
+ assert.deepEqual(launches[0][4], { step: true });
242
+ });
@@ -44,4 +44,13 @@ describe('status opens DB before deriveState (#3691)', () => {
44
44
  assert.match(quickSrc, /getIsolationMode\(\)\s*!==\s*"none"/,
45
45
  'quick.ts should compare isolation mode against "none"');
46
46
  });
47
+
48
+ test('quick task prompt handles external .gsd without staging quick files', () => {
49
+ assert.match(quickSrc, /isExternalGsdRoot/,
50
+ 'quick.ts should detect whether .gsd resolves outside the project repo');
51
+ assert.match(quickSrc, /do not stage or commit `\.gsd\/quick\/\.\.\.`/,
52
+ 'external-state quick tasks must tell the agent not to stage .gsd/quick files');
53
+ assert.match(quickSrc, /nothing in the project repo to commit/,
54
+ 'external-state quick tasks should allow summary-only work without a git commit');
55
+ });
47
56
  });