gsd-pi 2.73.1-dev.47ed32a → 2.73.1-dev.49397e5

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 (182) hide show
  1. package/dist/cli-web-branch.d.ts +4 -3
  2. package/dist/cli-web-branch.js +10 -7
  3. package/dist/cli.js +99 -206
  4. package/dist/headless-query.js +4 -1
  5. package/dist/logo.d.ts +1 -1
  6. package/dist/logo.js +1 -1
  7. package/dist/onboarding.js +59 -53
  8. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +59 -1
  9. package/dist/resources/extensions/gsd/auto/detect-stuck.js +11 -4
  10. package/dist/resources/extensions/gsd/auto/phases.js +60 -10
  11. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
  12. package/dist/resources/extensions/gsd/auto-post-unit.js +93 -57
  13. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +13 -0
  14. package/dist/resources/extensions/gsd/auto-verification.js +88 -3
  15. package/dist/resources/extensions/gsd/auto.js +29 -10
  16. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +21 -8
  17. package/dist/resources/extensions/gsd/commands/catalog.js +26 -1
  18. package/dist/resources/extensions/gsd/commands/handlers/ops.js +20 -0
  19. package/dist/resources/extensions/gsd/commands/handlers/workflow.js +68 -9
  20. package/dist/resources/extensions/gsd/commands-add-tests.js +111 -0
  21. package/dist/resources/extensions/gsd/commands-backlog.js +140 -0
  22. package/dist/resources/extensions/gsd/commands-do.js +79 -0
  23. package/dist/resources/extensions/gsd/commands-maintenance.js +6 -6
  24. package/dist/resources/extensions/gsd/commands-pr-branch.js +180 -0
  25. package/dist/resources/extensions/gsd/commands-session-report.js +82 -0
  26. package/dist/resources/extensions/gsd/commands-ship.js +187 -0
  27. package/dist/resources/extensions/gsd/db-writer.js +3 -5
  28. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  29. package/dist/resources/extensions/gsd/gsd-db.js +321 -0
  30. package/dist/resources/extensions/gsd/index.js +15 -2
  31. package/dist/resources/extensions/gsd/md-importer.js +3 -4
  32. package/dist/resources/extensions/gsd/memory-store.js +19 -51
  33. package/dist/resources/extensions/gsd/milestone-validation-gates.js +13 -12
  34. package/dist/resources/extensions/gsd/native-git-bridge.js +7 -4
  35. package/dist/resources/extensions/gsd/notification-widget.js +2 -2
  36. package/dist/resources/extensions/gsd/prompts/add-tests.md +35 -0
  37. package/dist/resources/extensions/gsd/state.js +66 -15
  38. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +3 -14
  39. package/dist/resources/extensions/gsd/triage-resolution.js +2 -5
  40. package/dist/resources/extensions/gsd/workflow-manifest.js +8 -69
  41. package/dist/resources/extensions/gsd/workflow-migration.js +21 -22
  42. package/dist/resources/extensions/gsd/workflow-projections.js +4 -1
  43. package/dist/resources/extensions/gsd/workflow-reconcile.js +14 -11
  44. package/dist/tsconfig.extensions.tsbuildinfo +1 -0
  45. package/dist/web/standalone/.next/BUILD_ID +1 -1
  46. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  47. package/dist/web/standalone/.next/build-manifest.json +2 -2
  48. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  49. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.html +1 -1
  66. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  73. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  74. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  75. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  76. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  77. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  78. package/package.json +3 -3
  79. package/packages/mcp-server/tsconfig.json +1 -0
  80. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -0
  81. package/packages/native/tsconfig.tsbuildinfo +1 -0
  82. package/packages/pi-agent-core/tsconfig.json +1 -0
  83. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -0
  84. package/packages/pi-ai/tsconfig.json +1 -0
  85. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -0
  86. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +10 -2
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
  92. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  98. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +5 -1
  99. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  100. package/packages/pi-coding-agent/package.json +1 -1
  101. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +12 -6
  102. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
  103. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
  104. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +5 -1
  105. package/packages/pi-coding-agent/tsconfig.json +1 -0
  106. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -0
  107. package/packages/pi-tui/dist/__tests__/tui.test.js +30 -0
  108. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
  109. package/packages/pi-tui/dist/tui.d.ts +1 -0
  110. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  111. package/packages/pi-tui/dist/tui.js +22 -3
  112. package/packages/pi-tui/dist/tui.js.map +1 -1
  113. package/packages/pi-tui/src/__tests__/tui.test.ts +38 -0
  114. package/packages/pi-tui/src/tui.ts +20 -3
  115. package/packages/pi-tui/tsconfig.json +1 -0
  116. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -0
  117. package/packages/rpc-client/tsconfig.json +1 -0
  118. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -0
  119. package/pkg/package.json +1 -1
  120. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +95 -1
  121. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +88 -0
  122. package/src/resources/extensions/gsd/auto/detect-stuck.ts +12 -4
  123. package/src/resources/extensions/gsd/auto/loop-deps.ts +6 -0
  124. package/src/resources/extensions/gsd/auto/phases.ts +90 -10
  125. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
  126. package/src/resources/extensions/gsd/auto-post-unit.ts +107 -58
  127. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +17 -0
  128. package/src/resources/extensions/gsd/auto-verification.ts +98 -3
  129. package/src/resources/extensions/gsd/auto.ts +33 -14
  130. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +24 -8
  131. package/src/resources/extensions/gsd/commands/catalog.ts +26 -1
  132. package/src/resources/extensions/gsd/commands/handlers/ops.ts +20 -0
  133. package/src/resources/extensions/gsd/commands/handlers/workflow.ts +74 -9
  134. package/src/resources/extensions/gsd/commands-add-tests.ts +137 -0
  135. package/src/resources/extensions/gsd/commands-backlog.ts +182 -0
  136. package/src/resources/extensions/gsd/commands-do.ts +109 -0
  137. package/src/resources/extensions/gsd/commands-maintenance.ts +6 -6
  138. package/src/resources/extensions/gsd/commands-pr-branch.ts +234 -0
  139. package/src/resources/extensions/gsd/commands-session-report.ts +101 -0
  140. package/src/resources/extensions/gsd/commands-ship.ts +219 -0
  141. package/src/resources/extensions/gsd/db-writer.ts +3 -5
  142. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  143. package/src/resources/extensions/gsd/gsd-db.ts +467 -0
  144. package/src/resources/extensions/gsd/index.ts +18 -2
  145. package/src/resources/extensions/gsd/md-importer.ts +3 -5
  146. package/src/resources/extensions/gsd/memory-store.ts +31 -62
  147. package/src/resources/extensions/gsd/milestone-validation-gates.ts +13 -14
  148. package/src/resources/extensions/gsd/native-git-bridge.ts +11 -12
  149. package/src/resources/extensions/gsd/notification-widget.ts +2 -2
  150. package/src/resources/extensions/gsd/prompts/add-tests.md +35 -0
  151. package/src/resources/extensions/gsd/state.ts +80 -17
  152. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
  153. package/src/resources/extensions/gsd/tests/auto-post-unit-step-message.test.ts +53 -0
  154. package/src/resources/extensions/gsd/tests/commands-backlog.test.ts +158 -0
  155. package/src/resources/extensions/gsd/tests/commands-do.test.ts +127 -0
  156. package/src/resources/extensions/gsd/tests/commands-pr-branch.test.ts +68 -0
  157. package/src/resources/extensions/gsd/tests/commands-session-report.test.ts +82 -0
  158. package/src/resources/extensions/gsd/tests/commands-ship.test.ts +71 -0
  159. package/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +14 -0
  160. package/src/resources/extensions/gsd/tests/complete-milestone-false-merge.test.ts +142 -0
  161. package/src/resources/extensions/gsd/tests/completed-at-reconcile.test.ts +42 -0
  162. package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
  163. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
  164. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +68 -8
  165. package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
  166. package/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts +154 -0
  167. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
  168. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +68 -1
  169. package/src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts +140 -0
  170. package/src/resources/extensions/gsd/tests/single-writer-invariant.test.ts +180 -0
  171. package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
  172. package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
  173. package/src/resources/extensions/gsd/tests/validate-milestone-stuck-guard.test.ts +179 -0
  174. package/src/resources/extensions/gsd/tests/workflow-logger-wiring.test.ts +223 -0
  175. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +3 -11
  176. package/src/resources/extensions/gsd/triage-resolution.ts +2 -7
  177. package/src/resources/extensions/gsd/workflow-manifest.ts +9 -104
  178. package/src/resources/extensions/gsd/workflow-migration.ts +21 -29
  179. package/src/resources/extensions/gsd/workflow-projections.ts +8 -1
  180. package/src/resources/extensions/gsd/workflow-reconcile.ts +15 -15
  181. /package/dist/web/standalone/.next/static/{WawMb3TMcYLkGJLTg_x1u → tAsSblRKK1lG7MVOwsO92}/_buildManifest.js +0 -0
  182. /package/dist/web/standalone/.next/static/{WawMb3TMcYLkGJLTg_x1u → tAsSblRKK1lG7MVOwsO92}/_ssgManifest.js +0 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Regression test for #4129: tasks.completed_at stays NULL when status is
