gsd-pi 2.78.1-dev.84a383f51 → 2.78.1-dev.9d08d820b

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 (127) hide show
  1. package/dist/bundled-resource-path.d.ts +7 -0
  2. package/dist/bundled-resource-path.js +34 -2
  3. package/dist/claude-cli-check.js +18 -6
  4. package/dist/headless-query.js +21 -6
  5. package/dist/loader.js +2 -3
  6. package/dist/resource-loader.js +2 -8
  7. package/dist/resources/.managed-resources-content-hash +1 -1
  8. package/dist/resources/extensions/claude-code-cli/readiness.js +19 -7
  9. package/dist/resources/extensions/gsd/auto/phases.js +3 -11
  10. package/dist/resources/extensions/gsd/auto/session.js +2 -6
  11. package/dist/resources/extensions/gsd/auto-dashboard.js +3 -2
  12. package/dist/resources/extensions/gsd/auto-dispatch.js +18 -6
  13. package/dist/resources/extensions/gsd/auto-prompts.js +63 -2
  14. package/dist/resources/extensions/gsd/auto-worktree.js +30 -13
  15. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -1
  16. package/dist/resources/extensions/gsd/bootstrap/subagent-input.js +22 -0
  17. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +84 -2
  18. package/dist/resources/extensions/gsd/commands-config.js +3 -2
  19. package/dist/resources/extensions/gsd/commands-extensions.js +46 -3
  20. package/dist/resources/extensions/gsd/commands-handlers.js +3 -2
  21. package/dist/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  22. package/dist/resources/extensions/gsd/doctor-providers.js +2 -1
  23. package/dist/resources/extensions/gsd/forensics.js +8 -6
  24. package/dist/resources/extensions/gsd/guided-flow.js +2 -1
  25. package/dist/resources/extensions/gsd/home-dir.js +16 -0
  26. package/dist/resources/extensions/gsd/key-manager.js +2 -1
  27. package/dist/resources/extensions/gsd/migrate/command.js +3 -2
  28. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  29. package/dist/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  30. package/dist/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  31. package/dist/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  32. package/dist/resources/extensions/gsd/unit-context-manifest.js +29 -4
  33. package/dist/resources/extensions/gsd/worktree-manager.js +20 -1
  34. package/dist/resources/extensions/gsd/worktree-resolver.js +4 -13
  35. package/dist/resources/extensions/gsd/worktree-root.js +124 -0
  36. package/dist/resources/extensions/gsd/worktree.js +4 -115
  37. package/dist/resources/extensions/ollama/index.js +15 -2
  38. package/dist/resources/extensions/ollama/model-capabilities.js +31 -0
  39. package/dist/resources/extensions/ollama/ollama-client.js +40 -4
  40. package/dist/resources/extensions/subagent/index.js +324 -178
  41. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  42. package/dist/web/standalone/.next/BUILD_ID +1 -1
  43. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  44. package/dist/web/standalone/.next/build-manifest.json +2 -2
  45. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  46. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/index.html +1 -1
  63. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  70. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  71. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  72. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  73. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  74. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  75. package/dist/worktree-cli.d.ts +1 -0
  76. package/dist/worktree-cli.js +9 -3
  77. package/package.json +1 -3
  78. package/packages/mcp-server/src/workflow-tools.test.ts +52 -0
  79. package/packages/native/tsconfig.tsbuildinfo +1 -1
  80. package/src/resources/extensions/claude-code-cli/readiness.ts +20 -7
  81. package/src/resources/extensions/gsd/auto/phases.ts +3 -11
  82. package/src/resources/extensions/gsd/auto/session.ts +2 -6
  83. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -2
  84. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -6
  85. package/src/resources/extensions/gsd/auto-prompts.ts +60 -2
  86. package/src/resources/extensions/gsd/auto-worktree.ts +44 -12
  87. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +6 -0
  88. package/src/resources/extensions/gsd/bootstrap/subagent-input.ts +20 -0
  89. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +103 -1
  90. package/src/resources/extensions/gsd/commands-config.ts +3 -2
  91. package/src/resources/extensions/gsd/commands-extensions.ts +43 -3
  92. package/src/resources/extensions/gsd/commands-handlers.ts +3 -2
  93. package/src/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  94. package/src/resources/extensions/gsd/doctor-providers.ts +2 -1
  95. package/src/resources/extensions/gsd/forensics.ts +10 -5
  96. package/src/resources/extensions/gsd/guided-flow.ts +2 -1
  97. package/src/resources/extensions/gsd/home-dir.ts +19 -0
  98. package/src/resources/extensions/gsd/journal.ts +4 -1
  99. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  100. package/src/resources/extensions/gsd/migrate/command.ts +3 -2
  101. package/src/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  102. package/src/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  103. package/src/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  104. package/src/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  105. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +15 -0
  106. package/src/resources/extensions/gsd/tests/commands-extensions-version-compare.test.ts +58 -0
  107. package/src/resources/extensions/gsd/tests/home-dir.test.ts +52 -0
  108. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +50 -1
  109. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +18 -1
  110. package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +17 -1
  111. package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +38 -3
  112. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +34 -33
  113. package/src/resources/extensions/gsd/tests/worktree.test.ts +8 -0
  114. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +116 -1
  115. package/src/resources/extensions/gsd/unit-context-manifest.ts +36 -4
  116. package/src/resources/extensions/gsd/worktree-manager.ts +40 -1
  117. package/src/resources/extensions/gsd/worktree-resolver.ts +4 -14
  118. package/src/resources/extensions/gsd/worktree-root.ts +144 -0
  119. package/src/resources/extensions/gsd/worktree.ts +8 -119
  120. package/src/resources/extensions/ollama/index.ts +16 -2
  121. package/src/resources/extensions/ollama/model-capabilities.ts +34 -0
  122. package/src/resources/extensions/ollama/ollama-client.ts +41 -4
  123. package/src/resources/extensions/ollama/tests/model-capabilities.test.ts +96 -0
  124. package/src/resources/extensions/ollama/tests/ollama-client-timeout-env.test.ts +147 -0
  125. package/src/resources/extensions/subagent/index.ts +165 -7
  126. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → -Ukk6_YxRd4GY4iUOnRUE}/_buildManifest.js +0 -0
  127. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → -Ukk6_YxRd4GY4iUOnRUE}/_ssgManifest.js +0 -0
