gsd-pi 2.33.1-dev.ee47f1b → 2.34.0-dev.bbb5216

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 (135) hide show
  1. package/dist/bundled-resource-path.d.ts +8 -0
  2. package/dist/bundled-resource-path.js +14 -0
  3. package/dist/headless-query.js +6 -6
  4. package/dist/resources/extensions/gsd/auto/session.js +27 -32
  5. package/dist/resources/extensions/gsd/auto-dashboard.js +29 -109
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +6 -1
  7. package/dist/resources/extensions/gsd/auto-dispatch.js +52 -81
  8. package/dist/resources/extensions/gsd/auto-loop.js +956 -0
  9. package/dist/resources/extensions/gsd/auto-observability.js +4 -2
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +75 -185
  11. package/dist/resources/extensions/gsd/auto-prompts.js +133 -101
  12. package/dist/resources/extensions/gsd/auto-recovery.js +59 -97
  13. package/dist/resources/extensions/gsd/auto-start.js +330 -309
  14. package/dist/resources/extensions/gsd/auto-supervisor.js +5 -11
  15. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +7 -7
  16. package/dist/resources/extensions/gsd/auto-timers.js +3 -4
  17. package/dist/resources/extensions/gsd/auto-verification.js +35 -73
  18. package/dist/resources/extensions/gsd/auto-worktree-sync.js +167 -0
  19. package/dist/resources/extensions/gsd/auto-worktree.js +291 -126
  20. package/dist/resources/extensions/gsd/auto.js +283 -1013
  21. package/dist/resources/extensions/gsd/captures.js +10 -4
  22. package/dist/resources/extensions/gsd/dispatch-guard.js +7 -8
  23. package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -18
  24. package/dist/resources/extensions/gsd/doctor-checks.js +3 -4
  25. package/dist/resources/extensions/gsd/git-service.js +1 -1
  26. package/dist/resources/extensions/gsd/gsd-db.js +296 -151
  27. package/dist/resources/extensions/gsd/index.js +92 -228
  28. package/dist/resources/extensions/gsd/post-unit-hooks.js +13 -13
  29. package/dist/resources/extensions/gsd/progress-score.js +61 -156
  30. package/dist/resources/extensions/gsd/quick.js +98 -122
  31. package/dist/resources/extensions/gsd/session-lock.js +13 -0
  32. package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
  33. package/dist/resources/extensions/gsd/undo.js +43 -48
  34. package/dist/resources/extensions/gsd/unit-runtime.js +16 -15
  35. package/dist/resources/extensions/gsd/verification-evidence.js +0 -1
  36. package/dist/resources/extensions/gsd/verification-gate.js +6 -35
  37. package/dist/resources/extensions/gsd/worktree-command.js +30 -24
  38. package/dist/resources/extensions/gsd/worktree-manager.js +2 -3
  39. package/dist/resources/extensions/gsd/worktree-resolver.js +344 -0
  40. package/dist/resources/extensions/gsd/worktree.js +7 -44
  41. package/dist/tool-bootstrap.js +59 -11
  42. package/dist/worktree-cli.js +7 -7
  43. package/package.json +1 -1
  44. package/packages/pi-ai/dist/models.generated.d.ts +3630 -5483
  45. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  46. package/packages/pi-ai/dist/models.generated.js +735 -2588
  47. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  48. package/packages/pi-ai/src/models.generated.ts +1039 -2892
  49. package/packages/pi-coding-agent/package.json +1 -1
  50. package/pkg/package.json +1 -1
  51. package/src/resources/extensions/gsd/auto/session.ts +47 -30
  52. package/src/resources/extensions/gsd/auto-dashboard.ts +28 -131
  53. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +6 -1
  54. package/src/resources/extensions/gsd/auto-dispatch.ts +135 -91
  55. package/src/resources/extensions/gsd/auto-loop.ts +1665 -0
  56. package/src/resources/extensions/gsd/auto-observability.ts +4 -2
  57. package/src/resources/extensions/gsd/auto-post-unit.ts +85 -228
  58. package/src/resources/extensions/gsd/auto-prompts.ts +138 -109
  59. package/src/resources/extensions/gsd/auto-recovery.ts +124 -118
  60. package/src/resources/extensions/gsd/auto-start.ts +440 -354
  61. package/src/resources/extensions/gsd/auto-supervisor.ts +5 -12
  62. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +8 -8
  63. package/src/resources/extensions/gsd/auto-timers.ts +3 -4
  64. package/src/resources/extensions/gsd/auto-verification.ts +76 -90
  65. package/src/resources/extensions/gsd/auto-worktree-sync.ts +204 -0
  66. package/src/resources/extensions/gsd/auto-worktree.ts +389 -141
  67. package/src/resources/extensions/gsd/auto.ts +515 -1199
  68. package/src/resources/extensions/gsd/captures.ts +10 -4
  69. package/src/resources/extensions/gsd/dispatch-guard.ts +13 -9
  70. package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -18
  71. package/src/resources/extensions/gsd/doctor-checks.ts +3 -4
  72. package/src/resources/extensions/gsd/git-service.ts +8 -1
  73. package/src/resources/extensions/gsd/gitignore.ts +4 -2
  74. package/src/resources/extensions/gsd/gsd-db.ts +375 -180
  75. package/src/resources/extensions/gsd/index.ts +104 -263
  76. package/src/resources/extensions/gsd/post-unit-hooks.ts +13 -13
  77. package/src/resources/extensions/gsd/progress-score.ts +65 -200
  78. package/src/resources/extensions/gsd/quick.ts +121 -125
  79. package/src/resources/extensions/gsd/session-lock.ts +11 -0
  80. package/src/resources/extensions/gsd/templates/preferences.md +1 -0
  81. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +32 -59
  82. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +75 -27
  83. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  84. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +37 -0
  85. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1458 -0
  86. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +8 -162
  87. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +2 -108
  88. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +1 -3
  89. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +0 -3
  90. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  91. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -55
  92. package/src/resources/extensions/gsd/tests/headless-query.test.ts +22 -0
  93. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +8 -11
  94. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +4 -6
  95. package/src/resources/extensions/gsd/tests/run-uat.test.ts +3 -3
  96. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +64 -0
  97. package/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +181 -0
  98. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +0 -3
  99. package/src/resources/extensions/gsd/tests/token-profile.test.ts +6 -6
  100. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +6 -6
  101. package/src/resources/extensions/gsd/tests/undo.test.ts +6 -0
  102. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +24 -26
  103. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +7 -201
  104. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  105. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  106. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +0 -3
  107. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +705 -0
  108. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +57 -106
  109. package/src/resources/extensions/gsd/tests/worktree.test.ts +5 -1
  110. package/src/resources/extensions/gsd/tests/write-gate.test.ts +43 -132
  111. package/src/resources/extensions/gsd/types.ts +90 -81
  112. package/src/resources/extensions/gsd/undo.ts +42 -46
  113. package/src/resources/extensions/gsd/unit-runtime.ts +14 -18
  114. package/src/resources/extensions/gsd/verification-evidence.ts +1 -3
  115. package/src/resources/extensions/gsd/verification-gate.ts +6 -39
  116. package/src/resources/extensions/gsd/worktree-command.ts +36 -24
  117. package/src/resources/extensions/gsd/worktree-manager.ts +2 -3
  118. package/src/resources/extensions/gsd/worktree-resolver.ts +485 -0
  119. package/src/resources/extensions/gsd/worktree.ts +7 -44
  120. package/dist/resources/extensions/gsd/auto-constants.js +0 -5
  121. package/dist/resources/extensions/gsd/auto-idempotency.js +0 -106
  122. package/dist/resources/extensions/gsd/auto-stuck-detection.js +0 -165
  123. package/dist/resources/extensions/gsd/mechanical-completion.js +0 -351
  124. package/src/resources/extensions/gsd/auto-constants.ts +0 -6
  125. package/src/resources/extensions/gsd/auto-idempotency.ts +0 -151
  126. package/src/resources/extensions/gsd/auto-stuck-detection.ts +0 -221
  127. package/src/resources/extensions/gsd/mechanical-completion.ts +0 -430
  128. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  129. package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +0 -127
  130. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +0 -123
  131. package/src/resources/extensions/gsd/tests/dispatch-stall-guard.test.ts +0 -126
  132. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +0 -874
  133. package/src/resources/extensions/gsd/tests/mechanical-completion.test.ts +0 -356
  134. package/src/resources/extensions/gsd/tests/progress-score.test.ts +0 -206
  135. package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -434
