gsd-pi 2.78.1-dev.8a893322c → 2.78.1-dev.a7b6e59b7

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 (79) hide show
  1. package/dist/cli-auto-routing.d.ts +1 -0
  2. package/dist/cli-auto-routing.js +5 -0
  3. package/dist/cli.js +5 -14
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +23 -11
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +55 -21
  7. package/dist/resources/extensions/gsd/auto-prompts.js +6 -0
  8. package/dist/resources/extensions/gsd/auto-worktree.js +15 -0
  9. package/dist/resources/extensions/gsd/auto.js +25 -9
  10. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  11. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  12. package/dist/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  13. package/dist/resources/extensions/gsd/worktree-resolver.js +24 -0
  14. package/dist/resources/skills/lint/SKILL.md +4 -0
  15. package/dist/resources/skills/review/SKILL.md +4 -0
  16. package/dist/resources/skills/test/SKILL.md +3 -0
  17. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  18. package/dist/web/standalone/.next/BUILD_ID +1 -1
  19. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  20. package/dist/web/standalone/.next/build-manifest.json +2 -2
  21. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  22. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.html +1 -1
  39. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  46. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  47. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  48. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  49. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  50. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  51. package/package.json +1 -1
  52. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +278 -0
  53. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
  55. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/agent-session.js +125 -55
  57. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  58. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +319 -0
  59. package/packages/pi-coding-agent/src/core/agent-session.ts +128 -59
  60. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  61. package/src/resources/extensions/gsd/auto/run-unit.ts +23 -11
  62. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +60 -24
  63. package/src/resources/extensions/gsd/auto-prompts.ts +6 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +15 -0
  65. package/src/resources/extensions/gsd/auto.ts +23 -6
  66. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  67. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  68. package/src/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  69. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1 -0
  70. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +8 -2
  71. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +12 -6
  72. package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +235 -0
  73. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +85 -0
  74. package/src/resources/extensions/gsd/worktree-resolver.ts +24 -0
  75. package/src/resources/skills/lint/SKILL.md +4 -0
  76. package/src/resources/skills/review/SKILL.md +4 -0
  77. package/src/resources/skills/test/SKILL.md +3 -0
  78. /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_buildManifest.js +0 -0
  79. /package/dist/web/standalone/.next/static/{QK8fABiGPmonfTgboN0Y9 → GlYncvckBGG33CSoJaSnB}/_ssgManifest.js +0 -0
@@ -40,6 +40,29 @@ export async function runUnit(
40
40
  ): Promise<UnitResult> {
41
41
  debugLog("runUnit", { phase: "start", unitType, unitId });
42
42
 
43
+ // Ensure cwd matches basePath BEFORE newSession() captures it. The new
44
+ // session reads process.cwd() during construction to anchor its tool
45
+ // runtime and system prompt; if cwd has drifted (async_bash, background
46
+ // jobs, prior unit cleanup), the session would otherwise be rooted to
47
+ // the wrong directory. Must be synchronous — no awaits between chdir
48
+ // and newSession (#1389, #4762 follow-up).
49
+ try {
50
+ if (process.cwd() !== s.basePath) {
51
+ process.chdir(s.basePath);
52
+ }
53
+ } catch (e) {
54
+ const msg = `Failed to chdir to basePath before newSession (basePath: ${s.basePath}): ${String(e)}`;
55
+ logWarning("engine", msg, { basePath: s.basePath, error: String(e) });
56
+ return {
57
+ status: "cancelled",
58
+ errorContext: {
59
+ message: msg,
60
+ category: "session-failed",
61
+ isTransient: true,
62
+ },
63
+ };
64
+ }
65
+
43
66
  // ── Session creation with timeout ──
44
67
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
45
68
 
@@ -120,17 +143,6 @@ export async function runUnit(
120
143
  _setCurrentResolve(resolve);
121
144
  });
122
145
 
123
- // Ensure cwd matches basePath before dispatch (#1389).
124
- // async_bash and background jobs can drift cwd away from the worktree.
125
- // Realigning here prevents commits from landing on the wrong branch.
126
- try {
127
- if (process.cwd() !== s.basePath) {
128
- process.chdir(s.basePath);
129
- }
130
- } catch (e) {
131
- logWarning("engine", "Failed to chdir to basePath before dispatch", { basePath: s.basePath, error: String(e) });
132
- }
133
-
134
146
  // ── Provider request-readiness pre-check (#4555) ──
135
147
  // Verify the provider can accept requests before dispatching. If the token
