gsd-pi 2.78.1-dev.9d08d820b → 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 (101) hide show
  1. package/README.md +1 -0
  2. package/dist/cli-auto-routing.d.ts +1 -0
  3. package/dist/cli-auto-routing.js +5 -0
  4. package/dist/cli.js +5 -14
  5. package/dist/resources/.managed-resources-content-hash +1 -1
  6. package/dist/resources/extensions/google-search/index.js +2 -6
  7. package/dist/resources/extensions/gsd/auto/run-unit.js +23 -11
  8. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +55 -21
  9. package/dist/resources/extensions/gsd/auto-prompts.js +6 -0
  10. package/dist/resources/extensions/gsd/auto-worktree.js +15 -0
  11. package/dist/resources/extensions/gsd/auto.js +25 -9
  12. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +12 -0
  13. package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -0
  14. package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
  15. package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
  16. package/dist/resources/extensions/gsd/commands/handlers/ops.js +8 -0
  17. package/dist/resources/extensions/gsd/commands-worktree.js +309 -0
  18. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  19. package/dist/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  20. package/dist/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  21. package/dist/resources/extensions/gsd/worktree-resolver.js +24 -0
  22. package/dist/resources/extensions/mcp-client/index.js +0 -6
  23. package/dist/resources/skills/lint/SKILL.md +4 -0
  24. package/dist/resources/skills/review/SKILL.md +4 -0
  25. package/dist/resources/skills/test/SKILL.md +3 -0
  26. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  27. package/dist/web/standalone/.next/BUILD_ID +1 -1
  28. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  29. package/dist/web/standalone/.next/build-manifest.json +2 -2
  30. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  31. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.html +1 -1
  48. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  55. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  57. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  58. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  59. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  60. package/dist/welcome-screen.js +27 -1
  61. package/package.json +1 -1
  62. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +278 -0
  63. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +7 -0
  65. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/agent-session.js +125 -55
  67. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +319 -0
  69. package/packages/pi-coding-agent/src/core/agent-session.ts +128 -59
  70. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  71. package/src/resources/extensions/google-search/index.ts +2 -9
  72. package/src/resources/extensions/gsd/auto/run-unit.ts +23 -11
  73. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +60 -24
  74. package/src/resources/extensions/gsd/auto-prompts.ts +6 -0
  75. package/src/resources/extensions/gsd/auto-worktree.ts +15 -0
  76. package/src/resources/extensions/gsd/auto.ts +23 -6
  77. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +13 -0
  78. package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -0
  79. package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
  80. package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
  81. package/src/resources/extensions/gsd/commands/handlers/ops.ts +10 -0
  82. package/src/resources/extensions/gsd/commands-worktree.ts +383 -0
  83. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  84. package/src/resources/extensions/gsd/prompts/parallel-research-slices.md +2 -0
  85. package/src/resources/extensions/gsd/prompts/rewrite-docs.md +2 -0
  86. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +1 -0
  87. package/src/resources/extensions/gsd/tests/bundled-skill-triggers.test.ts +50 -27
  88. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +48 -0
  89. package/src/resources/extensions/gsd/tests/google-search-stub.test.ts +25 -65
  90. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +34 -0
  91. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +8 -2
  92. package/src/resources/extensions/gsd/tests/stash-queued-context-files.test.ts +12 -6
  93. package/src/resources/extensions/gsd/tests/worktree-path-injection.test.ts +235 -0
  94. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +85 -0
  95. package/src/resources/extensions/gsd/worktree-resolver.ts +24 -0
  96. package/src/resources/extensions/mcp-client/index.ts +0 -7
  97. package/src/resources/skills/lint/SKILL.md +4 -0
  98. package/src/resources/skills/review/SKILL.md +4 -0
  99. package/src/resources/skills/test/SKILL.md +3 -0
  100. /package/dist/web/standalone/.next/static/{-Ukk6_YxRd4GY4iUOnRUE → GlYncvckBGG33CSoJaSnB}/_buildManifest.js +0 -0
  101. /package/dist/web/standalone/.next/static/{-Ukk6_YxRd4GY4iUOnRUE → GlYncvckBGG33CSoJaSnB}/_ssgManifest.js +0 -0
@@ -11,26 +11,23 @@ test("google-search stub: default export is a function", async (_t) => {
11
11
  assert.equal(typeof stubFn, "function");
12
12
  });
13
13
 
