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

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 (218) hide show
  1. package/README.md +4 -2
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +59 -21
  4. package/dist/resources/extensions/gsd/auto/resolve.js +17 -0
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +17 -2
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +1 -1
  7. package/dist/resources/extensions/gsd/auto-prompts.js +13 -1
  8. package/dist/resources/extensions/gsd/auto-recovery.js +43 -1
  9. package/dist/resources/extensions/gsd/auto-supervisor.js +8 -1
  10. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +2 -2
  11. package/dist/resources/extensions/gsd/auto.js +84 -5
  12. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +21 -2
  13. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +27 -20
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +75 -4
  15. package/dist/resources/extensions/gsd/clean-root-preflight.js +24 -6
  16. package/dist/resources/extensions/gsd/context-budget.js +37 -2
  17. package/dist/resources/extensions/gsd/db/unit-dispatches.js +39 -0
  18. package/dist/resources/extensions/gsd/db-base-schema.js +4 -2
  19. package/dist/resources/extensions/gsd/db-migration-steps.js +6 -0
  20. package/dist/resources/extensions/gsd/git-service.js +36 -4
  21. package/dist/resources/extensions/gsd/gsd-db.js +46 -13
  22. package/dist/resources/extensions/gsd/guided-flow.js +33 -4
  23. package/dist/resources/extensions/gsd/memory-store.js +69 -12
  24. package/dist/resources/extensions/gsd/migrate/command.js +40 -1
  25. package/dist/resources/extensions/gsd/migration-auto-check.js +87 -0
  26. package/dist/resources/extensions/gsd/pre-execution-checks.js +7 -0
  27. package/dist/resources/extensions/gsd/prompt-loader.js +28 -2
  28. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +16 -13
  29. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  30. package/dist/resources/extensions/gsd/prompts/quick-task.md +1 -5
  31. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  32. package/dist/resources/extensions/gsd/quick.js +34 -2
  33. package/dist/resources/extensions/gsd/tools/context-mode-tool-result.js +15 -0
  34. package/dist/resources/extensions/gsd/tools/exec-search-tool.js +5 -0
  35. package/dist/resources/extensions/gsd/tools/exec-tool.js +3 -15
  36. package/dist/resources/extensions/gsd/tools/memory-tools.js +1 -0
  37. package/dist/resources/extensions/gsd/tools/resume-tool.js +5 -0
  38. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +1 -1
  39. package/dist/resources/extensions/gsd/unit-context-composer.js +12 -3
  40. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  41. package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
  42. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  43. package/dist/web/standalone/.next/BUILD_ID +1 -1
  44. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  45. package/dist/web/standalone/.next/build-manifest.json +2 -2
  46. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  47. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/index.html +1 -1
  64. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  71. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  73. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  74. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  75. package/package.json +3 -3
  76. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  77. package/packages/mcp-server/dist/workflow-tools.js +22 -17
  78. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  79. package/packages/mcp-server/src/workflow-tools.test.ts +75 -2
  80. package/packages/mcp-server/src/workflow-tools.ts +30 -16
  81. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  82. package/packages/native/tsconfig.tsbuildinfo +1 -1
  83. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  84. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js +15 -0
  86. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +2 -0
  88. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/agent-session.js +12 -3
  90. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +3 -1
  92. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts +11 -0
  94. package/packages/pi-coding-agent/dist/core/compaction/compaction.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/compaction/compaction.js +9 -0
  96. package/packages/pi-coding-agent/dist/core/compaction/compaction.js.map +1 -1
  97. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts +2 -0
  98. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.d.ts.map +1 -0
  99. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js +103 -0
  100. package/packages/pi-coding-agent/dist/core/compaction-threshold.test.js.map +1 -0
  101. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +3 -0
  102. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  103. package/packages/pi-coding-agent/dist/core/extensions/runner.js +3 -0
  104. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js +2 -0
  106. package/packages/pi-coding-agent/dist/core/extensions/runner.test.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +12 -0
  108. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +20 -0
  111. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  112. package/packages/pi-coding-agent/dist/core/settings-manager.js +25 -0
  113. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  114. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +1 -0
  115. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +3 -0
  117. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  119. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +13 -5
  120. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  121. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js +53 -0
  122. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.test.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  124. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +3 -0
  125. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  126. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  127. package/packages/pi-coding-agent/src/core/agent-session-tool-refresh.test.ts +18 -0
  128. package/packages/pi-coding-agent/src/core/agent-session.ts +14 -3
  129. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +3 -1
  130. package/packages/pi-coding-agent/src/core/compaction/compaction.ts +18 -0
  131. package/packages/pi-coding-agent/src/core/compaction-threshold.test.ts +121 -0
  132. package/packages/pi-coding-agent/src/core/extensions/runner.test.ts +2 -0
  133. package/packages/pi-coding-agent/src/core/extensions/runner.ts +5 -0
  134. package/packages/pi-coding-agent/src/core/extensions/types.ts +12 -0
  135. package/packages/pi-coding-agent/src/core/settings-manager.ts +39 -1
  136. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +4 -0
  137. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts +56 -0
  138. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +22 -7
  139. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +3 -0
  140. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  141. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  142. package/packages/pi-tui/dist/tui.js +18 -8
  143. package/packages/pi-tui/dist/tui.js.map +1 -1
  144. package/packages/pi-tui/src/tui.ts +20 -8
  145. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  146. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -2
  147. package/src/resources/extensions/gsd/auto/phases.ts +85 -35
  148. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  149. package/src/resources/extensions/gsd/auto/run-unit.ts +22 -2
  150. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -1
  151. package/src/resources/extensions/gsd/auto-prompts.ts +17 -1
  152. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  153. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  154. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  155. package/src/resources/extensions/gsd/auto.ts +96 -4
  156. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  157. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +27 -19
  158. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +88 -4
  159. package/src/resources/extensions/gsd/clean-root-preflight.ts +32 -7
  160. package/src/resources/extensions/gsd/context-budget.ts +44 -2
  161. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  162. package/src/resources/extensions/gsd/db-base-schema.ts +4 -2
  163. package/src/resources/extensions/gsd/db-migration-steps.ts +8 -0
  164. package/src/resources/extensions/gsd/git-service.ts +46 -8
  165. package/src/resources/extensions/gsd/gsd-db.ts +50 -13
  166. package/src/resources/extensions/gsd/guided-flow.ts +49 -4
  167. package/src/resources/extensions/gsd/memory-store.ts +77 -12
  168. package/src/resources/extensions/gsd/migrate/command.ts +47 -1
  169. package/src/resources/extensions/gsd/migration-auto-check.ts +129 -0
  170. package/src/resources/extensions/gsd/pre-execution-checks.ts +7 -0
  171. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  172. package/src/resources/extensions/gsd/prompt-loader.ts +27 -2
  173. package/src/resources/extensions/gsd/prompts/complete-milestone.md +16 -13
  174. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +1 -1
  175. package/src/resources/extensions/gsd/prompts/quick-task.md +1 -5
  176. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  177. package/src/resources/extensions/gsd/quick.ts +37 -2
  178. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +215 -1
  179. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +56 -13
  180. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  181. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +166 -4
  182. package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +15 -6
  183. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +14 -1
  184. package/src/resources/extensions/gsd/tests/context-budget.test.ts +10 -1
  185. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +5 -1
  186. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +313 -0
  187. package/src/resources/extensions/gsd/tests/exec-history.test.ts +15 -0
  188. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +65 -0
  189. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
  190. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +239 -1
  191. package/src/resources/extensions/gsd/tests/memory-decay-factor.test.ts +90 -0
  192. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +48 -0
  193. package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +127 -0
  194. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +38 -0
  195. package/src/resources/extensions/gsd/tests/prompt-path-audit.test.ts +40 -0
  196. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +19 -0
  197. package/src/resources/extensions/gsd/tests/quick-external-gsd.test.ts +40 -0
  198. package/src/resources/extensions/gsd/tests/schema-v27-v28-sequence.test.ts +156 -0
  199. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  200. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  201. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +55 -0
  202. package/src/resources/extensions/gsd/tests/status-db-open.test.ts +9 -0
  203. package/src/resources/extensions/gsd/tests/unit-context-composer.test.ts +136 -4
  204. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  205. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  206. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +3 -0
  207. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
  208. package/src/resources/extensions/gsd/tools/context-mode-tool-result.ts +25 -0
  209. package/src/resources/extensions/gsd/tools/exec-search-tool.ts +7 -7
  210. package/src/resources/extensions/gsd/tools/exec-tool.ts +4 -23
  211. package/src/resources/extensions/gsd/tools/memory-tools.ts +1 -0
  212. package/src/resources/extensions/gsd/tools/resume-tool.ts +7 -7
  213. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +1 -1
  214. package/src/resources/extensions/gsd/unit-context-composer.ts +19 -4
  215. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  216. package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
  217. /package/dist/web/standalone/.next/static/{y73quA-XdLo9n41nxphjW → 4dQ9NTZJ8pEvFwBgDUX93}/_buildManifest.js +0 -0
  218. /package/dist/web/standalone/.next/static/{y73quA-XdLo9n41nxphjW → 4dQ9NTZJ8pEvFwBgDUX93}/_ssgManifest.js +0 -0
