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
@@ -70,6 +70,7 @@ function makeDeps(
70
70
  getAutoWorktreePath: () => null,
71
71
  autoCommitCurrentBranch: () => undefined,
72
72
  getCurrentBranch: () => "worktree/M001",
73
+ checkoutBranch: () => undefined,
73
74
  autoWorktreeBranch: (mid: string) => `worktree/${mid}`,
74
75
  resolveMilestoneFile: () => null, // no roadmap → early return path
75
76
  readFileSync: () => "",
@@ -0,0 +1,242 @@
1
+ // GSD-2 + src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts
2
+ // Regression: postflight stash pop must run even when mergeAndExit throws.
3
+
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import { _runMilestoneMergeWithStashRestore } from "../auto/phases.js";
8
+ import type { IterationContext } from "../auto/types.js";
9
+ import { MergeConflictError } from "../git-service.js";
10
+ import type {
11
+ PostflightResult,
12
+ PreflightResult,
13
+ } from "../clean-root-preflight.js";
14
+
15
+ interface CallLog {
16
+ preflightCalls: number;
17
+ mergeCalls: number;
18
+ postflightCalls: number;
19
+ stopAutoCalls: Array<string | undefined>;
20
+ notifyCalls: Array<{ message: string; level: string }>;
21
+ milestoneMergedInPhases: boolean;
22
+ }
23
+
24
+ function buildIc(opts: {
25
+ preflightResult: PreflightResult;
26
+ mergeBehavior: "succeed" | (() => never);
27
+ postflightResult: PostflightResult;
28
+ }): { ic: IterationContext; log: CallLog } {
29
+ const log: CallLog = {
30
+ preflightCalls: 0,
31
+ mergeCalls: 0,
32
+ postflightCalls: 0,
33
+ stopAutoCalls: [],
34
+ notifyCalls: [],
35
+ milestoneMergedInPhases: false,
36
+ };
37
+
38
+ const session = {
39
+ basePath: "/tmp/proj",
40
+ originalBasePath: "/tmp/proj",
41
+ get milestoneMergedInPhases() {
42
+ return log.milestoneMergedInPhases;
43
+ },
44
+ set milestoneMergedInPhases(v: boolean) {
45
+ log.milestoneMergedInPhases = v;
46
+ },
47
+ };
48
+
49
+ const ctx = {
50
+ ui: {
51
+ notify: (message: string, level: string) => {
52
+ log.notifyCalls.push({ message, level });
53
+ },
54
+ },
55
+ };
56
+
57
+ const deps = {
58
+ preflightCleanRoot: () => {
59
+ log.preflightCalls += 1;
60
+ return opts.preflightResult;
61
+ },
62
+ postflightPopStash: () => {
63
+ log.postflightCalls += 1;
64
+ return opts.postflightResult;
65
+ },
66
+ resolver: {
67
+ mergeAndExit: () => {
68
+ log.mergeCalls += 1;
69
+ if (opts.mergeBehavior !== "succeed") {
70
+ opts.mergeBehavior();
71
+ }
72
+ },
73
+ },
74
+ stopAuto: async (_c?: unknown, _p?: unknown, reason?: string) => {
75
+ log.stopAutoCalls.push(reason);
76
+ },
77
+ };
78
+
79
+ const ic = {
80
+ ctx,
81
+ pi: {},
82
+ s: session,
83
+ deps,
84
+ } as unknown as IterationContext;
85
+
86
+ return { ic, log };
87
+ }
88
+
89
+ const STASH_PUSHED: PreflightResult = {
90
+ stashPushed: true,
91
+ stashMarker: "gsd-preflight-stash:M002:42:1700000000000:abc",
92
+ summary: "Stashed uncommitted changes before merge (milestone M002).",
93
+ };
94
+
95
+ const STASH_NOT_PUSHED: PreflightResult = {
96
+ stashPushed: false,
97
+ summary: "",
98
+ };
99
+
100
+ const POP_OK: PostflightResult = {
101
+ restored: true,
102
+ needsManualRecovery: false,
103
+ message: "Restored stashed changes after milestone M002 merge.",
104
+ stashRef: "stash@{0}",
105
+ };
106
+
107
+ const POP_NEEDS_RECOVERY: PostflightResult = {
108
+ restored: false,
109
+ needsManualRecovery: true,
110
+ message: "git stash pop stash@{0} failed: conflict in lib/models.ts",
111
+ };
112
+
113
+ test("happy path: merge succeeds and stash is popped", async () => {
114
+ const { ic, log } = buildIc({
115
+ preflightResult: STASH_PUSHED,
116
+ mergeBehavior: "succeed",
117
+ postflightResult: POP_OK,
118
+ });
119
+
120
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
121
+
122
+ assert.equal(result, null, "happy path returns null (loop continues)");
123
+ assert.equal(log.preflightCalls, 1);
124
+ assert.equal(log.mergeCalls, 1);
125
+ assert.equal(log.postflightCalls, 1, "postflight pop must run on success");
126
+ assert.equal(log.stopAutoCalls.length, 0, "no stopAuto on happy path");
127
+ assert.equal(log.milestoneMergedInPhases, true, "merge flag set");
128
+ });
129
+
130
+ test("regression #5538-followup: postflight pop runs even when mergeAndExit throws non-conflict error", async () => {
131
+ // The original bug: when mergeAndExit threw, the catch block called
132
+ // stopAuto + return break BEFORE postflight pop ran. The user's
133
+ // gsd-preflight-stash:M00x stash was orphaned. This test exercises that
134
+ // exact scenario and asserts the pop is now invoked.
135
+ const { ic, log } = buildIc({
136
+ preflightResult: STASH_PUSHED,
137
+ mergeBehavior: () => {
138
+ throw new Error("native git merge failed: index lock present");
139
+ },
140
+ postflightResult: POP_OK,
141
+ });
142
+
143
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
144
+
145
+ assert.deepEqual(result, { action: "break", reason: "merge-failed" });
146
+ assert.equal(log.mergeCalls, 1);
147
+ assert.equal(
148
+ log.postflightCalls,
149
+ 1,
150
+ "postflight pop must run even on merge failure (was the bug)",
151
+ );
152
+ assert.equal(log.stopAutoCalls.length, 1);
153
+ assert.match(log.stopAutoCalls[0] ?? "", /Merge error on milestone M002/);
154
+ assert.equal(
155
+ log.milestoneMergedInPhases,
156
+ false,
157
+ "merge flag must NOT be set when merge throws",
158
+ );
159
+ });
160
+
161
+ test("regression #5538-followup: postflight pop runs even when mergeAndExit throws MergeConflictError", async () => {
162
+ const { ic, log } = buildIc({
163
+ preflightResult: STASH_PUSHED,
164
+ mergeBehavior: () => {
165
+ throw new MergeConflictError(
166
+ ["lib/models.ts", "app/page.tsx"],
167
+ "squash",
168
+ "milestone/M002",
169
+ "main",
170
+ );
171
+ },
172
+ postflightResult: POP_OK,
173
+ });
174
+
175
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
176
+
177
+ assert.deepEqual(result, { action: "break", reason: "merge-conflict" });
178
+ assert.equal(log.mergeCalls, 1);
179
+ assert.equal(
180
+ log.postflightCalls,
181
+ 1,
182
+ "postflight pop must run on merge conflict (was the bug)",
183
+ );
184
+ assert.equal(log.stopAutoCalls.length, 1);
185
+ assert.match(log.stopAutoCalls[0] ?? "", /Merge conflict on milestone M002/);
186
+ });
187
+
188
+ test("clean tree: no stash to pop, merge succeeds, no pop attempted", async () => {
189
+ const { ic, log } = buildIc({
190
+ preflightResult: STASH_NOT_PUSHED,
191
+ mergeBehavior: "succeed",
192
+ postflightResult: POP_OK,
193
+ });
194
+
195
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
196
+
197
+ assert.equal(result, null);
198
+ assert.equal(log.postflightCalls, 0, "no pop when nothing was stashed");
199
+ assert.equal(log.milestoneMergedInPhases, true);
200
+ });
201
+
202
+ test("merge succeeds but stash pop needs manual recovery -> postflight-stash-restore-failed break", async () => {
203
+ const { ic, log } = buildIc({
204
+ preflightResult: STASH_PUSHED,
205
+ mergeBehavior: "succeed",
206
+ postflightResult: POP_NEEDS_RECOVERY,
207
+ });
208
+
209
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
210
+
211
+ assert.deepEqual(result, {
212
+ action: "break",
213
+ reason: "postflight-stash-restore-failed",
214
+ });
215
+ assert.equal(log.postflightCalls, 1);
216
+ assert.equal(log.stopAutoCalls.length, 1);
217
+ assert.match(
218
+ log.stopAutoCalls[0] ?? "",
219
+ /Post-merge stash restore failed for milestone M002/,
220
+ );
221
+ });
222
+
223
+ test("merge error is reported even when stash pop also failed (merge-error takes priority)", async () => {
224
+ const { ic, log } = buildIc({
225
+ preflightResult: STASH_PUSHED,
226
+ mergeBehavior: () => {
227
+ throw new Error("network unreachable during push");
228
+ },
229
+ postflightResult: POP_NEEDS_RECOVERY,
230
+ });
231
+
232
+ const result = await _runMilestoneMergeWithStashRestore(ic, "M002");
233
+
234
+ assert.deepEqual(result, { action: "break", reason: "merge-failed" });
235
+ assert.equal(log.postflightCalls, 1, "postflight pop still attempted");
236
+ assert.equal(log.stopAutoCalls.length, 1, "stopAuto called once, not twice");
237
+ assert.match(
238
+ log.stopAutoCalls[0] ?? "",
239
+ /Merge error/,
240
+ "stopAuto message reflects merge error, not stash failure",
241
+ );
242
+ });
@@ -12,11 +12,18 @@
12
12
 
13
13
  import { describe, test, beforeEach, afterEach } from "node:test";
14
14
  import assert from "node:assert/strict";
15
- import { chmodSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
15
+ import { chmodSync, existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { tmpdir } from "node:os";
18
18
  import { execFileSync } from "node:child_process";
19
- import { nativeIsRepo, nativeCommit, nativeResetHard, nativeBranchDelete } from "../native-git-bridge.js";
19
+ import {
20
+ assertWorktreeMaterialized,
21
+ nativeBranchDelete,
22
+ nativeCommit,
23
+ nativeIsRepo,
24
+ nativeResetHard,
25
+ nativeWorktreeAdd,
26
+ } from "../native-git-bridge.js";
20
27
 
21
28
  // Note: prior static-analysis tests that scanned native-git-bridge.ts for
22
29
  // the raw shell-spawn pattern were removed under #4827 — the integration
@@ -115,4 +122,29 @@ describe("native-git-bridge #4180: fallback runtime behaviour", () => {
115
122
  /GSD_GIT_ERROR|git branch -D does-not-exist failed/,
116
123
  );
117
124
  });
125
+
126
+ test("assertWorktreeMaterialized rejects directories without a .git file", (t) => {
127
+ const dir = mkdtempSync(join(tmpdir(), "ngb-worktree-missing-git-"));
128
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
129
+
130
+ assert.throws(
131
+ () => assertWorktreeMaterialized(dir),
132
+ /missing \.git file/,
133
+ );
134
+ });
135
+
136
+ test("nativeWorktreeAdd materializes a valid .git marker", (t) => {
137
+ const wtPath = join(repo, ".gsd", "worktrees", "M001");
138
+ t.after(() => {
139
+ try { git(["worktree", "remove", "--force", wtPath], repo); } catch { /* noop */ }
140
+ });
141
+
142
+ nativeWorktreeAdd(repo, wtPath, "milestone/M001", true, "HEAD");
143
+
144
+ assert.equal(
145
+ existsSync(join(wtPath, ".git")),
146
+ true,
147
+ "created worktree must have the .git file required by later health checks",
148
+ );
149
+ });
118
150
  });
