gsd-pi 2.80.0-dev.cf9433f56 → 2.80.0-dev.d4fc28e6b

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 (237) hide show
  1. package/dist/cli.js +0 -19
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +29 -0
  4. package/dist/resources/extensions/gsd/auto/loop.js +71 -8
  5. package/dist/resources/extensions/gsd/auto/phases.js +150 -94
  6. package/dist/resources/extensions/gsd/auto/resolve.js +12 -0
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +10 -30
  8. package/dist/resources/extensions/gsd/auto/session.js +8 -0
  9. package/dist/resources/extensions/gsd/auto/workflow-dispatch-claim.js +33 -1
  10. package/dist/resources/extensions/gsd/auto/workflow-worker-heartbeat.js +9 -1
  11. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +5 -32
  12. package/dist/resources/extensions/gsd/auto-dispatch.js +16 -0
  13. package/dist/resources/extensions/gsd/auto-post-unit.js +17 -4
  14. package/dist/resources/extensions/gsd/auto-prompts.js +90 -15
  15. package/dist/resources/extensions/gsd/auto-start.js +197 -6
  16. package/dist/resources/extensions/gsd/auto-worktree.js +111 -1
  17. package/dist/resources/extensions/gsd/auto.js +18 -22
  18. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +86 -19
  19. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +49 -36
  20. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +15 -5
  21. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +9 -3
  22. package/dist/resources/extensions/gsd/bootstrap/journal-tools.js +7 -1
  23. package/dist/resources/extensions/gsd/bootstrap/memory-tools.js +9 -3
  24. package/dist/resources/extensions/gsd/bootstrap/query-tools.js +8 -2
  25. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +298 -54
  26. package/dist/resources/extensions/gsd/bootstrap/system-context.js +82 -23
  27. package/dist/resources/extensions/gsd/clean-root-preflight.js +24 -6
  28. package/dist/resources/extensions/gsd/commands-handlers.js +23 -9
  29. package/dist/resources/extensions/gsd/db/unit-dispatches.js +53 -0
  30. package/dist/resources/extensions/gsd/ecosystem/gsd-extension-api.js +2 -0
  31. package/dist/resources/extensions/gsd/guided-flow.js +47 -28
  32. package/dist/resources/extensions/gsd/native-git-bridge.js +32 -8
  33. package/dist/resources/extensions/gsd/orphan-stash-audit.js +101 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.js +13 -3
  35. package/dist/resources/extensions/gsd/pre-execution-checks.js +15 -0
  36. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  37. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  38. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  39. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  40. package/dist/resources/extensions/gsd/prompts/replan-slice.md +2 -2
  41. package/dist/resources/extensions/gsd/workflow-protocol.js +131 -0
  42. package/dist/resources/extensions/gsd/worktree-resolver.js +35 -4
  43. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  44. package/dist/web/standalone/.next/BUILD_ID +1 -1
  45. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  46. package/dist/web/standalone/.next/build-manifest.json +2 -2
  47. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  48. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.html +1 -1
  65. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  72. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  74. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  75. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  76. package/dist/welcome-screen.d.ts +2 -0
  77. package/dist/welcome-screen.js +9 -7
  78. package/package.json +1 -1
  79. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  80. package/packages/pi-agent-core/dist/agent-loop.js +4 -1
  81. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  82. package/packages/pi-agent-core/dist/agent.d.ts +5 -0
  83. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  84. package/packages/pi-agent-core/dist/agent.js +2 -0
  85. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  86. package/packages/pi-agent-core/dist/index.d.ts +1 -0
  87. package/packages/pi-agent-core/dist/index.d.ts.map +1 -1
  88. package/packages/pi-agent-core/dist/index.js +2 -0
  89. package/packages/pi-agent-core/dist/index.js.map +1 -1
  90. package/packages/pi-agent-core/dist/token-audit.d.ts +47 -0
  91. package/packages/pi-agent-core/dist/token-audit.d.ts.map +1 -0
  92. package/packages/pi-agent-core/dist/token-audit.js +221 -0
  93. package/packages/pi-agent-core/dist/token-audit.js.map +1 -0
  94. package/packages/pi-agent-core/dist/types.d.ts +9 -0
  95. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  96. package/packages/pi-agent-core/dist/types.js.map +1 -1
  97. package/packages/pi-agent-core/src/agent-loop.test.ts +128 -0
  98. package/packages/pi-agent-core/src/agent-loop.ts +4 -1
  99. package/packages/pi-agent-core/src/agent.ts +8 -0
  100. package/packages/pi-agent-core/src/index.ts +2 -0
  101. package/packages/pi-agent-core/src/token-audit.test.ts +189 -0
  102. package/packages/pi-agent-core/src/token-audit.ts +287 -0
  103. package/packages/pi-agent-core/src/types.ts +14 -0
  104. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  105. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js +18 -0
  106. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +12 -0
  108. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/agent-session.js +36 -7
  110. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  112. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -0
  113. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -0
  115. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/extensions/runner.js +3 -6
  117. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js +3 -3
  119. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +32 -1
  121. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  122. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/core/hooks-runner.test.js +2 -0
  124. package/packages/pi-coding-agent/dist/core/hooks-runner.test.js.map +1 -1
  125. package/packages/pi-coding-agent/dist/core/sdk-tool-filter.test.d.ts +2 -0
  126. package/packages/pi-coding-agent/dist/core/sdk-tool-filter.test.d.ts.map +1 -0
  127. package/packages/pi-coding-agent/dist/core/sdk-tool-filter.test.js +46 -0
  128. package/packages/pi-coding-agent/dist/core/sdk-tool-filter.test.js.map +1 -0
  129. package/packages/pi-coding-agent/dist/core/sdk.d.ts +10 -2
  130. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  131. package/packages/pi-coding-agent/dist/core/sdk.js +74 -2
  132. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  133. package/packages/pi-coding-agent/dist/core/skill-tool.test.js +22 -0
  134. package/packages/pi-coding-agent/dist/core/skill-tool.test.js.map +1 -1
  135. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts +6 -7
  136. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  137. package/packages/pi-coding-agent/dist/core/system-prompt.js +2 -3
  138. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  139. package/packages/pi-coding-agent/src/core/agent-session-tool-refresh.test.ts +25 -0
  140. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -7
  141. package/packages/pi-coding-agent/src/core/extensions/loader.ts +10 -0
  142. package/packages/pi-coding-agent/src/core/extensions/runner.test.ts +3 -3
  143. package/packages/pi-coding-agent/src/core/extensions/runner.ts +5 -5
  144. package/packages/pi-coding-agent/src/core/extensions/types.ts +35 -1
  145. package/packages/pi-coding-agent/src/core/hooks-runner.test.ts +2 -0
  146. package/packages/pi-coding-agent/src/core/sdk-tool-filter.test.ts +60 -0
  147. package/packages/pi-coding-agent/src/core/sdk.ts +85 -3
  148. package/packages/pi-coding-agent/src/core/skill-tool.test.ts +28 -0
  149. package/packages/pi-coding-agent/src/core/system-prompt.ts +8 -10
  150. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  151. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +30 -0
  152. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +26 -0
  153. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -2
  154. package/src/resources/extensions/gsd/auto/loop.ts +84 -8
  155. package/src/resources/extensions/gsd/auto/phases.ts +218 -154
  156. package/src/resources/extensions/gsd/auto/resolve.ts +19 -0
  157. package/src/resources/extensions/gsd/auto/run-unit.ts +10 -29
  158. package/src/resources/extensions/gsd/auto/session.ts +8 -0
  159. package/src/resources/extensions/gsd/auto/workflow-dispatch-claim.ts +63 -1
  160. package/src/resources/extensions/gsd/auto/workflow-worker-heartbeat.ts +14 -1
  161. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +8 -34
  162. package/src/resources/extensions/gsd/auto-dispatch.ts +16 -0
  163. package/src/resources/extensions/gsd/auto-post-unit.ts +18 -4
  164. package/src/resources/extensions/gsd/auto-prompts.ts +95 -14
  165. package/src/resources/extensions/gsd/auto-start.ts +230 -9
  166. package/src/resources/extensions/gsd/auto-worktree.ts +123 -0
  167. package/src/resources/extensions/gsd/auto.ts +18 -18
  168. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +100 -18
  169. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +50 -36
  170. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +16 -5
  171. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +10 -3
  172. package/src/resources/extensions/gsd/bootstrap/journal-tools.ts +8 -1
  173. package/src/resources/extensions/gsd/bootstrap/memory-tools.ts +10 -3
  174. package/src/resources/extensions/gsd/bootstrap/query-tools.ts +9 -2
  175. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +347 -54
  176. package/src/resources/extensions/gsd/bootstrap/system-context.ts +90 -22
  177. package/src/resources/extensions/gsd/clean-root-preflight.ts +32 -7
  178. package/src/resources/extensions/gsd/commands-handlers.ts +34 -15
  179. package/src/resources/extensions/gsd/db/unit-dispatches.ts +66 -0
  180. package/src/resources/extensions/gsd/ecosystem/gsd-extension-api.ts +3 -0
  181. package/src/resources/extensions/gsd/guided-flow.ts +52 -35
  182. package/src/resources/extensions/gsd/native-git-bridge.ts +39 -6
  183. package/src/resources/extensions/gsd/orphan-stash-audit.ts +117 -0
  184. package/src/resources/extensions/gsd/parallel-orchestrator.ts +13 -3
  185. package/src/resources/extensions/gsd/pre-execution-checks.ts +16 -0
  186. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  187. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  188. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  189. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  190. package/src/resources/extensions/gsd/prompts/replan-slice.md +2 -2
  191. package/src/resources/extensions/gsd/tests/artifact-retry-cap.test.ts +2 -2
  192. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +361 -10
  193. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +168 -6
  194. package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +15 -6
  195. package/src/resources/extensions/gsd/tests/complete-milestone-excerpt.test.ts +31 -0
  196. package/src/resources/extensions/gsd/tests/complete-slice-composer.test.ts +3 -2
  197. package/src/resources/extensions/gsd/tests/context-store.test.ts +7 -1
  198. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +5 -1
  199. package/src/resources/extensions/gsd/tests/execute-task-rendering.test.ts +5 -2
  200. package/src/resources/extensions/gsd/tests/fast-forward-reused-milestone-branch.test.ts +219 -0
  201. package/src/resources/extensions/gsd/tests/finalize-survivor-branch.test.ts +132 -0
  202. package/src/resources/extensions/gsd/tests/isolation-none-branch-guard.test.ts +6 -3
  203. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +5 -1
  204. package/src/resources/extensions/gsd/tests/journal-query-tool.test.ts +32 -0
  205. package/src/resources/extensions/gsd/tests/knowledge.test.ts +47 -0
  206. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +1 -0
  207. package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +242 -0
  208. package/src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts +34 -2
  209. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +3 -0
  210. package/src/resources/extensions/gsd/tests/orphan-merge-bootstrap.test.ts +133 -0
  211. package/src/resources/extensions/gsd/tests/orphan-stash-audit.test.ts +201 -0
  212. package/src/resources/extensions/gsd/tests/parallel-orchestrator-fast-forward.test.ts +113 -0
  213. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +7 -5
  214. package/src/resources/extensions/gsd/tests/prompt-duplication-cuts.test.ts +230 -0
  215. package/src/resources/extensions/gsd/tests/query-tools-db-open.test.ts +3 -3
  216. package/src/resources/extensions/gsd/tests/restore-tools-after-discuss.test.ts +38 -17
  217. package/src/resources/extensions/gsd/tests/select-resumable-milestone.test.ts +96 -0
  218. package/src/resources/extensions/gsd/tests/session-start-footer.test.ts +77 -0
  219. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +166 -0
  220. package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -0
  221. package/src/resources/extensions/gsd/tests/system-context-memory.test.ts +112 -0
  222. package/src/resources/extensions/gsd/tests/system-context-message-routing.test.ts +7 -9
  223. package/src/resources/extensions/gsd/tests/token-tool-gating.test.ts +291 -0
  224. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +50 -1
  225. package/src/resources/extensions/gsd/tests/unstructured-continue-context-injection.test.ts +5 -4
  226. package/src/resources/extensions/gsd/tests/workflow-dispatch-claim.test.ts +142 -0
  227. package/src/resources/extensions/gsd/tests/workflow-protocol-excerpt.test.ts +99 -0
  228. package/src/resources/extensions/gsd/tests/workflow-worker-heartbeat.test.ts +32 -1
  229. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +1 -0
  230. package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +22 -19
  231. package/src/resources/extensions/gsd/tests/worktree-project-root-degrade.test.ts +66 -0
  232. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +104 -3
  233. package/src/resources/extensions/gsd/workflow-protocol.ts +160 -0
  234. package/src/resources/extensions/gsd/worktree-resolver.ts +49 -4
  235. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +0 -97
  236. /package/dist/web/standalone/.next/static/{-5nHJWzSdG-WkPMul_khA → cWaxzf-sdbSSbbwYu8q7a}/_buildManifest.js +0 -0
  237. /package/dist/web/standalone/.next/static/{-5nHJWzSdG-WkPMul_khA → cWaxzf-sdbSSbbwYu8q7a}/_ssgManifest.js +0 -0