@@ -20,6 +20,16 @@ Pay particular attention to **Forward Intelligence** sections — they contain h
20
20
 
21
21
  ## Your Role in the Pipeline
22
22
 
23
+ ### Delegate Recon When Useful
24
+
25
+ This unit runs under the `planning-dispatch` tools-policy: you may use the `subagent` tool to delegate recon and sub-decomposition. Prefer delegation over inline work when:
26
+
27
+ - You'd otherwise read more than ~3 files to understand a subsystem touched by the sketch → dispatch the **scout** agent and work from its compressed report.
28
+ - A specific area of the refinement needs deeper architectural analysis → dispatch the **planner** agent for a focused sub-plan, then integrate.
29
+ - You need current external information (library docs, API behavior) → dispatch the **researcher** agent.
30
+
31
+ **Do not** dispatch implementation-tier agents (`worker`, `refactorer`, `tester`) — they would write user source and bypass write isolation. Implementation belongs in `execute-task`.
32
+
23
33
  ### Respect the Sketch Scope
24
34
 
25
35
  The sketch scope inlined above is a **hard constraint**. Plan within it. If, after exploring the codebase, the scope is too narrow to deliver the goal, surface this as a deviation in the plan's narrative and still produce the plan — do not silently expand the scope.
@@ -14,6 +14,7 @@ import assert from "node:assert/strict";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
+ import { AutoSession } from "../auto/session.ts";
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const AUTO_TS_PATH = join(__dirname, "..", "auto.ts");
@@ -32,6 +33,20 @@ function getRuntimeStateTsSource(): string {
32
33
  return readFileSync(RUNTIME_STATE_TS_PATH, "utf-8");
33
34
  }