@@ -0,0 +1,705 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ WorktreeResolver,
5
+ type WorktreeResolverDeps,
6
+ type NotifyCtx,
7
+ } from "../worktree-resolver.js";
8
+ import { AutoSession } from "../auto/session.js";
9
+
10
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
11
+
12
+ /** Track calls to mock deps for assertion. */
13
+ interface CallLog {
14
+ fn: string;
15
+ args: unknown[];
16
+ }
17
+
18
+ function makeSession(
19
+ overrides?: Partial<{ basePath: string; originalBasePath: string }>,
20
+ ): AutoSession {
21
+ const s = new AutoSession();
22
+ s.basePath = overrides?.basePath ?? "/project";
23
+ s.originalBasePath = overrides?.originalBasePath ?? "/project";
24
+ return s;
25
+ }
26
+
27
+ function makeDeps(
28
+ overrides?: Partial<WorktreeResolverDeps>,
29
+ ): WorktreeResolverDeps & { calls: CallLog[] } {
30
+ const calls: CallLog[] = [];
31
+
32
+ const deps: WorktreeResolverDeps & { calls: CallLog[] } = {
33
+ calls,
34
+ isInAutoWorktree: (basePath: string) => {
35
+ calls.push({ fn: "isInAutoWorktree", args: [basePath] });
36
+ return false;
37
+ },
38
+ shouldUseWorktreeIsolation: () => {
39
+ calls.push({ fn: "shouldUseWorktreeIsolation", args: [] });
40
+ return true;
41
+ },
42
+ getIsolationMode: () => {
43
+ calls.push({ fn: "getIsolationMode", args: [] });
44
+ return "worktree";
45
+ },
46
+ mergeMilestoneToMain: (
47
+ basePath: string,
48
+ milestoneId: string,
49
+ roadmapContent: string,
50
+ ) => {
51
+ calls.push({
52
+ fn: "mergeMilestoneToMain",
53
+ args: [basePath, milestoneId, roadmapContent],
54
+ });
55
+ return { pushed: false };
56
+ },
57
+ syncWorktreeStateBack: (
58
+ mainBasePath: string,
59
+ worktreePath: string,
60
+ milestoneId: string,
61
+ ) => {
62
+ calls.push({
63
+ fn: "syncWorktreeStateBack",
64
+ args: [mainBasePath, worktreePath, milestoneId],
65
+ });
66
+ return { synced: [] };
67
+ },
68
+ teardownAutoWorktree: (
69
+ basePath: string,
70
+ milestoneId: string,
71
+ opts?: { preserveBranch?: boolean },
72
+ ) => {
73
+ calls.push({
74
+ fn: "teardownAutoWorktree",
75
+ args: [basePath, milestoneId, opts],
76
+ });
77
+ },
78
+ createAutoWorktree: (basePath: string, milestoneId: string) => {
79
+ calls.push({ fn: "createAutoWorktree", args: [basePath, milestoneId] });
80
+ return `/project/.gsd/worktrees/${milestoneId}`;
81
+ },
82
+ enterAutoWorktree: (basePath: string, milestoneId: string) => {
83
+ calls.push({ fn: "enterAutoWorktree", args: [basePath, milestoneId] });
84
+ return `/project/.gsd/worktrees/${milestoneId}`;
85
+ },
86
+ getAutoWorktreePath: (basePath: string, milestoneId: string) => {
87
+ calls.push({ fn: "getAutoWorktreePath", args: [basePath, milestoneId] });
88
+ return null;
89
+ },
90
+ autoCommitCurrentBranch: (
91
+ basePath: string,
92
+ reason: string,
93
+ milestoneId: string,
94
+ ) => {
95
+ calls.push({
96
+ fn: "autoCommitCurrentBranch",
97
+ args: [basePath, reason, milestoneId],
98
+ });
99
+ },
100
+ getCurrentBranch: (basePath: string) => {
101
+ calls.push({ fn: "getCurrentBranch", args: [basePath] });
102
+ return "main";
103
+ },
104
+ autoWorktreeBranch: (milestoneId: string) => {
105
+ calls.push({ fn: "autoWorktreeBranch", args: [milestoneId] });
106
+ return `milestone/${milestoneId}`;
107
+ },
108
+ resolveMilestoneFile: (
109
+ basePath: string,
110
+ milestoneId: string,
111
+ fileType: string,
112
+ ) => {
113
+ calls.push({
114
+ fn: "resolveMilestoneFile",
115
+ args: [basePath, milestoneId, fileType],
116
+ });
117
+ return `/project/.gsd/milestones/${milestoneId}/${milestoneId}-ROADMAP.md`;
118
+ },
119
+ readFileSync: (path: string, _encoding: string) => {
120
+ calls.push({ fn: "readFileSync", args: [path] });
121
+ return "# Roadmap\n- [x] S01: Slice one\n";
122
+ },
123
+ GitServiceImpl: class MockGitServiceImpl {
124
+ basePath: string;
125
+ gitConfig: unknown;
126
+ constructor(basePath: string, gitConfig: unknown) {
127
+ calls.push({ fn: "GitServiceImpl", args: [basePath, gitConfig] });
128
+ this.basePath = basePath;
129
+ this.gitConfig = gitConfig;
130
+ }
131
+ } as unknown as WorktreeResolverDeps["GitServiceImpl"],
132
+ loadEffectiveGSDPreferences: () => {
133
+ calls.push({ fn: "loadEffectiveGSDPreferences", args: [] });
134
+ return { preferences: { git: {} } };
135
+ },
136
+ invalidateAllCaches: () => {
137
+ calls.push({ fn: "invalidateAllCaches", args: [] });
138
+ },
139
+ captureIntegrationBranch: (
140
+ basePath: string,
141
+ mid: string | undefined,
142
+ opts?: { commitDocs?: boolean },
143
+ ) => {
144
+ calls.push({
145
+ fn: "captureIntegrationBranch",
146
+ args: [basePath, mid, opts],
147
+ });
148
+ },
149
+ ...overrides,
150
+ };
151
+
152
+ // Re-apply overrides that add the call tracking
153
+ if (overrides) {
154
+ for (const [key, val] of Object.entries(overrides)) {
155
+ if (key !== "calls") {
156
+ (deps as unknown as Record<string, unknown>)[key] = val;
157
+ }
158
+ }
159
+ }
160
+
161
+ return deps;
162
+ }
163
+
164
+ function makeNotifyCtx(): NotifyCtx & {
165
+ messages: Array<{ msg: string; level?: string }>;
166
+ } {
167
+ const messages: Array<{ msg: string; level?: string }> = [];
168
+ return {
169
+ messages,
170
+ notify: (msg: string, level?: "info" | "warning" | "error" | "success") => {
171
+ messages.push({ msg, level });
172
+ },
173
+ };
174
+ }
175
+
176
+ function findCalls(calls: CallLog[], fn: string): CallLog[] {
177
+ return calls.filter((c) => c.fn === fn);
178
+ }
179
+
180
+ // ─── Getter Tests ────────────────────────────────────────────────────────────
181
+
182
+ test("workPath returns s.basePath", () => {
183
+ const s = makeSession({ basePath: "/project/.gsd/worktrees/M001" });
184
+ const resolver = new WorktreeResolver(s, makeDeps());
185
+ assert.equal(resolver.workPath, "/project/.gsd/worktrees/M001");
186
+ });
187
+
188
+ test("projectRoot returns originalBasePath when set", () => {
189
+ const s = makeSession({
190
+ basePath: "/project/.gsd/worktrees/M001",
191
+ originalBasePath: "/project",
192
+ });
193
+ const resolver = new WorktreeResolver(s, makeDeps());
194
+ assert.equal(resolver.projectRoot, "/project");
195
+ });
196
+
197
+ test("projectRoot falls back to basePath when originalBasePath is empty", () => {
198
+ const s = makeSession({ basePath: "/project", originalBasePath: "" });
199
+ const resolver = new WorktreeResolver(s, makeDeps());
200
+ assert.equal(resolver.projectRoot, "/project");
201
+ });
202
+
203
+ test("lockPath returns originalBasePath when set (same as lockBase)", () => {
204
+ const s = makeSession({
205
+ basePath: "/project/.gsd/worktrees/M001",
206
+ originalBasePath: "/project",
207
+ });
208
+ const resolver = new WorktreeResolver(s, makeDeps());
209
+ assert.equal(resolver.lockPath, "/project");
210
+ });
211
+
212
+ test("lockPath falls back to basePath when originalBasePath is empty", () => {
213
+ const s = makeSession({ basePath: "/project", originalBasePath: "" });
214
+ const resolver = new WorktreeResolver(s, makeDeps());
215
+ assert.equal(resolver.lockPath, "/project");
216
+ });
217
+
218
+ // ─── enterMilestone Tests ────────────────────────────────────────────────────
219
+
220
+ test("enterMilestone creates new worktree when none exists", () => {
221
+ const s = makeSession();
222
+ const deps = makeDeps({
223
+ getAutoWorktreePath: () => null,
224
+ });
225
+ const ctx = makeNotifyCtx();
226
+ const resolver = new WorktreeResolver(s, deps);
227
+
228
+ resolver.enterMilestone("M001", ctx);
229
+
230
+ assert.equal(s.basePath, "/project/.gsd/worktrees/M001");
231
+ assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 1);
232
+ assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0);
233
+ assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
234
+ assert.ok(
235
+ ctx.messages.some(
236
+ (m) => m.level === "info" && m.msg.includes("Entered worktree"),
237
+ ),
238
+ );
239
+ });
240
+
241
+ test("enterMilestone enters existing worktree instead of creating", () => {
242
+ const s = makeSession();
243
+ const deps = makeDeps({
244
+ getAutoWorktreePath: () => "/project/.gsd/worktrees/M001",
245
+ });
246
+ const ctx = makeNotifyCtx();
247
+ const resolver = new WorktreeResolver(s, deps);
248
+
249
+ resolver.enterMilestone("M001", ctx);
250
+
251
+ assert.equal(s.basePath, "/project/.gsd/worktrees/M001");
252
+ assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 1);
253
+ assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0);
254
+ });
255
+
256
+ test("enterMilestone is no-op when shouldUseWorktreeIsolation is false", () => {
257
+ const s = makeSession();
258
+ const deps = makeDeps({
259
+ shouldUseWorktreeIsolation: () => false,
260
+ });
261
+ const ctx = makeNotifyCtx();
262
+ const resolver = new WorktreeResolver(s, deps);
263
+
264
+ resolver.enterMilestone("M001", ctx);
265
+
266
+ assert.equal(s.basePath, "/project"); // unchanged
267
+ assert.equal(findCalls(deps.calls, "createAutoWorktree").length, 0);
268
+ assert.equal(findCalls(deps.calls, "enterAutoWorktree").length, 0);
269
+ });
270
+
271
+ test("enterMilestone does NOT update basePath on creation failure", () => {
272
+ const s = makeSession();
273
+ const deps = makeDeps({
274
+ getAutoWorktreePath: () => null,
275
+ createAutoWorktree: () => {
276
+ throw new Error("disk full");
277
+ },
278
+ });
279
+ const ctx = makeNotifyCtx();
280
+ const resolver = new WorktreeResolver(s, deps);
281
+
282
+ resolver.enterMilestone("M001", ctx);
283
+
284
+ assert.equal(s.basePath, "/project"); // unchanged — error recovery
285
+ assert.ok(
286
+ ctx.messages.some(
287
+ (m) => m.level === "warning" && m.msg.includes("disk full"),
288
+ ),
289
+ );
290
+ });
291
+
292
+ test("enterMilestone uses originalBasePath as base for worktree ops", () => {
293
+ const s = makeSession({
294
+ basePath: "/project/.gsd/worktrees/M001",
295
+ originalBasePath: "/project",
296
+ });
297
+ let createdFrom = "";
298
+ const deps = makeDeps({
299
+ getAutoWorktreePath: () => null,
300
+ createAutoWorktree: (basePath: string, _mid: string) => {
301
+ createdFrom = basePath;
302
+ return "/project/.gsd/worktrees/M002";
303
+ },
304
+ });
305
+ const ctx = makeNotifyCtx();
306
+ const resolver = new WorktreeResolver(s, deps);
307
+
308
+ resolver.enterMilestone("M002", ctx);
309
+
310
+ assert.equal(createdFrom, "/project"); // uses originalBasePath, not current basePath
311
+ });
312
+
313
+ // ─── exitMilestone Tests ─────────────────────────────────────────────────────
314
+
315
+ test("exitMilestone commits, tears down, and resets basePath", () => {
316
+ const s = makeSession({
317
+ basePath: "/project/.gsd/worktrees/M001",
318
+ originalBasePath: "/project",
319
+ });
320
+ const deps = makeDeps({
321
+ isInAutoWorktree: () => true,
322
+ });
323
+ const ctx = makeNotifyCtx();
324
+ const resolver = new WorktreeResolver(s, deps);
325
+
326
+ resolver.exitMilestone("M001", ctx);
327
+
328
+ assert.equal(s.basePath, "/project"); // reset to originalBasePath
329
+ assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 1);
330
+ assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1);
331
+ assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt
332
+ assert.equal(findCalls(deps.calls, "invalidateAllCaches").length, 1);
333
+ });
334
+
335
+ test("exitMilestone is no-op when not in worktree", () => {
336
+ const s = makeSession();
337
+ const deps = makeDeps({
338
+ isInAutoWorktree: () => false,
339
+ });
340
+ const ctx = makeNotifyCtx();
341
+ const resolver = new WorktreeResolver(s, deps);
342
+
343
+ resolver.exitMilestone("M001", ctx);
344
+
345
+ assert.equal(s.basePath, "/project"); // unchanged
346
+ assert.equal(findCalls(deps.calls, "autoCommitCurrentBranch").length, 0);
347
+ assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
348
+ });
349
+
350
+ test("exitMilestone passes preserveBranch option", () => {
351
+ const s = makeSession({
352
+ basePath: "/project/.gsd/worktrees/M001",
353
+ originalBasePath: "/project",
354
+ });
355
+ let preserveOpts: unknown = null;
356
+ const deps = makeDeps({
357
+ isInAutoWorktree: () => true,
358
+ teardownAutoWorktree: (
359
+ _basePath: string,
360
+ _mid: string,
361
+ opts?: { preserveBranch?: boolean },
362
+ ) => {
363
+ preserveOpts = opts;
364
+ },
365
+ });
366
+ const ctx = makeNotifyCtx();
367
+ const resolver = new WorktreeResolver(s, deps);
368
+
369
+ resolver.exitMilestone("M001", ctx, { preserveBranch: true });
370
+
371
+ assert.deepEqual(preserveOpts, { preserveBranch: true });
372
+ });
373
+
374
+ test("exitMilestone still resets basePath even if auto-commit fails", () => {
375
+ const s = makeSession({
376
+ basePath: "/project/.gsd/worktrees/M001",
377
+ originalBasePath: "/project",
378
+ });
379
+ const deps = makeDeps({
380
+ isInAutoWorktree: () => true,
381
+ autoCommitCurrentBranch: () => {
382
+ throw new Error("commit error");
383
+ },
384
+ });
385
+ const ctx = makeNotifyCtx();
386
+ const resolver = new WorktreeResolver(s, deps);
387
+
388
+ resolver.exitMilestone("M001", ctx);
389
+
390
+ // Should still complete: reset basePath, rebuild git service
391
+ assert.equal(s.basePath, "/project");
392
+ assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
393
+ });
394
+
395
+ // ─── mergeAndExit Tests (worktree mode) ──────────────────────────────────────
396
+
397
+ test("mergeAndExit in worktree mode reads roadmap and merges", () => {
398
+ const s = makeSession({
399
+ basePath: "/project/.gsd/worktrees/M001",
400
+ originalBasePath: "/project",
401
+ });
402
+ const deps = makeDeps({
403
+ isInAutoWorktree: () => true,
404
+ getIsolationMode: () => "worktree",
405
+ });
406
+ const ctx = makeNotifyCtx();
407
+ const resolver = new WorktreeResolver(s, deps);
408
+
409
+ resolver.mergeAndExit("M001", ctx);
410
+
411
+ assert.equal(findCalls(deps.calls, "syncWorktreeStateBack").length, 1);
412
+ assert.equal(findCalls(deps.calls, "resolveMilestoneFile").length, 1);
413
+ assert.equal(findCalls(deps.calls, "readFileSync").length, 1);
414
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
415
+ assert.equal(s.basePath, "/project"); // restored
416
+ assert.ok(ctx.messages.some((m) => m.msg.includes("merged to main")));
417
+ });
418
+
419
+ test("mergeAndExit in worktree mode shows pushed status", () => {
420
+ const s = makeSession({
421
+ basePath: "/project/.gsd/worktrees/M001",
422
+ originalBasePath: "/project",
423
+ });
424
+ const deps = makeDeps({
425
+ isInAutoWorktree: () => true,
426
+ getIsolationMode: () => "worktree",
427
+ mergeMilestoneToMain: () => ({ pushed: true }),
428
+ });
429
+ const ctx = makeNotifyCtx();
430
+ const resolver = new WorktreeResolver(s, deps);
431
+
432
+ resolver.mergeAndExit("M001", ctx);
433
+
434
+ assert.ok(ctx.messages.some((m) => m.msg.includes("Pushed to remote")));
435
+ });
436
+
437
+ test("mergeAndExit falls back to teardown when roadmap is missing", () => {
438
+ const s = makeSession({
439
+ basePath: "/project/.gsd/worktrees/M001",
440
+ originalBasePath: "/project",
441
+ });
442
+ const deps = makeDeps({
443
+ isInAutoWorktree: () => true,
444
+ getIsolationMode: () => "worktree",
445
+ resolveMilestoneFile: () => null,
446
+ });
447
+ const ctx = makeNotifyCtx();
448
+ const resolver = new WorktreeResolver(s, deps);
449
+
450
+ resolver.mergeAndExit("M001", ctx);
451
+
452
+ assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 1);
453
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
454
+ assert.equal(s.basePath, "/project"); // restored
455
+ assert.ok(ctx.messages.some((m) => m.msg.includes("no roadmap for merge")));
456
+ });
457
+
458
+ test("mergeAndExit in worktree mode restores to project root on merge failure", () => {
459
+ const s = makeSession({
460
+ basePath: "/project/.gsd/worktrees/M001",
461
+ originalBasePath: "/project",
462
+ });
463
+ const deps = makeDeps({
464
+ isInAutoWorktree: () => true,
465
+ getIsolationMode: () => "worktree",
466
+ mergeMilestoneToMain: () => {
467
+ throw new Error("conflict in main");
468
+ },
469
+ });
470
+ const ctx = makeNotifyCtx();
471
+ const resolver = new WorktreeResolver(s, deps);
472
+
473
+ resolver.mergeAndExit("M001", ctx);
474
+
475
+ assert.equal(s.basePath, "/project"); // error recovery — restored
476
+ assert.ok(
477
+ ctx.messages.some(
478
+ (m) => m.level === "warning" && m.msg.includes("conflict in main"),
479
+ ),
480
+ );
481
+ assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt after recovery
482
+ });
483
+
484
+ // ─── mergeAndExit Tests (branch mode) ────────────────────────────────────────
485
+
486
+ test("mergeAndExit in branch mode merges when on milestone branch", () => {
487
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
488
+ const deps = makeDeps({
489
+ isInAutoWorktree: () => false,
490
+ getIsolationMode: () => "branch",
491
+ getCurrentBranch: () => "milestone/M001",
492
+ autoWorktreeBranch: () => "milestone/M001",
493
+ });
494
+ const ctx = makeNotifyCtx();
495
+ const resolver = new WorktreeResolver(s, deps);
496
+
497
+ resolver.mergeAndExit("M001", ctx);
498
+
499
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 1);
500
+ assert.ok(ctx.messages.some((m) => m.msg.includes("branch mode")));
501
+ });
502
+
503
+ test("mergeAndExit in branch mode skips when not on milestone branch", () => {
504
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
505
+ const deps = makeDeps({
506
+ isInAutoWorktree: () => false,
507
+ getIsolationMode: () => "branch",
508
+ getCurrentBranch: () => "main",
509
+ autoWorktreeBranch: () => "milestone/M001",
510
+ });
511
+ const ctx = makeNotifyCtx();
512
+ const resolver = new WorktreeResolver(s, deps);
513
+
514
+ resolver.mergeAndExit("M001", ctx);
515
+
516
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
517
+ assert.equal(ctx.messages.length, 0);
518
+ });
519
+
520
+ test("mergeAndExit in branch mode handles merge failure gracefully", () => {
521
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
522
+ const deps = makeDeps({
523
+ isInAutoWorktree: () => false,
524
+ getIsolationMode: () => "branch",
525
+ getCurrentBranch: () => "milestone/M001",
526
+ autoWorktreeBranch: () => "milestone/M001",
527
+ mergeMilestoneToMain: () => {
528
+ throw new Error("branch merge conflict");
529
+ },
530
+ });
531
+ const ctx = makeNotifyCtx();
532
+ const resolver = new WorktreeResolver(s, deps);
533
+
534
+ resolver.mergeAndExit("M001", ctx);
535
+
536
+ assert.ok(
537
+ ctx.messages.some(
538
+ (m) => m.level === "warning" && m.msg.includes("branch merge conflict"),
539
+ ),
540
+ );
541
+ });
542
+
543
+ test("mergeAndExit in branch mode skips when no roadmap", () => {
544
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
545
+ const deps = makeDeps({
546
+ isInAutoWorktree: () => false,
547
+ getIsolationMode: () => "branch",
548
+ getCurrentBranch: () => "milestone/M001",
549
+ autoWorktreeBranch: () => "milestone/M001",
550
+ resolveMilestoneFile: () => null,
551
+ });
552
+ const ctx = makeNotifyCtx();
553
+ const resolver = new WorktreeResolver(s, deps);
554
+
555
+ resolver.mergeAndExit("M001", ctx);
556
+
557
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
558
+ });
559
+
560
+ test("mergeAndExit in branch mode rebuilds GitService after merge", () => {
561
+ const s = makeSession({ basePath: "/project", originalBasePath: "/project" });
562
+ const deps = makeDeps({
563
+ isInAutoWorktree: () => false,
564
+ getIsolationMode: () => "branch",
565
+ getCurrentBranch: () => "milestone/M001",
566
+ autoWorktreeBranch: () => "milestone/M001",
567
+ });
568
+ const ctx = makeNotifyCtx();
569
+ const resolver = new WorktreeResolver(s, deps);
570
+
571
+ resolver.mergeAndExit("M001", ctx);
572
+
573
+ assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1);
574
+ });
575
+
576
+ // ─── mergeAndExit Tests (none mode) ──────────────────────────────────────────
577
+
578
+ test("mergeAndExit in none mode is a no-op", () => {
579
+ const s = makeSession();
580
+ const deps = makeDeps({
581
+ getIsolationMode: () => "none",
582
+ });
583
+ const ctx = makeNotifyCtx();
584
+ const resolver = new WorktreeResolver(s, deps);
585
+
586
+ resolver.mergeAndExit("M001", ctx);
587
+
588
+ assert.equal(findCalls(deps.calls, "mergeMilestoneToMain").length, 0);
589
+ assert.equal(findCalls(deps.calls, "teardownAutoWorktree").length, 0);
590
+ assert.equal(ctx.messages.length, 0);
591
+ });
592
+
593
+ // ─── mergeAndEnterNext Tests ─────────────────────────────────────────────────
594
+
595
+ test("mergeAndEnterNext calls mergeAndExit then enterMilestone", () => {
596
+ const s = makeSession({
597
+ basePath: "/project/.gsd/worktrees/M001",
598
+ originalBasePath: "/project",
599
+ });
600
+ const callOrder: string[] = [];
601
+ const deps = makeDeps({
602
+ isInAutoWorktree: () => true,
603
+ getIsolationMode: () => "worktree",
604
+ shouldUseWorktreeIsolation: () => true,
605
+ mergeMilestoneToMain: (
606
+ basePath: string,
607
+ milestoneId: string,
608
+ _roadmap: string,
609
+ ) => {
610
+ callOrder.push(`merge:${milestoneId}`);
611
+ return { pushed: false };
612
+ },
613
+ getAutoWorktreePath: () => null,
614
+ createAutoWorktree: (basePath: string, milestoneId: string) => {
615
+ callOrder.push(`create:${milestoneId}`);
616
+ return `/project/.gsd/worktrees/${milestoneId}`;
617
+ },
618
+ });
619
+ const ctx = makeNotifyCtx();
620
+ const resolver = new WorktreeResolver(s, deps);
621
+
622
+ resolver.mergeAndEnterNext("M001", "M002", ctx);
623
+
624
+ assert.deepEqual(callOrder, ["merge:M001", "create:M002"]);
625
+ assert.equal(s.basePath, "/project/.gsd/worktrees/M002");
626
+ });
627
+
628
+ test("mergeAndEnterNext enters next milestone even if merge fails", () => {
629
+ const s = makeSession({
630
+ basePath: "/project/.gsd/worktrees/M001",
631
+ originalBasePath: "/project",
632
+ });
633
+ const deps = makeDeps({
634
+ isInAutoWorktree: (basePath: string) => basePath.includes("worktrees"),
635
+ getIsolationMode: () => "worktree",
636
+ shouldUseWorktreeIsolation: () => true,
637
+ mergeMilestoneToMain: () => {
638
+ throw new Error("merge failed");
639
+ },
640
+ getAutoWorktreePath: () => null,
641
+ createAutoWorktree: (_basePath: string, milestoneId: string) => {
642
+ return `/project/.gsd/worktrees/${milestoneId}`;
643
+ },
644
+ });
645
+ const ctx = makeNotifyCtx();
646
+ const resolver = new WorktreeResolver(s, deps);
647
+
648
+ resolver.mergeAndEnterNext("M001", "M002", ctx);
649
+
650
+ // Merge failed but enter should still happen
651
+ assert.equal(s.basePath, "/project/.gsd/worktrees/M002");
652
+ assert.ok(
653
+ ctx.messages.some(
654
+ (m) => m.level === "warning" && m.msg.includes("merge failed"),
655
+ ),
656
+ );
657
+ assert.ok(
658
+ ctx.messages.some(
659
+ (m) => m.level === "info" && m.msg.includes("Entered worktree"),
660
+ ),
661
+ );
662
+ });
663
+
664
+ // ─── GitService Rebuild Atomicity ────────────────────────────────────────────
665
+
666
+ test("GitService is rebuilt with the NEW basePath after enterMilestone", () => {
667
+ const s = makeSession();
668
+ let gitServiceBasePath = "";
669
+ const deps = makeDeps({
670
+ getAutoWorktreePath: () => null,
671
+ GitServiceImpl: class {
672
+ constructor(basePath: string, _config: unknown) {
673
+ gitServiceBasePath = basePath;
674
+ }
675
+ } as unknown as WorktreeResolverDeps["GitServiceImpl"],
676
+ });
677
+ const ctx = makeNotifyCtx();
678
+ const resolver = new WorktreeResolver(s, deps);
679
+
680
+ resolver.enterMilestone("M001", ctx);
681
+
682
+ assert.equal(gitServiceBasePath, "/project/.gsd/worktrees/M001"); // new path, not old
683
+ });
684
+
685
+ test("GitService is rebuilt with originalBasePath after exitMilestone", () => {
686
+ const s = makeSession({
687
+ basePath: "/project/.gsd/worktrees/M001",
688
+ originalBasePath: "/project",
689
+ });
690
+ let gitServiceBasePath = "";
691
+ const deps = makeDeps({
692
+ isInAutoWorktree: () => true,
693
+ GitServiceImpl: class {
694
+ constructor(basePath: string, _config: unknown) {
695
+ gitServiceBasePath = basePath;
696
+ }
697
+ } as unknown as WorktreeResolverDeps["GitServiceImpl"],
698
+ });
699
+ const ctx = makeNotifyCtx();
700
+ const resolver = new WorktreeResolver(s, deps);
701
+
702
+ resolver.exitMilestone("M001", ctx);
703
+
704
+ assert.equal(gitServiceBasePath, "/project"); // project root, not worktree
705
+ });