@@ -10,6 +10,8 @@ import {
10
10
  _resetPendingResolve,
11
11
  _hasPendingResolveForTest,
12
12
  _setActiveSession,
13
+ _setSessionSwitchInFlight,
14
+ _consumePendingSwitchCancellation,
13
15
  isSessionSwitchInFlight,
14
16
  } from "../auto/resolve.js";
15
17
  import { runUnit } from "../auto/run-unit.js";
@@ -206,6 +208,28 @@ test("runUnit returns cancelled when session creation fails", async () => {
206
208
  assert.equal(pi.calls.length, 0);
207
209
  });
208
210
 
211
+ test("runUnit clears queued switch cancellation when session creation fails", async () => {
212
+ _resetPendingResolve();
213
+
214
+ const ctx = makeMockCtx();
215
+ const pi = makeMockPi();
216
+ const s = makeMockSession({
217
+ newSessionThrows: "connection refused",
218
+ onNewSessionStart: () => {
219
+ resolveAgentEndCancelled({
220
+ message: "Claude Code process aborted by user",
221
+ category: "aborted",
222
+ isTransient: false,
223
+ });
224
+ },
225
+ });
226
+
227
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
228
+
229
+ assert.equal(result.status, "cancelled");
230
+ assert.equal(_consumePendingSwitchCancellation(), null);
231
+ });
232
+
209
233
  test("runUnit returns cancelled when session creation times out", async () => {
210
234
  _resetPendingResolve();
211
235
 
@@ -221,6 +245,34 @@ test("runUnit returns cancelled when session creation times out", async () => {
221
245
  assert.equal(pi.calls.length, 0);
222
246
  });
223
247
 
248
+ test("runUnit consumes a cancellation queued during session switch before dispatch", async () => {
249
+ _resetPendingResolve();
250
+
251
+ const ctx = makeMockCtx();
252
+ const pi = makeMockPi();
253
+ let cancellationQueued = false;
254
+ const s = makeMockSession({
255
+ newSessionDelayMs: 10,
256
+ onNewSessionStart: () => {
257
+ setTimeout(() => {
258
+ cancellationQueued = !resolveAgentEndCancelled({
259
+ message: "Claude Code process aborted by user",
260
+ category: "aborted",
261
+ isTransient: false,
262
+ });
263
+ }, 0);
264
+ },
265
+ });
266
+
267
+ const result = await runUnit(ctx, pi, s, "plan-slice", "M009/S01", "prompt");
268
+
269
+ assert.equal(cancellationQueued, true);
270
+ assert.equal(result.status, "cancelled");
271
+ assert.equal(result.errorContext?.category, "aborted");
272
+ assert.equal(result.errorContext?.message, "Claude Code process aborted by user");
273
+ assert.equal(pi.calls.length, 0, "queued switch cancellation must prevent prompt dispatch");
274
+ });
275
+
224
276
  test("runUnit keeps the session-switch guard across a late newSession settlement", async () => {
225
277
  _resetPendingResolve();
226
278
  mock.timers.enable();
@@ -637,7 +689,11 @@ function makeMockDeps(
637
689
  resolveMilestoneFile: () => null,
638
690
  reconcileMergeState: () => "clean",
639
691
  preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
640
- postflightPopStash: () => {},
692
+ postflightPopStash: () => ({
693
+ restored: true,
694
+ needsManualRecovery: false,
695
+ message: "restored",
696
+ }),
641
697
  getLedger: () => null,
642
698
  getProjectTotals: () => ({ cost: 0 }),
643
699
  formatCost: (c: number) => `$${c.toFixed(2)}`,
@@ -805,6 +861,145 @@ test("autoLoop exits on terminal complete state", async (t) => {
805
861
  );
806
862
  });
807
863
 
864
+ test("autoLoop stops before success notification when postflight stash restore needs recovery", async () => {
865
+ _resetPendingResolve();
866
+
867
+ const notifications: Array<{ msg: string; level: string }> = [];
868
+ const ctx = makeMockCtx();
869
+ ctx.ui.setStatus = () => {};
870
+ ctx.ui.notify = (msg: string, level: string) => {
871
+ notifications.push({ msg, level });
872
+ };
873
+ const pi = makeMockPi();
874
+ const s = makeLoopSession();
875
+ let stopReason = "";
876
+
877
+ const deps = makeMockDeps({
878
+ deriveState: async () => {
879
+ deps.callLog.push("deriveState");
880
+ return {
881
+ phase: "complete",
882
+ activeMilestone: { id: "M001", title: "Test", status: "complete" },
883
+ activeSlice: null,
884
+ activeTask: null,
885
+ registry: [{ id: "M001", status: "complete" }],
886
+ blockers: [],
887
+ } as any;
888
+ },
889
+ preflightCleanRoot: () => ({
890
+ stashPushed: true,
891
+ stashMarker: "gsd-preflight-stash:M001:test",
892
+ summary: "stashed",
893
+ }),
894
+ postflightPopStash: () => ({
895
+ restored: false,
896
+ needsManualRecovery: true,
897
+ message: "git stash pop stash@{0} failed after merge of milestone M001",
898
+ stashRef: "stash@{0}",
899
+ }),
900
+ sendDesktopNotification: () => {
901
+ deps.callLog.push("sendDesktopNotification");
902
+ },
903
+ logCmuxEvent: () => {
904
+ deps.callLog.push("logCmuxEvent");
905
+ },
906
+ stopAuto: async (_ctx, _pi, reason) => {
907
+ deps.callLog.push("stopAuto");
908
+ stopReason = reason ?? "";
909
+ },
910
+ });
911
+
912
+ await autoLoop(ctx, pi, s, deps);
913
+
914
+ assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
915
+ assert.ok(
916
+ notifications.some(
917
+ (n) => n.level === "error" && n.msg.includes("Post-merge stash restore failed for milestone M001"),
918
+ ),
919
+ "failed postflight restore must be surfaced as an error",
920
+ );
921
+ assert.ok(
922
+ !deps.callLog.includes("sendDesktopNotification"),
923
+ "must not emit milestone success desktop notification after stash restore failure",
924
+ );
925
+ assert.ok(
926
+ !deps.callLog.includes("logCmuxEvent"),
927
+ "must not emit milestone success cmux event after stash restore failure",
928
+ );
929
+ });
930
+
931
+ test("autoLoop marks transition merge complete before postflight recovery stop", async () => {
932
+ _resetPendingResolve();
933
+
934
+ const ctx = makeMockCtx();
935
+ ctx.ui.setStatus = () => {};
936
+ ctx.ui.notify = () => {};
937
+ const pi = makeMockPi();
938
+ const s = makeLoopSession();
939
+ let mergeCalls = 0;
940
+ let stopReason = "";
941
+
942
+ const deps = makeMockDeps({
943
+ deriveState: async () => {
944
+ deps.callLog.push("deriveState");
945
+ return {
946
+ phase: "executing",
947
+ activeMilestone: { id: "M002", title: "Next", status: "active" },
948
+ activeSlice: null,
949
+ activeTask: null,
950
+ registry: [
951
+ { id: "M001", title: "Done", status: "complete" },
952
+ { id: "M002", title: "Next", status: "active" },
953
+ ],
954
+ blockers: [],
955
+ } as any;
956
+ },
957
+ preflightCleanRoot: () => ({
958
+ stashPushed: true,
959
+ stashMarker: "gsd-preflight-stash:M001:test",
960
+ summary: "stashed",
961
+ }),
962
+ postflightPopStash: () => ({
963
+ restored: false,
964
+ needsManualRecovery: true,
965
+ message: "git stash pop stash@{0} failed after merge of milestone M001",
966
+ stashRef: "stash@{0}",
967
+ }),
968
+ resolver: {
969
+ get workPath() {
970
+ return "/tmp/project";
971
+ },
972
+ get projectRoot() {
973
+ return "/tmp/project";
974
+ },
975
+ get lockPath() {
976
+ return "/tmp/project";
977
+ },
978
+ enterMilestone: () => {
979
+ assert.fail("must not enter the next milestone after postflight recovery fails");
980
+ },
981
+ exitMilestone: () => {},
982
+ mergeAndExit: () => {
983
+ mergeCalls += 1;
984
+ },
985
+ mergeAndEnterNext: () => {},
986
+ } as any,
987
+ stopAuto: async (_ctx, _pi, reason) => {
988
+ deps.callLog.push("stopAuto");
989
+ stopReason = reason ?? "";
990
+ if (!s.milestoneMergedInPhases) {
991
+ deps.resolver.mergeAndExit("M001", ctx.ui);
992
+ }
993
+ },
994
+ });
995
+
996
+ await autoLoop(ctx, pi, s, deps);
997
+
998
+ assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
999
+ assert.equal(s.milestoneMergedInPhases, true);
1000
+ assert.equal(mergeCalls, 1, "postflight recovery stop must not re-run an already completed transition merge");
1001
+ });
1002
+
808
1003
  test("autoLoop pauses when provider readiness cancels before dispatch", async () => {
809
1004
  _resetPendingResolve();
810
1005
 
@@ -2089,6 +2284,25 @@ test("resolveAgentEndCancelled without args produces no errorContext field", asy
2089
2284
  assert.equal(resolved.errorContext, undefined, "errorContext must not be present when no args passed");
2090
2285
  });
2091
2286
 
2287
+ test("resolveAgentEndCancelled queues cancellation that arrives during session switch", () => {
2288
+ _resetPendingResolve();
2289
+
2290
+ _setSessionSwitchInFlight(true);
2291
+ const resolved = resolveAgentEndCancelled({
2292
+ message: "Claude Code process aborted by user",
2293
+ category: "aborted",
2294
+ isTransient: false,
2295
+ });
2296
+
2297
+ assert.equal(resolved, false);
2298
+ const pending = _consumePendingSwitchCancellation();
2299
+ assert.ok(pending?.errorContext, "queued cancellation should preserve errorContext");
2300
+ assert.equal(pending.errorContext.category, "aborted");
2301
+ assert.equal(pending.errorContext.message, "Claude Code process aborted by user");
2302
+ assert.equal(_consumePendingSwitchCancellation(), null);
2303
+ _resetPendingResolve();
2304
+ });
2305
+
2092
2306
  // ─── #1571: artifact verification retry ──────────────────────────────────────
2093
2307
 
2094
2308
  test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", async () => {
@@ -1,17 +1,20 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
3
6
 
4
7
  import { runFinalize } from "../auto/phases.ts";
5
8
  import { AutoSession } from "../auto/session.ts";
9
+ import { readUnitRuntimeRecord, writeUnitRuntimeRecord } from "../unit-runtime.ts";
6
10
 
7
- test("runFinalize clears currentUnit after successful finalize", async () => {
8
- const s = new AutoSession();
9
- s.basePath = "/tmp/gsd-finalize-current-unit";
10
- s.currentUnit = {
11
- type: "execute-task",
12
- id: "M001/S01/T01",
13
- startedAt: Date.now(),
14
- };
11
+ async function runSuccessfulFinalize(s: AutoSession) {
12
+ const unit = s.currentUnit;
13
+ assert.ok(unit, "test setup must provide currentUnit");
14
+
15
+ writeUnitRuntimeRecord(s.basePath, unit.type, unit.id, unit.startedAt, {
16
+ phase: "dispatched",
17
+ });
15
18
 
16
19
  const deps = {
17
20
  clearUnitTimeout() {},
@@ -26,7 +29,7 @@ test("runFinalize clears currentUnit after successful finalize", async () => {
26
29
  postUnitPostVerification: async () => "continue",
27
30
  };
28
31
 
29
- const result = await runFinalize(
32
+ return runFinalize(
30
33
  {
31
34
  ctx: { ui: { notify() {} } },
32
35
  pi: {},
@@ -38,8 +41,8 @@ test("runFinalize clears currentUnit after successful finalize", async () => {
38
41
  nextSeq: () => 1,
39
42
  } as any,
40
43
  {
41
- unitType: "execute-task",
42
- unitId: "M001/S01/T01",
44
+ unitType: unit.type,
45
+ unitId: unit.id,
43
46
  prompt: "",
44
47
  finalPrompt: "",
45
48
  pauseAfterUatDispatch: false,
@@ -55,7 +58,47 @@ test("runFinalize clears currentUnit after successful finalize", async () => {
55
58
  consecutiveFinalizeTimeouts: 0,
56
59
  },
57
60
  );
61
+ }
62
+
63
+ test("runFinalize clears currentUnit after successful finalize", async () => {
64
+ const base = mkdtempSync(join(tmpdir(), "gsd-finalize-current-unit-"));
65
+ const s = new AutoSession();
66
+ s.basePath = base;
67
+ s.currentUnit = {
68
+ type: "execute-task",
69
+ id: "M001/S01/T01",
70
+ startedAt: Date.now(),
71
+ };
72
+
73
+ try {
74
+ const result = await runSuccessfulFinalize(s);
75
+
76
+ assert.equal(result.action, "next");
77
+ assert.equal(s.currentUnit, null);
78
+ } finally {
79
+ rmSync(base, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test("runFinalize marks unit runtime finalized after successful finalize", async () => {
84
+ const base = mkdtempSync(join(tmpdir(), "gsd-finalize-runtime-"));
85
+ const s = new AutoSession();
86
+ const startedAt = Date.now();
87
+ s.basePath = base;
88
+ s.currentUnit = {
89
+ type: "complete-milestone",
90
+ id: "M001",
91
+ startedAt,
92
+ };
93
+
94
+ try {
95
+ const result = await runSuccessfulFinalize(s);
96
+ const runtime = readUnitRuntimeRecord(base, "complete-milestone", "M001");
58
97
 
59
- assert.equal(result.action, "next");
60
- assert.equal(s.currentUnit, null);
98
+ assert.equal(result.action, "next");
99
+ assert.equal(runtime?.phase, "finalized");
100
+ assert.equal(runtime?.lastProgressKind, "finalize-success");
101
+ } finally {
102
+ rmSync(base, { recursive: true, force: true });
103
+ }
61
104
  });
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
- import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps, writeBlockerPlaceholder } from "../auto-recovery.ts";
8
+ import { verifyExpectedArtifact, hasImplementationArtifacts, resolveExpectedArtifactPath, diagnoseExpectedArtifact, buildLoopRemediationSteps, writeBlockerPlaceholder, refreshRecoveryDbForArtifact } from "../auto-recovery.ts";
9
9
  import { resolveMilestoneFile } from "../paths.ts";
10
10
  import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertGateRow, insertTask, getMilestoneCommitAttributionShas } from "../gsd-db.ts";
11
11
  import { clearParseCache } from "../files.ts";
@@ -175,6 +175,19 @@ test("resolveExpectedArtifactPath returns correct path for all slice-level types
175
175
  }
176
176
  });
177
177
 
178
+ test("refreshRecoveryDbForArtifact treats missing execute-task DB rows as fatal mismatches", () => {
179
+ makeTmpProject();
180
+
181
+ const result = refreshRecoveryDbForArtifact("execute-task", "M001/S01/T01");
182
+
183
+ assert.deepEqual(result, {
184
+ ok: false,
185
+ fatal: true,
186
+ reason: "execute-task-artifact-db-missing",
187
+ message: "Stuck recovery found execute-task M001/S01/T01 artifacts, but no matching DB task row exists after refresh.",
188
+ });
189
+ });
190
+
178
191
  // ─── diagnoseExpectedArtifact ─────────────────────────────────────────────
179
192
 
180
193
  test("diagnoseExpectedArtifact returns description for known types", () => {
@@ -3,8 +3,14 @@
3
3
 
4
4
  import { describe, test } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { readFileSync } from "node:fs";
6
+ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+
10
+ import { autoSession } from "../auto-runtime-state.ts";
11
+ import { dispatchHookUnit } from "../auto.ts";
12
+ import { registerHooks } from "../bootstrap/register-hooks.ts";
13
+ import { clearDiscussionFlowState, getPendingGate } from "../bootstrap/write-gate.ts";
8
14
 
9
15
  const autoTimersPath = join(import.meta.dirname, "..", "auto-timers.ts");
10
16
  const autoTimersSrc = readFileSync(autoTimersPath, "utf-8");
@@ -18,6 +24,37 @@ const runUnitSrc = readFileSync(runUnitPath, "utf-8");
18
24
  const registerHooksPath = join(import.meta.dirname, "..", "bootstrap", "register-hooks.ts");
19
25
  const registerHooksSrc = readFileSync(registerHooksPath, "utf-8");
20
26
 
27
+ function makeHookHarness() {
28
+ const handlers = new Map<string, Array<(event: any, ctx: any) => Promise<any>>>();
29
+ const pi = {
30
+ on(name: string, handler: (event: any, ctx: any) => Promise<any>) {
31
+ const current = handlers.get(name) ?? [];
32
+ current.push(handler);
33
+ handlers.set(name, current);
34
+ },
35
+ };
36
+ const ctx = {
37
+ ui: {
38
+ notify: () => {},
39
+ setStatus: () => {},
40
+ setWidget: () => {},
41
+ },
42
+ modelRegistry: {
43
+ setDisabledModelProviders: () => {},
44
+ },
45
+ setCompactionThresholdOverride: () => {},
46
+ };
47
+ async function emit(name: string, event: any): Promise<any> {
48
+ for (const handler of handlers.get(name) ?? []) {
49
+ const result = await handler(event, ctx);
50
+ if (result?.block) return result;
51
+ }
52
+ return undefined;
53
+ }
54
+ registerHooks(pi as any, []);
55
+ return { emit };
56
+ }
57
+
21
58
  describe("#3512: gsd-auto-wrapup must not interrupt in-flight tool calls", () => {
22
59
  test("soft timeout wrapup gates triggerTurn on getInFlightToolCount() === 0", () => {
23
60
  // The soft timeout sendMessage must NOT use a hardcoded `triggerTurn: true`.
@@ -73,6 +110,61 @@ describe("#3512: gsd-auto-wrapup must not interrupt in-flight tool calls", () =>
73
110
  });
74
111
  });
75
112
 
113
+ describe("hook dispatch session cwd", () => {
114
+ test("dispatchHookUnit passes basePath explicitly to newSession", async (t) => {
115
+ const originalCwd = process.cwd();
116
+ const basePath = mkdtempSync(join(tmpdir(), "gsd-hook-cwd-"));
117
+ mkdirSync(join(basePath, ".gsd"), { recursive: true });
118
+ autoSession.reset();
119
+ t.after(() => {
120
+ try {
121
+ process.chdir(originalCwd);
122
+ } catch {
123
+ // best effort cleanup after cwd-sensitive dispatch tests
124
+ }
125
+ autoSession.reset();
126
+ rmSync(basePath, { recursive: true, force: true });
127
+ });
128
+
129
+ let newSessionOptions: unknown;
130
+ const ctx = {
131
+ ui: {
132
+ notify: () => {},
133
+ setStatus: () => {},
134
+ setWidget: () => {},
135
+ },
136
+ modelRegistry: {
137
+ getAvailable: () => [],
138
+ },
139
+ sessionManager: {
140
+ getSessionFile: () => join(basePath, "session.jsonl"),
141
+ },
142
+ newSession: async (options?: unknown) => {
143
+ newSessionOptions = options;
144
+ return { cancelled: false };
145
+ },
146
+ };
147
+ const pi = {
148
+ sendMessage: () => {},
149
+ setModel: async () => true,
150
+ };
151
+
152
+ const dispatched = await dispatchHookUnit(
153
+ ctx as any,
154
+ pi as any,
155
+ "review",
156
+ "execute-task",
157
+ "M001/S01/T01",
158
+ "review the completed unit",
159
+ undefined,
160
+ basePath,
161
+ );
162
+
163
+ assert.equal(dispatched, true);
164
+ assert.deepEqual(newSessionOptions, { cwd: basePath });
165
+ });
166
+ });
167
+
76
168
  describe("#4276: pending/skipped tools stay visible to auto-mode hooks", () => {
77
169
  test("tool_call handler marks GSD tools in-flight before execution_start", () => {
78
170
  const startMarker = 'pi.on("tool_call", async (event, ctx) => {';
@@ -193,7 +285,7 @@ describe("#4365: tool_execution_start hook must pass toolName to markToolStart",
193
285
  });
194
286
 
195
287
  describe("deep setup approval questions pause immediately", () => {
196
- test("register-hooks sets pending gate during message_update without aborting the stream", () => {
288
+ test("register-hooks defers the pending gate during message_update without aborting the stream", () => {
197
289
  const startMarker = 'pi.on("message_update"';
198
290
  const endMarker = 'pi.on("session_shutdown"';
199
291
  const messageUpdateSection = registerHooksSrc.slice(
@@ -210,8 +302,8 @@ describe("deep setup approval questions pause immediately", () => {
210
302
  "message_update must detect approval/question boundaries",
211
303
  );
212
304
  assert.ok(
213
- messageUpdateSection.includes("approvalGateIdForUnit") && messageUpdateSection.includes("setPendingGate"),
214
- "plain-text approval questions must set the durable write gate",
305
+ messageUpdateSection.includes("approvalGateIdForUnit") && messageUpdateSection.includes("deferApprovalGate"),
306
+ "plain-text approval questions must defer the durable write gate until same-turn draft persistence can finish",
215
307
  );
216
308
  assert.ok(
217
309
  messageUpdateSection.includes("getDiscussionMilestoneIdFor") && messageUpdateSection.includes('"discuss-milestone"'),
@@ -222,4 +314,74 @@ describe("deep setup approval questions pause immediately", () => {
222
314
  "message_update must NOT abort the stream — aborting eats the model's question text on external CLI providers; the pending gate set above blocks subsequent tool calls instead",
223
315
  );
224
316
  });
317
+
318
+ test("plain-text approval boundary defers durable gate until same-turn CONTEXT-DRAFT can save", async () => {
319
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-deferred-approval-")));
320
+ const previousCwd = process.cwd();
321
+ try {
322
+ mkdirSync(join(base, ".gsd", "milestones", "M003"), { recursive: true });
323
+ process.chdir(base);
324
+ clearDiscussionFlowState(base);
325
+ autoSession.reset();
326
+ autoSession.basePath = base;
327
+ autoSession.currentUnit = {
328
+ type: "discuss-milestone",
329
+ id: "M003",
330
+ startedAt: Date.now(),
331
+ };
332
+
333
+ const { emit } = makeHookHarness();
334
+ await emit("message_update", {
335
+ message: {
336
+ role: "assistant",
337
+ content: [{ type: "text", text: "Did I capture that correctly? If not, tell me what I missed." }],
338
+ },
339
+ });
340
+
341
+ assert.equal(
342
+ getPendingGate(base),
343
+ null,
344
+ "approval text should not install the durable pending gate until the assistant turn ends",
345
+ );
346
+
347
+ const draftResult = await emit("tool_call", {
348
+ toolCallId: "draft-save",
349
+ toolName: "gsd_summary_save",
350
+ input: {
351
+ milestone_id: "M003",
352
+ artifact_type: "CONTEXT-DRAFT",
353
+ content: "# M003 Draft\n",
354
+ },
355
+ });
356
+ assert.equal(
357
+ draftResult?.block,
358
+ undefined,
359
+ "same-turn CONTEXT-DRAFT persistence should remain allowed after the approval text streams",
360
+ );
361
+
362
+ const finalContextResult = await emit("tool_call", {
363
+ toolCallId: "final-context",
364
+ toolName: "gsd_summary_save",
365
+ input: {
366
+ milestone_id: "M003",
367
+ artifact_type: "CONTEXT",
368
+ content: "# M003 Context\n",
369
+ },
370
+ });
371
+ assert.equal(finalContextResult?.block, true, "final CONTEXT must still wait for approval");
372
+ assert.match(finalContextResult.reason, /Approval question "depth_verification_M003_confirm"/);
373
+
374
+ await emit("agent_end", { messages: [] });
375
+ assert.equal(
376
+ getPendingGate(base),
377
+ "depth_verification_M003_confirm",
378
+ "agent_end should activate the durable pending gate for the next turn",
379
+ );
380
+ } finally {
381
+ process.chdir(previousCwd);
382
+ autoSession.reset();
383
+ clearDiscussionFlowState(base);
384
+ rmSync(base, { recursive: true, force: true });
385
+ }
386
+ });
225
387
  });
@@ -131,9 +131,11 @@ test("postflightPopStash — restores stashed changes and emits info notificatio
131
131
  run('git commit -m "simulate merge"', repo);
132
132
 
133
133
  const postNotifications: Array<{ msg: string; level: string }> = [];
134
- postflightPopStash(repo, "M004", preflight.stashMarker, (msg, level) => {
134
+ const postflight = postflightPopStash(repo, "M004", preflight.stashMarker, (msg, level) => {
135
135
  postNotifications.push({ msg, level });
136
136
  });
137
+ assert.equal(postflight.restored, true, "postflight must report successful restore");
138
+ assert.equal(postflight.needsManualRecovery, false, "successful restore must not need manual recovery");
137
139
 
138
140
  // The stashed README.md change must be restored
139
141
  const content = readFileSync(join(repo, "README.md"), "utf-8");
@@ -171,7 +173,8 @@ test("preflight + merge + postflight round-trip preserves uncommitted changes",
171
173
  run('git commit -m "feat: add feature"', repo);
172
174
 
173
175
  // Postflight: pop stash
174
- postflightPopStash(repo, "M005", preflight.stashMarker, () => {});
176
+ const postflight = postflightPopStash(repo, "M005", preflight.stashMarker, () => {});
177
+ assert.equal(postflight.needsManualRecovery, false, "clean restore must not stop auto-mode");
175
178
 
176
179
  // README.md must still have our local content
177
180
  const restored = readFileSync(join(repo, "README.md"), "utf-8");
@@ -197,9 +200,12 @@ test("postflightPopStash conflict warning names the exact stash ref", () => {
197
200
  run('git commit -m "simulate conflicting merge"', repo);
198
201
 
199
202
  const notifications: Array<{ msg: string; level: string }> = [];
200
- postflightPopStash(repo, "M005C", preflight.stashMarker, (msg, level) => {
203
+ const postflight = postflightPopStash(repo, "M005C", preflight.stashMarker, (msg, level) => {
201
204
  notifications.push({ msg, level });
202
205
  });
206
+ assert.equal(postflight.restored, false, "conflicted restore must report restored=false");
207
+ assert.equal(postflight.needsManualRecovery, true, "conflicted restore must require manual recovery");
208
+ assert.match(postflight.message, /failed after merge of milestone M005C/);
203
209
 
204
210
  const warning = notifications.find((n) => n.level === "warning")?.msg ?? "";
205
211
  assert.match(warning, /git stash pop stash@\{\d+\}/);
@@ -219,7 +225,8 @@ test("postflightPopStash restores the matching GSD stash, not stash@{0}", () =>
219
225
  writeFileSync(join(repo, "other.txt"), "other stash\n");
220
226
  run('git stash push --include-untracked -m "unrelated newer stash"', repo);
221
227
 
222
- postflightPopStash(repo, "M006", preflight.stashMarker, () => {});
228
+ const postflight = postflightPopStash(repo, "M006", preflight.stashMarker, () => {});
229
+ assert.equal(postflight.needsManualRecovery, false, "targeted restore must not need manual recovery");
223
230
 
224
231
  const content = readFileSync(join(repo, "README.md"), "utf-8");
225
232
  assert.equal(content.replace(/\r\n/g, "\n"), "# target stash\n");
@@ -242,7 +249,8 @@ test("postflightPopStash restores the exact preflight marker when another same-m
242
249
  writeFileSync(join(repo, "same-milestone.txt"), "newer same milestone stash\n");
243
250
  run('git stash push --include-untracked -m "gsd-preflight-stash [gsd-preflight-stash:M007:other]"', repo);
244
251
 
245
- postflightPopStash(repo, "M007", preflight.stashMarker, () => {});
252
+ const postflight = postflightPopStash(repo, "M007", preflight.stashMarker, () => {});
253
+ assert.equal(postflight.needsManualRecovery, false, "exact marker restore must not need manual recovery");
246
254
 
247
255
  const content = readFileSync(join(repo, "README.md"), "utf-8");
248
256
  assert.equal(content.replace(/\r\n/g, "\n"), "# target stash\n");
@@ -260,7 +268,8 @@ test("postflightPopStash falls back to milestone marker prefix when exact marker
260
268
  writeFileSync(join(repo, "README.md"), "# fallback stash\n");
261
269
  run('git stash push --include-untracked -m "gsd-preflight-stash [gsd-preflight-stash:M008:fallback]"', repo);
262
270
 
263
- postflightPopStash(repo, "M008", undefined, () => {});
271
+ const postflight = postflightPopStash(repo, "M008", undefined, () => {});
272
+ assert.equal(postflight.needsManualRecovery, false, "fallback marker restore must not need manual recovery");
264
273
 
265
274
  const content = readFileSync(join(repo, "README.md"), "utf-8");
266
275
  assert.equal(content.replace(/\r\n/g, "\n"), "# fallback stash\n");