34
35
 
36
+ test("AutoSession.lockBasePath uses GSD_PROJECT_ROOT for symlink-resolved worktrees", () => {
37
+ const savedProjectRoot = process.env.GSD_PROJECT_ROOT;
38
+ process.env.GSD_PROJECT_ROOT = "/real/project";
39
+ try {
40
+ const session = new AutoSession();
41
+ session.basePath = "/Users/dev/.gsd/projects/abc123/worktrees/M001/slices/S01";
42
+
43
+ assert.equal(session.lockBasePath, "/real/project");
44
+ } finally {
45
+ if (savedProjectRoot === undefined) delete process.env.GSD_PROJECT_ROOT;
46
+ else process.env.GSD_PROJECT_ROOT = savedProjectRoot;
47
+ }
48
+ });
49
+
35
50
  // ── Invariant 1: No module-level mutable variables in auto.ts ────────────────
36
51
 
37
52
  test("auto.ts has no module-level let declarations", () => {
@@ -0,0 +1,58 @@
1
+ // GSD2 — Regression test for Issue #4946 isVersionGreater (commands-extensions.ts)
2
+ //
3
+ // Covers the inline npm-version comparator that replaced the `semver` import in
4
+ // commands-extensions.ts. The original import broke `tsc -p tsconfig.json` whenever
5
+ // `@types/semver` failed to install — the file is type-checked transitively despite
6
+ // being under the `src/resources` exclude. The replacement keeps the same comparison
7
+ // semantics for the realistic input space (npm extension version strings) without
8
+ // pulling in the full semver type surface.
9
+
10
+ import test, { describe } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { isVersionGreater } from "../commands-extensions.ts";
13
+
14
+ describe("isVersionGreater — npm extension version comparison (#4946)", () => {
15
+ test("strictly greater patch beats lesser patch", () => {
16
+ assert.equal(isVersionGreater("1.2.4", "1.2.3"), true);
17
+ assert.equal(isVersionGreater("1.2.3", "1.2.4"), false);
18
+ });
19
+
20
+ test("equal versions are not strictly greater", () => {
21
+ assert.equal(isVersionGreater("1.2.3", "1.2.3"), false);
22
+ });
23
+
24
+ test("numeric (not lexicographic) component comparison", () => {
25
+ // Lexicographic compare would say "1.10.0" < "1.9.0".
26
+ assert.equal(isVersionGreater("1.10.0", "1.9.0"), true);
27
+ assert.equal(isVersionGreater("1.9.0", "1.10.0"), false);
28
+ });
29
+
30
+ test("major bump beats minor and patch differences", () => {
31
+ assert.equal(isVersionGreater("2.0.0", "1.99.99"), true);
32
+ assert.equal(isVersionGreater("1.99.99", "2.0.0"), false);
33
+ });
34
+
35
+ test("missing trailing components default to zero", () => {
36
+ assert.equal(isVersionGreater("1.2", "1.1.9"), true);
37
+ assert.equal(isVersionGreater("1", "0.9.9"), true);
38
+ assert.equal(isVersionGreater("1.2", "1.2.0"), false);
39
+ });
40
+
41
+ test("release version beats prerelease at the same release number", () => {
42
+ assert.equal(isVersionGreater("1.0.0", "1.0.0-beta.1"), true);
43
+ assert.equal(isVersionGreater("1.0.0-beta.1", "1.0.0"), false);
44
+ });
45
+
46
+ test("prerelease ordering: beta.2 > beta.1, rc.1 > beta.9", () => {
47
+ assert.equal(isVersionGreater("1.0.0-beta.2", "1.0.0-beta.1"), true);
48
+ assert.equal(isVersionGreater("1.0.0-rc.1", "1.0.0-beta.9"), true);
49
+ });
50
+
51
+ test("non-numeric junk in components doesn't crash — coerces to 0", () => {
52
+ // Defensive: we don't throw on garbage input; we treat unparseable
53
+ // components as 0. This matches the behaviour the extension installer
54
+ // can rely on without surfacing an error to the user.
55
+ assert.equal(isVersionGreater("1.x.0", "1.0.0"), false);
56
+ assert.equal(isVersionGreater("abc", "0.0.1"), false);
57
+ });
58
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tests for getHomeDir() — cross-platform home directory resolution.
3
+ *
4
+ * @see https://github.com/gsd-build/gsd-2/issues/5015
5
+ */
6
+ import { describe, it, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { homedir } from "node:os";
9
+
10
+ describe("getHomeDir", () => {
11
+ let savedHome: string | undefined;
12
+ let savedUserProfile: string | undefined;
13
+ let getHomeDir: () => string;
14
+
15
+ beforeEach(async () => {
16
+ savedHome = process.env.HOME;
17
+ savedUserProfile = process.env.USERPROFILE;
18
+ const mod = await import("../home-dir.ts");
19
+ getHomeDir = mod.getHomeDir;
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (savedHome !== undefined) {
24
+ process.env.HOME = savedHome;
25
+ } else {
26
+ delete process.env.HOME;
27
+ }
28
+ if (savedUserProfile !== undefined) {
29
+ process.env.USERPROFILE = savedUserProfile;
30
+ } else {
31
+ delete process.env.USERPROFILE;
32
+ }
33
+ });
34
+
35
+ it("returns HOME when set", () => {
36
+ process.env.HOME = "/test/home";
37
+ delete process.env.USERPROFILE;
38
+ assert.equal(getHomeDir(), "/test/home");
39
+ });
40
+
41
+ it("falls back to USERPROFILE when HOME is unset", () => {
42
+ delete process.env.HOME;
43
+ process.env.USERPROFILE = String.raw`C:\Users\test`;
44
+ assert.equal(getHomeDir(), String.raw`C:\Users\test`);
45
+ });
46
+
47
+ it("falls back to os.homedir() when both HOME and USERPROFILE are unset", () => {
48
+ delete process.env.HOME;
49
+ delete process.env.USERPROFILE;
50
+ assert.equal(getHomeDir(), homedir());
51
+ });
52
+ });
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { describe, test, afterEach } from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, symlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
  import { execSync } from "node:child_process";
@@ -21,6 +21,7 @@ import {
21
21
  getAutoWorktreeOriginalBase,
22
22
  getActiveAutoWorktreeContext,
23
23
  syncGsdStateToWorktree,
24
+ _resetAutoWorktreeOriginalBaseForTests,
24
25
  } from "../../auto-worktree.ts";
25
26
 
26
27
  // Note: execSync is used intentionally in tests for git operations with
@@ -142,6 +143,54 @@ describe("auto-worktree lifecycle", () => {
142
143
  teardownAutoWorktree(tempDir, "M003");
143
144
  });
144
145
 
146
+ test("symlink-resolved auto worktree is detected after module state reset", () => {
147
+ tempDir = createTempRepo();
148
+ const savedGsdHome = process.env.GSD_HOME;
149
+ const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "auto-wt-home-")));
150
+ const storage = join(fakeHome, ".gsd", "projects", "abc123def456");
151
+ mkdirSync(join(storage, "milestones", "M001"), { recursive: true });
152
+ writeFileSync(join(storage, "milestones", "M001", "CONTEXT.md"), "# M001\n");
153
+ symlinkSync(storage, join(tempDir, ".gsd"));
154
+ process.env.GSD_HOME = join(fakeHome, ".gsd");
155
+
156
+ try {
157
+ const wtPath = createAutoWorktree(tempDir, "M001");
158
+ const realWtPath = realpathSync(wtPath);
159
+ assert.ok(realWtPath.startsWith(storage), "git registered the symlink-resolved worktree path");
160
+
161
+ _resetAutoWorktreeOriginalBaseForTests();
162
+ process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
163
+
164
+ assert.ok(isInAutoWorktree(tempDir), "structural detection works without module originalBase");
165
+ const resolved = getAutoWorktreePath(realWtPath, "M001");
166
+ assert.ok(resolved, "existing worktree is found when basePath is the worktree path");
167
+ assert.equal(realpathSync(resolved!), realWtPath);
168
+ assert.equal(existsSync(join(realWtPath, ".gsd", "worktrees", "M001")), false);
169
+
170
+ enterAutoWorktree(tempDir, "M001");
171
+ process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
172
+ assert.deepStrictEqual(
173
+ getActiveAutoWorktreeContext(),
174
+ {
175
+ originalBase: tempDir,
176
+ worktreeName: "M001",
177
+ branch: "milestone/M001",
178
+ },
179
+ "active context is detected from a symlink-resolved worktree cwd",
180
+ );
181
+ } finally {
182
+ process.chdir(tempDir);
183
+ try {
184
+ teardownAutoWorktree(tempDir, "M001");
185
+ } catch {
186
+ // Best-effort cleanup for partially-created temp worktrees.
187
+ }
188
+ if (savedGsdHome === undefined) delete process.env.GSD_HOME;
189
+ else process.env.GSD_HOME = savedGsdHome;
190
+ rmSync(fakeHome, { recursive: true, force: true });
191
+ }
192
+ });
193
+
145
194
  test("coexistence with manual worktree", async () => {
146
195
  tempDir = createTempRepo();
147
196
  const msDir = join(tempDir, ".gsd", "milestones", "M003");
@@ -10,7 +10,7 @@
10
10
  import { describe, test } from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
 
13
- import { _resolveReportBasePath } from "../auto/phases.ts";
13
+ import { _resolveDispatchGuardBasePath, _resolveReportBasePath } from "../auto/phases.ts";
14
14
 
15
15
  describe("_resolveReportBasePath", () => {
16
16
  test("uses originalBasePath when set (worktree scenario)", () => {
@@ -48,4 +48,21 @@ describe("_resolveReportBasePath", () => {
48
48
 
49
49
  assert.equal(_resolveReportBasePath(session), "/home/user/repo");
50
50
  });
51
+
52
+ test("uses GSD_PROJECT_ROOT for symlink-resolved worktree paths", () => {
53
+ const savedProjectRoot = process.env.GSD_PROJECT_ROOT;
54
+ process.env.GSD_PROJECT_ROOT = "/real/project";
55
+ try {
56
+ const session = {
57
+ originalBasePath: "",
58
+ basePath: "/Users/dev/.gsd/projects/abc123/worktrees/M001/slices/S01",
59
+ };
60
+
61
+ assert.equal(_resolveReportBasePath(session), "/real/project");
62
+ assert.equal(_resolveDispatchGuardBasePath(session), "/real/project");
63
+ } finally {
64
+ if (savedProjectRoot === undefined) delete process.env.GSD_PROJECT_ROOT;
65
+ else process.env.GSD_PROJECT_ROOT = savedProjectRoot;
66
+ }
67
+ });
51
68
  });
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test, beforeEach, afterEach } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpdir } from "node:os";
10
10
  import { appendOverride, loadActiveOverrides } from "../files.ts";
@@ -73,6 +73,22 @@ describe("steer worktree path resolution (#3476)", () => {
73
73
  assert.equal(result, null, "returns null for worktree without .git file");
74
74
  });
75
75
 
76
+ test("getAutoWorktreePath returns null when .git is a directory", () => {
77
+ mkdirSync(join(worktreePath, ".git"), { recursive: true });
78
+
79
+ const result = getAutoWorktreePath(projectRoot, "M001");
80
+
81
+ assert.equal(result, null, "returns null for standalone .git directories");
82
+ });
83
+
84
+ test("getAutoWorktreePath returns null when .git file is not a gitdir pointer", () => {
85
+ writeFileSync(join(worktreePath, ".git"), "not-a-gitdir\n", "utf-8");
86
+
87
+ const result = getAutoWorktreePath(projectRoot, "M001");
88
+
89
+ assert.equal(result, null, "returns null for invalid .git files");
90
+ });
91
+
76
92
  test("override routing: inactive worktree directory should not receive overrides", async () => {
77
93
  // Simulate the handleSteer path-resolution logic:
78
94
  // When no auto-mode is running, even if a worktree dir exists,
@@ -199,8 +199,8 @@ test("#4934: every manifest declares a tools policy", () => {
199
199
  }
200
200
  });
201
201
 
202
- test("#4934: tools.mode is one of the four declared policies", () => {
203
- const validModes = new Set(["all", "read-only", "planning", "docs"]);
202
+ test("#4934: tools.mode is one of the declared policies", () => {
203
+ const validModes = new Set(["all", "read-only", "planning", "planning-dispatch", "docs"]);
204
204
  for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
205
205
  const mode = (manifest as { tools: { mode: string } }).tools.mode;
206
206
  assert.ok(
@@ -219,7 +219,42 @@ test('#4934: only execute-task and reactive-execute may use tools.mode "all" (fu
219
219
  allowedAllUnits.has(unitType),
220
220
  `manifest "${unitType}" declares tools.mode = "all" but is not on the execute-track. ` +
221
221
  'Only execute-task and reactive-execute should have full source write access; ' +
222
- 'planning/discuss/research units must use "planning" (or "docs" for rewrite-docs).',
222
+ 'planning/discuss/research units must use "planning" or "planning-dispatch" (or "docs" for rewrite-docs).',
223
+ );
224
+ }
225
+ }
226
+ });
227
+
228
+ test('planning-dispatch mode is reserved for slice-level decomposition and completion units', () => {
229
+ const allowedDispatchUnits = new Set([
230
+ "plan-slice",
231
+ "refine-slice",
232
+ "complete-slice",
233
+ "complete-milestone",
234
+ ]);
235
+ for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
236
+ const mode = (manifest as { tools: { mode: string } }).tools.mode;
237
+ if (mode === "planning-dispatch") {
238
+ assert.ok(
239
+ allowedDispatchUnits.has(unitType),
240
+ `manifest "${unitType}" declares tools.mode = "planning-dispatch" but is not on the dispatch-allowed allowlist. ` +
241
+ 'planning-dispatch is intentionally narrow — extend the allowlist consciously when a new unit type genuinely benefits from subagent delegation.',
242
+ );
243
+ }
244
+ }
245
+ });
246
+
247
+ test('planning-dispatch manifests declare non-empty allowedSubagents lists', () => {
248
+ for (const [unitType, manifest] of Object.entries(UNIT_MANIFESTS)) {
249
+ if (manifest.tools.mode !== "planning-dispatch") continue;
250
+ assert.ok(
251
+ Array.isArray(manifest.tools.allowedSubagents) && manifest.tools.allowedSubagents.length > 0,
252
+ `manifest "${unitType}" has planning-dispatch policy but no allowedSubagents — explicit allowlist is required for runtime dispatch gating`,
253
+ );
254
+ for (const agent of manifest.tools.allowedSubagents) {
255
+ assert.ok(
256
+ typeof agent === "string" && agent.length > 0,
257
+ `manifest "${unitType}" has empty/invalid allowedSubagents entry: ${JSON.stringify(agent)}`,
223
258
  );
224
259
  }
225
260
  }
@@ -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', () => {