gsd-pi 2.78.1-dev.84a383f51 → 2.78.1-dev.8a893322c

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 (147) hide show
  1. package/README.md +1 -0
  2. package/dist/bundled-resource-path.d.ts +7 -0
  3. package/dist/bundled-resource-path.js +34 -2
  4. package/dist/claude-cli-check.js +18 -6
  5. package/dist/headless-query.js +21 -6
  6. package/dist/loader.js +2 -3
  7. package/dist/resource-loader.js +2 -8
  8. package/dist/resources/.managed-resources-content-hash +1 -1
  9. package/dist/resources/extensions/claude-code-cli/readiness.js +19 -7
  10. package/dist/resources/extensions/google-search/index.js +2 -6
  11. package/dist/resources/extensions/gsd/auto/phases.js +3 -11
  12. package/dist/resources/extensions/gsd/auto/session.js +2 -6
  13. package/dist/resources/extensions/gsd/auto-dashboard.js +3 -2
  14. package/dist/resources/extensions/gsd/auto-dispatch.js +18 -6
  15. package/dist/resources/extensions/gsd/auto-prompts.js +63 -2
  16. package/dist/resources/extensions/gsd/auto-worktree.js +30 -13
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +19 -1
  18. package/dist/resources/extensions/gsd/bootstrap/subagent-input.js +22 -0
  19. package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -0
  20. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +84 -2
  21. package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
  22. package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
  23. package/dist/resources/extensions/gsd/commands/handlers/ops.js +8 -0
  24. package/dist/resources/extensions/gsd/commands-config.js +3 -2
  25. package/dist/resources/extensions/gsd/commands-extensions.js +46 -3
  26. package/dist/resources/extensions/gsd/commands-handlers.js +3 -2
  27. package/dist/resources/extensions/gsd/commands-worktree.js +309 -0
  28. package/dist/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  29. package/dist/resources/extensions/gsd/doctor-providers.js +2 -1
  30. package/dist/resources/extensions/gsd/forensics.js +8 -6
  31. package/dist/resources/extensions/gsd/guided-flow.js +2 -1
  32. package/dist/resources/extensions/gsd/home-dir.js +16 -0
  33. package/dist/resources/extensions/gsd/key-manager.js +2 -1
  34. package/dist/resources/extensions/gsd/migrate/command.js +3 -2
  35. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  36. package/dist/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  37. package/dist/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  38. package/dist/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  39. package/dist/resources/extensions/gsd/unit-context-manifest.js +29 -4
  40. package/dist/resources/extensions/gsd/worktree-manager.js +20 -1
  41. package/dist/resources/extensions/gsd/worktree-resolver.js +4 -13
  42. package/dist/resources/extensions/gsd/worktree-root.js +124 -0
  43. package/dist/resources/extensions/gsd/worktree.js +4 -115
  44. package/dist/resources/extensions/mcp-client/index.js +0 -6
  45. package/dist/resources/extensions/ollama/index.js +15 -2
  46. package/dist/resources/extensions/ollama/model-capabilities.js +31 -0
  47. package/dist/resources/extensions/ollama/ollama-client.js +40 -4
  48. package/dist/resources/extensions/subagent/index.js +324 -178
  49. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  50. package/dist/web/standalone/.next/BUILD_ID +1 -1
  51. package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
  52. package/dist/web/standalone/.next/build-manifest.json +2 -2
  53. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  54. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.html +1 -1
  71. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  76. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
  78. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  80. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  81. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  82. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  83. package/dist/welcome-screen.js +27 -1
  84. package/dist/worktree-cli.d.ts +1 -0
  85. package/dist/worktree-cli.js +9 -3
  86. package/package.json +1 -3
  87. package/packages/mcp-server/src/workflow-tools.test.ts +52 -0
  88. package/packages/native/tsconfig.tsbuildinfo +1 -1
  89. package/src/resources/extensions/claude-code-cli/readiness.ts +20 -7
  90. package/src/resources/extensions/google-search/index.ts +2 -9
  91. package/src/resources/extensions/gsd/auto/phases.ts +3 -11
  92. package/src/resources/extensions/gsd/auto/session.ts +2 -6
  93. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -2
  94. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -6
  95. package/src/resources/extensions/gsd/auto-prompts.ts +60 -2
  96. package/src/resources/extensions/gsd/auto-worktree.ts +44 -12
  97. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +19 -0
  98. package/src/resources/extensions/gsd/bootstrap/subagent-input.ts +20 -0
  99. package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -0
  100. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +103 -1
  101. package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
  102. package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
  103. package/src/resources/extensions/gsd/commands/handlers/ops.ts +10 -0
  104. package/src/resources/extensions/gsd/commands-config.ts +3 -2
  105. package/src/resources/extensions/gsd/commands-extensions.ts +43 -3
  106. package/src/resources/extensions/gsd/commands-handlers.ts +3 -2
  107. package/src/resources/extensions/gsd/commands-worktree.ts +383 -0
  108. package/src/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  109. package/src/resources/extensions/gsd/doctor-providers.ts +2 -1
  110. package/src/resources/extensions/gsd/forensics.ts +10 -5
  111. package/src/resources/extensions/gsd/guided-flow.ts +2 -1
  112. package/src/resources/extensions/gsd/home-dir.ts +19 -0
  113. package/src/resources/extensions/gsd/journal.ts +4 -1
  114. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  115. package/src/resources/extensions/gsd/migrate/command.ts +3 -2
  116. package/src/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  117. package/src/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  118. package/src/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  119. package/src/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  120. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +15 -0
  121. package/src/resources/extensions/gsd/tests/bundled-skill-triggers.test.ts +50 -27
  122. package/src/resources/extensions/gsd/tests/commands-extensions-version-compare.test.ts +58 -0
  123. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +48 -0
  124. package/src/resources/extensions/gsd/tests/google-search-stub.test.ts +25 -65
  125. package/src/resources/extensions/gsd/tests/home-dir.test.ts +52 -0
  126. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +50 -1
  127. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +18 -1
  128. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +34 -0
  129. package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +17 -1
  130. package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +38 -3
  131. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +34 -33
  132. package/src/resources/extensions/gsd/tests/worktree.test.ts +8 -0
  133. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +116 -1
  134. package/src/resources/extensions/gsd/unit-context-manifest.ts +36 -4
  135. package/src/resources/extensions/gsd/worktree-manager.ts +40 -1
  136. package/src/resources/extensions/gsd/worktree-resolver.ts +4 -14
  137. package/src/resources/extensions/gsd/worktree-root.ts +144 -0
  138. package/src/resources/extensions/gsd/worktree.ts +8 -119
  139. package/src/resources/extensions/mcp-client/index.ts +0 -7
  140. package/src/resources/extensions/ollama/index.ts +16 -2
  141. package/src/resources/extensions/ollama/model-capabilities.ts +34 -0
  142. package/src/resources/extensions/ollama/ollama-client.ts +41 -4
  143. package/src/resources/extensions/ollama/tests/model-capabilities.test.ts +96 -0
  144. package/src/resources/extensions/ollama/tests/ollama-client-timeout-env.test.ts +147 -0
  145. package/src/resources/extensions/subagent/index.ts +165 -7
  146. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_buildManifest.js +0 -0
  147. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_ssgManifest.js +0 -0
