@opengsd/gsd-pi 1.9.0-dev.5c1a3ff2 → 1.9.0-dev.d2070876

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 (177) hide show
  1. package/dist/headless.js +2 -1
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/loop.js +24 -4
  4. package/dist/resources/extensions/gsd/auto/run-unit.js +19 -1
  5. package/dist/resources/extensions/gsd/auto/unit-phase.js +37 -5
  6. package/dist/resources/extensions/gsd/auto-dispatch.js +2 -0
  7. package/dist/resources/extensions/gsd/auto-prompts.js +12 -4
  8. package/dist/resources/extensions/gsd/auto-runtime-state.js +5 -5
  9. package/dist/resources/extensions/gsd/auto-start.js +29 -9
  10. package/dist/resources/extensions/gsd/auto-tool-tracking.js +40 -0
  11. package/dist/resources/extensions/gsd/auto-worktree-sync.js +7 -3
  12. package/dist/resources/extensions/gsd/auto.js +15 -5
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +2 -2
  14. package/dist/resources/extensions/gsd/commands-verdict.js +10 -9
  15. package/dist/resources/extensions/gsd/compat/compat-marker.js +80 -5
  16. package/dist/resources/extensions/gsd/db/engine.js +5 -2
  17. package/dist/resources/extensions/gsd/doctor-engine-checks.js +21 -2
  18. package/dist/resources/extensions/gsd/error-classifier.js +12 -2
  19. package/dist/resources/extensions/gsd/exec-sandbox.js +1 -0
  20. package/dist/resources/extensions/gsd/flat-phase-migration.js +12 -0
  21. package/dist/resources/extensions/gsd/gsd-db.js +75 -31
  22. package/dist/resources/extensions/gsd/guided-flow.js +2 -2
  23. package/dist/resources/extensions/gsd/markdown-renderer.js +3 -9
  24. package/dist/resources/extensions/gsd/paths.js +17 -7
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +5 -5
  26. package/dist/resources/extensions/gsd/state-reconciliation/drift/external-markdown-edit.js +98 -2
  27. package/dist/resources/extensions/gsd/state-reconciliation/drift/external-planning-edit.js +4 -1
  28. package/dist/resources/extensions/gsd/state-reconciliation/drift/roadmap.js +5 -2
  29. package/dist/resources/extensions/gsd/stop-notice.js +9 -0
  30. package/dist/resources/extensions/gsd/worktree-state-projection.js +20 -18
  31. package/dist/resources/extensions/shared/interview-ui.js +43 -6
  32. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  33. package/dist/web/standalone/.next/BUILD_ID +1 -1
  34. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  35. package/dist/web/standalone/.next/build-manifest.json +2 -2
  36. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  37. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.html +1 -1
  54. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  61. package/dist/web/standalone/.next/server/chunks/1991.js +2 -2
  62. package/dist/web/standalone/.next/server/chunks/5124.js +1 -1
  63. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  64. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  65. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  66. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  67. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  68. package/package.json +2 -1
  69. package/packages/cloud-mcp-gateway/package.json +2 -2
  70. package/packages/contracts/package.json +1 -1
  71. package/packages/daemon/package.json +4 -4
  72. package/packages/gsd-agent-core/package.json +5 -5
  73. package/packages/gsd-agent-modes/package.json +7 -7
  74. package/packages/gsd-cloud/README.md +9 -3
  75. package/packages/gsd-cloud/dist/background-cli.test.d.ts +2 -0
  76. package/packages/gsd-cloud/dist/background-cli.test.d.ts.map +1 -0
  77. package/packages/gsd-cloud/dist/background-cli.test.js +248 -0
  78. package/packages/gsd-cloud/dist/background-cli.test.js.map +1 -0
  79. package/packages/gsd-cloud/dist/cli.d.ts.map +1 -1
  80. package/packages/gsd-cloud/dist/cli.js +121 -15
  81. package/packages/gsd-cloud/dist/cli.js.map +1 -1
  82. package/packages/gsd-cloud/dist/cloud-config.d.ts +1 -1
  83. package/packages/gsd-cloud/dist/cloud-config.d.ts.map +1 -1
  84. package/packages/gsd-cloud/dist/cloud-config.js +7 -1
  85. package/packages/gsd-cloud/dist/cloud-config.js.map +1 -1
  86. package/packages/gsd-cloud/dist/cloud-runtime.d.ts +1 -3
  87. package/packages/gsd-cloud/dist/cloud-runtime.d.ts.map +1 -1
  88. package/packages/gsd-cloud/dist/cloud-runtime.js +9 -7
  89. package/packages/gsd-cloud/dist/cloud-runtime.js.map +1 -1
  90. package/packages/gsd-cloud/dist/runtime-process.d.ts +27 -0
  91. package/packages/gsd-cloud/dist/runtime-process.d.ts.map +1 -0
  92. package/packages/gsd-cloud/dist/runtime-process.js +272 -0
  93. package/packages/gsd-cloud/dist/runtime-process.js.map +1 -0
  94. package/packages/gsd-cloud/dist/runtime-process.test.d.ts +2 -0
  95. package/packages/gsd-cloud/dist/runtime-process.test.d.ts.map +1 -0
  96. package/packages/gsd-cloud/dist/runtime-process.test.js +97 -0
  97. package/packages/gsd-cloud/dist/runtime-process.test.js.map +1 -0
  98. package/packages/gsd-cloud/package.json +2 -2
  99. package/packages/mcp-server/dist/cli-runner.d.ts +3 -0
  100. package/packages/mcp-server/dist/cli-runner.d.ts.map +1 -1
  101. package/packages/mcp-server/dist/cli-runner.js +119 -33
  102. package/packages/mcp-server/dist/cli-runner.js.map +1 -1
  103. package/packages/mcp-server/package.json +4 -4
  104. package/packages/native/package.json +1 -1
  105. package/packages/pi-agent-core/package.json +1 -1
  106. package/packages/pi-ai/dist/models.generated.d.ts +17 -0
  107. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  108. package/packages/pi-ai/dist/models.generated.js +17 -0
  109. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  110. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  111. package/packages/pi-ai/dist/providers/openai-completions.js +103 -0
  112. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  113. package/packages/pi-ai/package.json +1 -1
  114. package/packages/pi-coding-agent/dist/core/skills.d.ts +5 -0
  115. package/packages/pi-coding-agent/dist/core/skills.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/skills.js +54 -0
  117. package/packages/pi-coding-agent/dist/core/skills.js.map +1 -1
  118. package/packages/pi-coding-agent/package.json +7 -7
  119. package/packages/pi-tui/package.json +2 -2
  120. package/packages/rpc-client/package.json +2 -2
  121. package/pkg/package.json +1 -1
  122. package/src/resources/extensions/gsd/auto/loop.ts +48 -4
  123. package/src/resources/extensions/gsd/auto/run-unit.ts +20 -1
  124. package/src/resources/extensions/gsd/auto/unit-phase.ts +41 -5
  125. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -0
  126. package/src/resources/extensions/gsd/auto-prompts.ts +14 -4
  127. package/src/resources/extensions/gsd/auto-runtime-state.ts +12 -7
  128. package/src/resources/extensions/gsd/auto-start.ts +31 -8
  129. package/src/resources/extensions/gsd/auto-tool-tracking.ts +56 -0
  130. package/src/resources/extensions/gsd/auto-worktree-sync.ts +10 -3
  131. package/src/resources/extensions/gsd/auto.ts +20 -7
  132. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +2 -2
  133. package/src/resources/extensions/gsd/commands-verdict.ts +16 -9
  134. package/src/resources/extensions/gsd/compat/compat-marker.ts +99 -5
  135. package/src/resources/extensions/gsd/db/engine.ts +5 -2
  136. package/src/resources/extensions/gsd/doctor-engine-checks.ts +27 -2
  137. package/src/resources/extensions/gsd/error-classifier.ts +13 -2
  138. package/src/resources/extensions/gsd/exec-sandbox.ts +1 -0
  139. package/src/resources/extensions/gsd/flat-phase-migration.ts +14 -0
  140. package/src/resources/extensions/gsd/gsd-db.ts +85 -33
  141. package/src/resources/extensions/gsd/guided-flow.ts +2 -2
  142. package/src/resources/extensions/gsd/markdown-renderer.ts +3 -9
  143. package/src/resources/extensions/gsd/paths.ts +17 -7
  144. package/src/resources/extensions/gsd/prompts/validate-milestone.md +5 -5
  145. package/src/resources/extensions/gsd/state-reconciliation/drift/external-markdown-edit.ts +132 -2
  146. package/src/resources/extensions/gsd/state-reconciliation/drift/external-planning-edit.ts +4 -1
  147. package/src/resources/extensions/gsd/state-reconciliation/drift/roadmap.ts +4 -2
  148. package/src/resources/extensions/gsd/stop-notice.ts +11 -0
  149. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +264 -0
  150. package/src/resources/extensions/gsd/tests/auto-start-index-lock.test.ts +21 -1
  151. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +74 -1
  152. package/src/resources/extensions/gsd/tests/commands-verdict.test.ts +10 -0
  153. package/src/resources/extensions/gsd/tests/compat-marker.test.ts +25 -0
  154. package/src/resources/extensions/gsd/tests/db-engine-cjs-compat.test.ts +12 -0
  155. package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +83 -0
  156. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +33 -1
  157. package/src/resources/extensions/gsd/tests/external-markdown-edit.test.ts +38 -1
  158. package/src/resources/extensions/gsd/tests/flat-phase-migration.test.ts +8 -9
  159. package/src/resources/extensions/gsd/tests/flat-phase-validation-path.test.ts +20 -1
  160. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +32 -0
  161. package/src/resources/extensions/gsd/tests/guided-discuss-milestone-prompt-rendering.test.ts +22 -0
  162. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +13 -0
  163. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +21 -0
  164. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +121 -1
  165. package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +105 -0
  166. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +52 -0
  167. package/src/resources/extensions/gsd/tests/stop-notice.test.ts +21 -1
  168. package/src/resources/extensions/gsd/tests/terminated-transient.test.ts +14 -0
  169. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +19 -0
  170. package/src/resources/extensions/gsd/tests/validate-milestone-prompt-verification-classes.test.ts +7 -0
  171. package/src/resources/extensions/gsd/tests/worktree-state-projection.test.ts +2 -2
  172. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +60 -0
  173. package/src/resources/extensions/gsd/worktree-state-projection.ts +21 -24
  174. package/src/resources/extensions/shared/interview-ui.ts +47 -8
  175. package/src/resources/extensions/shared/tests/interview-ui-border.test.ts +29 -0
  176. /package/dist/web/standalone/.next/static/{6YWV7VAPg-jqjfPswrKZb → sggX-h41OjePo-FjdArP5}/_buildManifest.js +0 -0
  177. /package/dist/web/standalone/.next/static/{6YWV7VAPg-jqjfPswrKZb → sggX-h41OjePo-FjdArP5}/_ssgManifest.js +0 -0