@@ -1,4 +1,5 @@
1
- // GSD-2 — Regression test for #3615: unstructured "continue" must inject task context
1
+ // Project/App: GSD-2
2
+ // File Purpose: Regression test for unstructured continue task-context injection.
2
3
  // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
4
 
4
5
  /**
@@ -55,12 +56,12 @@ describe("#3615 — structural: fallback exists with correct guards", () => {
55
56
  );
56
57
  });
57
58
 
58
- test("fallback is intent-gated via RESUME_INTENT_PATTERNS", () => {
59
+ test("fallback is intent-gated via isLowEntropyResumePrompt", () => {
59
60
  const afterFallback = fnBody.indexOf("// Fallback:");
60
61
  const fallbackSection = fnBody.slice(afterFallback);
61
62
  assert.ok(
62
- fallbackSection.includes("RESUME_INTENT_PATTERNS"),
63
- "fallback must check RESUME_INTENT_PATTERNS before deriveState",
63
+ fallbackSection.includes("isLowEntropyResumePrompt(prompt)"),
64
+ "fallback must check isLowEntropyResumePrompt before deriveState",
64
65
  );
65
66
  });
66
67
 
@@ -7,7 +7,9 @@ import test from "node:test";
7
7
  import type { AutoSession } from "../auto/session.ts";
8
8
  import type { IterationData } from "../auto/types.ts";
9
9
  import {
10
+ ensureDispatchLease,
10
11
  openDispatchClaim,
12
+ type EnsureDispatchLeaseDeps,
11
13
  type OpenDispatchClaimDeps,
12
14
  } from "../auto/workflow-dispatch-claim.ts";
13
15
 
@@ -49,6 +51,25 @@ function makeDeps(overrides?: Partial<OpenDispatchClaimDeps>): OpenDispatchClaim
49
51
  };
50
52
  }
51
53
 
54
+ function makeLeaseDeps(overrides?: Partial<EnsureDispatchLeaseDeps>): {
55
+ deps: EnsureDispatchLeaseDeps;
56
+ calls: unknown[];
57
+ failures: unknown[];
58
+ } {
59
+ const calls: unknown[] = [];
60
+ const failures: unknown[] = [];
61
+ const deps: EnsureDispatchLeaseDeps = {
62
+ claimMilestoneLease: (workerId, milestoneId) => {
63
+ calls.push(["claim", workerId, milestoneId]);
64
+ return { ok: true, token: 8, expiresAt: "2030-01-01T00:00:00.000Z" };
65
+ },
66
+ logLeaseRecovered: details => calls.push(["recovered", details]),
67
+ logLeaseRecoveryFailed: details => failures.push(details),
68
+ ...overrides,
69
+ };
70
+ return { deps, calls, failures };
71
+ }
72
+
52
73
  test("openDispatchClaim degrades when worker identity or lease token is missing", () => {
53
74
  assert.deepEqual(
54
75
  openDispatchClaim(makeSession({ workerId: null }), "flow", "turn", makeIterationData(), makeDeps({
@@ -156,3 +177,124 @@ test("openDispatchClaim degrades on claim write failures", () => {
156
177
  assert.deepEqual(outcome, { kind: "degraded" });
157
178
  assert.deepEqual(logged, [writeError]);
158
179
  });
180
+
181
+ test("ensureDispatchLease degrades without worker identity or milestone id", () => {
182
+ const { deps, calls } = makeLeaseDeps({
183
+ claimMilestoneLease: () => assert.fail("claimMilestoneLease should not be called"),
184
+ });
185
+
186
+ assert.deepEqual(
187
+ ensureDispatchLease(makeSession({ workerId: null }), "M001", deps),
188
+ { kind: "degraded", reason: "missing-worker" },
189
+ );
190
+ assert.deepEqual(
191
+ ensureDispatchLease(makeSession(), undefined, deps),
192
+ { kind: "degraded", reason: "missing-milestone" },
193
+ );
194
+ assert.deepEqual(calls, []);
195
+ });
196
+
197
+ test("ensureDispatchLease reuses an existing numeric token", () => {
198
+ const { deps, calls } = makeLeaseDeps({
199
+ claimMilestoneLease: () => assert.fail("claimMilestoneLease should not be called"),
200
+ });
201
+
202
+ const session = makeSession({ milestoneLeaseToken: 7 });
203
+ const outcome = ensureDispatchLease(session, "M001", deps);
204
+
205
+ assert.deepEqual(outcome, { kind: "ready", token: 7, recovered: false });
206
+ assert.equal(session.milestoneLeaseToken, 7);
207
+ assert.deepEqual(calls, []);
208
+ });
209
+
210
+ test("ensureDispatchLease claims a lease when the session has no token", () => {
211
+ const { deps, calls, failures } = makeLeaseDeps();
212
+ const session = makeSession({
213
+ currentMilestoneId: "M001",
214
+ milestoneLeaseToken: null,
215
+ });
216
+
217
+ const outcome = ensureDispatchLease(session, "M001", deps);
218
+
219
+ assert.deepEqual(outcome, { kind: "ready", token: 8, recovered: false });
220
+ assert.equal(session.currentMilestoneId, "M001");
221
+ assert.equal(session.milestoneLeaseToken, 8);
222
+ assert.deepEqual(calls, [
223
+ ["claim", "worker-1", "M001"],
224
+ ["recovered", {
225
+ milestoneId: "M001",
226
+ workerId: "worker-1",
227
+ token: 8,
228
+ recovered: false,
229
+ }],
230
+ ]);
231
+ assert.deepEqual(failures, []);
232
+ });
233
+
234
+ test("ensureDispatchLease force-reclaims after a stale dispatch claim", () => {
235
+ const { deps, calls } = makeLeaseDeps({
236
+ claimMilestoneLease: (workerId, milestoneId) => {
237
+ calls.push(["claim", workerId, milestoneId]);
238
+ return { ok: true, token: 9, expiresAt: "2030-01-01T00:00:00.000Z" };
239
+ },
240
+ });
241
+ const session = makeSession({ milestoneLeaseToken: 7 });
242
+
243
+ const outcome = ensureDispatchLease(session, "M001", deps, { forceReclaim: true });
244
+
245
+ assert.deepEqual(outcome, { kind: "ready", token: 9, recovered: true });
246
+ assert.equal(session.milestoneLeaseToken, 9);
247
+ assert.deepEqual(calls, [
248
+ ["claim", "worker-1", "M001"],
249
+ ["recovered", {
250
+ milestoneId: "M001",
251
+ workerId: "worker-1",
252
+ token: 9,
253
+ recovered: true,
254
+ }],
255
+ ]);
256
+ });
257
+
258
+ test("ensureDispatchLease blocks when another worker holds the lease", () => {
259
+ const { deps, failures } = makeLeaseDeps({
260
+ claimMilestoneLease: () => ({
261
+ ok: false,
262
+ error: "held_by",
263
+ byWorker: "worker-2",
264
+ expiresAt: "2030-01-01T00:00:00.000Z",
265
+ }),
266
+ });
267
+ const session = makeSession({ milestoneLeaseToken: null });
268
+
269
+ const outcome = ensureDispatchLease(session, "M001", deps);
270
+
271
+ assert.deepEqual(outcome, {
272
+ kind: "blocked",
273
+ reason: "Milestone M001 is held by worker worker-2 until 2030-01-01T00:00:00.000Z.",
274
+ });
275
+ assert.equal(session.milestoneLeaseToken, null);
276
+ assert.deepEqual(failures, [{
277
+ milestoneId: "M001",
278
+ workerId: "worker-1",
279
+ reason: "Milestone M001 is held by worker worker-2 until 2030-01-01T00:00:00.000Z.",
280
+ }]);
281
+ });
282
+
283
+ test("ensureDispatchLease fails closed on claim errors", () => {
284
+ const { deps, failures } = makeLeaseDeps({
285
+ claimMilestoneLease: () => {
286
+ throw new Error("db unavailable");
287
+ },
288
+ });
289
+ const session = makeSession({ milestoneLeaseToken: null });
290
+
291
+ const outcome = ensureDispatchLease(session, "M001", deps);
292
+
293
+ assert.deepEqual(outcome, { kind: "failed", reason: "db unavailable" });
294
+ assert.equal(session.milestoneLeaseToken, null);
295
+ assert.deepEqual(failures, [{
296
+ milestoneId: "M001",
297
+ workerId: "worker-1",
298
+ reason: "db unavailable",
299
+ }]);
300
+ });
@@ -0,0 +1,99 @@
1
+ // Project/App: GSD-2
2
+ // File Purpose: Tests for capped GSD workflow protocol and doctor-heal payload helpers.
3
+
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import {
8
+ buildDoctorHealIssuePayload,
9
+ buildDoctorHealSummary,
10
+ buildWorkflowDispatchContent,
11
+ buildWorkflowProtocolExcerpt,
12
+ } from "../workflow-protocol.ts";
13
+
14
+ test("workflow protocol helper emits capped excerpt plus source path", () => {
15
+ const workflow = `# Protocol\n${"FULL_WORKFLOW_BODY ".repeat(500)}`;
16
+ const excerpt = buildWorkflowProtocolExcerpt(workflow, "/tmp/GSD-WORKFLOW.md", { maxChars: 1200 });
17
+
18
+ assert.match(excerpt, /Source: `\/tmp\/GSD-WORKFLOW\.md`/);
19
+ assert.match(excerpt, /\[Workflow Protocol Truncated\]/);
20
+ assert.ok(excerpt.length < workflow.length);
21
+ assert.ok(excerpt.length < 1600);
22
+ });
23
+
24
+ test("workflow dispatch uses excerpt instead of full workflow body", () => {
25
+ const workflow = `# Protocol\n${"FULL_WORKFLOW_BODY ".repeat(500)}`;
26
+ const content = buildWorkflowDispatchContent({
27
+ workflow,
28
+ workflowPath: "/tmp/GSD-WORKFLOW.md",
29
+ task: "Run the selected unit.",
30
+ maxProtocolChars: 1200,
31
+ });
32
+
33
+ assert.match(content, /## GSD Workflow Protocol Excerpt/);
34
+ assert.match(content, /## Your Task/);
35
+ assert.match(content, /Run the selected unit/);
36
+ assert.ok(content.length < workflow.length);
37
+ });
38
+
39
+ test("workflow protocol excerpt includes late verification and advance rules", () => {
40
+ const workflow = [
41
+ "# GSD Workflow",
42
+ "intro",
43
+ "## Quick Start",
44
+ "quick",
45
+ "## File Format Reference",
46
+ "format ".repeat(400),
47
+ "## The Phases",
48
+ "phase overview",
49
+ "### Phase 4: Execute",
50
+ "execute rules",
51
+ "### Phase 5: Verify",
52
+ "verification rules",
53
+ "### Phase 7: Advance",
54
+ "advance rules",
55
+ ].join("\n");
56
+
57
+ const excerpt = buildWorkflowProtocolExcerpt(workflow, "/tmp/GSD-WORKFLOW.md", { maxChars: 1300 });
58
+
59
+ assert.match(excerpt, /Quick Start/);
60
+ assert.match(excerpt, /Phase 5: Verify/);
61
+ assert.match(excerpt, /Phase 7: Advance/);
62
+ assert.doesNotMatch(excerpt, /format format format format format/);
63
+ });
64
+
65
+ test("doctor heal summary omits duplicated full report body", () => {
66
+ const report = [
67
+ "# GSD doctor heal prep.",
68
+ "Scope: M001",
69
+ "Status: warning",
70
+ "Warnings: 9",
71
+ "",
72
+ "VERY_LONG_FULL_REPORT_BODY ".repeat(300),
73
+ ].join("\n");
74
+
75
+ const summary = buildDoctorHealSummary(report, { maxChars: 900 });
76
+
77
+ assert.match(summary, /GSD doctor heal prep/);
78
+ assert.match(summary, /Warnings: 9/);
79
+ assert.ok(summary.length <= 900);
80
+ assert.doesNotMatch(summary, /VERY_LONG_FULL_REPORT_BODY VERY_LONG_FULL_REPORT_BODY VERY_LONG_FULL_REPORT_BODY/);
81
+ });
82
+
83
+ test("doctor heal issue payload keeps top actionable issues and caps detail", () => {
84
+ const issues = Array.from({ length: 20 }, (_, index) =>
85
+ `### Issue ${index + 1}\n${`detail ${index + 1} `.repeat(80)}`,
86
+ ).join("\n");
87
+
88
+ const payload = buildDoctorHealIssuePayload(issues, {
89
+ maxIssues: 3,
90
+ maxIssueChars: 180,
91
+ maxChars: 900,
92
+ });
93
+
94
+ assert.match(payload, /Issue 1/);
95
+ assert.match(payload, /Issue 3/);
96
+ assert.doesNotMatch(payload, /Issue 4/);
97
+ assert.match(payload, /17 additional actionable issue/);
98
+ assert.ok(payload.length <= 900);
99
+ });
@@ -14,9 +14,11 @@ function makeDeps(overrides?: Partial<MaintainWorkerHeartbeatDeps>): {
14
14
  deps: MaintainWorkerHeartbeatDeps;
15
15
  calls: unknown[];
16
16
  errors: unknown[];
17
+ misses: unknown[];
17
18
  } {
18
19
  const calls: unknown[] = [];
19
20
  const errors: unknown[] = [];
21
+ const misses: unknown[] = [];
20
22
  const deps: MaintainWorkerHeartbeatDeps = {
21
23
  heartbeatAutoWorker: workerId => calls.push(["heartbeat", workerId]),
22
24
  refreshMilestoneLease: (workerId, milestoneId, token) => {
@@ -24,9 +26,10 @@ function makeDeps(overrides?: Partial<MaintainWorkerHeartbeatDeps>): {
24
26
  return true;
25
27
  },
26
28
  logHeartbeatFailure: err => errors.push(err),
29
+ logLeaseRefreshMiss: details => misses.push(details),
27
30
  ...overrides,
28
31
  };
29
- return { deps, calls, errors };
32
+ return { deps, calls, errors, misses };
30
33
  }
31
34
 
32
35
  test("maintainWorkerHeartbeat no-ops without a worker id", () => {
@@ -121,3 +124,31 @@ test("maintainWorkerHeartbeat logs and suppresses lease refresh failures", () =>
121
124
  ]);
122
125
  assert.deepEqual(errors, [failure]);
123
126
  });
127
+
128
+ test("maintainWorkerHeartbeat clears stale lease tokens when refresh misses", () => {
129
+ const { deps, calls, errors, misses } = makeDeps({
130
+ refreshMilestoneLease: (workerId, milestoneId, token) => {
131
+ calls.push(["refresh", workerId, milestoneId, token]);
132
+ return false;
133
+ },
134
+ });
135
+ const session: WorkerHeartbeatSession = {
136
+ workerId: "worker-1",
137
+ currentMilestoneId: "M001",
138
+ milestoneLeaseToken: 7,
139
+ };
140
+
141
+ maintainWorkerHeartbeat(session, deps);
142
+
143
+ assert.deepEqual(calls, [
144
+ ["heartbeat", "worker-1"],
145
+ ["refresh", "worker-1", "M001", 7],
146
+ ]);
147
+ assert.deepEqual(errors, []);
148
+ assert.deepEqual(misses, [{
149
+ workerId: "worker-1",
150
+ milestoneId: "M001",
151
+ fencingToken: 7,
152
+ }]);
153
+ assert.equal(session.milestoneLeaseToken, null);
154
+ });
@@ -39,6 +39,7 @@ function makeDeps(
39
39
  getAutoWorktreePath: () => null,
40
40
  autoCommitCurrentBranch: () => {},
41
41
  getCurrentBranch: () => "main",
42
+ checkoutBranch: () => {},
42
43
  autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
43
44
  resolveMilestoneFile: (_basePath: string, milestoneId: string) =>
44
45
  `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`,
@@ -79,7 +79,7 @@ async function waitFor(condition: () => boolean, label: string): Promise<void> {
79
79
  assert.fail(`Timed out waiting for ${label} after ${timeoutMs}ms`);
80
80
  }
81
81
 
82
- test("runUnit changes cwd to basePath before creating a new session", async (t) => {
82
+ test("runUnit passes basePath as workspaceRoot without changing process cwd", async (t) => {
83
83
  _resetPendingResolve();
84
84
 
85
85
  const originalCwd = process.cwd();
@@ -93,13 +93,15 @@ test("runUnit changes cwd to basePath before creating a new session", async (t)
93
93
 
94
94
  process.chdir(drifted);
95
95
 
96
+ let newSessionWorkspaceRoot: string | undefined;
96
97
  let cwdAtNewSession: string | undefined;
97
98
  const session = {
98
99
  active: true,
99
100
  basePath: base,
100
101
  verbose: false,
101
102
  cmdCtx: {
102
- newSession: () => {
103
+ newSession: (options?: { workspaceRoot?: string }) => {
104
+ newSessionWorkspaceRoot = options?.workspaceRoot;
103
105
  cwdAtNewSession = process.cwd();
104
106
  return Promise.resolve({ cancelled: false });
105
107
  },
@@ -119,10 +121,12 @@ test("runUnit changes cwd to basePath before creating a new session", async (t)
119
121
 
120
122
  const result = await resultPromise;
121
123
  assert.equal(result.status, "completed");
122
- assert.equal(cwdAtNewSession, base);
124
+ assert.equal(newSessionWorkspaceRoot, base);
125
+ assert.equal(cwdAtNewSession, drifted);
126
+ assert.equal(process.cwd(), drifted);
123
127
  });
124
128
 
125
- test("runUnit cancels before creating a session when basePath chdir fails", async (t) => {
129
+ test("runUnit does not chdir or cancel when basePath is not a live directory", async (t) => {
126
130
  _resetPendingResolve();
127
131
 
128
132
  const originalCwd = process.cwd();
@@ -136,14 +140,14 @@ test("runUnit cancels before creating a session when basePath chdir fails", asyn
136
140
 
137
141
  process.chdir(drifted);
138
142
 
139
- let newSessionCalled = false;
143
+ let newSessionWorkspaceRoot: string | undefined;
140
144
  const session = {
141
145
  active: true,
142
146
  basePath: base,
143
147
  verbose: false,
144
148
  cmdCtx: {
145
- newSession: () => {
146
- newSessionCalled = true;
149
+ newSession: (options?: { workspaceRoot?: string }) => {
150
+ newSessionWorkspaceRoot = options?.workspaceRoot;
147
151
  return Promise.resolve({ cancelled: false });
148
152
  },
149
153
  },
@@ -156,15 +160,14 @@ test("runUnit cancels before creating a session when basePath chdir fails", asyn
156
160
  } as any;
157
161
  const ctx = { ui: { notify: () => {} }, model: { id: "test-model" } } as any;
158
162
 
159
- const result = await runUnit(ctx, pi, session, "task", "T01", "prompt");
163
+ const resultPromise = runUnit(ctx, pi, session, "task", "T01", "prompt");
164
+ await waitFor(() => pi.calls.length === 1, "runUnit dispatch");
165
+ resolveAgentEnd({ messages: [{ role: "assistant" }] });
160
166
 
161
- assert.equal(result.status, "cancelled");
162
- assert.equal(result.errorContext?.category, "session-failed");
163
- assert.equal(result.errorContext?.isTransient, true);
164
- assert.match(result.errorContext?.message ?? "", /Failed to chdir to basePath before newSession/);
165
- assert.ok(result.errorContext?.message.includes(base), "error should include the failed basePath");
166
- assert.equal(newSessionCalled, false, "newSession must not run after chdir failure");
167
- assert.equal(pi.calls.length, 0, "unit must not dispatch after chdir failure");
167
+ const result = await resultPromise;
168
+ assert.equal(result.status, "completed");
169
+ assert.equal(newSessionWorkspaceRoot, base);
170
+ assert.equal(process.cwd(), drifted);
168
171
  });
169
172
 
170
173
  test("direct dispatch redirects to the canonical milestone worktree before newSession", async (t) => {
@@ -185,12 +188,12 @@ test("direct dispatch redirects to the canonical milestone worktree before newSe
185
188
 
186
189
  process.chdir(drifted);
187
190
 
188
- let cwdAtNewSession: string | undefined;
191
+ let newSessionWorkspaceRoot: string | undefined;
189
192
  let sentPrompt: string | undefined;
190
193
  const ctx = {
191
194
  ui: { notify: () => {} },
192
- newSession: async () => {
193
- cwdAtNewSession = process.cwd();
195
+ newSession: async (options?: { workspaceRoot?: string }) => {
196
+ newSessionWorkspaceRoot = options?.workspaceRoot;
194
197
  return { cancelled: false };
195
198
  },
196
199
  } as any;
@@ -202,7 +205,7 @@ test("direct dispatch redirects to the canonical milestone worktree before newSe
202
205
 
203
206
  await dispatchDirectPhase(ctx, pi, "research-milestone", base);
204
207
 
205
- assert.equal(cwdAtNewSession, worktreeRoot);
208
+ assert.equal(newSessionWorkspaceRoot, worktreeRoot);
206
209
  assert.equal(process.cwd(), drifted);
207
210
  assert.ok(sentPrompt?.includes(worktreeRoot), "prompt should name the canonical worktree root");
208
211
  });
@@ -0,0 +1,66 @@
1
+ // GSD-2 + Worktree dispatch guard: degrade empty worktrees over real project roots.
2
+
3
+ import { describe, test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+
6
+ import { shouldDegradeEmptyWorktreeToProjectRoot } from "../auto/phases.ts";
7
+ import type { ProjectClassification } from "../detection.ts";
8
+
9
+ function classification(kind: ProjectClassification["kind"]): ProjectClassification {
10
+ return {
11
+ kind,
12
+ signals: {
13
+ detectedFiles: [],
14
+ isGitRepo: true,
15
+ isMonorepo: false,
16
+ xcodePlatforms: [],
17
+ hasCI: false,
18
+ hasTests: false,
19
+ verificationCommands: [],
20
+ },
21
+ trackedFiles: [],
22
+ untrackedFiles: [],
23
+ contentFiles: [],
24
+ markers: [],
25
+ reason: kind,
26
+ };
27
+ }
28
+
29
+ describe("worktree project-root degradation", () => {
30
+ test("degrades when worktree is greenfield but project root has content", () => {
31
+ assert.equal(
32
+ shouldDegradeEmptyWorktreeToProjectRoot(
33
+ classification("greenfield"),
34
+ classification("typed-existing"),
35
+ ),
36
+ true,
37
+ );
38
+ assert.equal(
39
+ shouldDegradeEmptyWorktreeToProjectRoot(
40
+ classification("greenfield"),
41
+ classification("untyped-existing"),
42
+ ),
43
+ true,
44
+ );
45
+ });
46
+
47
+ test("keeps true greenfield worktrees in worktree mode", () => {
48
+ assert.equal(
49
+ shouldDegradeEmptyWorktreeToProjectRoot(
50
+ classification("greenfield"),
51
+ classification("greenfield"),
52
+ ),
53
+ false,
54
+ );
55
+ });
56
+
57
+ test("does not degrade when project root classification is invalid", () => {
58
+ assert.equal(
59
+ shouldDegradeEmptyWorktreeToProjectRoot(
60
+ classification("greenfield"),
61
+ classification("invalid-repo"),
62
+ ),
63
+ false,
64
+ );
65
+ });
66
+ });
@@ -118,6 +118,9 @@ function makeDeps(
118
118
  calls.push({ fn: "getCurrentBranch", args: [basePath] });
119
119
  return "main";
120
120
  },
121
+ checkoutBranch: (basePath: string, branch: string) => {
122
+ calls.push({ fn: "checkoutBranch", args: [basePath, branch] });
123
+ },
121
124
  autoWorktreeBranch: (milestoneId: string) => {
122
125
  calls.push({ fn: "autoWorktreeBranch", args: [milestoneId] });
123
126
  return `milestone/${milestoneId}`;
@@ -802,21 +805,92 @@ test("mergeAndExit in branch mode merges when on milestone branch", () => {
802
805
  assert.ok(ctx.messages.some((m) => m.msg.includes("branch mode")));
803
806
  });
804
807
 
805
- test("mergeAndExit in branch mode skips when not on milestone branch", () => {
808
+ test("mergeAndExit in branch mode checks out the milestone branch and merges (#5538-followup)", () => {
809
+ // Regression: previously this case silently returned without merging,
810
+ // stranding the milestone's commits on the branch (the test12345 repro).
811
+ // The fix forces a checkout first; merge proceeds when checkout succeeds.
806
812
  const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
813
+ let currentBranch = "main";
814
+ const checkoutInvocations: Array<{ basePath: string; branch: string }> = [];
807
815
  const deps = makeDeps({
808
816
  isInAutoWorktree: () => false,
809
817
  getIsolationMode: () => "branch",
810
- getCurrentBranch: () => "main",
818
+ getCurrentBranch: () => currentBranch,
811
819
  autoWorktreeBranch: () => "milestone/M001",
820
+ checkoutBranch: (basePath: string, branch: string) => {
821
+ checkoutInvocations.push({ basePath, branch });
822
+ currentBranch = branch;
823
+ },
812
824
  });
813
825
  const ctx = makeNotifyCtx();
814
826
  const resolver = new WorktreeResolver(s, deps);
815
827
 
816
828
  resolver.mergeAndExit("M001", ctx);
817
829
 
830
+ assert.equal(checkoutInvocations.length, 1, "must attempt checkout when on wrong branch");
831
+ assert.deepEqual(checkoutInvocations[0], { basePath: "/project", branch: "milestone/M001" });
832
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
833
+ });
834
+
835
+ test("mergeAndExit in branch mode throws when checkout fails", () => {
836
+ // Regression for the silent-skip bug: if the working tree is on the wrong
837
+ // branch and checkout fails, we must throw so the caller pauses auto-mode
838
+ // — never silently advance with the milestone unmerged.
839
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
840
+ const deps = makeDeps({
841
+ isInAutoWorktree: () => false,
842
+ getIsolationMode: () => "branch",
843
+ getCurrentBranch: () => "main",
844
+ autoWorktreeBranch: () => "milestone/M001",
845
+ checkoutBranch: () => {
846
+ throw new Error("dirty working tree blocks checkout");
847
+ },
848
+ });
849
+ const ctx = makeNotifyCtx();
850
+ const resolver = new WorktreeResolver(s, deps);
851
+
852
+ assert.throws(
853
+ () => resolver.mergeAndExit("M001", ctx),
854
+ /dirty working tree blocks checkout/,
855
+ );
856
+ assert.equal(
857
+ findCalls(deps.calls, "mergeMilestoneToMain").length,
858
+ 0,
859
+ "merge must not run when checkout failed",
860
+ );
861
+ const errorNotify = ctx.messages.find((m) => m.level === "error");
862
+ assert.ok(errorNotify, "an error notification must be emitted");
863
+ assert.match(errorNotify!.msg, /milestone\/M001 failed/);
864
+ assert.match(errorNotify!.msg, /Resolve manually/);
865
+ assert.equal(
866
+ ctx.messages.some((m) => m.level === "warning" && m.msg.includes("Milestone merge failed")),
867
+ false,
868
+ "checkout failures with explicit recovery guidance must not emit a duplicate warning",
869
+ );
870
+ });
871
+
872
+ test("mergeAndExit in branch mode throws when checkout reports success but HEAD is still wrong", () => {
873
+ // Defense in depth: even if checkoutBranch returns without throwing, we
874
+ // re-verify and throw if HEAD didn't actually move. Prevents merging on
875
+ // top of the wrong branch on platforms where the checkout is a no-op.
876
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
877
+ const deps = makeDeps({
878
+ isInAutoWorktree: () => false,
879
+ getIsolationMode: () => "branch",
880
+ getCurrentBranch: () => "main", // never changes — simulates no-op checkout
881
+ autoWorktreeBranch: () => "milestone/M001",
882
+ checkoutBranch: () => {
883
+ // Pretend success — but getCurrentBranch will still return "main".
884
+ },
885
+ });
886
+ const ctx = makeNotifyCtx();
887
+ const resolver = new WorktreeResolver(s, deps);
888
+
889
+ assert.throws(
890
+ () => resolver.mergeAndExit("M001", ctx),
891
+ /reported success but current branch is main/,
892
+ );
818
893
  assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
819
- assert.equal(ctx.messages.length, 0);
820
894
  });
821
895
 
822
896
  test("mergeAndExit in branch mode handles merge failure gracefully", () => {
@@ -1039,6 +1113,33 @@ test("mergeAndEnterNext enters next milestone even if merge fails", () => {
1039
1113
  );
1040
1114
  });
1041
1115
 
1116
+ test("mergeAndEnterNext halts after branch-mode user-notified checkout failure", () => {
1117
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
1118
+ const deps = makeDeps({
1119
+ isInAutoWorktree: () => false,
1120
+ getIsolationMode: () => "branch",
1121
+ getCurrentBranch: () => "main",
1122
+ autoWorktreeBranch: () => "milestone/M001",
1123
+ checkoutBranch: () => {
1124
+ throw new Error("dirty working tree blocks checkout");
1125
+ },
1126
+ });
1127
+ const ctx = makeNotifyCtx();
1128
+ const resolver = new WorktreeResolver(s, deps);
1129
+
1130
+ assert.throws(
1131
+ () => resolver.mergeAndEnterNext("M001", "M002", ctx),
1132
+ /dirty working tree blocks checkout/,
1133
+ );
1134
+ assert.equal(
1135
+ findCalls(deps.calls, "enterBranchModeForMilestone").length,
1136
+ 0,
1137
+ "must not enter the next milestone after a user-notified branch-mode failure",
1138
+ );
1139
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
1140
+ assert.ok(ctx.messages.some((m) => m.level === "error" && m.msg.includes("Resolve manually")));
1141
+ });
1142
+
1042
1143
  // ─── GitService Rebuild Atomicity ────────────────────────────────────────────
1043
1144
 
1044
1145
  test("GitService is rebuilt with the NEW basePath after enterMilestone", () => {