@@ -20,7 +20,7 @@ import {
20
20
  listWorktrees,
21
21
  worktreePath,
22
22
  } from "../worktree-manager.ts";
23
- import { describe, test } from 'node:test';
23
+ import { test } from 'node:test';
24
24
  import assert from 'node:assert/strict';
25
25
 
26
26
 
@@ -28,37 +28,41 @@ function run(command: string, cwd: string): string {
28
28
  return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
29
29
  }
30
30
 
31
- // Set up a test repo with .gsd/ as a symlink to an external directory,
32
- // mimicking the external state directory layout (~/.gsd/projects/<hash>/).
33
- // Resolve tmpdir to handle macOS /tmp -> /private/var/... symlink.
34
- const realTmp = realpathSync(tmpdir());
35
- const base = mkdtempSync(join(realTmp, "gsd-wt-symlink-test-"));
36
- const externalState = mkdtempSync(join(realTmp, "gsd-wt-symlink-ext-"));
37
-
38
- run("git init -b main", base);
39
- run('git config user.name "Test"', base);
40
- run('git config user.email "test@example.com"', base);
41
-
42
- // Create external state directory structure
43
- mkdirSync(join(externalState, "worktrees"), { recursive: true });
44
-
45
- // Create .gsd as a symlink to the external state directory
46
- symlinkSync(externalState, join(base, ".gsd"));
47
-
48
- // Verify the symlink is in place
49
- assert.ok(existsSync(join(base, ".gsd")), ".gsd symlink exists");
50
- assert.ok(
51
- realpathSync(join(base, ".gsd")) === externalState,
52
- ".gsd resolves to external state dir",
53
- );
31
+ test('worktree-symlink-removal removes the git-registered symlink target safely', (t) => {
32
+ console.log("\n=== #1852: removeWorktree with symlinked .gsd/ ===");
54
33
 
55
- // Create initial commit so we have a valid repo
56
- writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
57
- run("git add .", base);
58
- run('git commit -m "init"', base);
34
+ // Set up a test repo with .gsd/ as a symlink to an external directory,
35
+ // mimicking the external state directory layout (~/.gsd/projects/<hash>/).
36
+ // Resolve tmpdir to handle macOS /tmp -> /private/var/... symlink.
37
+ const realTmp = realpathSync(tmpdir());
38
+ const base = mkdtempSync(join(realTmp, "gsd-wt-symlink-test-"));
39
+ const externalState = mkdtempSync(join(realTmp, "gsd-wt-symlink-ext-"));
40
+ t.after(() => {
41
+ rmSync(base, { recursive: true, force: true });
42
+ rmSync(externalState, { recursive: true, force: true });
43
+ });
44
+
45
+ run("git init -b main", base);
46
+ run('git config user.name "Test"', base);
47
+ run('git config user.email "test@example.com"', base);
48
+
49
+ // Create external state directory structure
50
+ mkdirSync(join(externalState, "worktrees"), { recursive: true });
51
+
52
+ // Create .gsd as a symlink to the external state directory
53
+ symlinkSync(externalState, join(base, ".gsd"));
54
+
55
+ // Verify the symlink is in place
56
+ assert.ok(existsSync(join(base, ".gsd")), ".gsd symlink exists");
57
+ assert.ok(
58
+ realpathSync(join(base, ".gsd")) === externalState,
59
+ ".gsd resolves to external state dir",
60
+ );
59
61
 
60
- describe('worktree-symlink-removal', async () => {
61
- console.log("\n=== #1852: removeWorktree with symlinked .gsd/ ===");
62
+ // Create initial commit so we have a valid repo
63
+ writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
64
+ run("git add .", base);
65
+ run('git commit -m "init"', base);
62
66
 
63
67
  // Create a worktree — git will resolve the symlink and register
64
68
  // the worktree at the external path
@@ -127,7 +131,4 @@ describe('worktree-symlink-removal', async () => {
127
131
  const listed = listWorktrees(base);
128
132
  assert.deepStrictEqual(listed.length, 0, "no worktrees listed after removal");
129
133
 
130
- // Cleanup
131
- rmSync(base, { recursive: true, force: true });
132
- rmSync(externalState, { recursive: true, force: true });
133
134
  });
@@ -255,6 +255,14 @@ describe('worktree', async () => {
255
255
  "returns unchanged for non-worktree path",
256
256
  );
257
257
 
258
+ const nestedRepoDir = join(base, "packages", "demo");
259
+ mkdirSync(nestedRepoDir, { recursive: true });
260
+ assert.deepStrictEqual(
261
+ normalizePath(resolveProjectRoot(nestedRepoDir)),
262
+ normalizePath(base),
263
+ "resolves normal repo subdirectories to the project root",
264
+ );
265
+
258
266
  // Without GSD_PROJECT_ROOT, direct layout with nested subdirs
259
267
  assert.deepStrictEqual(
260
268
  resolveProjectRoot("/data/.gsd/worktrees/M003/nested"),
@@ -9,12 +9,21 @@ import test from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import { join, sep } from 'node:path';
11
11
 
12
- import { shouldBlockPlanningUnit } from '../bootstrap/write-gate.ts';
12
+ import { ALLOWED_PLANNING_DISPATCH_AGENTS, shouldBlockPlanningUnit } from '../bootstrap/write-gate.ts';
13
+ import { extractSubagentAgentClasses } from '../bootstrap/subagent-input.ts';
13
14
  import { isDeterministicPolicyError } from '../auto-tool-tracking.ts';
14
15
  import type { ToolsPolicy } from '../unit-context-manifest.ts';
15
16
 
16
17
  const BASE = join('/tmp', 'fake-project');
17
18
  const PLANNING: ToolsPolicy = { mode: 'planning' };
19
+ const PLANNING_DISPATCH: ToolsPolicy = {
20
+ mode: 'planning-dispatch',
21
+ allowedSubagents: [...ALLOWED_PLANNING_DISPATCH_AGENTS],
22
+ };
23
+ const PLANNING_DISPATCH_REVIEW: ToolsPolicy = {
24
+ mode: 'planning-dispatch',
25
+ allowedSubagents: ['reviewer', 'security', 'tester'],
26
+ };
18
27
  const READ_ONLY: ToolsPolicy = { mode: 'read-only' };
19
28
  const ALL: ToolsPolicy = { mode: 'all' };
20
29
  const DOCS: ToolsPolicy = {
@@ -143,6 +152,112 @@ test('planning-unit: blocks task tool (alt subagent name)', () => {
143
152
  assert.strictEqual(r.block, true);
144
153
  });
145
154
 
155
+ test('planning-dispatch: allows subagent dispatch (delegated recon/planner during slice planning)', () => {
156
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'plan-slice', PLANNING_DISPATCH, ['scout']);
157
+ assert.strictEqual(r.block, false);
158
+ });
159
+
160
+ test('planning-dispatch: allows task dispatch (delegated recon/planner during slice planning)', () => {
161
+ const r = shouldBlockPlanningUnit('task', '', BASE, 'plan-slice', PLANNING_DISPATCH, ['planner']);
162
+ assert.strictEqual(r.block, false);
163
+ });
164
+
165
+ test('planning-dispatch: extracts subagent classes from single, parallel, and chain inputs', () => {
166
+ assert.deepEqual(extractSubagentAgentClasses({ agent: ' scout ' }), ['scout']);
167
+ assert.deepEqual(
168
+ extractSubagentAgentClasses({ tasks: [{ agent: 'planner' }, { agent: ' tester ' }] }),
169
+ ['planner', 'tester'],
170
+ );
171
+ assert.deepEqual(
172
+ extractSubagentAgentClasses({ chain: [{ agent: 'reviewer' }, { agent: 'security' }] }),
173
+ ['reviewer', 'security'],
174
+ );
175
+ });
176
+
177
+ test('planning-dispatch: blocks subagent dispatch when agentClasses is undefined (stale caller shim)', () => {
178
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'plan-slice', PLANNING_DISPATCH, undefined);
179
+ assert.strictEqual(r.block, true);
180
+ assert.match(r.reason!, /stale caller/);
181
+ assert.match(r.reason!, /tools-policy "planning-dispatch"/);
182
+ });
183
+
184
+ test('planning-dispatch: allows explicitly empty agent classes for downstream validation', () => {
185
+ const emptyClasses = extractSubagentAgentClasses({});
186
+ assert.deepEqual(emptyClasses, []);
187
+ const empty = shouldBlockPlanningUnit('subagent', '', BASE, 'plan-slice', PLANNING_DISPATCH, emptyClasses);
188
+ assert.strictEqual(empty.block, false);
189
+ });
190
+
191
+ test('planning-dispatch: allows all globally allowed specialists when listed by policy', () => {
192
+ const policy: ToolsPolicy = {
193
+ mode: 'planning-dispatch',
194
+ allowedSubagents: [...ALLOWED_PLANNING_DISPATCH_AGENTS],
195
+ };
196
+ const r = shouldBlockPlanningUnit(
197
+ 'subagent',
198
+ '',
199
+ BASE,
200
+ 'complete-milestone',
201
+ policy,
202
+ [...ALLOWED_PLANNING_DISPATCH_AGENTS],
203
+ );
204
+ assert.strictEqual(r.block, false);
205
+ });
206
+
207
+ test('planning-dispatch: blocks implementation-tier agent', () => {
208
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'plan-slice', PLANNING_DISPATCH, ['worker']);
209
+ assert.strictEqual(r.block, true);
210
+ assert.match(r.reason!, /"worker"/);
211
+ assert.match(r.reason!, /read-only specialists/);
212
+ });
213
+
214
+ test('planning-dispatch: blocks globally disallowed agent even if listed by policy', () => {
215
+ const policy: ToolsPolicy = {
216
+ mode: 'planning-dispatch',
217
+ allowedSubagents: ['refactorer'],
218
+ };
219
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'refine-slice', policy, ['refactorer']);
220
+ assert.strictEqual(r.block, true);
221
+ assert.match(r.reason!, /"refactorer"/);
222
+ assert.match(r.reason!, /read-only specialists/);
223
+ assert.doesNotMatch(r.reason!, /ToolsPolicy\.allowedSubagents|permitted agents for this unit/);
224
+ });
225
+
226
+ test('planning-dispatch: blocks mixed batch containing a disallowed agent', () => {
227
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'plan-slice', PLANNING_DISPATCH, ['scout', 'worker']);
228
+ assert.strictEqual(r.block, true);
229
+ assert.match(r.reason!, /"worker"/);
230
+ });
231
+
232
+ test('planning-dispatch: allows review-tier agent under closeout policy', () => {
233
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'complete-slice', PLANNING_DISPATCH_REVIEW, ['reviewer']);
234
+ assert.strictEqual(r.block, false);
235
+ });
236
+
237
+ test('planning-dispatch: blocks recon agent under closeout policy', () => {
238
+ const r = shouldBlockPlanningUnit('subagent', '', BASE, 'complete-slice', PLANNING_DISPATCH_REVIEW, ['scout']);
239
+ assert.strictEqual(r.block, true);
240
+ assert.match(r.reason!, /"scout"/);
241
+ assert.match(r.reason!, /ToolsPolicy\.allowedSubagents|permitted agents for this unit/);
242
+ assert.doesNotMatch(r.reason!, /read-only specialists/);
243
+ });
244
+
245
+ test('planning-dispatch: still blocks writes to user source (write isolation preserved)', () => {
246
+ const r = shouldBlockPlanningUnit('write', join(BASE, 'src', 'main.ts'), BASE, 'plan-slice', PLANNING_DISPATCH);
247
+ assert.strictEqual(r.block, true);
248
+ });
249
+
250
+ test('planning-dispatch: still allows writes inside .gsd/', () => {
251
+ const r = shouldBlockPlanningUnit(
252
+ 'write',
253
+ join(BASE, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'PLAN.md'),
254
+ BASE,
255
+ 'plan-slice',
256
+ PLANNING_DISPATCH,
257
+ );
258
+ assert.strictEqual(r.block, false);
259
+ });
260
+
146
261
  // ─── planning mode: pass-through tools ────────────────────────────────────
147
262
 
148
263
  test('planning-unit: allows read tool', () => {
@@ -111,6 +111,12 @@ export type PreferencesPolicy = "none" | "active-only" | "full";
111
111
  * Task subagent dispatch denied. Catches the bug class
112
112
  * where a discuss-milestone turn modifies user source
113
113
  * files (forensics: ~/Github/test-apps/b23, #4934).
114
+ * - "planning-dispatch"
115
+ * — Same read + .gsd/** write + safe-Bash surface as
116
+ * "planning", but permits controlled subagent dispatch
117
+ * only to the agents listed in the ToolsPolicy
118
+ * `allowedSubagents` field. See write-gate.ts for the
119
+ * runtime agent-class enforcement details.
114
120
  * - "docs" — Read tools always; writes restricted to .gsd/** AND
115
121
  * the explicit `allowedPathGlobs` set; Bash safe-allowlist;
116
122
  * no subagents. Reserved for rewrite-docs, which legitimately
@@ -125,6 +131,7 @@ export type ToolsPolicy =
125
131
  | { readonly mode: "all" }
126
132
  | { readonly mode: "read-only" }
127
133
  | { readonly mode: "planning" }
134
+ | { readonly mode: "planning-dispatch"; readonly allowedSubagents: readonly string[] }
128
135
  | { readonly mode: "docs"; readonly allowedPathGlobs: readonly string[] };
129
136
 
130
137
  // ─── Computed-artifact registry (#4924 v2 contract) ───────────────────────
@@ -268,6 +275,18 @@ const COMMON_BUDGET_SMALL = 250_000; // ~65K tokens
268
275
 
269
276
  const TOOLS_ALL: ToolsPolicy = { mode: "all" };
270
277
  const TOOLS_PLANNING: ToolsPolicy = { mode: "planning" };
278
+ // Like TOOLS_PLANNING but permits dispatch to read-only recon/planning
279
+ // specialists. Runtime-enforced by write-gate.ts before the subagent tool runs.
280
+ const TOOLS_PLANNING_DISPATCH_RECON: ToolsPolicy = {
281
+ mode: "planning-dispatch",
282
+ allowedSubagents: ["scout", "planner"],
283
+ };
284
+ // Like TOOLS_PLANNING_DISPATCH_RECON, but for closeout units that fan out
285
+ // verification work to review-tier specialists.
286
+ const TOOLS_PLANNING_DISPATCH_REVIEW: ToolsPolicy = {
287
+ mode: "planning-dispatch",
288
+ allowedSubagents: ["reviewer", "security", "tester"],
289
+ };
271
290
  const TOOLS_DOCS: ToolsPolicy = {
272
291
  mode: "docs",
273
292
  // Globs are resolved relative to project basePath. The set is intentionally
@@ -376,7 +395,11 @@ export const UNIT_MANIFESTS: Record<UnitType, UnitContextManifest> = {
376
395
  memory: "prompt-relevant",
377
396
  codebaseMap: false,
378
397
  preferences: "active-only",
379
- tools: TOOLS_PLANNING,
398
+ // planning-dispatch: completion is a high-leverage place to fan out to
399
+ // reviewer / security / tester subagents. They read the diff and report
400
+ // findings; they do not write user source. Write isolation to .gsd/ is
401
+ // preserved.
402
+ tools: TOOLS_PLANNING_DISPATCH_REVIEW,
380
403
  artifacts: {
381
404
  // #4780 landed slice-summary as excerpt for this unit; phase 2 of
382
405
  // the architecture will read this manifest as the source of truth
@@ -409,7 +432,10 @@ export const UNIT_MANIFESTS: Record<UnitType, UnitContextManifest> = {
409
432
  memory: "prompt-relevant",
410
433
  codebaseMap: true,
411
434
  preferences: "active-only",
412
- tools: TOOLS_PLANNING,
435
+ // planning-dispatch: allows subagent dispatch so the planner can fan out
436
+ // to scout for codebase recon and to planner/decompose-style specialists
437
+ // for sub-decomposition. Write-isolation to .gsd/ is preserved.
438
+ tools: TOOLS_PLANNING_DISPATCH_RECON,
413
439
  artifacts: {
414
440
  inline: ["roadmap", "slice-research", "dependency-summaries", "requirements", "decisions", "templates"],
415
441
  excerpt: [],
@@ -423,7 +449,10 @@ export const UNIT_MANIFESTS: Record<UnitType, UnitContextManifest> = {
423
449
  memory: "prompt-relevant",
424
450
  codebaseMap: true,
425
451
  preferences: "active-only",
426
- tools: TOOLS_PLANNING,
452
+ // See plan-slice — same rationale: dispatch to scout/planner-style
453
+ // specialists during refinement is materially better than re-doing recon
454
+ // inline.
455
+ tools: TOOLS_PLANNING_DISPATCH_RECON,
427
456
  artifacts: {
428
457
  inline: ["slice-plan", "slice-research", "dependency-summaries", "templates"],
429
458
  excerpt: [],
@@ -451,7 +480,10 @@ export const UNIT_MANIFESTS: Record<UnitType, UnitContextManifest> = {
451
480
  memory: "prompt-relevant",
452
481
  codebaseMap: false,
453
482
  preferences: "active-only",
454
- tools: TOOLS_PLANNING,
483
+ // See complete-milestone — same rationale: dispatch to reviewer / security /
484
+ // tester subagents to fan out review work without bloating this unit's
485
+ // context.
486
+ tools: TOOLS_PLANNING_DISPATCH_REVIEW,
455
487
  artifacts: {
456
488
  // Phase 3 migration (#4782): matches today's actual
457
489
  // buildCompleteSlicePrompt inlining order. Overrides prepend +
@@ -39,6 +39,11 @@ import {
39
39
  nativeWorktreeRemove,
40
40
  } from "./native-git-bridge.js";
41
41
  import { emitCanonicalRootRedirect } from "./worktree-telemetry.js";
42
+ import {
43
+ isGsdWorktreePath,
44
+ normalizeWorktreePathForCompare,
45
+ resolveWorktreeProjectRoot,
46
+ } from "./worktree-root.js";
42
47
 
43
48
  // ─── Types ─────────────────────────────────────────────────────────────────
44
49
 
@@ -75,6 +80,20 @@ function normalizePathForComparison(path: string): string {
75
80
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
76
81
  }
77
82
 
83
+ function normalizeBasePathForWorktreeOps(basePath: string): string {
84
+ const resolved = resolveWorktreeProjectRoot(basePath);
85
+ if (
86
+ isGsdWorktreePath(basePath) &&
87
+ normalizeWorktreePathForCompare(resolved) === normalizeWorktreePathForCompare(basePath)
88
+ ) {
89
+ throw new GSDError(
90
+ GSD_GIT_ERROR,
91
+ `Cannot resolve project root from worktree path: ${basePath}. Run the command from the project root or set GSD_PROJECT_ROOT.`,
92
+ );
93
+ }
94
+ return resolved;
95
+ }
96
+
78
97
  // ─── resolveGitDir ─────────────────────────────────────────────────────────
79
98
 
80
99
  /**
@@ -106,7 +125,7 @@ export function resolveGitDir(basePath: string): string {
106
125
  }
107
126
 
108
127
  export function worktreesDir(basePath: string): string {
109
- return join(basePath, ".gsd", "worktrees");
128
+ return join(resolveWorktreeProjectRoot(basePath), ".gsd", "worktrees");
110
129
  }
111
130
 
112
131
  export function worktreePath(basePath: string, name: string): string {
@@ -195,6 +214,8 @@ export function resolveCanonicalMilestoneRoot(
195
214
  * @param opts.branch — override the default `worktree/<name>` branch name
196
215
  */
197
216
  export function createWorktree(basePath: string, name: string, opts: { branch?: string; startPoint?: string; reuseExistingBranch?: boolean } = {}): WorktreeInfo {
217
+ basePath = normalizeBasePathForWorktreeOps(basePath);
218
+
198
219
  // Validate name: alphanumeric, hyphens, underscores only
199
220
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
200
221
  throw new GSDError(GSD_PARSE_ERROR, `Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`);
@@ -297,6 +318,8 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
297
318
  * Uses native worktree list and filters to those under .gsd/worktrees/.
298
319
  */
299
320
  export function listWorktrees(basePath: string): WorktreeInfo[] {
321
+ basePath = normalizeBasePathForWorktreeOps(basePath);
322
+
300
323
  const baseVariants = [resolve(basePath)];
301
324
  if (existsSync(basePath)) {
302
325
  baseVariants.push(realpathSync(basePath));
@@ -459,6 +482,8 @@ export function removeWorktree(
459
482
  name: string,
460
483
  opts: { deleteBranch?: boolean; force?: boolean; branch?: string } = {},
461
484
  ): void {
485
+ basePath = normalizeBasePathForWorktreeOps(basePath);
486
+
462
487
  let wtPath = worktreePath(basePath, name);
463
488
  const branch = opts.branch ?? worktreeBranchName(name);
464
489
  const { deleteBranch = true, force = true } = opts;
@@ -714,6 +739,8 @@ function parseDiffNameStatus(entries: { status: string; path: string }[]): Workt
714
739
  * Returns a summary of added, modified, and removed GSD artifacts.
715
740
  */
716
741
  export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary {
742
+ basePath = normalizeBasePathForWorktreeOps(basePath);
743
+
717
744
  const branch = worktreeBranchName(name);
718
745
  const mainBranch = nativeDetectMainBranch(basePath);
719
746
 
@@ -729,6 +756,8 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
729
756
  * content, this correctly returns an empty diff.
730
757
  */
731
758
  export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary {
759
+ basePath = normalizeBasePathForWorktreeOps(basePath);
760
+
732
761
  const branch = worktreeBranchName(name);
733
762
  const mainBranch = nativeDetectMainBranch(basePath);
734
763
 
@@ -742,6 +771,8 @@ export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSum
742
771
  * Uses direct diff (not merge-base) so the preview matches the actual merge outcome.
743
772
  */
744
773
  export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] {
774
+ basePath = normalizeBasePathForWorktreeOps(basePath);
775
+
745
776
  const branch = worktreeBranchName(name);
746
777
  const mainBranch = nativeDetectMainBranch(basePath);
747
778
 
@@ -760,6 +791,8 @@ export function diffWorktreeNumstat(basePath: string, name: string): FileLineSta
760
791
  * Returns the raw unified diff for LLM consumption.
761
792
  */
762
793
  export function getWorktreeGSDDiff(basePath: string, name: string): string {
794
+ basePath = normalizeBasePathForWorktreeOps(basePath);
795
+
763
796
  const branch = worktreeBranchName(name);
764
797
  const mainBranch = nativeDetectMainBranch(basePath);
765
798
 
@@ -771,6 +804,8 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string {
771
804
  * Returns the raw unified diff for LLM consumption.
772
805
  */
773
806
  export function getWorktreeCodeDiff(basePath: string, name: string): string {
807
+ basePath = normalizeBasePathForWorktreeOps(basePath);
808
+
774
809
  const branch = worktreeBranchName(name);
775
810
  const mainBranch = nativeDetectMainBranch(basePath);
776
811
 
@@ -781,6 +816,8 @@ export function getWorktreeCodeDiff(basePath: string, name: string): string {
781
816
  * Get commit log for the worktree branch since it diverged from main.
782
817
  */
783
818
  export function getWorktreeLog(basePath: string, name: string): string {
819
+ basePath = normalizeBasePathForWorktreeOps(basePath);
820
+
784
821
  const branch = worktreeBranchName(name);
785
822
  const mainBranch = nativeDetectMainBranch(basePath);
786
823
 
@@ -795,6 +832,8 @@ export function getWorktreeLog(basePath: string, name: string): string {
795
832
  * Returns the merge commit message.
796
833
  */
797
834
  export function mergeWorktreeToMain(basePath: string, name: string, commitMessage: string): string {
835
+ basePath = normalizeBasePathForWorktreeOps(basePath);
836
+
798
837
  const branch = worktreeBranchName(name);
799
838
  const mainBranch = nativeDetectMainBranch(basePath);
800
839
  const current = nativeGetCurrentBranch(basePath);
@@ -23,6 +23,7 @@ import { emitJournalEvent } from "./journal.js";
23
23
  import { emitWorktreeCreated, emitWorktreeMerged } from "./worktree-telemetry.js";
24
24
  import { getCollapseCadence, getMilestoneResquash, resquashMilestoneOnMain } from "./slice-cadence.js";
25
25
  import { loadEffectiveGSDPreferences } from "./preferences.js";
26
+ import { resolveWorktreeProjectRoot } from "./worktree-root.js";
26
27
 
27
28
  // ─── Dependency Interface ──────────────────────────────────────────────────
28
29
 
@@ -84,31 +85,20 @@ export interface NotifyCtx {
84
85
 
85
86
  // ─── Path Helpers ──────────────────────────────────────────────────────────
86
87
 
87
- /**
88
- * Worktree marker segment — present in any path produced by worktreePath().
89
- * Used to strip the worktree suffix and recover the project root (#3729).
90
- */
91
- const WORKTREE_MARKER = "/.gsd/worktrees/";
92
-
93
88
  /**
94
89
  * Resolve the project root from session path state.
95
90
  *
96
91
  * Prefers `originalBasePath` (always the project root when set), but falls
97
92
  * back to `basePath` when `originalBasePath` is falsy (e.g. fresh AutoSession
98
93
  * with default empty string). If `basePath` itself is inside a worktree
99
- * directory (contains `/.gsd/worktrees/`), strip that suffix to recover the
100
- * actual project root preventing double-nested worktree paths (#3729).
94
+ * directory (including symlink-resolved ~/.gsd/projects/<hash>/worktrees
95
+ * paths), recover the actual project root to prevent double nesting (#3729).
101
96
  */
102
97
  export function resolveProjectRoot(
103
98
  originalBasePath: string,
104
99
  basePath: string,
105
100
  ): string {
106
- let resolved = originalBasePath || basePath;
107
- const markerIdx = resolved.indexOf(WORKTREE_MARKER);
108
- if (markerIdx !== -1) {
109
- resolved = resolved.slice(0, markerIdx);
110
- }
111
- return resolved;
101
+ return resolveWorktreeProjectRoot(basePath, originalBasePath);
112
102
  }
113
103
 
114
104
  // ─── WorktreeResolver ──────────────────────────────────────────────────────
@@ -0,0 +1,144 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export interface WorktreeSegment {
6
+ gsdIdx: number;
7
+ afterWorktrees: number;
8
+ }
9
+
10
+ export function normalizeWorktreePathForCompare(path: string): string {
11
+ let normalized: string;
12
+ try {
13
+ normalized = realpathSync(path);
14
+ } catch {
15
+ normalized = resolve(path);
16
+ }
17
+ const slashed = normalized.replaceAll("\\", "/");
18
+ const trimmed = slashed.replace(/\/+$/, "");
19
+ return process.platform === "win32" ? (trimmed || "/").toLowerCase() : (trimmed || "/");
20
+ }
21
+
22
+ /**
23
+ * Find the GSD worktree segment in both direct project layout and the
24
+ * symlink-resolved external-state layout used by ~/.gsd/projects/<hash>.
25
+ */
26
+ export function findWorktreeSegment(normalizedPath: string): WorktreeSegment | null {
27
+ const directMarker = "/.gsd/worktrees/";
28
+ const directIdx = normalizedPath.indexOf(directMarker);
29
+ if (directIdx !== -1) {
30
+ return { gsdIdx: directIdx, afterWorktrees: directIdx + directMarker.length };
31
+ }
32
+
33
+ const externalRe = /\/\.gsd\/projects\/[^/]+\/worktrees\//;
34
+ const externalMatch = normalizedPath.match(externalRe);
35
+ if (externalMatch && externalMatch.index !== undefined) {
36
+ return {
37
+ gsdIdx: externalMatch.index,
38
+ afterWorktrees: externalMatch.index + externalMatch[0].length,
39
+ };
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ export function isGsdWorktreePath(path: string): boolean {
46
+ return findWorktreeSegment(path.replaceAll("\\", "/")) !== null;
47
+ }
48
+
49
+ /**
50
+ * Resolve the canonical project root for worktree operations.
51
+ *
52
+ * `originalBasePath` wins when available because session state already knows the
53
+ * root. `GSD_PROJECT_ROOT` is the next strongest signal for worker processes.
54
+ * Otherwise, derive the root from direct `.gsd/worktrees` paths, or recover it
55
+ * from the worktree `.git` file for symlink-resolved ~/.gsd/project paths.
56
+ */
57
+ export function resolveWorktreeProjectRoot(
58
+ basePath: string,
59
+ originalBasePath?: string | null,
60
+ ): string {
61
+ const preferred =
62
+ originalBasePath?.trim() ||
63
+ process.env.GSD_PROJECT_ROOT?.trim() ||
64
+ basePath;
65
+
66
+ return resolveProjectRootFromPath(preferred);
67
+ }
68
+
69
+ function resolveProjectRootFromPath(path: string): string {
70
+ const normalizedPath = path.replaceAll("\\", "/");
71
+ const segment = findWorktreeSegment(normalizedPath);
72
+ if (!segment) return resolveGitWorkingTreeRoot(path) ?? path;
73
+
74
+ const sepChar = path.includes("\\") ? "\\" : "/";
75
+ const gsdMarker = `${sepChar}.gsd${sepChar}`;
76
+ const markerIdx = path.indexOf(gsdMarker);
77
+ const candidate = markerIdx !== -1
78
+ ? path.slice(0, markerIdx)
79
+ : path.slice(0, segment.gsdIdx);
80
+
81
+ const gsdHome = normalizeWorktreePathForCompare(process.env.GSD_HOME || join(homedir(), ".gsd"));
82
+ const candidateGsdPath = normalizeWorktreePathForCompare(join(candidate, ".gsd"));
83
+
84
+ if (candidateGsdPath === gsdHome || candidateGsdPath.startsWith(`${gsdHome}/`)) {
85
+ const realRoot = resolveProjectRootFromGitFile(path);
86
+ return realRoot ?? path;
87
+ }
88
+
89
+ return candidate;
90
+ }
91
+
92
+ function resolveGitWorkingTreeRoot(path: string): string | null {
93
+ try {
94
+ let dir = existsSync(path) && !statSync(path).isDirectory()
95
+ ? resolve(path, "..")
96
+ : path;
97
+
98
+ for (let i = 0; i < 30; i++) {
99
+ const gitPath = join(dir, ".git");
100
+ if (existsSync(gitPath)) return dir;
101
+
102
+ const parent = resolve(dir, "..");
103
+ if (parent === dir) break;
104
+ dir = parent;
105
+ }
106
+ } catch {
107
+ // Non-fatal: callers either keep the original path or fail closed.
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function resolveProjectRootFromGitFile(worktreePath: string): string | null {
113
+ try {
114
+ let dir = worktreePath;
115
+ for (let i = 0; i < 30; i++) {
116
+ const gitPath = join(dir, ".git");
117
+ if (existsSync(gitPath)) {
118
+ const content = readFileSync(gitPath, "utf8").trim();
119
+ if (content.startsWith("gitdir: ")) {
120
+ const gitDir = resolve(dir, content.slice(8));
121
+ const dotGitDir = resolve(gitDir, "..", "..");
122
+ if (dotGitDir.endsWith(".git") || dotGitDir.endsWith(".git/") || dotGitDir.endsWith(".git\\")) {
123
+ return resolve(dotGitDir, "..");
124
+ }
125
+
126
+ const commonDirPath = join(gitDir, "commondir");
127
+ if (existsSync(commonDirPath)) {
128
+ const commonDir = readFileSync(commonDirPath, "utf8").trim();
129
+ const resolvedCommonDir = resolve(gitDir, commonDir);
130
+ return resolve(resolvedCommonDir, "..");
131
+ }
132
+ }
133
+ break;
134
+ }
135
+
136
+ const parent = resolve(dir, "..");
137
+ if (parent === dir) break;
138
+ dir = parent;
139
+ }
140
+ } catch {
141
+ // Non-fatal: callers either keep the original path or fail closed.
142
+ }
143
+ return null;
144
+ }