@@ -110,6 +110,9 @@ function makeDeps(overrides?: Partial<WorktreeResolverDeps>): WorktreeResolverDe
110
110
  calls.push({ fn: "getCurrentBranch", args: [basePath] });
111
111
  return "main";
112
112
  },
113
+ checkoutBranch: (basePath: string, branch: string) => {
114
+ calls.push({ fn: "checkoutBranch", args: [basePath, branch] });
115
+ },
113
116
  autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
114
117
  resolveMilestoneFile: (basePath: string, milestoneId: string, fileType: string) => {
115
118
  calls.push({ fn: "resolveMilestoneFile", args: [basePath, milestoneId, fileType] });
@@ -0,0 +1,133 @@
1
+ // GSD-2 + src/resources/extensions/gsd/tests/orphan-merge-bootstrap.test.ts
2
+ // Regression: bootstrap must actively merge orphan completed-but-unmerged
3
+ // milestones, not just seed `s.currentMilestoneId` (the seed approach was
4
+ // silently overwritten at auto-start.ts:948 — caught in audit of PR #5549).
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+
9
+ import { _mergeOrphanCompletedMilestone } from "../auto-start.js";
10
+ import type { WorktreeResolver } from "../worktree-resolver.js";
11
+
12
+ interface FakeResolverState {
13
+ mergeCalls: Array<{ milestoneId: string }>;
14
+ shouldThrow?: unknown;
15
+ }
16
+
17
+ function fakeResolver(state: FakeResolverState): WorktreeResolver {
18
+ return {
19
+ mergeAndExit: (milestoneId: string) => {
20
+ state.mergeCalls.push({ milestoneId });
21
+ if (state.shouldThrow) throw state.shouldThrow;
22
+ },
23
+ } as unknown as WorktreeResolver;
24
+ }
25
+
26
+ interface FakeUiState {
27
+ notifications: Array<{ message: string; level: string }>;
28
+ }
29
+
30
+ function fakeUi(state: FakeUiState): {
31
+ notify: (msg: string, level?: "info" | "warning" | "error" | "success") => void;
32
+ } {
33
+ return {
34
+ notify: (message: string, level?: "info" | "warning" | "error" | "success") => {
35
+ state.notifications.push({ message, level: level ?? "info" });
36
+ },
37
+ };
38
+ }
39
+
40
+ test("happy path: orphan merge runs, returns merged:true, emits info notify", () => {
41
+ const resolverState: FakeResolverState = { mergeCalls: [] };
42
+ const uiState: FakeUiState = { notifications: [] };
43
+ const result = _mergeOrphanCompletedMilestone(
44
+ fakeResolver(resolverState),
45
+ "M002",
46
+ fakeUi(uiState),
47
+ );
48
+
49
+ assert.deepEqual(result, { merged: true });
50
+ assert.deepEqual(resolverState.mergeCalls, [{ milestoneId: "M002" }]);
51
+ assert.equal(uiState.notifications.length, 1);
52
+ assert.deepEqual(uiState.notifications[0], {
53
+ message: "Detected unmerged completed milestone M002. Merging now.",
54
+ level: "info",
55
+ });
56
+ });
57
+
58
+ test("regression: mergeAndExit throwing (e.g. wrong-branch from PR #5549 commit 5) does not bubble out", () => {
59
+ // Commit 5 (68ef58a3c) made `_mergeBranchMode` throw on wrong branch
60
+ // instead of silently returning false. If `_mergeOrphanCompletedMilestone`
61
+ // didn't catch the throw, bootstrap would surface an unhandled exception
62
+ // to the slash-command caller — the exact regression risk that motivated
63
+ // wrapping in try/catch.
64
+ const boom = new Error("dirty working tree blocks checkout");
65
+ const resolverState: FakeResolverState = { mergeCalls: [], shouldThrow: boom };
66
+ const uiState: FakeUiState = { notifications: [] };
67
+
68
+ const result = _mergeOrphanCompletedMilestone(
69
+ fakeResolver(resolverState),
70
+ "M002",
71
+ fakeUi(uiState),
72
+ );
73
+
74
+ assert.equal(result.merged, false);
75
+ assert.equal(result.error, boom);
76
+
77
+ // First notify announces the merge attempt; second notify reports the failure.
78
+ assert.equal(uiState.notifications.length, 2);
79
+ assert.equal(uiState.notifications[0].level, "info");
80
+ assert.equal(uiState.notifications[1].level, "warning");
81
+ assert.match(
82
+ uiState.notifications[1].message,
83
+ /Could not merge orphan milestone M002/,
84
+ );
85
+ assert.match(uiState.notifications[1].message, /dirty working tree blocks checkout/);
86
+ assert.match(uiState.notifications[1].message, /Resolve manually/);
87
+ });
88
+
89
+ test("non-Error thrown values are still captured and notified", () => {
90
+ // Defensive: thrown strings, numbers, etc. must not crash the formatter.
91
+ const resolverState: FakeResolverState = {
92
+ mergeCalls: [],
93
+ // mimic a thrown non-Error value
94
+ shouldThrow: "git lock held",
95
+ };
96
+ const uiState: FakeUiState = { notifications: [] };
97
+
98
+ const result = _mergeOrphanCompletedMilestone(
99
+ fakeResolver(resolverState),
100
+ "M002",
101
+ fakeUi(uiState),
102
+ );
103
+
104
+ assert.equal(result.merged, false);
105
+ assert.equal(result.error, resolverState.shouldThrow);
106
+ assert.equal(uiState.notifications[1].level, "warning");
107
+ assert.match(uiState.notifications[1].message, /git lock held/);
108
+ });
109
+
110
+ test("the mergeAndExit call receives a notify-bound NotifyCtx the resolver can invoke", () => {
111
+ // The resolver's NotifyCtx must be wired to ui.notify so user-facing
112
+ // messages from inside mergeAndExit (e.g. "Milestone Mxxx merged") still
113
+ // reach the UI. Verify by having the fake resolver invoke the ctx.notify.
114
+ const uiState: FakeUiState = { notifications: [] };
115
+ const ui = fakeUi(uiState);
116
+
117
+ const resolver = {
118
+ mergeAndExit: (_milestoneId: string, ctx: { notify: (msg: string, level?: "info" | "warning" | "error" | "success") => void }) => {
119
+ ctx.notify("inner success message", "success");
120
+ },
121
+ } as unknown as WorktreeResolver;
122
+
123
+ const result = _mergeOrphanCompletedMilestone(resolver, "M002", ui);
124
+
125
+ assert.equal(result.merged, true);
126
+ // 1: outer "Detected unmerged completed milestone..."
127
+ // 2: inner "inner success message" emitted via the bound ctx.notify
128
+ assert.equal(uiState.notifications.length, 2);
129
+ assert.deepEqual(uiState.notifications[1], {
130
+ message: "inner success message",
131
+ level: "success",
132
+ });
133
+ });
@@ -0,0 +1,201 @@
1
+ // GSD-2 + src/resources/extensions/gsd/tests/orphan-stash-audit.test.ts
2
+ // Regression: orphaned gsd-preflight-stash entries from completed milestones
3
+ // must be auto-applied at startup so the user's pre-merge work returns.
4
+
5
+ import { describe, test, beforeEach, afterEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import {
13
+ auditOrphanedPreflightStashes,
14
+ _isAlreadyRestoredApplyError,
15
+ } from "../orphan-stash-audit.js";
16
+
17
+ function git(cwd: string, ...args: string[]): string {
18
+ return execFileSync("git", args, {
19
+ cwd,
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ encoding: "utf-8",
22
+ env: {
23
+ ...process.env,
24
+ GIT_TERMINAL_PROMPT: "0",
25
+ GIT_AUTHOR_NAME: "test",
26
+ GIT_AUTHOR_EMAIL: "test@example.com",
27
+ GIT_COMMITTER_NAME: "test",
28
+ GIT_COMMITTER_EMAIL: "test@example.com",
29
+ },
30
+ });
31
+ }
32
+
33
+ function pushPreflightStash(repo: string, milestoneId: string, fileName: string, content: string): string {
34
+ writeFileSync(join(repo, fileName), content);
35
+ const marker = `gsd-preflight-stash:${milestoneId}:42:1700000000000:abcd`;
36
+ git(repo, "stash", "push", "--include-untracked", "-m", `gsd-preflight-stash [${marker}]`);
37
+ return marker;
38
+ }
39
+
40
+ describe("auditOrphanedPreflightStashes", () => {
41
+ let repo: string;
42
+
43
+ beforeEach(() => {
44
+ repo = mkdtempSync(join(tmpdir(), "orphan-stash-audit-"));
45
+ git(repo, "init", "-q");
46
+ git(repo, "config", "user.email", "test@example.com");
47
+ git(repo, "config", "user.name", "test");
48
+ writeFileSync(join(repo, "seed.txt"), "seed\n");
49
+ git(repo, "add", "seed.txt");
50
+ git(repo, "commit", "-q", "-m", "initial");
51
+ });
52
+
53
+ afterEach(() => {
54
+ if (repo && existsSync(repo)) {
55
+ rmSync(repo, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("returns empty result when there are no stashes", () => {
60
+ const result = auditOrphanedPreflightStashes(repo, () => true);
61
+ assert.deepEqual(result, { applied: [], warnings: [] });
62
+ });
63
+
64
+ test("applies an orphan preflight stash when its milestone is complete", () => {
65
+ pushPreflightStash(repo, "M002", "leftover.txt", "lost work\n");
66
+
67
+ // Verify the file is gone (stashed away) before the audit runs.
68
+ assert.equal(existsSync(join(repo, "leftover.txt")), false, "stash push must remove the file");
69
+
70
+ const result = auditOrphanedPreflightStashes(repo, (id) => id === "M002");
71
+
72
+ assert.equal(result.applied.length, 1, "expected exactly one stash applied");
73
+ assert.equal(result.applied[0].milestoneId, "M002");
74
+ assert.match(result.applied[0].stashRef, /^stash@\{\d+\}$/);
75
+ assert.equal(result.warnings.length, 0);
76
+
77
+ // The user's pre-merge content must be back in the working tree.
78
+ assert.equal(existsSync(join(repo, "leftover.txt")), true);
79
+ assert.equal(readFileSync(join(repo, "leftover.txt"), "utf-8"), "lost work\n");
80
+
81
+ // The stash entry must remain (apply, not pop) so the user has a backup.
82
+ const list = git(repo, "stash", "list");
83
+ assert.match(list, /gsd-preflight-stash:M002:/);
84
+ });
85
+
86
+ test("ignores stashes whose milestone is not complete", () => {
87
+ pushPreflightStash(repo, "M003", "wip.txt", "still-working\n");
88
+
89
+ const result = auditOrphanedPreflightStashes(repo, () => false);
90
+
91
+ assert.deepEqual(result, { applied: [], warnings: [] });
92
+ // File stays stashed.
93
+ assert.equal(existsSync(join(repo, "wip.txt")), false);
94
+ });
95
+
96
+ test("ignores non-gsd stash entries", () => {
97
+ writeFileSync(join(repo, "manual.txt"), "manual\n");
98
+ git(repo, "stash", "push", "--include-untracked", "-m", "user manual stash");
99
+
100
+ const result = auditOrphanedPreflightStashes(repo, () => true);
101
+
102
+ assert.deepEqual(result, { applied: [], warnings: [] });
103
+ });
104
+
105
+ test("collects a warning when the completion callback throws", () => {
106
+ pushPreflightStash(repo, "M004", "danger.txt", "boom\n");
107
+
108
+ const result = auditOrphanedPreflightStashes(repo, () => {
109
+ throw new Error("db unavailable");
110
+ });
111
+
112
+ assert.equal(result.applied.length, 0);
113
+ assert.equal(result.warnings.length, 1);
114
+ assert.match(result.warnings[0], /Could not determine completion status for M004/);
115
+ assert.match(result.warnings[0], /db unavailable/);
116
+ });
117
+
118
+ test("collects a warning when stash apply fails (conflicting working tree)", () => {
119
+ // Push a stash containing a change to seed.txt; then dirty seed.txt with
120
+ // a conflicting modification before the audit runs so apply fails.
121
+ writeFileSync(join(repo, "seed.txt"), "stashed\n");
122
+ const marker = `gsd-preflight-stash:M005:42:1700:zz`;
123
+ git(repo, "stash", "push", "-m", `gsd-preflight-stash [${marker}]`);
124
+
125
+ // Dirty the working tree so apply will conflict.
126
+ writeFileSync(join(repo, "seed.txt"), "conflicting modification\n");
127
+
128
+ const result = auditOrphanedPreflightStashes(repo, () => true);
129
+
130
+ assert.equal(result.applied.length, 0);
131
+ assert.equal(result.warnings.length, 1);
132
+ assert.match(result.warnings[0], /Could not apply orphaned preflight stash/);
133
+ assert.match(result.warnings[0], /M005/);
134
+ assert.match(result.warnings[0], /git stash apply/);
135
+
136
+ const list = git(repo, "stash", "list");
137
+ assert.match(list, /gsd-preflight-stash:M005:/);
138
+ });
139
+
140
+ test("returns empty result when basePath is not a git repo", () => {
141
+ const nonRepo = mkdtempSync(join(tmpdir(), "orphan-stash-not-repo-"));
142
+ try {
143
+ const result = auditOrphanedPreflightStashes(nonRepo, () => true);
144
+ assert.deepEqual(result, { applied: [], warnings: [] });
145
+ } finally {
146
+ rmSync(nonRepo, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ test("repeat run is a silent no-op when files were already restored (peer-review regression)", () => {
151
+ // Codex peer review caught: the first audit applies the stash and the
152
+ // file appears in the working tree. The stash entry stays in `git stash
153
+ // list` (apply, not pop). On the next audit, `git stash apply` exits
154
+ // non-zero with "already exists, no checkout" because the untracked file
155
+ // already exists. The original code surfaced that as a warning every
156
+ // startup. The fix detects that error and treats it as the idempotent
157
+ // steady state.
158
+ pushPreflightStash(repo, "M002", "leftover.txt", "lost work\n");
159
+
160
+ const first = auditOrphanedPreflightStashes(repo, () => true);
161
+ assert.equal(first.applied.length, 1, "first run applies");
162
+ assert.equal(first.warnings.length, 0);
163
+
164
+ const second = auditOrphanedPreflightStashes(repo, () => true);
165
+ assert.equal(second.applied.length, 0, "second run skips silently");
166
+ assert.equal(
167
+ second.warnings.length,
168
+ 0,
169
+ "second run must NOT warn — files are already restored from first run",
170
+ );
171
+ });
172
+ });
173
+
174
+ test("_isAlreadyRestoredApplyError detects the git stash apply already-exists error", () => {
175
+ // Real-world stderr produced by git when an --include-untracked stash is
176
+ // applied while the file already exists in the working tree.
177
+ const realError: { stderr: string } = {
178
+ stderr: "leftover.txt already exists, no checkout\nCould not restore untracked files from stash entry\n",
179
+ };
180
+ assert.equal(_isAlreadyRestoredApplyError(realError), true);
181
+
182
+ // Buffer-form stderr (when encoding is not set explicitly).
183
+ const bufErr = { stderr: Buffer.from("foo.ts already exists, no checkout\n") };
184
+ assert.equal(_isAlreadyRestoredApplyError(bufErr), true);
185
+
186
+ // Some Node versions surface the message in err.message.
187
+ const messageOnly = new Error("Command failed: git stash apply\nfoo.ts already exists, no checkout");
188
+ assert.equal(_isAlreadyRestoredApplyError(messageOnly), true);
189
+
190
+ // Unrelated errors must NOT be classified as already-restored.
191
+ const realConflict: { stderr: string } = {
192
+ stderr: "CONFLICT (content): Merge conflict in lib/models.ts\n",
193
+ };
194
+ assert.equal(_isAlreadyRestoredApplyError(realConflict), false);
195
+
196
+ // Defensive null/undefined handling.
197
+ assert.equal(_isAlreadyRestoredApplyError(null), false);
198
+ assert.equal(_isAlreadyRestoredApplyError(undefined), false);
199
+ assert.equal(_isAlreadyRestoredApplyError("not an error"), false);
200
+ assert.equal(_isAlreadyRestoredApplyError({}), false);
201
+ });