@@ -41,6 +41,7 @@ import { claimMilestoneLease } from "../db/milestone-leases.js";
41
41
  import { recordDispatchClaim, markCanceled } from "../db/unit-dispatches.js";
42
42
  import { setRuntimeKv, getRuntimeKv } from "../db/runtime-kv.js";
43
43
  import { SourceObservationStore } from "../source-observations.js";
44
+ import { autoCommitCurrentBranch } from "../worktree.js";
44
45
 
45
46
  // ─── Helpers ─────────────────────────────────────────────────────────────────
46
47
 
@@ -832,6 +833,37 @@ test("runUnit returns cancelled when session creation fails", async () => {
832
833
  assert.equal(pi.calls.length, 0);
833
834
  });
834
835
 
836
+ test("runUnit returns cancelled when command context lacks newSession", async () => {
837
+ _resetPendingResolve();
838
+
839
+ const ctx = {
840
+ ...makeMockCtx(),
841
+ ui: {
842
+ notify: () => {},
843
+ setStatus: () => {},
844
+ setWorkingMessage: () => {},
845
+ },
846
+ sessionManager: {
847
+ getEntries: () => [],
848
+ },
849
+ modelRegistry: {
850
+ getProviderAuthMode: () => undefined,
851
+ isProviderRequestReady: () => true,
852
+ },
853
+ } as any;
854
+ const pi = makeMockPi();
855
+ const s = makeMockSession();
856
+ s.cmdCtx = {} as any;
857
+
858
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
859
+
860
+ assert.equal(result.status, "cancelled");
861
+ assert.equal(result.errorContext?.category, "session-failed");
862
+ assert.equal(result.errorContext?.isTransient, false);
863
+ assert.match(result.errorContext?.message ?? "", /missing newSession/);
864
+ assert.equal(pi.calls.length, 0);
865
+ });
866
+
835
867
  test("runUnit: TypeError from newSession is classified as structural (isTransient: false)", async () => {
836
868
  // Regression for #572: a TypeError thrown from newSession (e.g. "something is
837
869
  // not a function") indicates a programming error, not a transient provider
@@ -1582,6 +1614,130 @@ test("autoLoop exits when s.active is set to false", async (t) => {
1582
1614
  );
1583
1615
  });
1584
1616
 
1617
+ test("autoLoop stops before dispatch when command context lacks newSession", async () => {
1618
+ _resetPendingResolve();
1619
+
1620
+ const ctx = makeMockCtx();
1621
+ ctx.ui.setStatus = () => {};
1622
+ const pi = makeMockPi();
1623
+ const s = makeLoopSession({
1624
+ cmdCtx: {
1625
+ getContextUsage: () => ({ percent: 10, tokens: 1000, limit: 10000 }),
1626
+ },
1627
+ });
1628
+ let stopReason: string | undefined;
1629
+ let preserveWorktree: boolean | undefined;
1630
+ let deriveCalled = false;
1631
+
1632
+ try {
1633
+ const deps = makeMockDeps({
1634
+ stopAuto: async (_ctx, _pi, reason, options) => {
1635
+ stopReason = reason;
1636
+ preserveWorktree = options?.preserveWorktree;
1637
+ s.active = false;
1638
+ },
1639
+ deriveState: async () => {
1640
+ deriveCalled = true;
1641
+ throw new Error("deriveState should not run without command session support");
1642
+ },
1643
+ });
1644
+
1645
+ await autoLoop(ctx, pi, s, deps);
1646
+
1647
+ assert.equal(stopReason, "Auto-mode has no command context for dispatch.");
1648
+ assert.equal(preserveWorktree, true);
1649
+ assert.equal(deriveCalled, false);
1650
+ assert.equal(pi.calls.length, 0);
1651
+ } finally {
1652
+ rmSync(s.basePath, { recursive: true, force: true });
1653
+ }
1654
+ });
1655
+
1656
+ test("autoLoop commits open unit work when command context lacks newSession", async () => {
1657
+ _resetPendingResolve();
1658
+
1659
+ const ctx = makeMockCtx();
1660
+ ctx.ui.setStatus = () => {};
1661
+ const pi = makeMockPi();
1662
+ const s = makeLoopSession({
1663
+ cmdCtx: {
1664
+ getContextUsage: () => ({ percent: 10, tokens: 1000, limit: 10000 }),
1665
+ },
1666
+ currentUnit: { type: "execute-task", id: "T01", startedAt: Date.now() },
1667
+ });
1668
+ const outputPath = join(s.basePath, "executor-output.txt");
1669
+ let autoCommitArgs: { unitType: string; unitId: string } | undefined;
1670
+ let preserveWorktree: boolean | undefined;
1671
+
1672
+ try {
1673
+ const deps = makeMockDeps({
1674
+ autoCommitUnit: async (basePath, unitType, unitId) => {
1675
+ autoCommitArgs = { unitType, unitId };
1676
+ return autoCommitCurrentBranch(basePath, unitType, unitId);
1677
+ },
1678
+ stopAuto: async (_ctx, _pi, _reason, options) => {
1679
+ preserveWorktree = options?.preserveWorktree;
1680
+ s.active = false;
1681
+ },
1682
+ deriveState: async () => {
1683
+ throw new Error("deriveState should not run without command session support");
1684
+ },
1685
+ });
1686
+
1687
+ writeFileSync(outputPath, "open unit work before stop\n", "utf-8");
1688
+
1689
+ await autoLoop(ctx, pi, s, deps);
1690
+
1691
+ assert.deepEqual(autoCommitArgs, { unitType: "execute-task", unitId: "T01" });
1692
+ assert.equal(preserveWorktree, true);
1693
+ const committed = execSync("git show HEAD:executor-output.txt", {
1694
+ cwd: s.basePath,
1695
+ encoding: "utf-8",
1696
+ });
1697
+ assert.equal(committed, "open unit work before stop\n");
1698
+ assert.equal(pi.calls.length, 0);
1699
+ } finally {
1700
+ rmSync(s.basePath, { recursive: true, force: true });
1701
+ }
1702
+ });
1703
+
1704
+ test("autoLoop snapshots dirty work when unit dispatch crashes", async () => {
1705
+ _resetPendingResolve();
1706
+
1707
+ const ctx = makeMockCtx();
1708
+ ctx.ui.setStatus = () => {};
1709
+ const pi = makeMockPi();
1710
+ const s = makeLoopSession();
1711
+ const outputPath = join(s.basePath, "executor-output.txt");
1712
+ let autoCommitCalls = 0;
1713
+
1714
+ try {
1715
+ const deps = makeMockDeps({
1716
+ ensurePreconditions: () => {
1717
+ writeFileSync(outputPath, "saved before crash\n", "utf-8");
1718
+ throw new Error("dispatch crash after file write");
1719
+ },
1720
+ autoCommitUnit: async (basePath, unitType, unitId) => {
1721
+ autoCommitCalls++;
1722
+ const commitMsg = autoCommitCurrentBranch(basePath, unitType, unitId);
1723
+ s.active = false;
1724
+ return commitMsg;
1725
+ },
1726
+ });
1727
+
1728
+ await autoLoop(ctx, pi, s, deps);
1729
+
1730
+ assert.equal(autoCommitCalls, 1);
1731
+ const committed = execSync("git show HEAD:executor-output.txt", {
1732
+ cwd: s.basePath,
1733
+ encoding: "utf-8",
1734
+ });
1735
+ assert.equal(committed, "saved before crash\n");
1736
+ } finally {
1737
+ rmSync(s.basePath, { recursive: true, force: true });
1738
+ }
1739
+ });
1740
+
1585
1741
  test("autoLoop pauses visibly when Auto Orchestration Module is not wired", async () => {
1586
1742
  _resetPendingResolve();
1587
1743
 
@@ -5421,6 +5577,114 @@ test("runUnitPhase retries 0-tool units with ordinary network-related assistant
5421
5577
  assert.equal(deps.callLog.includes("pauseAuto"), false);
5422
5578
  });
5423
5579
 
5580
+ test("runUnitPhase pauses 0-tool units with pseudo tool-call text as serialization drift", async (t) => {
5581
+ _resetPendingResolve();
5582
+
5583
+ const basePath = makeLoopTestBase("gsd-zero-tool-pseudo-tool-");
5584
+ t.after(() => {
5585
+ rmSync(basePath, { recursive: true, force: true });
5586
+ });
5587
+
5588
+ const notifications: string[] = [];
5589
+ const ctx = {
5590
+ ...makeMockCtx(),
5591
+ ui: {
5592
+ notify: (msg: string) => { notifications.push(msg); },
5593
+ setStatus: () => {},
5594
+ setWorkingMessage: () => {},
5595
+ },
5596
+ sessionManager: {
5597
+ getEntries: () => [],
5598
+ },
5599
+ modelRegistry: {
5600
+ getProviderAuthMode: () => undefined,
5601
+ isProviderRequestReady: () => true,
5602
+ },
5603
+ } as any;
5604
+ const pi = {
5605
+ ...makeMockPi(),
5606
+ sendMessage: () => {
5607
+ queueMicrotask(() => resolveAgentEnd(makeEvent([
5608
+ {
5609
+ role: "assistant",
5610
+ content: [
5611
+ {
5612
+ type: "text",
5613
+ text: 'bash<arg_key>command</arg_key><arg_value>ls -la /tmp && echo "---SRC---"</arg_value></tool_call>',
5614
+ },
5615
+ ],
5616
+ },
5617
+ ])));
5618
+ },
5619
+ } as any;
5620
+ const s = makeLoopSession({
5621
+ basePath,
5622
+ canonicalProjectRoot: basePath,
5623
+ originalBasePath: basePath,
5624
+ });
5625
+ const mockLedger = {
5626
+ version: 1,
5627
+ projectStartedAt: Date.now(),
5628
+ units: [] as any[],
5629
+ };
5630
+ const deps = makeMockDeps({
5631
+ closeoutUnit: async () => {
5632
+ mockLedger.units.push({
5633
+ type: "execute-task",
5634
+ id: "M001/S01/T01",
5635
+ startedAt: s.currentUnit?.startedAt ?? Date.now(),
5636
+ toolCalls: 0,
5637
+ assistantMessages: 1,
5638
+ tokens: { input: 100, output: 20, total: 120, cacheRead: 0, cacheWrite: 0 },
5639
+ cost: 0.01,
5640
+ });
5641
+ },
5642
+ getLedger: () => mockLedger,
5643
+ });
5644
+ let seq = 0;
5645
+
5646
+ const result = await runUnitPhase(
5647
+ { ctx, pi, s, deps, prefs: undefined, iteration: 1, flowId: "flow-zero-tool-pseudo-tool", nextSeq: () => ++seq },
5648
+ {
5649
+ unitType: "execute-task",
5650
+ unitId: "M001/S01/T01",
5651
+ prompt: "do work",
5652
+ finalPrompt: "do work",
5653
+ pauseAfterUatDispatch: false,
5654
+ state: {
5655
+ phase: "executing",
5656
+ activeMilestone: { id: "M001", title: "Milestone" },
5657
+ activeSlice: { id: "S01", title: "Slice" },
5658
+ activeTask: { id: "T01", title: "Task" },
5659
+ registry: [{ id: "M001", title: "Milestone", status: "active" }],
5660
+ recentDecisions: [],
5661
+ blockers: [],
5662
+ nextAction: "",
5663
+ progress: { milestones: { done: 0, total: 1 } },
5664
+ requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
5665
+ } as any,
5666
+ mid: "M001",
5667
+ midTitle: "Milestone",
5668
+ isRetry: false,
5669
+ previousTier: undefined,
5670
+ },
5671
+ { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 },
5672
+ );
5673
+
5674
+ assert.equal(result.action, "break");
5675
+ assert.equal((result as any).reason, "zero-tool-serialization-drift");
5676
+ assert.equal(deps.callLog.includes("pauseAuto"), true);
5677
+ assert.equal(s.zeroToolRetryCount.has("execute-task/M001/S01/T01"), false);
5678
+ assert.ok(
5679
+ notifications.some((msg) => msg.includes("serialization drift") && msg.includes("bash<arg_key>command")),
5680
+ "serialization drift notification should include a snippet of the pseudo tool-call text",
5681
+ );
5682
+ assert.ok(
5683
+ !notifications.some((msg) => msg.includes("context exhaustion")),
5684
+ "pseudo tool-call text should not be labeled as context exhaustion",
5685
+ );
5686
+ });
5687
+
5424
5688
  test("runUnitPhase pauses auto-mode when zero-tool-call retry is exhausted", async (t) => {
5425
5689
  _resetPendingResolve();
5426
5690
 
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
 
7
- import { hasGitIndexLockForTest } from "../auto-start.ts";
7
+ import { hasGitIndexLockForTest, reconcileFlatPhaseBootstrapLayout } from "../auto-start.ts";
8
8
 
9
9
  test("bootstrapAutoSession detects .git/index.lock without deleting it", (t) => {
10
10
  const base = mkdtempSync(join(tmpdir(), "gsd-index-lock-"));
@@ -24,3 +24,23 @@ test("bootstrapAutoSession reports no index lock when the lock file is absent",
24
24
 
25
25
  assert.equal(hasGitIndexLockForTest(base), false);
26
26
  });
27
+
28
+ test("bootstrapAutoSession removes empty legacy milestones dir in flat-phase projects", (t) => {
29
+ const base = mkdtempSync(join(tmpdir(), "gsd-flat-bootstrap-clean-"));
30
+ t.after(() => rmSync(base, { recursive: true, force: true }));
31
+ mkdirSync(join(base, ".gsd", "phases"), { recursive: true });
32
+ mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
33
+
34
+ assert.equal(reconcileFlatPhaseBootstrapLayout(base), true);
35
+ assert.equal(existsSync(join(base, ".gsd", "phases")), true);
36
+ assert.equal(existsSync(join(base, ".gsd", "milestones")), false);
37
+ });
38
+
39
+ test("bootstrapAutoSession creates phases instead of milestones for empty GSD state", (t) => {
40
+ const base = mkdtempSync(join(tmpdir(), "gsd-flat-bootstrap-init-"));
41
+ t.after(() => rmSync(base, { recursive: true, force: true }));
42
+
43
+ assert.equal(reconcileFlatPhaseBootstrapLayout(base), true);
44
+ assert.equal(existsSync(join(base, ".gsd", "phases")), true);
45
+ assert.equal(existsSync(join(base, ".gsd", "milestones")), false);
46
+ });
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { describe, test } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs";
6
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { execSync } from "node:child_process";
7
8
  import { join } from "node:path";
8
9
  import { tmpdir } from "node:os";
9
10
 
@@ -96,6 +97,78 @@ describe("hook dispatch session workspace root", () => {
96
97
  assert.equal(dispatched, true);
97
98
  assert.deepEqual(newSessionOptions, { workspaceRoot: basePath });
98
99
  });
100
+
101
+ test("dispatchHookUnit commits in-flight work when command context lacks newSession", async (t) => {
102
+ const originalCwd = process.cwd();
103
+ const basePath = realpathSync(mkdtempSync(join(tmpdir(), "gsd-hook-nosnap-")));
104
+ mkdirSync(join(basePath, ".gsd"), { recursive: true });
105
+ execSync("git init --initial-branch=main", { cwd: basePath, stdio: "ignore" });
106
+ execSync("git config user.email test@test.com", { cwd: basePath, stdio: "ignore" });
107
+ execSync("git config user.name Test", { cwd: basePath, stdio: "ignore" });
108
+ execSync("git commit --allow-empty -m init", { cwd: basePath, stdio: "ignore" });
109
+
110
+ autoSession.reset();
111
+ autoSession.active = true;
112
+ autoSession.basePath = basePath;
113
+ autoSession.originalBasePath = basePath;
114
+ // Simulate an in-flight unit whose executor already wrote files, then a hook
115
+ // fires with a command context missing newSession.
116
+ autoSession.currentUnit = {
117
+ type: "execute-task",
118
+ id: "M001/S01/T01",
119
+ startedAt: Date.now(),
120
+ };
121
+ t.after(() => {
122
+ try {
123
+ process.chdir(originalCwd);
124
+ } catch {
125
+ // best effort cleanup after cwd-sensitive dispatch tests
126
+ }
127
+ autoSession.reset();
128
+ rmSync(basePath, { recursive: true, force: true });
129
+ });
130
+
131
+ const outputPath = join(basePath, "executor-output.txt");
132
+ writeFileSync(outputPath, "in-flight executor work\n", "utf-8");
133
+
134
+ // Command context intentionally lacks newSession — the guard must snapshot
135
+ // the dirty work before stopping instead of discarding it.
136
+ const ctx = {
137
+ ui: {
138
+ notify: () => {},
139
+ setStatus: () => {},
140
+ setWidget: () => {},
141
+ },
142
+ modelRegistry: {
143
+ getAvailable: () => [],
144
+ },
145
+ sessionManager: {
146
+ getSessionFile: () => join(basePath, "session.jsonl"),
147
+ },
148
+ };
149
+ const pi = {
150
+ sendMessage: () => {},
151
+ setModel: async () => true,
152
+ };
153
+
154
+ const dispatched = await dispatchHookUnit(
155
+ ctx as any,
156
+ pi as any,
157
+ "review",
158
+ "execute-task",
159
+ "M001/S01/T01",
160
+ "review the completed unit",
161
+ undefined,
162
+ basePath,
163
+ );
164
+
165
+ assert.equal(dispatched, false);
166
+ const committed = execSync("git show HEAD:executor-output.txt", {
167
+ cwd: basePath,
168
+ encoding: "utf-8",
169
+ });
170
+ assert.equal(committed, "in-flight executor work\n");
171
+ });
99
172
  });
100
173
 
101
174
  describe("deep setup approval questions pause immediately", () => {
@@ -201,6 +201,7 @@ test("handleVerdict rejects missing verdict", async () => {
201
201
  const { ctx, calls } = makeMockCtx();
202
202
  await handleVerdict("", ctx, "/tmp/unused");
203
203
  assert.equal(calls.length, 1);
204
+ assert.match(calls[0].message, /^Verdict rejected:/);
204
205
  assert.match(calls[0].message, /Usage: \/gsd verdict/);
205
206
  assert.equal(calls[0].kind, "warning");
206
207
  });
@@ -209,6 +210,7 @@ test("handleVerdict rejects invalid verdict", async () => {
209
210
  const { ctx, calls } = makeMockCtx();
210
211
  await handleVerdict("yolo", ctx, "/tmp/unused");
211
212
  assert.equal(calls.length, 1);
213
+ assert.match(calls[0].message, /^Verdict rejected:/);
212
214
  assert.match(calls[0].message, /Invalid verdict "yolo"/);
213
215
  assert.equal(calls[0].kind, "warning");
214
216
  });
@@ -250,6 +252,10 @@ test("handleVerdict rejects when milestone validation is missing", async () => {
250
252
  calls.some((c) => /No milestone validation found/.test(c.message)),
251
253
  `expected missing-validation warning, got: ${JSON.stringify(calls)}`,
252
254
  );
255
+ assert.ok(
256
+ calls.some((c) => /^Verdict rejected:/.test(c.message)),
257
+ `expected terminal rejection prefix, got: ${JSON.stringify(calls)}`,
258
+ );
253
259
  } finally {
254
260
  closeDatabase();
255
261
  invalidateStateCache();
@@ -280,6 +286,10 @@ test("handleVerdict pass override flips verdict and preserves sections", async (
280
286
  calls.some((c) => c.kind === "success" && /needs-attention.*->.*pass/.test(c.message)),
281
287
  `expected success notification, got: ${JSON.stringify(calls)}`,
282
288
  );
289
+ assert.ok(
290
+ calls.some((c) => c.kind === "success" && /^Verdict recorded:/.test(c.message)),
291
+ `expected terminal recorded prefix, got: ${JSON.stringify(calls)}`,
292
+ );
283
293
  } finally {
284
294
  closeDatabase();
285
295
  invalidateStateCache();
@@ -242,6 +242,31 @@ test("readCompatMarker preserves legitimate nested projection keys", () => {
242
242
  assert.equal(Object.keys(marker.projections).length, 2);
243
243
  });
244
244
 
245
+ test("readCompatMarker heals invalid keys that canonicalize back under .gsd", () => {
246
+ const base = makeTmpBase();
247
+ const safeKey = "m1/roadmap.md";
248
+ mkdirSync(join(base, ".gsd", "m1"), { recursive: true });
249
+ writeFileSync(join(base, ".gsd", safeKey), "# Roadmap\n", "utf-8");
250
+ writeCompatMarker(base, {
251
+ schema: 2,
252
+ lastWriter: "gsd-pi",
253
+ lastProjectedAt: "2026-07-07T00:00:00.000Z",
254
+ projections: {
255
+ [`../.gsd/${safeKey}`]: { sha: "aaaaaaaaaaaaaaaa", entities: ["M001"] },
256
+ },
257
+ planning: { active: false, layout: null, projections: {}, passthrough: {} },
258
+ piVersion: "1.8.1",
259
+ });
260
+
261
+ const marker = readCompatMarker(base);
262
+ assert.ok(marker.projections[safeKey], "derivable traversal-form key must be rewritten");
263
+ assert.equal(marker.projections[`../.gsd/${safeKey}`], undefined);
264
+ assert.ok(
265
+ !readdirSync(join(base, ".gsd")).some((f: string) => f.startsWith(".compat.json.bad-")),
266
+ "healed marker must not be quarantined",
267
+ );
268
+ });
269
+
245
270
  test("an escaping key in planning.passthrough also invalidates the whole marker", () => {
246
271
  const base = makeTmpBase();
247
272
  writeCompatMarker(base, {
@@ -0,0 +1,12 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ test("db engine stays free of import.meta syntax for CommonJS chunks", () => {
8
+ const enginePath = join(dirname(fileURLToPath(import.meta.url)), "..", "db", "engine.ts");
9
+ const source = readFileSync(enginePath, "utf-8");
10
+
11
+ assert.doesNotMatch(source, /\bimport\.meta\b/);
12
+ });
@@ -167,6 +167,45 @@ test("checkEngineHealth keeps PLAN checkbox divergence after stale projection fl
167
167
  assert.match(readFileSync(plan.planPath, "utf-8"), /- \[ \] \*\*T01\*\*:/);
168
168
  });
169
169
 
170
+ test("checkEngineHealth ignores stale suffixed flat-phase duplicate when bare milestone exists", async (t) => {
171
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-checkbox-flat-duplicate-"));
172
+ t.after(() => rmSync(base, { recursive: true, force: true }));
173
+
174
+ const gsdDir = join(base, ".gsd");
175
+ mkdirSync(gsdDir, { recursive: true });
176
+
177
+ openDatabase(join(gsdDir, "gsd.db"));
178
+ insertMilestone({ id: "M003", title: "M003-vaz73w: New milestone M003-vaz73w", status: "active" });
179
+ insertSlice({ id: "S01", milestoneId: "M003", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
180
+ for (const taskId of ["T01", "T02", "T03"]) {
181
+ insertTask({ id: taskId, milestoneId: "M003", sliceId: "S01", title: taskId, status: "complete", sequence: Number(taskId.slice(1)) });
182
+ }
183
+
184
+ insertMilestone({ id: "M003-vaz73w", title: "New milestone M003-vaz73w", status: "active" });
185
+ insertSlice({ id: "S01", milestoneId: "M003-vaz73w", title: "Slice", status: "pending", risk: "low", depends: [], sequence: 1 });
186
+ for (const taskId of ["T01", "T02", "T03"]) {
187
+ insertTask({ id: taskId, milestoneId: "M003-vaz73w", sliceId: "S01", title: taskId, status: "complete", sequence: Number(taskId.slice(1)) });
188
+ }
189
+
190
+ const canonicalPlan = await renderPlanFromDb(base, "M003", "S01");
191
+ const staleDir = join(gsdDir, "phases", "03-vaz73w-new-milestone-m003-vaz73w");
192
+ mkdirSync(staleDir, { recursive: true });
193
+ writeFileSync(
194
+ join(staleDir, "03-01-PLAN.md"),
195
+ canonicalPlan.content.replace("- [x] **T03**:", "- [ ] **T03**:"),
196
+ "utf-8",
197
+ );
198
+
199
+ const issues: any[] = [];
200
+ await checkEngineHealth(base, issues, []);
201
+
202
+ assert.deepEqual(
203
+ issues.filter((issue) => issue.code === "checkbox_db_status_divergence").map((issue) => issue.unitId),
204
+ [],
205
+ "stale suffixed duplicate phase projection must not compete with the bare milestone projection",
206
+ );
207
+ });
208
+
170
209
  test("checkEngineHealth reads task checkboxes from the <tasks> block, not a stray line above it", async (t) => {
171
210
  const base = mkdtempSync(join(tmpdir(), "gsd-doctor-task-section-"));
172
211
  t.after(() => rmSync(base, { recursive: true, force: true }));
@@ -428,6 +467,50 @@ test("checkEngineHealth repair prunes stale phases artifact rows with present mi
428
467
  assert.deepEqual(rows.map((row) => row.path), []);
429
468
  });
430
469
 
470
+ test("checkEngineHealth repair prunes stale phases artifact rows with renamed flat-phase files", async (t) => {
471
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-prune-renamed-flat-phase-artifact-"));
472
+ t.after(() => rmSync(base, { recursive: true, force: true }));
473
+
474
+ const gsdDir = join(base, ".gsd");
475
+ const stalePath = "phases/01-new-milestone-m001/01-01-PLAN.md";
476
+ const replacementPath = "phases/01-lokably-brand-foundation-and-welcome-pag/01-01-PLAN.md";
477
+ mkdirSync(join(gsdDir, "phases", "01-lokably-brand-foundation-and-welcome-pag"), { recursive: true });
478
+ writeFileSync(join(gsdDir, replacementPath), "# Plan\n", "utf-8");
479
+
480
+ openDatabase(join(gsdDir, "gsd.db"));
481
+ insertArtifact({
482
+ path: stalePath,
483
+ artifact_type: "PLAN",
484
+ milestone_id: "M001",
485
+ slice_id: "S01",
486
+ task_id: null,
487
+ full_content: "# stale plan\n",
488
+ });
489
+
490
+ const beforeIssues: any[] = [];
491
+ await checkEngineHealth(base, beforeIssues, []);
492
+
493
+ const beforeIssue = beforeIssues.find((issue) => issue.code === "artifact_file_missing" && issue.file === stalePath);
494
+ assert.ok(beforeIssue, "stale renamed phases row should be reported when repair is off");
495
+ assert.equal(beforeIssue.fixable, true, "renamed phases rows with a flat replacement should be fixable");
496
+
497
+ const repairIssues: any[] = [];
498
+ const fixes: string[] = [];
499
+ await checkEngineHealth(base, repairIssues, fixes, { repair: true });
500
+
501
+ assert.equal(
502
+ repairIssues.some((issue) => issue.code === "artifact_file_missing" && issue.file === stalePath),
503
+ false,
504
+ "repair should not report stale rows it pruned",
505
+ );
506
+ assert.ok(fixes.includes(`pruned stale flat-phase artifact row ${stalePath}`));
507
+
508
+ const rows = _getAdapter()!
509
+ .prepare("SELECT path FROM artifacts ORDER BY path")
510
+ .all() as Array<{ path: string }>;
511
+ assert.deepEqual(rows.map((row) => row.path), []);
512
+ });
513
+
431
514
  test("checkEngineHealth repair keeps phases rows whose own file is still present", async (t) => {
432
515
  const base = mkdtempSync(join(tmpdir(), "gsd-doctor-keep-present-phase-artifact-"));
433
516
  t.after(() => rmSync(base, { recursive: true, force: true }));
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { test } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
6
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
 
@@ -152,6 +152,38 @@ test('runExecSandbox: node runtime executes JS', async () => {
152
152
  }
153
153
  });
154
154
 
155
+ test('runExecSandbox: node runtime forwards NODE_PATH for GSD-installed dependencies', async () => {
156
+ const base = freshBase();
157
+ try {
158
+ const dependencyRoot = join(base, 'parent-node_modules');
159
+ const packageDir = join(dependencyRoot, 'fake-gsd-dep');
160
+ const worktree = join(base, 'project', '.gsd', 'worktrees', 'M003');
161
+ mkdirSync(packageDir, { recursive: true });
162
+ mkdirSync(worktree, { recursive: true });
163
+ writeFileSync(
164
+ join(packageDir, 'package.json'),
165
+ JSON.stringify({ name: 'fake-gsd-dep', version: '1.0.0', main: 'index.js' }),
166
+ );
167
+ writeFileSync(join(packageDir, 'index.js'), "module.exports = 'resolved-via-node-path';\n");
168
+
169
+ const result = await runExecSandbox(
170
+ { runtime: 'node', script: "console.log(require('fake-gsd-dep'))" },
171
+ baseOpts(worktree, {
172
+ env: {
173
+ PATH: '/usr/bin:/bin',
174
+ HOME: '/tmp',
175
+ NODE_PATH: dependencyRoot,
176
+ },
177
+ }),
178
+ );
179
+
180
+ assert.equal(result.exit_code, 0, readFileSync(result.stderr_path, 'utf-8'));
181
+ assert.ok(result.digest.includes('resolved-via-node-path'));
182
+ } finally {
183
+ cleanup(base);
184
+ }
185
+ });
186
+
155
187
  test('runExecSandbox: rewrites NUL redirects for bash on Windows', async () => {
156
188
  const originalPlatform = process.platform;
157
189
  Object.defineProperty(process, 'platform', { value: 'win32' });