3
+ * reconciled to 'complete' via the file-existence path in state.ts.
4
+ *
5
+ * Root cause: reconcileSliceTasks called
6
+ * updateTaskStatus(milestoneId, sliceId, t.id, "complete")
7
+ * without a completedAt timestamp, so the column stays NULL.
8
+ *
9
+ * Fix: pass new Date().toISOString() as the 5th argument.
10
+ */
11
+
12
+ import { describe, test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { readFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const stateSource = readFileSync(join(__dirname, "..", "state.ts"), "utf-8");
20
+
21
+ describe("completed-at reconcile (#4129)", () => {
22
+ test("reconcileSliceTasks passes a completedAt timestamp when setting status to complete", () => {
23
+ // Before the fix, state.ts had:
24
+ // updateTaskStatus(milestoneId, sliceId, t.id, "complete")
25
+ // which leaves completed_at NULL in the DB.
26
+ // After the fix, a timestamp must be passed as the 5th argument.
27
+ assert.doesNotMatch(
28
+ stateSource,
29
+ /updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*\)/,
30
+ "updateTaskStatus must not be called without a completedAt timestamp when reconciling tasks to 'complete' (#4129)",
31
+ );
32
+ });
33
+
34
+ test("reconcileSliceTasks passes new Date().toISOString() as the completedAt argument", () => {
35
+ // Positive assertion: the fixed call must include a timestamp.
36
+ assert.match(
37
+ stateSource,
38
+ /updateTaskStatus\(\s*milestoneId\s*,\s*sliceId\s*,\s*t\.id\s*,\s*["']complete["']\s*,\s*new Date\(\)\.toISOString\(\)\s*\)/,
39
+ "reconcileSliceTasks must pass new Date().toISOString() as completedAt when setting task status to 'complete' (#4129)",
40
+ );
41
+ });
42
+ });
@@ -351,8 +351,9 @@ skills_used: []
351
351
  const dbState = await deriveStateFromDb(base);
352
352
 
353
353
  assertStatesEqual(dbState, fileState, 'E-blocked');
354
- assert.deepStrictEqual(dbState.phase, 'blocked', 'E-blocked: phase is blocked');
355
- assert.ok(dbState.blockers.length > 0, 'E-blocked: has blockers');
354
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
355
+ assert.deepStrictEqual(dbState.phase, 'planning', 'E-blocked: phase is planning (fallback picks a slice)');
356
+ assert.ok(dbState.activeSlice !== null, 'E-blocked: activeSlice is set via fallback');
356
357
 
357
358
  closeDatabase();
358
359
  } finally {
@@ -616,9 +616,10 @@ describe('derive-state-db', async () => {
616
616
  invalidateStateCache();
617
617
  const dbState = await deriveStateFromDb(base);
618
618
 
619
- assert.deepStrictEqual(dbState.phase, 'blocked', 'blocked-db: phase is blocked');
619
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
620
+ assert.deepStrictEqual(dbState.phase, 'planning', 'blocked-db: phase is planning (fallback picks a slice)');
620
621
  assert.deepStrictEqual(dbState.phase, fileState.phase, 'blocked-db: phase matches filesystem');
621
- assert.ok(dbState.blockers.length > 0, 'blocked-db: has blockers');
622
+ assert.ok(dbState.activeSlice !== null, 'blocked-db: activeSlice is set via fallback');
622
623
 
623
624
  closeDatabase();
624
625
  } finally {
@@ -307,27 +307,87 @@ describe('derive-state-helpers', () => {
307
307
  }
308
308
  });
309
309
 
310
- // ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
311
- test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
310
+ // ─── buildCompletenessSet: DB status is authoritative ──────────────
311
+ test('buildCompletenessSet: DB status=complete marks milestone complete', async () => {
312
312
  const base = createFixtureBase();
313
313
  try {
314
- // M001 has summary on disk but DB status is still 'active'
315
314
  writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
316
315
  writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
317
- // M002 is the real active milestone
318
316
  writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
319
317
 
320
318
  openDatabase(':memory:');
321
- insertMilestone({ id: 'M001', title: 'First', status: 'active' });
319
+ insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
322
320
  insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
323
321
 
324
322
  invalidateStateCache();
325
323
  const state = await deriveStateFromDb(base);
326
324
 
327
- // M001 should be complete (summary on disk), M002 should be active
328
325
  const m1 = state.registry.find(e => e.id === 'M001');
329
- assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
330
- assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
326
+ assert.equal(m1?.status, 'complete', 'DB status=complete registry entry complete');
327
+ assert.equal(state.activeMilestone?.id, 'M002', 'M002 is the active milestone');
328
+ } finally {
329
+ closeDatabase();
330
+ cleanup(base);
331
+ }
332
+ });
333
+
334
+ // ─── Regression #4179: orphan SUMMARY must NOT flip DB-active milestone ───
335
+ // A crashed complete-milestone turn (or stale/manual SUMMARY.md) can leave
336
+ // a milestone SUMMARY on disk while the DB row still reads 'active'. The
337
+ // read-side of state derivation must NOT treat the orphan SUMMARY as a
338
+ // completion signal, or the auto-loop advances and merges work that was
339
+ // never actually finished (same failure class as #4175, read-side twin).
340
+ test('buildCompletenessSet (#4179): orphan SUMMARY on disk does not mark DB-active milestone complete', async () => {
341
+ const base = createFixtureBase();
342
+ try {
343
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
344
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Orphan Summary\n\nLeft over from crashed turn.');
345
+
346
+ openDatabase(':memory:');
347
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
348
+ // Slice still in-flight — auto should resume, not merge.
349
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
350
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
351
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'In-flight', status: 'pending' });
352
+
353
+ invalidateStateCache();
354
+ const state = await deriveStateFromDb(base);
355
+
356
+ const m1 = state.registry.find(e => e.id === 'M001');
357
+ assert.notEqual(m1?.status, 'complete', 'orphan SUMMARY must not mark milestone complete');
358
+ assert.equal(m1?.status, 'active', 'M001 remains active — DB is authoritative');
359
+ assert.equal(state.activeMilestone?.id, 'M001', 'M001 is still the active milestone');
360
+ assert.notEqual(state.phase, 'completing-milestone', 'must not short-circuit into completion');
361
+ } finally {
362
+ closeDatabase();
363
+ cleanup(base);
364
+ }
365
+ });
366
+
367
+ // Regression #4179 (companion): DB-active milestone with all slices done +
368
+ // validation terminal + orphan SUMMARY must still flow through completing-milestone
369
+ // (re-runs complete-milestone), not be reported as already-complete.
370
+ test('buildRegistryAndFindActive (#4179): orphan SUMMARY with validation-terminal falls through to completing-milestone', async () => {
371
+ const base = createFixtureBase();
372
+ try {
373
+ writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
374
+ writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
375
+ writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', PLAN_CONTENT);
376
+ writeFile(base, 'milestones/M001/M001-VALIDATION.md', '---\nverdict: passed\n---\n# Validation\nAll good.');
377
+ writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Orphan Summary\n\nLeft over.');
378
+
379
+ openDatabase(':memory:');
380
+ insertMilestone({ id: 'M001', title: 'First', status: 'active' });
381
+ insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'complete', risk: 'low', depends: [] });
382
+ insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'complete', risk: 'low', depends: ['S01'] });
383
+
384
+ invalidateStateCache();
385
+ const state = await deriveStateFromDb(base);
386
+
387
+ const m1 = state.registry.find(e => e.id === 'M001');
388
+ assert.equal(m1?.status, 'active', 'M001 stays active despite orphan SUMMARY + validation-terminal');
389
+ assert.equal(state.activeMilestone?.id, 'M001', 'M001 is still the active milestone');
390
+ assert.equal(state.phase, 'completing-milestone', 'phase flows through completing-milestone (re-run)');
331
391
  } finally {
332
392
  closeDatabase();
333
393
  cleanup(base);
@@ -446,9 +446,9 @@ Continue from step 2.
446
446
 
447
447
  const state2 = await deriveState(base2);
448
448
 
449
- assert.deepStrictEqual(state2.phase, 'blocked', 'blocked-B: phase is blocked');
450
- assert.deepStrictEqual(state2.activeSlice, null, 'blocked-B: activeSlice is null');
451
- assert.ok(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty');
449
+ // With partial-dep fallback, S01 is picked despite unmet dep on S99
450
+ assert.deepStrictEqual(state2.phase, 'planning', 'blocked-B: phase is planning (fallback picks S01)');
451
+ assert.deepStrictEqual(state2.activeSlice?.id, 'S01', 'blocked-B: activeSlice is S01 via fallback');
452
452
  } finally {
453
453
  cleanup(base2);
454
454
  }
@@ -0,0 +1,154 @@
1
+ // Structural contracts for GSD extension bootstrap isolation.
2
+ //
3
+ // The /gsd command must survive failures in the full extension bootstrap
4
+ // (register-extension.ts). This guards against the regression where a
5
+ // Windows-specific import failure in register-shortcuts.ts silently
6
+ // prevented /gsd from being registered at all (#4168, #4172).
7
+
8
+ import { describe, test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const indexSrc = readFileSync(join(__dirname, "../index.ts"), "utf-8");
16
+ const registerExtSrc = readFileSync(
17
+ join(__dirname, "../bootstrap/register-extension.ts"),
18
+ "utf-8",
19
+ );
20
+
21
+ // ─── index.ts: core /gsd command must be registered before full bootstrap ─────
22
+
23
+ describe("index.ts bootstrap isolation", () => {
24
+ test("imports registerGSDCommand from commands/index.js separately", () => {
25
+ assert.ok(
26
+ indexSrc.includes('./commands/index.js"') || indexSrc.includes("./commands/index.js'"),
27
+ "index.ts must import registerGSDCommand from ./commands/index.js",
28
+ );
29
+ });
30
+
31
+ test("calls registerGSDCommand before importing register-extension.js", () => {
32
+ const gsdCommandCallPos = indexSrc.indexOf("registerGSDCommand(pi)");
33
+ const bootstrapImportPos = indexSrc.indexOf(
34
+ './bootstrap/register-extension.js"',
35
+ );
36
+
37
+ assert.ok(gsdCommandCallPos >= 0, "must call registerGSDCommand(pi)");
38
+ assert.ok(bootstrapImportPos >= 0, "must import register-extension.js");
39
+ assert.ok(
40
+ gsdCommandCallPos < bootstrapImportPos,
41
+ "registerGSDCommand(pi) must be called BEFORE importing register-extension.js",
42
+ );
43
+ });
44
+
45
+ test("wraps register-extension.js import in try-catch", () => {
46
+ // The dynamic import of register-extension.js must be inside a try block
47
+ const tryPos = indexSrc.indexOf("try {");
48
+ const bootstrapImportPos = indexSrc.indexOf(
49
+ './bootstrap/register-extension.js"',
50
+ );
51
+ const catchPos = indexSrc.indexOf("catch (err)");
52
+
53
+ assert.ok(tryPos >= 0, "must have try block");
54
+ assert.ok(catchPos >= 0, "must have catch block");
55
+ assert.ok(
56
+ tryPos < bootstrapImportPos && bootstrapImportPos < catchPos,
57
+ "register-extension.js import must be wrapped in try-catch",
58
+ );
59
+ });
60
+
61
+ test("logs warning on bootstrap failure via workflow-logger", () => {
62
+ assert.ok(
63
+ indexSrc.includes("logWarning"),
64
+ "must use logWarning when bootstrap fails",
65
+ );
66
+ assert.ok(
67
+ indexSrc.includes("Extension setup partially failed"),
68
+ "warning message must indicate partial failure with /gsd still available",
69
+ );
70
+ });
71
+ });
72
+
73
+ // ─── register-extension.ts: no double-registration + defensive wrapping ───────
74
+
75
+ describe("register-extension.ts defensive registration", () => {
76
+ test("does NOT import or call registerGSDCommand (avoids double-registration)", () => {
77
+ // registerGSDCommand is now called by index.ts, not register-extension.ts
78
+ assert.ok(
79
+ !registerExtSrc.includes("import { registerGSDCommand }"),
80
+ "register-extension.ts must NOT import registerGSDCommand",
81
+ );
82
+
83
+ // Check the function body of registerGsdExtension doesn't call it
84
+ const funcBodyStart = registerExtSrc.indexOf(
85
+ "export function registerGsdExtension",
86
+ );
87
+ const funcBody = registerExtSrc.slice(funcBodyStart);
88
+ assert.ok(
89
+ !funcBody.includes("registerGSDCommand(pi)"),
90
+ "registerGsdExtension must NOT call registerGSDCommand(pi)",
91
+ );
92
+ });
93
+
94
+ test("still registers worktree, exit, and kill commands", () => {
95
+ const funcBodyStart = registerExtSrc.indexOf(
96
+ "export function registerGsdExtension",
97
+ );
98
+ const funcBody = registerExtSrc.slice(funcBodyStart);
99
+
100
+ assert.ok(
101
+ funcBody.includes("registerWorktreeCommand(pi)"),
102
+ "must register worktree command",
103
+ );
104
+ assert.ok(
105
+ funcBody.includes("registerExitCommand(pi)"),
106
+ "must register exit command",
107
+ );
108
+ assert.ok(
109
+ funcBody.includes('"kill"'),
110
+ "must register kill command",
111
+ );
112
+ });
113
+
114
+ test("wraps non-critical registrations in individual try-catch blocks", () => {
115
+ const funcBodyStart = registerExtSrc.indexOf(
116
+ "export function registerGsdExtension",
117
+ );
118
+ const funcBody = registerExtSrc.slice(funcBodyStart);
119
+
120
+ // Each non-critical registration should be wrapped with error handling
121
+ const registrationNames = [
122
+ "dynamic-tools",
123
+ "db-tools",
124
+ "journal-tools",
125
+ "query-tools",
126
+ "shortcuts",
127
+ "hooks",
128
+ ];
129
+
130
+ for (const name of registrationNames) {
131
+ assert.ok(
132
+ funcBody.includes(`"${name}"`),
133
+ `non-critical registration "${name}" must be present`,
134
+ );
135
+ }
136
+
137
+ // Must have try-catch inside the registration loop
138
+ assert.ok(
139
+ funcBody.includes("try {") && funcBody.includes("catch (err)"),
140
+ "must have try-catch for non-critical registrations",
141
+ );
142
+ });
143
+
144
+ test("logs warning when a non-critical registration fails", () => {
145
+ assert.ok(
146
+ registerExtSrc.includes("Failed to register"),
147
+ "must log descriptive warning for individual registration failures",
148
+ );
149
+ assert.ok(
150
+ registerExtSrc.includes("logWarning"),
151
+ "must use logWarning from workflow-logger",
152
+ );
153
+ });
154
+ });
@@ -691,7 +691,7 @@ describe("transition boundary failures", () => {
691
691
  );
692
692
  });
693
693
 
694
- test("blocked state: all slices have unmet deps → blocked phase", async () => {
694
+ test("blocked state: all slices have unmet deps → fallback picks slice", async () => {
695
695
  base = makeTempDir();
696
696
  const mDir = join(base, ".gsd", "milestones", "M001");
697
697
  mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
@@ -736,7 +736,9 @@ describe("transition boundary failures", () => {
736
736
 
737
737
  invalidateAllCaches();
738
738
  const state = await deriveStateFromDb(base);
739
- assert.equal(state.phase, "blocked", "circular deps should produce blocked phase");
739
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
740
+ assert.equal(state.phase, "planning", "circular deps: fallback picks a slice instead of blocking");
741
+ assert.ok(state.activeSlice !== null, "activeSlice set via fallback");
740
742
  });
741
743
  });
742
744
 
@@ -92,6 +92,7 @@ function makeMockDeps(
92
92
  getPriorSliceCompletionBlocker: () => null,
93
93
  getMainBranch: () => "main",
94
94
  closeoutUnit: async () => {},
95
+ autoCommitUnit: async () => null,
95
96
  recordOutcome: () => {},
96
97
  writeLock: () => {},
97
98
  captureAvailableSkills: () => {},
@@ -567,7 +568,15 @@ test("unit-end event contains errorContext when unit is cancelled with structure
567
568
  const { resolveAgentEndCancelled, _resetPendingResolve } = await import("../auto-loop.js");
568
569
  _resetPendingResolve();
569
570
 
570
- const deps = makeMockDeps(capture);
571
+ let pauseCalls = 0;
572
+ let commitCalls = 0;
573
+ const deps = makeMockDeps(capture, {
574
+ pauseAuto: async () => { pauseCalls++; },
575
+ autoCommitUnit: async () => {
576
+ commitCalls++;
577
+ return "commit";
578
+ },
579
+ });
571
580
  const ic = makeIC(deps);
572
581
  const iterData: IterationData = {
573
582
  unitType: "execute-task",
@@ -593,10 +602,68 @@ test("unit-end event contains errorContext when unit is cancelled with structure
593
602
  // Transient timeout cancellations pause (recoverable) instead of hard-stopping
594
603
  assert.equal(result.action, "break");
595
604
  assert.equal((result as any).reason, "session-timeout");
605
+ assert.equal(pauseCalls, 1, "timeout cancellations should pause auto-mode exactly once");
606
+ assert.equal(commitCalls, 1, "timeout cancellations should flush a unit auto-commit once");
596
607
 
597
608
  // Verify error classification used structured errorContext on the window entry
598
609
  const entry = loopState.recentUnits[loopState.recentUnits.length - 1];
599
610
  assert.ok(entry.error, "window entry must have error set");
600
611
  assert.ok(entry.error!.startsWith("timeout:"), "error must start with category from errorContext");
601
612
  assert.ok(entry.error!.includes("Hard timeout error"), "error must include the errorContext message");
613
+
614
+ const endEvents = capture.events.filter(e => e.eventType === "unit-end");
615
+ assert.equal(endEvents.length, 1, "timeout cancellations should still emit unit-end");
616
+ assert.equal((endEvents[0].data as any).status, "cancelled");
617
+ assert.equal((endEvents[0].data as any).artifactVerified, false);
618
+ assert.equal((endEvents[0].data as any).errorContext.category, "timeout");
619
+ });
620
+
621
+ test("session-failed cancellations close out and emit unit-end before hard stop", async () => {
622
+ const capture = createEventCapture();
623
+ const { resolveAgentEndCancelled, _resetPendingResolve } = await import("../auto-loop.js");
624
+ _resetPendingResolve();
625
+
626
+ let closeoutCalls = 0;
627
+ let commitCalls = 0;
628
+ let stopCalls = 0;
629
+ const deps = makeMockDeps(capture, {
630
+ closeoutUnit: async () => { closeoutCalls++; },
631
+ autoCommitUnit: async () => {
632
+ commitCalls++;
633
+ return "commit";
634
+ },
635
+ stopAuto: async () => { stopCalls++; },
636
+ });
637
+ const ic = makeIC(deps);
638
+ const iterData: IterationData = {
639
+ unitType: "execute-task",
640
+ unitId: "M001/S01/T01",
641
+ prompt: "do stuff",
642
+ finalPrompt: "do stuff",
643
+ pauseAfterUatDispatch: false,
644
+ state: { phase: "executing", activeMilestone: { id: "M001" }, activeSlice: { id: "S01" }, registry: [], blockers: [] } as any,
645
+ mid: "M001",
646
+ midTitle: "Test",
647
+ isRetry: false,
648
+ previousTier: undefined,
649
+ };
650
+ const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
651
+
652
+ const unitPromise = runUnitPhase(ic, iterData, loopState);
653
+ await new Promise(r => setTimeout(r, 50));
654
+
655
+ resolveAgentEndCancelled({ message: "session bootstrap exploded", category: "session-failed", isTransient: false });
656
+
657
+ const result = await unitPromise;
658
+ assert.equal(result.action, "break");
659
+ assert.equal((result as any).reason, "session-failed");
660
+ assert.equal(closeoutCalls, 1, "session-failed cancellations should close out the unit before stopping");
661
+ assert.equal(commitCalls, 1, "session-failed cancellations should try one auto-commit flush");
662
+ assert.equal(stopCalls, 1, "session-failed cancellations should hard-stop auto-mode");
663
+
664
+ const endEvents = capture.events.filter(e => e.eventType === "unit-end");
665
+ assert.equal(endEvents.length, 1, "session-failed cancellations should emit unit-end");
666
+ assert.equal((endEvents[0].data as any).status, "cancelled");
667
+ assert.equal((endEvents[0].data as any).artifactVerified, false);
668
+ assert.equal((endEvents[0].data as any).errorContext.category, "session-failed");
602
669
  });
@@ -0,0 +1,140 @@
1
+ // native-git-bridge-exec-fallback.test.ts — regression for #4180
2
+ //
3
+ // nativeCommit, nativeIsRepo, and nativeResetHard used execSync() (string
4
+ // command) in their fallback paths. On Windows, execSync spawns cmd.exe which
5
+ // cannot resolve git when Git for Windows is installed via MSYS2/bash but not
6
+ // in cmd.exe's PATH. All other fallback paths in this file use execFileSync()
7
+ // which invokes the binary directly — these three must do the same.
8
+ //
9
+ // Static-analysis tests fail before the fix (source still has execSync calls)
10
+ // and pass after (replaced with execFileSync). Integration tests verify the
11
+ // fallback functions behave correctly on all platforms.
12
+
13
+ import { describe, test, beforeEach, afterEach } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { execFileSync } from "node:child_process";
19
+ import { nativeIsRepo, nativeCommit, nativeResetHard } from "../native-git-bridge.js";
20
+
21
+ // ─── Static analysis ──────────────────────────────────────────────────────
22
+ // Verify the fallback paths of the three affected functions do not call the
23
+ // raw execSync() string-command variant. Replacing all execFileSync( tokens
24
+ // first ensures we match only the bare execSync( form.
25
+
26
+ const SRC_PATH = join(import.meta.dirname, "..", "native-git-bridge.ts");
27
+
28
+ function extractFunctionBody(src: string, fnName: string): string {
29
+ const idx = src.indexOf(`export function ${fnName}`);
30
+ if (idx === -1) throw new Error(`${fnName} not found in source`);
31
+ return src.slice(idx, idx + 1500);
32
+ }
33
+
34
+ function hasRawExecSync(body: string): boolean {
35
+ const withoutFileSync = body.replace(/execFileSync\(/g, "__FILESYNC__");
36
+ return withoutFileSync.includes("execSync(");
37
+ }
38
+
39
+ describe("native-git-bridge #4180: fallback paths use execFileSync not execSync", () => {
40
+ const src = readFileSync(SRC_PATH, "utf-8");
41
+
42
+ test("nativeIsRepo fallback does not use raw execSync", () => {
43
+ const body = extractFunctionBody(src, "nativeIsRepo");
44
+ assert.equal(
45
+ hasRawExecSync(body),
46
+ false,
47
+ "nativeIsRepo fallback must use execFileSync to avoid cmd.exe PATH failures on Windows",
48
+ );
49
+ });
50
+
51
+ test("nativeCommit fallback does not use raw execSync", () => {
52
+ const body = extractFunctionBody(src, "nativeCommit");
53
+ assert.equal(
54
+ hasRawExecSync(body),
55
+ false,
56
+ "nativeCommit fallback must use execFileSync to avoid cmd.exe PATH failures on Windows",
57
+ );
58
+ });
59
+
60
+ test("nativeResetHard fallback does not use raw execSync", () => {
61
+ const body = extractFunctionBody(src, "nativeResetHard");
62
+ assert.equal(
63
+ hasRawExecSync(body),
64
+ false,
65
+ "nativeResetHard fallback must use execFileSync to avoid cmd.exe PATH failures on Windows",
66
+ );
67
+ });
68
+ });
69
+
70
+ // ─── Integration tests ────────────────────────────────────────────────────
71
+ // Verify correct runtime behaviour through the fallback path (native module
72
+ // is disabled by default in tests — GSD_ENABLE_NATIVE_GSD_GIT is not set).
73
+
74
+ function git(args: string[], cwd: string): string {
75
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
76
+ }
77
+
78
+ describe("native-git-bridge #4180: fallback runtime behaviour", () => {
79
+ let repo: string;
80
+
81
+ beforeEach(() => {
82
+ repo = mkdtempSync(join(tmpdir(), "ngb4180-"));
83
+ git(["init"], repo);
84
+ git(["config", "user.email", "test@test.com"], repo);
85
+ git(["config", "user.name", "Test"], repo);
86
+ writeFileSync(join(repo, "file.txt"), "initial\n");
87
+ git(["add", "."], repo);
88
+ git(["commit", "-m", "init"], repo);
89
+ });
90
+
91
+ afterEach(() => {
92
+ rmSync(repo, { recursive: true, force: true });
93
+ });
94
+
95
+ test("nativeIsRepo returns true for a valid git repository", () => {
96
+ assert.equal(nativeIsRepo(repo), true);
97
+ });
98
+
99
+ test("nativeIsRepo returns false for a plain directory", (t) => {
100
+ const dir = mkdtempSync(join(tmpdir(), "ngb4180-notrepo-"));
101
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
102
+ assert.equal(nativeIsRepo(dir), false);
103
+ });
104
+
105
+ test("nativeCommit commits staged changes and returns non-null output", () => {
106
+ writeFileSync(join(repo, "file.txt"), "modified\n");
107
+ git(["add", "."], repo);
108
+
109
+ const result = nativeCommit(repo, "test: regression commit #4180");
110
+ assert.ok(result !== null, "should return output string for a successful commit");
111
+
112
+ const subject = git(["log", "-1", "--format=%s"], repo);
113
+ assert.equal(subject, "test: regression commit #4180");
114
+ });
115
+
116
+ test("nativeCommit returns null when nothing is staged", () => {
117
+ const result = nativeCommit(repo, "test: nothing staged");
118
+ assert.equal(result, null);
119
+ });
120
+
121
+ test("nativeCommit respects the allowEmpty option", () => {
122
+ const result = nativeCommit(repo, "test: empty commit #4180", { allowEmpty: true });
123
+ assert.ok(result !== null, "allow-empty commit should return output");
124
+
125
+ const subject = git(["log", "-1", "--format=%s"], repo);
126
+ assert.equal(subject, "test: empty commit #4180");
127
+ });
128
+
129
+ test("nativeResetHard discards unstaged working tree changes", () => {
130
+ writeFileSync(join(repo, "file.txt"), "dirty content\n");
131
+
132
+ const statusBefore = git(["status", "--short"], repo);
133
+ assert.ok(statusBefore.length > 0, "repo should be dirty before reset");
134
+
135
+ nativeResetHard(repo);
136
+
137
+ const content = readFileSync(join(repo, "file.txt"), "utf-8");
138
+ assert.equal(content, "initial\n", "file should be restored to HEAD content after hard reset");
139
+ });
140
+ });