14
- test("google-search stub: registers session_start handler", async (_t) => {
15
- // STUB-01: stub calls pi.on("session_start", ...)
14
+ test("google-search stub: registers no event handlers", async (_t) => {
15
+ // STUB-01: deprecation notice is suppressed — stub must not call pi.on() at all
16
16
  const mod = await import("../../google-search/index.ts");
17
17
  const stubFn = mod.default;
18
18
 
19
- let capturedEvent: string | undefined;
20
- let capturedHandler: unknown;
19
+ let onCallCount = 0;
21
20
 
22
21
  const mockPi = {
23
- on(event: string, handler: unknown) {
24
- capturedEvent = event;
25
- capturedHandler = handler;
22
+ on(_event: string, _handler: unknown) {
23
+ onCallCount++;
26
24
  },
27
25
  registerTool: () => {},
28
26
  };
29
27
 
30
28
  stubFn(mockPi as never);
31
29
 
32
- assert.equal(capturedEvent, "session_start");
33
- assert.equal(typeof capturedHandler, "function");
30
+ assert.equal(onCallCount, 0, "stub should not register any event handlers");
34
31
  });
35
32
 
36
33
  test("google-search stub: does NOT call registerTool", async (_t) => {
@@ -50,82 +47,45 @@ test("google-search stub: does NOT call registerTool", async (_t) => {
50
47
  assert.equal(registerToolCalled, false);
51
48
  });
52
49
 
53
- test("google-search stub: session_start warning contains package name", async (_t) => {
54
- // STUB-01: warning includes @gsd-extensions/google-search
50
+ test("google-search stub: does not emit any notifications", async (_t) => {
51
+ // STUB-01: deprecation notice is suppressed — stub must not call ctx.ui.notify()
55
52
  const mod = await import("../../google-search/index.ts");
56
53
  const stubFn = mod.default;
57
54
 
58
- let capturedHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
55
+ let notifyCallCount = 0;
59
56
 
60
57
  const mockPi = {
61
- on(_event: string, handler: (event: unknown, ctx: unknown) => Promise<void>) {
62
- capturedHandler = handler;
63
- },
58
+ on(_event: string, _handler: unknown) {},
64
59
  registerTool: () => {},
65
60
  };
66
61
 
62
+ // Verify no notify is emitted by the stub itself (it registers no handlers,
63
+ // so there is nothing to invoke — this simply confirms no top-level notify call).
67
64
  stubFn(mockPi as never);
68
65
 
69
- assert.ok(capturedHandler, "session_start handler should have been registered");
70
-
71
- let capturedMessage: string | undefined;
72
- const mockCtx = {
73
- ui: {
74
- notify(message: string, _level: string) {
75
- capturedMessage = message;
76
- },
77
- },
78
- };
79
-
80
- await capturedHandler!({}, mockCtx);
81
-
82
- assert.ok(
83
- capturedMessage?.includes("@gsd-extensions/google-search"),
84
- `Expected message to include "@gsd-extensions/google-search", got: "${capturedMessage}"`,
85
- );
66
+ assert.equal(notifyCallCount, 0, "stub should not emit any notifications");
86
67
  });
87
68
 
88
- test("google-search stub: session_start warning explains package is not yet published", async (_t) => {
89
- // STUB-01: stub must NOT advise `gsd extensions install` — the replacement
90
- // package is not yet on npm, so that command would 404. The message must
91
- // explain the extraction is in progress and no user action is required.
69
+ test("google-search stub: is a complete no-op (no handlers, no tools, no notifications)", async (_t) => {
70
+ // STUB-01: the deprecation notice is suppressed until @gsd-extensions/google-search
71
+ // ships. The stub must call nothing on the ExtensionAPI.
92
72
  const mod = await import("../../google-search/index.ts");
93
73
  const stubFn = mod.default;
94
74
 
95
- let capturedHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
75
+ let onCallCount = 0;
76
+ let registerToolCallCount = 0;
96
77
 
97
78
  const mockPi = {
98
- on(_event: string, handler: (event: unknown, ctx: unknown) => Promise<void>) {
99
- capturedHandler = handler;
79
+ on(_event: string, _handler: unknown) {
80
+ onCallCount++;
81
+ },
82
+ registerTool: () => {
83
+ registerToolCallCount++;
100
84
  },
101
- registerTool: () => {},
102
85
  };
103
86
 
104
87
  stubFn(mockPi as never);
105
88
 
106
- assert.ok(capturedHandler, "session_start handler should have been registered");
107
-
108
- let capturedMessage: string | undefined;
109
- const mockCtx = {
110
- ui: {
111
- notify(message: string, _level: string) {
112
- capturedMessage = message;
113
- },
114
- },
115
- };
116
-
117
- await capturedHandler!({}, mockCtx);
118
-
119
- assert.ok(
120
- !capturedMessage?.includes("gsd extensions install"),
121
- `Expected message NOT to include unpublished install command, got: "${capturedMessage}"`,
122
- );
123
- assert.ok(
124
- capturedMessage?.includes("not yet published"),
125
- `Expected message to include "not yet published", got: "${capturedMessage}"`,
126
- );
127
- assert.ok(
128
- capturedMessage?.includes("No action needed"),
129
- `Expected message to include "No action needed", got: "${capturedMessage}"`,
130
- );
89
+ assert.equal(onCallCount, 0, "stub should not register any event handlers");
90
+ assert.equal(registerToolCallCount, 0, "stub should not register any tools");
131
91
  });
@@ -18,9 +18,11 @@ import { shouldBlockQueueExecution } from "../bootstrap/write-gate.ts";
18
18
  import {
19
19
  resetEvidence,
20
20
  recordToolCall,
21
+ recordToolResult,
21
22
  getEvidence,
22
23
  saveEvidenceToDisk,
23
24
  loadEvidenceFromDisk,
25
+ type BashEvidence,
24
26
  } from "../safety/evidence-collector.ts";
25
27
  import { validateFileChanges } from "../safety/file-change-validator.ts";
26
28
 
@@ -110,6 +112,38 @@ test("safety-harness-bug2: loadEvidenceFromDisk returns empty array when no file
110
112
  assert.equal(getEvidence().length, 0, "no evidence on fresh unit is correct — not a false positive");
111
113
  });
112
114
 
115
+ test("safety-harness-bug2-race: bash evidence survives mid-unit reset between tool_call and tool_execution_end", (t) => {
116
+ // Reproduces the race where runUnitPhase re-fires (resetEvidence + loadEvidenceFromDisk)
117
+ // between a bash tool_call and its tool_execution_end. Pre-fix, the call entry lived
118
+ // only in memory until tool_execution_end; the reset wiped it and recordToolResult
119
+ // silently no-op'd, producing the "task complete with no bash calls" false positive.
120
+ // Post-fix, register-hooks.ts persists at tool_call time too — so the entry survives
121
+ // the reset via the disk round-trip.
122
+ const base = mkdtempSync(join(tmpdir(), "gsd-evidence-race-"));
123
+ t.after(() => rmSync(base, { recursive: true, force: true }));
124
+
125
+ resetEvidence();
126
+
127
+ // tool_call fires: record AND persist (post-fix register-hooks.ts behavior).
128
+ recordToolCall("tc-bash-1", "bash", { command: "grep -q saveTodos app.js" });
129
+ saveEvidenceToDisk(base, "M001", "S01", "T02");
130
+
131
+ // Mid-unit race: runUnitPhase re-fires, calling resetEvidence + loadEvidenceFromDisk.
132
+ resetEvidence();
133
+ assert.equal(getEvidence().length, 0, "memory cleared by mid-unit reset");
134
+ loadEvidenceFromDisk(base, "M001", "S01", "T02");
135
+ assert.equal(getEvidence().length, 1, "entry restored from disk-persisted tool_call");
136
+
137
+ // tool_execution_end fires: result must update the restored entry by toolCallId.
138
+ recordToolResult("tc-bash-1", "bash", "Command exited with code 0\nfound\n", false);
139
+
140
+ const bash = getEvidence().filter((e): e is BashEvidence => e.kind === "bash");
141
+ assert.equal(bash.length, 1, "bash entry must survive race + result update");
142
+ assert.equal(bash[0].exitCode, 0, "result populated the restored entry");
143
+ assert.equal(bash[0].command, "grep -q saveTodos app.js", "command preserved across race");
144
+ assert.ok(bash[0].outputSnippet.includes("found"), "output snippet captured");
145
+ });
146
+
113
147
  // ─── Bug 3: git diff HEAD~1 scope check ─────────────────────────────────────
114
148
 
115
149
  test("safety-harness-bug3: validateFileChanges works on initial commit (no HEAD~1)", (t) => {
@@ -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
  });
@@ -0,0 +1,235 @@
1
+ import test, { after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const ownsGsdHome = process.env.GSD_HOME_TEST_OVERRIDE === undefined;
8
+ const previousGsdHome = process.env.GSD_HOME;
9
+ const synthesizedGsdHome = join(tmpdir(), `gsd-test-home-${process.pid}-${Date.now()}`);
10
+ process.env.GSD_HOME = process.env.GSD_HOME_TEST_OVERRIDE
11
+ ?? synthesizedGsdHome;
12
+
13
+ after(() => {
14
+ if (ownsGsdHome) {
15
+ rmSync(synthesizedGsdHome, { recursive: true, force: true });
16
+ }
17
+ if (previousGsdHome === undefined) {
18
+ delete process.env.GSD_HOME;
19
+ } else {
20
+ process.env.GSD_HOME = previousGsdHome;
21
+ }
22
+ });
23
+
24
+ const { dispatchDirectPhase } = await import("../auto-direct-dispatch.ts");
25
+ const {
26
+ buildDiscussMilestonePrompt,
27
+ buildParallelResearchSlicesPrompt,
28
+ buildRewriteDocsPrompt,
29
+ } = await import("../auto-prompts.ts");
30
+ const { invalidateStateCache } = await import("../state.ts");
31
+ const { resolveAgentEnd, runUnit, _resetPendingResolve } = await import("../auto-loop.ts");
32
+
33
+ function writeMilestone(base: string, mid = "M001", title = "Worktree Path Injection"): void {
34
+ const milestoneDir = join(base, ".gsd", "milestones", mid);
35
+ mkdirSync(milestoneDir, { recursive: true });
36
+ writeFileSync(
37
+ join(milestoneDir, `${mid}-CONTEXT.md`),
38
+ `# ${mid}: ${title}\n\nContext.\n`,
39
+ "utf-8",
40
+ );
41
+ writeFileSync(
42
+ join(milestoneDir, `${mid}-ROADMAP.md`),
43
+ [
44
+ `# ${mid}: ${title}`,
45
+ "",
46
+ "## Slices",
47
+ "",
48
+ "- [ ] **S01: First slice** `risk:low` `depends:[]`",
49
+ "",
50
+ ].join("\n"),
51
+ "utf-8",
52
+ );
53
+ }
54
+
55
+ function makeLiveMilestoneWorktree(base: string, mid = "M001"): string {
56
+ const worktreeRoot = join(base, ".gsd", "worktrees", mid);
57
+ mkdirSync(worktreeRoot, { recursive: true });
58
+ writeFileSync(
59
+ join(worktreeRoot, ".git"),
60
+ `gitdir: ${join(base, ".git", "worktrees", mid)}\n`,
61
+ "utf-8",
62
+ );
63
+ writeMilestone(worktreeRoot, mid);
64
+ return worktreeRoot;
65
+ }
66
+
67
+ async function waitFor(condition: () => boolean, label: string): Promise<void> {
68
+ const rawTimeout = process.env.READABLE_WAIT_TIMEOUT_MS;
69
+ const parsedTimeout = rawTimeout === undefined ? NaN : Number.parseInt(rawTimeout, 10);
70
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : 1000;
71
+ const deadline = Date.now() + timeoutMs;
72
+
73
+ while (Date.now() < deadline) {
74
+ if (condition()) return;
75
+ await new Promise((resolve) => setTimeout(resolve, 5));
76
+ }
77
+ if (condition()) return;
78
+ assert.fail(`Timed out waiting for ${label} after ${timeoutMs}ms`);
79
+ }
80
+
81
+ test("runUnit changes cwd to basePath before creating a new session", async (t) => {
82
+ _resetPendingResolve();
83
+
84
+ const originalCwd = process.cwd();
85
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rununit-base-")));
86
+ const drifted = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rununit-drift-")));
87
+ t.after(() => {
88
+ process.chdir(originalCwd);
89
+ rmSync(base, { recursive: true, force: true });
90
+ rmSync(drifted, { recursive: true, force: true });
91
+ });
92
+
93
+ process.chdir(drifted);
94
+
95
+ let cwdAtNewSession: string | undefined;
96
+ const session = {
97
+ active: true,
98
+ basePath: base,
99
+ verbose: false,
100
+ cmdCtx: {
101
+ newSession: () => {
102
+ cwdAtNewSession = process.cwd();
103
+ return Promise.resolve({ cancelled: false });
104
+ },
105
+ },
106
+ } as any;
107
+ const pi = {
108
+ calls: [] as unknown[],
109
+ sendMessage(...args: unknown[]) {
110
+ this.calls.push(args);
111
+ },
112
+ } as any;
113
+ const ctx = { ui: { notify: () => {} }, model: { id: "test-model" } } as any;
114
+
115
+ const resultPromise = runUnit(ctx, pi, session, "task", "T01", "prompt");
116
+ await waitFor(() => pi.calls.length === 1, "runUnit dispatch");
117
+ resolveAgentEnd({ messages: [{ role: "assistant" }] });
118
+
119
+ const result = await resultPromise;
120
+ assert.equal(result.status, "completed");
121
+ assert.equal(cwdAtNewSession, base);
122
+ });
123
+
124
+ test("runUnit cancels before creating a session when basePath chdir fails", async (t) => {
125
+ _resetPendingResolve();
126
+
127
+ const originalCwd = process.cwd();
128
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rununit-missing-base-")));
129
+ const drifted = realpathSync(mkdtempSync(join(tmpdir(), "gsd-rununit-missing-drift-")));
130
+ rmSync(base, { recursive: true, force: true });
131
+ t.after(() => {
132
+ process.chdir(originalCwd);
133
+ rmSync(drifted, { recursive: true, force: true });
134
+ });
135
+
136
+ process.chdir(drifted);
137
+
138
+ let newSessionCalled = false;
139
+ const session = {
140
+ active: true,
141
+ basePath: base,
142
+ verbose: false,
143
+ cmdCtx: {
144
+ newSession: () => {
145
+ newSessionCalled = true;
146
+ return Promise.resolve({ cancelled: false });
147
+ },
148
+ },
149
+ } as any;
150
+ const pi = {
151
+ calls: [] as unknown[],
152
+ sendMessage(...args: unknown[]) {
153
+ this.calls.push(args);
154
+ },
155
+ } as any;
156
+ const ctx = { ui: { notify: () => {} }, model: { id: "test-model" } } as any;
157
+
158
+ const result = await runUnit(ctx, pi, session, "task", "T01", "prompt");
159
+
160
+ assert.equal(result.status, "cancelled");
161
+ assert.equal(result.errorContext?.category, "session-failed");
162
+ assert.equal(result.errorContext?.isTransient, true);
163
+ assert.match(result.errorContext?.message ?? "", /Failed to chdir to basePath before newSession/);
164
+ assert.ok(result.errorContext?.message.includes(base), "error should include the failed basePath");
165
+ assert.equal(newSessionCalled, false, "newSession must not run after chdir failure");
166
+ assert.equal(pi.calls.length, 0, "unit must not dispatch after chdir failure");
167
+ });
168
+
169
+ test("direct dispatch redirects to the canonical milestone worktree before newSession", async (t) => {
170
+ invalidateStateCache();
171
+
172
+ const originalCwd = process.cwd();
173
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-direct-base-")));
174
+ const drifted = realpathSync(mkdtempSync(join(tmpdir(), "gsd-direct-drift-")));
175
+ writeMilestone(base);
176
+ const worktreeRoot = makeLiveMilestoneWorktree(base);
177
+
178
+ t.after(() => {
179
+ process.chdir(originalCwd);
180
+ rmSync(base, { recursive: true, force: true });
181
+ rmSync(drifted, { recursive: true, force: true });
182
+ invalidateStateCache();
183
+ });
184
+
185
+ process.chdir(drifted);
186
+
187
+ let cwdAtNewSession: string | undefined;
188
+ let sentPrompt: string | undefined;
189
+ const ctx = {
190
+ ui: { notify: () => {} },
191
+ newSession: async () => {
192
+ cwdAtNewSession = process.cwd();
193
+ return { cancelled: false };
194
+ },
195
+ } as any;
196
+ const pi = {
197
+ sendMessage(message: { content: string }) {
198
+ sentPrompt = message.content;
199
+ },
200
+ } as any;
201
+
202
+ await dispatchDirectPhase(ctx, pi, "research-milestone", base);
203
+
204
+ assert.equal(cwdAtNewSession, worktreeRoot);
205
+ assert.equal(process.cwd(), drifted);
206
+ assert.ok(sentPrompt?.includes(worktreeRoot), "prompt should name the canonical worktree root");
207
+ });
208
+
209
+ test("worktree-aware prompt builders include the explicit working directory", async (t) => {
210
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-prompt-base-")));
211
+ writeMilestone(base);
212
+ t.after(() => rmSync(base, { recursive: true, force: true }));
213
+
214
+ const prompts = await Promise.all([
215
+ buildDiscussMilestonePrompt("M001", "Worktree Path Injection", base),
216
+ buildParallelResearchSlicesPrompt(
217
+ "M001",
218
+ "Worktree Path Injection",
219
+ [{ id: "S01", title: "First slice" }],
220
+ base,
221
+ ),
222
+ buildRewriteDocsPrompt(
223
+ "M001",
224
+ "Worktree Path Injection",
225
+ null,
226
+ base,
227
+ [{ change: "Refresh docs", timestamp: "2026-04-27T00:00:00.000Z", appliedAt: "test" }] as any,
228
+ ),
229
+ ]);
230
+
231
+ for (const prompt of prompts) {
232
+ assert.match(prompt, /working directory/i);
233
+ assert.ok(prompt.includes(base), "prompt should include the provided working directory");
234
+ }
235
+ });
@@ -1,5 +1,8 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, mkdirSync, realpathSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
  import {
4
7
  WorktreeResolver,
5
8
  type WorktreeResolverDeps,
@@ -1142,3 +1145,85 @@ test("mergeAndExit propagates non-MergeConflictError to caller (#4380)", () => {
1142
1145
  "non-MergeConflictError must propagate to the caller, not be swallowed",
1143
1146
  );
1144
1147
  });
1148
+
1149
+ // ─── Regression: mergeAndExit anchors cwd at project root before merge work ─
1150
+ // (de73fb43d headless `gsd auto` exits-on-task regression)
1151
+ //
1152
+ // Background: the auto loop runs tasks inside the milestone worktree
1153
+ // (process.cwd() === worktreePath). When the milestone completes, the
1154
+ // worktree dir is torn down. If cwd was still inside it at that moment,
1155
+ // every subsequent process.cwd() throws ENOENT — and after de73fb43d
1156
+ // auto/run-unit.ts:50 turns that ENOENT into a session-failed cancel,
1157
+ // which in headless mode bubbles up to a "Auto-mode stopped" notify
1158
+ // and process.exit(0). mergeAndExit must therefore guarantee cwd is
1159
+ // anchored at the project root regardless of which merge path runs.
1160
+
1161
+ test("mergeAndExit chdirs to project root before merge work (regression: headless gsd auto exit)", () => {
1162
+ // Set up real dirs so process.chdir actually succeeds. realpathSync
1163
+ // canonicalizes the macOS /var → /private/var symlink so equality holds.
1164
+ const projectRoot = realpathSync(mkdtempSync(join(tmpdir(), "gsd-resolver-cwd-")));
1165
+ const worktreePath = join(projectRoot, ".gsd/worktrees/M001");
1166
+ mkdirSync(worktreePath, { recursive: true });
1167
+ const previousCwd = process.cwd();
1168
+
1169
+ try {
1170
+ process.chdir(worktreePath);
1171
+ assert.equal(process.cwd(), worktreePath, "precondition: cwd is in worktree");
1172
+
1173
+ const s = makeSession({
1174
+ basePath: worktreePath,
1175
+ originalBasePath: projectRoot,
1176
+ });
1177
+ const deps = makeDeps({
1178
+ isInAutoWorktree: () => true,
1179
+ getIsolationMode: () => "worktree",
1180
+ });
1181
+ const ctx = makeNotifyCtx();
1182
+ const resolver = new WorktreeResolver(s, deps);
1183
+
1184
+ resolver.mergeAndExit("M001", ctx);
1185
+
1186
+ assert.equal(
1187
+ process.cwd(),
1188
+ projectRoot,
1189
+ "mergeAndExit must leave cwd at the project root, not the (about-to-be-removed) worktree",
1190
+ );
1191
+ } finally {
1192
+ try { process.chdir(previousCwd); } catch { /* best-effort */ }
1193
+ rmSync(projectRoot, { recursive: true, force: true });
1194
+ }
1195
+ });
1196
+
1197
+ test("mergeAndExit anchors cwd even on isolation-degraded skip path", () => {
1198
+ // The skip paths (isolation-degraded, mode-none, missing-original-base)
1199
+ // bypass the per-mode merge helpers entirely. They must still leave cwd
1200
+ // at the project root so a subsequent worktree teardown elsewhere does
1201
+ // not strand cwd in a deleted dir.
1202
+ const projectRoot = realpathSync(mkdtempSync(join(tmpdir(), "gsd-resolver-cwd-degraded-")));
1203
+ const worktreePath = join(projectRoot, ".gsd/worktrees/M001");
1204
+ mkdirSync(worktreePath, { recursive: true });
1205
+ const previousCwd = process.cwd();
1206
+
1207
+ try {
1208
+ process.chdir(worktreePath);
1209
+ const s = makeSession({
1210
+ basePath: worktreePath,
1211
+ originalBasePath: projectRoot,
1212
+ });
1213
+ s.isolationDegraded = true;
1214
+ const deps = makeDeps({ getIsolationMode: () => "worktree" });
1215
+ const ctx = makeNotifyCtx();
1216
+ const resolver = new WorktreeResolver(s, deps);
1217
+
1218
+ resolver.mergeAndExit("M001", ctx);
1219
+
1220
+ assert.equal(
1221
+ process.cwd(),
1222
+ projectRoot,
1223
+ "isolation-degraded skip must still anchor cwd at project root",
1224
+ );
1225
+ } finally {
1226
+ try { process.chdir(previousCwd); } catch { /* best-effort */ }
1227
+ rmSync(projectRoot, { recursive: true, force: true });
1228
+ }
1229
+ });
@@ -404,6 +404,30 @@ export class WorktreeResolver {
404
404
  mergeAndExit(milestoneId: string, ctx: NotifyCtx): void {
405
405
  this.validateMilestoneId(milestoneId);
406
406
 
407
+ // Anchor cwd at the project root before any merge work. Some merge code
408
+ // paths (mergeMilestoneToMain, slice-cadence) chdir explicitly; others
409
+ // (branch-mode, isolation-degraded skip, missing-original-base skip)
410
+ // do not. If the worktree dir is later torn down while cwd still points
411
+ // into it, every subsequent process.cwd() throws ENOENT — and after
412
+ // de73fb43d that surfaces as a session-failed cancel and (in headless
413
+ // mode) terminates the whole gsd process. Best-effort: silent on
414
+ // failure so existing test fixtures that use synthetic paths still pass.
415
+ if (this.s.originalBasePath) {
416
+ try {
417
+ // process.cwd() can throw ENOENT when cwd was removed, so attempt
418
+ // recovery directly.
419
+ process.chdir(this.s.originalBasePath);
420
+ } catch (err) {
421
+ debugLog("WorktreeResolver", {
422
+ action: "mergeAndExit",
423
+ phase: "pre-merge-chdir-failed",
424
+ milestoneId,
425
+ originalBasePath: this.s.originalBasePath,
426
+ error: err instanceof Error ? err.message : String(err),
427
+ });
428
+ }
429
+ }
430
+
407
431
  // #4764 — telemetry: record start timestamp so we can emit merge duration.
408
432
  const mergeStartedAt = new Date().toISOString();
409
433
  const mergeStartMs = Date.now();
@@ -627,13 +627,6 @@ export default function (pi: ExtensionAPI) {
627
627
 
628
628
  // ── Lifecycle ─────────────────────────────────────────────────────────────
629
629
 
630
- pi.on("session_start", async (_event, ctx) => {
631
- const servers = readConfigs();
632
- if (servers.length > 0) {
633
- ctx.ui.notify(`MCP client ready — ${servers.length} server(s) configured`, "info");
634
- }
635
- });
636
-
637
630
  pi.on("session_shutdown", async () => {
638
631
  await closeAll();
639
632
  });
@@ -7,6 +7,10 @@ description: Lint and format code. Auto-detects ESLint, Biome, Prettier, or lang
7
7
  Lint and format code in the current project. Auto-detect the project's linter and formatter toolchain, run them against the target files, and report results grouped by severity with actionable fix suggestions.
8
8
  </objective>
9
9
 
10
+ <working_directory_awareness>
11
+ **Before running any `git` or build command:** check whether your dispatch context specifies a working directory (look for "Working directory:" in your initial prompt). If it does and `pwd` does not match it, prefix every git invocation with `-C <that path>` (e.g. `git -C /path/to/worktree diff --name-only`) and run linters/formatters with the explicit path argument. Linting the wrong directory is a silent failure mode.
12
+ </working_directory_awareness>
13
+
10
14
  <arguments>
11
15
  This skill accepts optional arguments after `/lint`:
12
16