136
148
  // has expired since bootstrap, return cancelled immediately so the unit is
@@ -30,6 +30,8 @@ import {
30
30
  import { loadEffectiveGSDPreferences } from "./preferences.js";
31
31
  import type { MinimalModelRegistry } from "./context-budget.js";
32
32
  import { pauseAuto } from "./auto.js";
33
+ import { resolveCanonicalMilestoneRoot } from "./worktree-manager.js";
34
+ import { logWarning } from "./workflow-logger.js";
33
35
  import {
34
36
  getWorkflowTransportSupportError,
35
37
  getRequiredWorkflowToolsForAutoUnit,
@@ -50,6 +52,14 @@ export async function dispatchDirectPhase(
50
52
  return;
51
53
  }
52
54
 
55
+ const projectRoot = base;
56
+
57
+ // Switch the dispatch base to the canonical milestone worktree if one
58
+ // exists. Without this, /gsd dispatch invoked from the project root would
59
+ // build prompts and create a session anchored to the project root even
60
+ // though the milestone's actual code lives in the worktree.
61
+ const dispatchBase = resolveCanonicalMilestoneRoot(base, mid);
62
+
53
63
  const normalized = phase.toLowerCase();
54
64
  let unitType: string;
55
65
  let unitId: string;
@@ -70,7 +80,7 @@ export async function dispatchDirectPhase(
70
80
 
71
81
  // When require_slice_discussion is enabled, pause auto-mode before
72
82
  // each new slice so the user can discuss requirements first (#789).
73
- const sliceContextFile = resolveSliceFile(base, mid, sid, "CONTEXT");
83
+ const sliceContextFile = resolveSliceFile(dispatchBase, mid, sid, "CONTEXT");
74
84
  const requireDiscussion = loadEffectiveGSDPreferences()?.preferences?.phases?.require_slice_discussion;
75
85
  if (requireDiscussion && !sliceContextFile) {
76
86
  ctx.ui.notify(
@@ -83,11 +93,11 @@ export async function dispatchDirectPhase(
83
93
 
84
94
  unitType = "research-slice";
85
95
  unitId = `${mid}/${sid}`;
86
- prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base);
96
+ prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, dispatchBase);
87
97
  } else {
88
98
  unitType = "research-milestone";
89
99
  unitId = mid;
90
- prompt = await buildResearchMilestonePrompt(mid, midTitle, base);
100
+ prompt = await buildResearchMilestonePrompt(mid, midTitle, dispatchBase);
91
101
  }
92
102
  break;
93
103
  }
@@ -106,7 +116,7 @@ export async function dispatchDirectPhase(
106
116
  unitType = "plan-slice";
107
117
  unitId = `${mid}/${sid}`;
108
118
  prompt = await buildPlanSlicePrompt(
109
- mid, midTitle, sid, sTitle, base, undefined,
119
+ mid, midTitle, sid, sTitle, dispatchBase, undefined,
110
120
  {
111
121
  sessionContextWindow: ctx.model?.contextWindow,
112
122
  modelRegistry: ctx.modelRegistry as MinimalModelRegistry | undefined,
@@ -115,7 +125,7 @@ export async function dispatchDirectPhase(
115
125
  } else {
116
126
  unitType = "plan-milestone";
117
127
  unitId = mid;
118
- prompt = await buildPlanMilestonePrompt(mid, midTitle, base);
128
+ prompt = await buildPlanMilestonePrompt(mid, midTitle, dispatchBase);
119
129
  }
120
130
  break;
121
131
  }
@@ -137,7 +147,7 @@ export async function dispatchDirectPhase(
137
147
  unitType = "execute-task";
138
148
  unitId = `${mid}/${sid}/${tid}`;
139
149
  prompt = await buildExecuteTaskPrompt(
140
- mid, sid, sTitle, tid, tTitle, base,
150
+ mid, sid, sTitle, tid, tTitle, dispatchBase,
141
151
  {
142
152
  sessionContextWindow: ctx.model?.contextWindow,
143
153
  modelRegistry: ctx.modelRegistry as MinimalModelRegistry | undefined,
@@ -159,11 +169,11 @@ export async function dispatchDirectPhase(
159
169
  }
160
170
  unitType = "complete-slice";
161
171
  unitId = `${mid}/${sid}`;
162
- prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base);
172
+ prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, dispatchBase);
163
173
  } else {
164
174
  unitType = "complete-milestone";
165
175
  unitId = mid;
166
- prompt = await buildCompleteMilestonePrompt(mid, midTitle, base);
176
+ prompt = await buildCompleteMilestonePrompt(mid, midTitle, dispatchBase);
167
177
  }
168
178
  break;
169
179
  }
@@ -177,7 +187,7 @@ export async function dispatchDirectPhase(
177
187
  }
178
188
  if (completedSliceIds.length === 0) {
179
189
  // File-based fallback: parse roadmap checkboxes
180
- const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
190
+ const roadmapPath = resolveMilestoneFile(dispatchBase, mid, "ROADMAP");
181
191
  if (roadmapPath) {
182
192
  const roadmapContent = await loadFile(roadmapPath);
183
193
  if (roadmapContent) {
@@ -192,7 +202,7 @@ export async function dispatchDirectPhase(
192
202
  const completedSliceId = completedSliceIds[completedSliceIds.length - 1];
193
203
  unitType = "reassess-roadmap";
194
204
  unitId = `${mid}/${completedSliceId}`;
195
- prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base);
205
+ prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, dispatchBase);
196
206
  break;
197
207
  }
198
208
 
@@ -208,7 +218,7 @@ export async function dispatchDirectPhase(
208
218
  }
209
219
  if (uatCompletedSliceIds.length === 0) {
210
220
  // File-based fallback: parse roadmap checkboxes
211
- const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
221
+ const roadmapPath = resolveMilestoneFile(dispatchBase, mid, "ROADMAP");
212
222
  if (roadmapPath) {
213
223
  const roadmapContent = await loadFile(roadmapPath);
214
224
  if (roadmapContent) {
@@ -221,7 +231,7 @@ export async function dispatchDirectPhase(
221
231
  return;
222
232
  }
223
233
  const sid = uatCompletedSliceIds[uatCompletedSliceIds.length - 1];
224
- const uatFile = resolveSliceFile(base, mid, sid, "UAT");
234
+ const uatFile = resolveSliceFile(dispatchBase, mid, sid, "UAT");
225
235
  if (!uatFile) {
226
236
  ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
227
237
  return;
@@ -231,10 +241,10 @@ export async function dispatchDirectPhase(
231
241
  ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning");
232
242
  return;
233
243
  }
234
- const uatPath = relSliceFile(base, mid, sid, "UAT");
244
+ const uatPath = relSliceFile(dispatchBase, mid, sid, "UAT");
235
245
  unitType = "run-uat";
236
246
  unitId = `${mid}/${sid}`;
237
- prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base);
247
+ prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, dispatchBase);
238
248
  break;
239
249
  }
240
250
 
@@ -248,7 +258,7 @@ export async function dispatchDirectPhase(
248
258
  }
249
259
  unitType = "replan-slice";
250
260
  unitId = `${mid}/${sid}`;
251
- prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base);
261
+ prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, dispatchBase);
252
262
  break;
253
263
  }
254
264
 
@@ -264,7 +274,7 @@ export async function dispatchDirectPhase(
264
274
  ctx.model?.provider,
265
275
  getRequiredWorkflowToolsForAutoUnit(unitType),
266
276
  {
267
- projectRoot: base,
277
+ projectRoot,
268
278
  surface: "direct phase dispatch",
269
279
  unitType,
270
280
  authMode: ctx.model?.provider ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) : undefined,
@@ -277,13 +287,39 @@ export async function dispatchDirectPhase(
277
287
  }
278
288
 
279
289
  ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info");
280
- const result = await ctx.newSession();
281
- if (result.cancelled) {
282
- ctx.ui.notify("Session creation cancelled.", "warning");
283
- return;
290
+
291
+ const originalCwd = process.cwd();
292
+
293
+ try {
294
+ // Ensure cwd matches dispatchBase BEFORE newSession() captures it. Synchronous —
295
+ // no awaits between chdir and newSession.
296
+ try {
297
+ if (process.cwd() !== dispatchBase) {
298
+ process.chdir(dispatchBase);
299
+ }
300
+ } catch (err) {
301
+ const msg = `Failed to chdir before direct-dispatch newSession (basePath: ${dispatchBase}): ${err instanceof Error ? err.message : String(err)}`;
302
+ logWarning("engine", msg, { file: "auto-direct-dispatch.ts", basePath: dispatchBase, error: err instanceof Error ? err.message : String(err) });
303
+ ctx.ui.notify(`${msg}. Cancelling dispatch to avoid running in the wrong directory.`, "error");
304
+ return;
305
+ }
306
+
307
+ const result = await ctx.newSession();
308
+ if (result.cancelled) {
309
+ ctx.ui.notify("Session creation cancelled.", "warning");
310
+ return;
311
+ }
312
+ pi.sendMessage(
313
+ { customType: "gsd-dispatch", content: prompt, display: false },
314
+ { triggerTurn: true },
315
+ );
316
+ } finally {
317
+ try {
318
+ if (process.cwd() !== originalCwd) {
319
+ process.chdir(originalCwd);
320
+ }
321
+ } catch (err) {
322
+ logWarning("engine", `Failed to restore cwd after direct dispatch: ${err instanceof Error ? err.message : String(err)}`, { file: "auto-direct-dispatch.ts", basePath: originalCwd });
323
+ }
284
324
  }
285
- pi.sendMessage(
286
- { customType: "gsd-dispatch", content: prompt, display: false },
287
- { triggerTurn: true },
288
- );
289
325
  }
@@ -1267,6 +1267,7 @@ export async function buildDiscussMilestonePrompt(
1267
1267
  const discussTemplates = inlineTemplate("context", "Context");
1268
1268
 
1269
1269
  const basePrompt = loadPrompt("guided-discuss-milestone", {
1270
+ workingDirectory: base,
1270
1271
  milestoneId: mid,
1271
1272
  milestoneTitle: midTitle,
1272
1273
  inlinedTemplates: discussTemplates,
@@ -2645,6 +2646,7 @@ export async function buildParallelResearchSlicesPrompt(
2645
2646
  }
2646
2647
 
2647
2648
  return loadPrompt("parallel-research-slices", {
2649
+ workingDirectory: basePath,
2648
2650
  mid,
2649
2651
  midTitle,
2650
2652
  sliceCount: String(slices.length),
@@ -2681,6 +2683,7 @@ export async function buildGateEvaluatePrompt(
2681
2683
 
2682
2684
  const subagentSections: string[] = [];
2683
2685
  const gateListLines: string[] = [];
2686
+ const normalizedBase = base.replaceAll("\\", "/");
2684
2687
 
2685
2688
  for (const def of gateDefs) {
2686
2689
  gateListLines.push(`- **${def.id}**: ${def.question}`);
@@ -2688,6 +2691,8 @@ export async function buildGateEvaluatePrompt(
2688
2691
  const subPrompt = [
2689
2692
  `You are evaluating quality gate **${def.id}** for slice ${sid} (${sTitle}).`,
2690
2693
  "",
2694
+ `**Working directory:** \`${normalizedBase}\`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT \`cd\` to any other directory.`,
2695
+ "",
2691
2696
  `## Question: ${def.question}`,
2692
2697
  "",
2693
2698
  def.guidance,
@@ -2804,6 +2809,7 @@ export async function buildRewriteDocsPrompt(
2804
2809
  const documentList = docList.length > 0 ? docList.join("\n") : "- No active plan documents found.";
2805
2810
 
2806
2811
  return loadPrompt("rewrite-docs", {
2812
+ workingDirectory: base,
2807
2813
  milestoneId: mid,
2808
2814
  milestoneTitle: midTitle,
2809
2815
  sliceId: sid ?? "none",
@@ -2372,5 +2372,20 @@ export function mergeMilestoneToMain(
2372
2372
  originalBase = null;
2373
2373
  nudgeGitBranchCache(previousCwd);
2374
2374
 
2375
+ // 15. Anchor cwd at the project root on success-return. Step 12 removed
2376
+ // the worktree dir; if cwd was inside it, every subsequent process.cwd()
2377
+ // would throw ENOENT and trip auto/run-unit.ts:50's session-failed cancel
2378
+ // path (the de73fb43d regression that closes headless gsd auto). Step 3
2379
+ // already chdir'd here, but defending the success-return contract makes
2380
+ // future maintainers safe against intervening chdir's between step 3 and
2381
+ // here.
2382
+ try {
2383
+ // process.cwd() can throw ENOENT when cwd was removed, so attempt
2384
+ // recovery directly.
2385
+ process.chdir(originalBasePath_);
2386
+ } catch (err) {
2387
+ logWarning("worktree", `chdir to project root after merge failed: ${err instanceof Error ? err.message : String(err)}`);
2388
+ }
2389
+
2375
2390
  return { commitMessage, pushed, prCreated, codeFilesChanged };
2376
2391
  }
@@ -1864,16 +1864,21 @@ export async function dispatchHookUnit(
1864
1864
  hookModel: string | undefined,
1865
1865
  targetBasePath: string,
1866
1866
  ): Promise<boolean> {
1867
+ const wasActive = s.active;
1868
+ const previousBasePath = s.basePath;
1869
+ const previousCurrentUnit = s.currentUnit ? { ...s.currentUnit } : null;
1870
+
1867
1871
  if (!s.active) {
1868
1872
  s.active = true;
1869
1873
  s.stepMode = true;
1870
1874
  s.cmdCtx = ctx as ExtensionCommandContext;
1871
- s.basePath = targetBasePath;
1872
1875
  s.autoStartTime = Date.now();
1873
1876
  s.currentUnit = null;
1874
1877
  s.pendingQuickTasks = [];
1875
1878
  }
1876
1879
 
1880
+ s.basePath = targetBasePath;
1881
+
1877
1882
  const hookUnitType = `hook/${hookName}`;
1878
1883
  const hookStartedAt = Date.now();
1879
1884
 
@@ -1883,6 +1888,23 @@ export async function dispatchHookUnit(
1883
1888
  startedAt: hookStartedAt,
1884
1889
  };
1885
1890
 
1891
+ // Ensure cwd matches basePath BEFORE newSession() captures it (#1389).
1892
+ // newSession() snapshots process.cwd() during construction; chdir-ing
1893
+ // afterward leaves the session rooted to whatever cwd was when the call
1894
+ // was made. Must be synchronous — no awaits between chdir and newSession.
1895
+ try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch (err) {
1896
+ const msg = `Failed to chdir before hook newSession (basePath: ${s.basePath}): ${err instanceof Error ? err.message : String(err)}`;
1897
+ logWarning("engine", msg, { file: "auto.ts", basePath: s.basePath, error: err instanceof Error ? err.message : String(err) });
1898
+ ctx.ui.notify(`${msg}. Cancelling hook dispatch to avoid running in the wrong directory.`, "error");
1899
+ if (wasActive) {
1900
+ s.basePath = previousBasePath;
1901
+ s.currentUnit = previousCurrentUnit;
1902
+ } else {
1903
+ s.reset();
1904
+ }
1905
+ return false;
1906
+ }
1907
+
1886
1908
  const result = await s.cmdCtx!.newSession();
1887
1909
  if (result.cancelled) {
1888
1910
  await stopAuto(ctx, pi);
@@ -1939,11 +1961,6 @@ export async function dispatchHookUnit(
1939
1961
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
1940
1962
  ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
1941
1963
 
1942
- // Ensure cwd matches basePath before hook dispatch (#1389)
1943
- try { if (process.cwd() !== s.basePath) process.chdir(s.basePath); } catch (err) {
1944
- logWarning("engine", `chdir failed before hook dispatch: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1945
- }
1946
-
1947
1964
  debugLog("dispatchHookUnit", {
1948
1965
  phase: "send-message",
1949
1966
  promptLength: hookPrompt.length,
@@ -1,3 +1,5 @@
1
+ **Working directory:** `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory.
2
+
1
3
  Discuss milestone {{milestoneId}} ("{{milestoneTitle}}"). Identify gray areas, ask the user about them, and write `{{milestoneId}}-CONTEXT.md` in the milestone directory with the decisions. Use the **Context** output template below. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow; do not override required artifact rules.
2
4
 
3
5
  **Structured questions available: {{structuredQuestionsAvailable}}**
@@ -1,5 +1,7 @@
1
1
  # Parallel Slice Research
2
2
 
3
+ **Working directory:** `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory.
4
+
3
5
  You are dispatching parallel research agents for **{{sliceCount}} slices** in milestone **{{mid}} — {{midTitle}}**.
4
6
 
5
7
  ## Slices to Research
@@ -1,5 +1,7 @@
1
1
  You are executing GSD auto-mode.
2
2
 
3
+ **Working directory:** `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory.
4
+
3
5
  ## UNIT: Rewrite Documents — Apply Override(s) for Milestone {{milestoneId}} ("{{milestoneTitle}}")
4
6
 
5
7
  An override was issued by the user that changes a fundamental decision or approach. Your job is to propagate this change across all active planning documents so they are internally consistent and future tasks execute correctly.
@@ -63,6 +63,7 @@ function makeMockSession(opts?: {
63
63
  const session = {
64
64
  active: true,
65
65
  verbose: false,
66
+ basePath: process.cwd(),
66
67
  cmdCtx: {
67
68
  newSession: (options?: { abortSignal?: AbortSignal }) => {
68
69
  opts?.onNewSessionStart?.(session);
@@ -21,6 +21,7 @@ import { _clearGsdRootCache } from "../paths.ts";
21
21
  // Isolate from user's global preferences (which may have git.main_branch set)
22
22
  let originalHome: string | undefined;
23
23
  let fakeHome: string;
24
+ const testCwd = process.cwd();
24
25
 
25
26
  test.before(() => {
26
27
  originalHome = process.env.HOME;
@@ -37,6 +38,11 @@ test.after(() => {
37
38
  rmSync(fakeHome, { recursive: true, force: true });
38
39
  });
39
40
 
41
+ function cleanupTempRepo(repo: string): void {
42
+ try { process.chdir(testCwd); } catch { /* best-effort */ }
43
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
44
+ }
45
+
40
46
  function run(cmd: string, cwd: string): string {
41
47
  return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
42
48
  }
@@ -106,7 +112,7 @@ test("#2766: stash pop conflict on .gsd/ files is auto-resolved", () => {
106
112
  try { stashList = run("git stash list", repo); } catch { /* empty stash */ }
107
113
  assert.strictEqual(stashList, "", "stash is empty after .gsd/ conflict auto-resolution");
108
114
  } finally {
109
- try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
115
+ cleanupTempRepo(repo);
110
116
  }
111
117
  });
112
118
 
@@ -141,6 +147,6 @@ test("#2766: stash pop conflict on non-.gsd files preserves stash for manual res
141
147
  "merge succeeds even with non-.gsd stash pop conflict",
142
148
  );
143
149
  } finally {
144
- try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
150
+ cleanupTempRepo(repo);
145
151
  }
146
152
  });
@@ -35,6 +35,7 @@ import { _clearGsdRootCache } from "../paths.ts";
35
35
  // Isolate from user's global preferences (which may have git.main_branch set)
36
36
  let originalHome: string | undefined;
37
37
  let fakeHome: string;
38
+ const testCwd = process.cwd();
38
39
 
39
40
  test.before(() => {
40
41
  originalHome = process.env.HOME;
@@ -51,6 +52,13 @@ test.after(() => {
51
52
  rmSync(fakeHome, { recursive: true, force: true });
52
53
  });
53
54
 
55
+ function cleanupTempPaths(...paths: string[]): void {
56
+ try { process.chdir(testCwd); } catch { /* best-effort */ }
57
+ for (const p of paths) {
58
+ rmSync(p, { recursive: true, force: true });
59
+ }
60
+ }
61
+
54
62
  function run(cmd: string, cwd: string): string {
55
63
  return execSync(cmd, {
56
64
  cwd,
@@ -271,7 +279,7 @@ test("#2505: mergeMilestoneToMain preserves queued CONTEXT files (not swept into
271
279
  }
272
280
  }
273
281
  } finally {
274
- rmSync(repo, { recursive: true, force: true });
282
+ cleanupTempPaths(repo);
275
283
  }
276
284
  });
277
285
 
@@ -308,8 +316,7 @@ test("#2505: pre-merge stash handles symlinked .gsd without traversing it", () =
308
316
  assert.equal(readFileSync(join(repo, "README.md"), "utf-8").replace(/\r\n/g, "\n"), "# test\n\nDirty change.\n");
309
317
  assert.equal(readFileSync(join(repo, "local-note.txt"), "utf-8"), "local scratch\n");
310
318
  } finally {
311
- rmSync(repo, { recursive: true, force: true });
312
- rmSync(stateDir, { recursive: true, force: true });
319
+ cleanupTempPaths(repo, stateDir);
313
320
  }
314
321
  });
315
322
 
@@ -375,7 +382,7 @@ test("#2505: back-to-back merges preserve queued CONTEXT files", () => {
375
382
  "M013 context content preserved after back-to-back merges",
376
383
  );
377
384
  } finally {
378
- rmSync(repo, { recursive: true, force: true });
385
+ cleanupTempPaths(repo);
379
386
  }
380
387
  });
381
388
 
@@ -430,7 +437,6 @@ test("#4573: gitignored .gsd symlink does not break pre-merge stash", () => {
430
437
  ".gsd symlink remains in place",
431
438
  );
432
439
  } finally {
433
- rmSync(repo, { recursive: true, force: true });
434
- rmSync(stateDir, { recursive: true, force: true });
440
+ cleanupTempPaths(repo, stateDir);
435
441
  }
436
442
  });