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.
- package/README.md +1 -0
- package/dist/bundled-resource-path.d.ts +7 -0
- package/dist/bundled-resource-path.js +34 -2
- package/dist/claude-cli-check.js +18 -6
- package/dist/headless-query.js +21 -6
- package/dist/loader.js +2 -3
- package/dist/resource-loader.js +2 -8
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/readiness.js +19 -7
- package/dist/resources/extensions/google-search/index.js +2 -6
- package/dist/resources/extensions/gsd/auto/phases.js +3 -11
- package/dist/resources/extensions/gsd/auto/session.js +2 -6
- package/dist/resources/extensions/gsd/auto-dashboard.js +3 -2
- package/dist/resources/extensions/gsd/auto-dispatch.js +18 -6
- package/dist/resources/extensions/gsd/auto-prompts.js +63 -2
- package/dist/resources/extensions/gsd/auto-worktree.js +30 -13
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +19 -1
- package/dist/resources/extensions/gsd/bootstrap/subagent-input.js +22 -0
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -0
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +84 -2
- package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
- package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
- package/dist/resources/extensions/gsd/commands/handlers/ops.js +8 -0
- package/dist/resources/extensions/gsd/commands-config.js +3 -2
- package/dist/resources/extensions/gsd/commands-extensions.js +46 -3
- package/dist/resources/extensions/gsd/commands-handlers.js +3 -2
- package/dist/resources/extensions/gsd/commands-worktree.js +309 -0
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +6 -0
- package/dist/resources/extensions/gsd/doctor-providers.js +2 -1
- package/dist/resources/extensions/gsd/forensics.js +8 -6
- package/dist/resources/extensions/gsd/guided-flow.js +2 -1
- package/dist/resources/extensions/gsd/home-dir.js +16 -0
- package/dist/resources/extensions/gsd/key-manager.js +2 -1
- package/dist/resources/extensions/gsd/migrate/command.js +3 -2
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +10 -0
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +10 -0
- package/dist/resources/extensions/gsd/prompts/refine-slice.md +10 -0
- package/dist/resources/extensions/gsd/unit-context-manifest.js +29 -4
- package/dist/resources/extensions/gsd/worktree-manager.js +20 -1
- package/dist/resources/extensions/gsd/worktree-resolver.js +4 -13
- package/dist/resources/extensions/gsd/worktree-root.js +124 -0
- package/dist/resources/extensions/gsd/worktree.js +4 -115
- package/dist/resources/extensions/mcp-client/index.js +0 -6
- package/dist/resources/extensions/ollama/index.js +15 -2
- package/dist/resources/extensions/ollama/model-capabilities.js +31 -0
- package/dist/resources/extensions/ollama/ollama-client.js +40 -4
- package/dist/resources/extensions/subagent/index.js +324 -178
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/welcome-screen.js +27 -1
- package/dist/worktree-cli.d.ts +1 -0
- package/dist/worktree-cli.js +9 -3
- package/package.json +1 -3
- package/packages/mcp-server/src/workflow-tools.test.ts +52 -0
- package/packages/native/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/claude-code-cli/readiness.ts +20 -7
- package/src/resources/extensions/google-search/index.ts +2 -9
- package/src/resources/extensions/gsd/auto/phases.ts +3 -11
- package/src/resources/extensions/gsd/auto/session.ts +2 -6
- package/src/resources/extensions/gsd/auto-dashboard.ts +3 -2
- package/src/resources/extensions/gsd/auto-dispatch.ts +18 -6
- package/src/resources/extensions/gsd/auto-prompts.ts +60 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +44 -12
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +19 -0
- package/src/resources/extensions/gsd/bootstrap/subagent-input.ts +20 -0
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -0
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +103 -1
- package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
- package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
- package/src/resources/extensions/gsd/commands/handlers/ops.ts +10 -0
- package/src/resources/extensions/gsd/commands-config.ts +3 -2
- package/src/resources/extensions/gsd/commands-extensions.ts +43 -3
- package/src/resources/extensions/gsd/commands-handlers.ts +3 -2
- package/src/resources/extensions/gsd/commands-worktree.ts +383 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +6 -0
- package/src/resources/extensions/gsd/doctor-providers.ts +2 -1
- package/src/resources/extensions/gsd/forensics.ts +10 -5
- package/src/resources/extensions/gsd/guided-flow.ts +2 -1
- package/src/resources/extensions/gsd/home-dir.ts +19 -0
- package/src/resources/extensions/gsd/journal.ts +4 -1
- package/src/resources/extensions/gsd/key-manager.ts +2 -1
- package/src/resources/extensions/gsd/migrate/command.ts +3 -2
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +10 -0
- package/src/resources/extensions/gsd/prompts/plan-slice.md +10 -0
- package/src/resources/extensions/gsd/prompts/refine-slice.md +10 -0
- package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/bundled-skill-triggers.test.ts +50 -27
- package/src/resources/extensions/gsd/tests/commands-extensions-version-compare.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +48 -0
- package/src/resources/extensions/gsd/tests/google-search-stub.test.ts +25 -65
- package/src/resources/extensions/gsd/tests/home-dir.test.ts +52 -0
- package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +50 -1
- package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +18 -1
- package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +34 -0
- package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +17 -1
- package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +38 -3
- package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +34 -33
- package/src/resources/extensions/gsd/tests/worktree.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +116 -1
- package/src/resources/extensions/gsd/unit-context-manifest.ts +36 -4
- package/src/resources/extensions/gsd/worktree-manager.ts +40 -1
- package/src/resources/extensions/gsd/worktree-resolver.ts +4 -14
- package/src/resources/extensions/gsd/worktree-root.ts +144 -0
- package/src/resources/extensions/gsd/worktree.ts +8 -119
- package/src/resources/extensions/mcp-client/index.ts +0 -7
- package/src/resources/extensions/ollama/index.ts +16 -2
- package/src/resources/extensions/ollama/model-capabilities.ts +34 -0
- package/src/resources/extensions/ollama/ollama-client.ts +41 -4
- package/src/resources/extensions/ollama/tests/model-capabilities.test.ts +96 -0
- package/src/resources/extensions/ollama/tests/ollama-client-timeout-env.test.ts +147 -0
- package/src/resources/extensions/subagent/index.ts +165 -7
- /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_buildManifest.js +0 -0
- /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 {
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
100
|
-
* actual project root
|
|
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
|
-
|
|
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
|
+
}
|