@opengsd/gsd-core 1.20.1 → 1.21.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -37,6 +37,9 @@ const { isPhaseComplete } = verificationMod;
|
|
|
37
37
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
38
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
39
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
|
|
41
|
+
const configFileReadMod = require("./config-file-read.cjs");
|
|
42
|
+
const { readConfigFileBounded } = configFileReadMod;
|
|
40
43
|
// #612: `resolvePhaseIdConvention` is the federated (workstream -> root)
|
|
41
44
|
// `phase_id_convention` reader, from the same §7 owner module `planningPaths`
|
|
42
45
|
// comes from. Resolved once in `buildPlanningSnapshot` — see the
|
|
@@ -287,11 +290,12 @@ function buildStateFields(statePath) {
|
|
|
287
290
|
*/
|
|
288
291
|
function buildConfigField(cwd) {
|
|
289
292
|
const configPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
|
|
290
|
-
if (!node_fs_1.default.existsSync(configPath)) {
|
|
291
|
-
return { value: null, scope: SCOPE.UNREADABLE, exists: false };
|
|
292
|
-
}
|
|
293
293
|
try {
|
|
294
|
-
|
|
294
|
+
// Bounded read (#5759): null is an absent file; a FIFO/device/oversized one throws and is
|
|
295
|
+
// reported as present-but-unreadable below.
|
|
296
|
+
const raw = readConfigFileBounded(configPath);
|
|
297
|
+
if (raw === null)
|
|
298
|
+
return { value: null, scope: SCOPE.UNREADABLE, exists: false };
|
|
295
299
|
const parsed = JSON.parse(raw);
|
|
296
300
|
return { value: parsed, scope: SCOPE.COMPLETE, exists: true };
|
|
297
301
|
}
|
|
@@ -19,6 +19,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
19
19
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
20
20
|
const node_path_1 = __importDefault(require("node:path"));
|
|
21
21
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
23
|
+
const configFileReadMod = require("./config-file-read.cjs");
|
|
24
|
+
const { readConfigFileBounded } = configFileReadMod;
|
|
22
25
|
const clock_cjs_1 = require("./clock.cjs");
|
|
23
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
27
|
const planningScopeMod = require("./planning-scope.cjs");
|
|
@@ -154,7 +157,12 @@ const CONFIG_VALUE_ABSENT = Object.freeze({ present: false, value: undefined });
|
|
|
154
157
|
function ownConfigValue(configPath, keyPath) {
|
|
155
158
|
let node;
|
|
156
159
|
try {
|
|
157
|
-
|
|
160
|
+
// Bounded read (#5759): null (absent) and any throw (FIFO, device, directory, oversized,
|
|
161
|
+
// errno) both mean this file sets nothing — never a block.
|
|
162
|
+
const raw = readConfigFileBounded(configPath);
|
|
163
|
+
if (raw === null)
|
|
164
|
+
return CONFIG_VALUE_ABSENT;
|
|
165
|
+
node = JSON.parse(raw);
|
|
158
166
|
}
|
|
159
167
|
catch {
|
|
160
168
|
return CONFIG_VALUE_ABSENT;
|
|
@@ -268,11 +276,12 @@ function worktreesOptedOut(cwd) {
|
|
|
268
276
|
*/
|
|
269
277
|
function resolvePhaseIdConvention(cwd, ws) {
|
|
270
278
|
const readFrom = (dir) => {
|
|
271
|
-
const configPath = node_path_1.default.join(dir, 'config.json');
|
|
272
|
-
if (!node_fs_1.default.existsSync(configPath))
|
|
273
|
-
return null;
|
|
274
279
|
try {
|
|
275
|
-
|
|
280
|
+
// Bounded read (#5759): null is an absent file, a throw is an unreadable one; both mean "unset".
|
|
281
|
+
const raw = readConfigFileBounded(node_path_1.default.join(dir, 'config.json'));
|
|
282
|
+
if (raw === null)
|
|
283
|
+
return null;
|
|
284
|
+
const parsed = JSON.parse(raw);
|
|
276
285
|
const value = parsed['phase_id_convention'];
|
|
277
286
|
return typeof value === 'string' && value !== '' ? value : null;
|
|
278
287
|
}
|
|
@@ -452,7 +461,8 @@ function withPlanningLock(cwd, fn, clock) {
|
|
|
452
461
|
// written atomically (writeFileSync …{flag:'wx'}) so there is no empty-body
|
|
453
462
|
// create window here — only the double-steal needs hardening.
|
|
454
463
|
try {
|
|
455
|
-
|
|
464
|
+
// Exact 64-bit (dev, ino): an NTFS file id does not fit a JS number (never serialized).
|
|
465
|
+
const decisionId = node_fs_1.default.statSync(lockPath, { bigint: true });
|
|
456
466
|
// Snapshot the decision-time body too: (dev, ino) alone is defeated by inode
|
|
457
467
|
// REUSE (a racer's unlink+recreate can land on the same inode), so the body
|
|
458
468
|
// content binds the identity as well — mirrors capability-lock.cts's (dev,
|
|
@@ -469,7 +479,7 @@ function withPlanningLock(cwd, fn, clock) {
|
|
|
469
479
|
// Verified-live, but recover anyway once the lock crosses the absolute
|
|
470
480
|
// deadman ceiling — defeats a pid-reuse false-alive that would otherwise
|
|
471
481
|
// block forever (R4-FIX; mtime age is from lock creation, not this call).
|
|
472
|
-
const age = clock.now() -
|
|
482
|
+
const age = clock.now() - Number(decisionId.mtimeMs);
|
|
473
483
|
stealable = age > deadmanCeilingMs;
|
|
474
484
|
}
|
|
475
485
|
if (stealable) {
|
|
@@ -480,7 +490,7 @@ function withPlanningLock(cwd, fn, clock) {
|
|
|
480
490
|
// NOT delete the replacement; back off and re-evaluate.
|
|
481
491
|
let confirmStat;
|
|
482
492
|
try {
|
|
483
|
-
confirmStat = node_fs_1.default.statSync(lockPath);
|
|
493
|
+
confirmStat = node_fs_1.default.statSync(lockPath, { bigint: true });
|
|
484
494
|
}
|
|
485
495
|
catch {
|
|
486
496
|
continue; // vanished between decision and steal — retry the create.
|
|
@@ -492,8 +502,8 @@ function withPlanningLock(cwd, fn, clock) {
|
|
|
492
502
|
catch {
|
|
493
503
|
confirmBody = null;
|
|
494
504
|
}
|
|
495
|
-
const sameInstance = typeof
|
|
496
|
-
confirmStat.dev ===
|
|
505
|
+
const sameInstance = typeof decisionId.dev === 'bigint' && typeof decisionId.ino === 'bigint' &&
|
|
506
|
+
confirmStat.dev === decisionId.dev && confirmStat.ino === decisionId.ino &&
|
|
497
507
|
decisionBody !== null && confirmBody === decisionBody;
|
|
498
508
|
if (!sameInstance) {
|
|
499
509
|
clock.sleep(100); // a racer won the steal + recreated — re-evaluate, don't delete it.
|
|
@@ -679,7 +679,7 @@ function cmdWriteProfile(cwd, options, raw) {
|
|
|
679
679
|
outputPath = node_path_1.default.join(cwd, outputPath);
|
|
680
680
|
}
|
|
681
681
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(outputPath));
|
|
682
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, template);
|
|
682
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, template, { allowSpecialTarget: Boolean(options.output) });
|
|
683
683
|
const result = {
|
|
684
684
|
profile_path: outputPath,
|
|
685
685
|
dimensions_scored: dimensionsScored,
|
|
@@ -843,7 +843,7 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
|
|
843
843
|
outputPath = node_path_1.default.join(cwd, outputPath);
|
|
844
844
|
}
|
|
845
845
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(outputPath));
|
|
846
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, template);
|
|
846
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, template, { allowSpecialTarget: Boolean(options.output) });
|
|
847
847
|
const result = {
|
|
848
848
|
command_path: outputPath,
|
|
849
849
|
command_name: (0, runtime_slash_cjs_1.formatGsdSlash)('dev-preferences', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
|
|
@@ -970,6 +970,9 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
|
|
970
970
|
targetPath = node_path_1.default.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : node_path_1.default.join(cwd, configClaudeMdPath);
|
|
971
971
|
}
|
|
972
972
|
let action;
|
|
973
|
+
// Only a path the user typed after --output may be a device or pipe (/dev/stdout); the
|
|
974
|
+
// global and config-derived targets keep refusing special files (#5759).
|
|
975
|
+
const userChosenTarget = !options.global && Boolean(options.output);
|
|
973
976
|
let existingContent = (0, shell_command_projection_cjs_1.platformReadSync)(targetPath);
|
|
974
977
|
if (existingContent !== null) {
|
|
975
978
|
const startMarker = '<!-- GSD:profile-start -->';
|
|
@@ -986,11 +989,11 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
|
|
986
989
|
existingContent = existingContent.trimEnd() + '\n\n' + sectionContent + '\n';
|
|
987
990
|
action = 'appended';
|
|
988
991
|
}
|
|
989
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, existingContent);
|
|
992
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, existingContent, { allowSpecialTarget: userChosenTarget });
|
|
990
993
|
}
|
|
991
994
|
else {
|
|
992
995
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(targetPath));
|
|
993
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, sectionContent + '\n');
|
|
996
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, sectionContent + '\n', { allowSpecialTarget: userChosenTarget });
|
|
994
997
|
action = 'created';
|
|
995
998
|
}
|
|
996
999
|
const result = {
|
|
@@ -1100,7 +1103,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
|
|
1100
1103
|
existingContent = sections.join('\n\n') + '\n';
|
|
1101
1104
|
action = 'created';
|
|
1102
1105
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(outputPath));
|
|
1103
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, existingContent);
|
|
1106
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, existingContent, { allowSpecialTarget: Boolean(options.output) });
|
|
1104
1107
|
}
|
|
1105
1108
|
else if (!/<!-- GSD:[a-z]+-start/.test(existingContent) && !options.force) {
|
|
1106
1109
|
// #1098: the target instruction file already exists and contains NO GSD
|
|
@@ -1158,7 +1161,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
|
|
1158
1161
|
if (!options.auto && fileContent.indexOf('<!-- GSD:profile-start') === -1) {
|
|
1159
1162
|
fileContent = fileContent.trimEnd() + '\n\n' + buildClaudeMdProfilePlaceholder((0, runtime_slash_cjs_1.resolveRuntime)(cwd)) + '\n';
|
|
1160
1163
|
}
|
|
1161
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, fileContent);
|
|
1164
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, fileContent, { allowSpecialTarget: Boolean(options.output) });
|
|
1162
1165
|
}
|
|
1163
1166
|
const finalContent = (0, shell_command_projection_cjs_1.platformReadSync)(outputPath);
|
|
1164
1167
|
let profileStatus;
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Project Context Module — ADR-4908 §1-§3 (#6043, Phase 1 of #4908).
|
|
4
|
+
*
|
|
5
|
+
* `resolveProjectContext(startDir, { explicit })` is the only code that decides
|
|
6
|
+
* which root a process acts in. It answers four questions once and returns a
|
|
7
|
+
* frozen value: where the `.planning` entry is (`projectRoot`), where its
|
|
8
|
+
* content really lives (`planningRoot`), which checkout the process runs in
|
|
9
|
+
* (`worktreeRoot`) and which checkout is the main one (`repoRoot`).
|
|
10
|
+
*
|
|
11
|
+
* Git is consulted through the shell-projection seam, bounded by
|
|
12
|
+
* `DEFAULT_GIT_TIMEOUT_MS`, and never throws: a timeout or spawn failure
|
|
13
|
+
* degrades to the lexical answer and is reported in `provenance`.
|
|
14
|
+
*
|
|
15
|
+
* The `.planning` ancestor walk (`walkUpForPlanning`) is the algorithm that
|
|
16
|
+
* lived in `project-root.cts` (#1362, #1414, #1422, #2843, #4815, #5624, #5759),
|
|
17
|
+
* moved here unchanged apart from the provenance tag it returns. It never
|
|
18
|
+
* spawns git: `findProjectRoot` in `project-root.cts` still delegates to it
|
|
19
|
+
* and is called per tool call from the isolation-sentinel hook.
|
|
20
|
+
*/
|
|
21
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
22
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.walkUpForPlanning = walkUpForPlanning;
|
|
26
|
+
exports.resolveProjectContext = resolveProjectContext;
|
|
27
|
+
exports.legacyDispatchCwd = legacyDispatchCwd;
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
|
|
32
|
+
const configFileReadMod = require("./config-file-read.cjs");
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module
|
|
34
|
+
const worktreeSafetyMod = require("./worktree-safety.cjs");
|
|
35
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
36
|
+
const { readConfigFileBounded } = configFileReadMod;
|
|
37
|
+
const { DEFAULT_GIT_TIMEOUT_MS, parseWorktreeEntries } = worktreeSafetyMod;
|
|
38
|
+
const FIND_PROJECT_ROOT_MAX_DEPTH = 10;
|
|
39
|
+
function walkUpForPlanning(startDir) {
|
|
40
|
+
let resolvedStart;
|
|
41
|
+
try {
|
|
42
|
+
resolvedStart = node_path_1.default.resolve(startDir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { root: startDir, source: 'none' };
|
|
46
|
+
}
|
|
47
|
+
const fsRoot = node_path_1.default.parse(resolvedStart).root;
|
|
48
|
+
const home = node_os_1.default.homedir();
|
|
49
|
+
let realHome = null;
|
|
50
|
+
// A relative `$HOME` (`HOME=.`) would resolve against the process cwd and
|
|
51
|
+
// make that directory count as `$HOME`; keep the spelled comparison for it.
|
|
52
|
+
if (node_path_1.default.isAbsolute(home)) {
|
|
53
|
+
try {
|
|
54
|
+
realHome = node_fs_1.default.realpathSync.native(home);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// no `$HOME` on disk: only the spelled comparison applies
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// #5624: the `$HOME` stop holds however `$HOME` or the walked path is
|
|
61
|
+
// spelled. With `/home/me -> /data/me`, a cwd spelled `/data/me/...` (what
|
|
62
|
+
// `process.cwd()` reports) never has `/home/me` as a parent, so comparing
|
|
63
|
+
// spellings alone walked past `$HOME`. The isolation hooks resolve through
|
|
64
|
+
// this function too, so they keep the same boundary (#3050).
|
|
65
|
+
function isHome(d) {
|
|
66
|
+
if (d === home)
|
|
67
|
+
return true;
|
|
68
|
+
if (realHome === null)
|
|
69
|
+
return false;
|
|
70
|
+
try {
|
|
71
|
+
return node_fs_1.default.realpathSync.native(d) === realHome;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// If startDir already contains .planning/, it IS the project root.
|
|
78
|
+
try {
|
|
79
|
+
const ownPlanningDir = resolvedStart + node_path_1.default.sep + '.planning';
|
|
80
|
+
if (node_fs_1.default.existsSync(ownPlanningDir) && node_fs_1.default.statSync(ownPlanningDir).isDirectory()) {
|
|
81
|
+
return { root: startDir, source: 'ancestor' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// fall through
|
|
86
|
+
}
|
|
87
|
+
// #5624: started at `$HOME` itself, with no `.planning/` of its own, there is
|
|
88
|
+
// nothing to walk: every ancestor is above `$HOME`.
|
|
89
|
+
if (isHome(resolvedStart))
|
|
90
|
+
return { root: startDir, source: 'none' };
|
|
91
|
+
// A `.planning` that is itself a symlink is a deliberate hop into a separate
|
|
92
|
+
// tree (the documented "keep planning content out of the tracked repo"
|
|
93
|
+
// convention: `.planning -> ~/.gsd-external-planning/<project>/`, its own git
|
|
94
|
+
// repo). `fs.existsSync(d + '/.git')` DEREFERENCES `d`, so at `d ===
|
|
95
|
+
// <the .planning symlink path>` it finds the EXTERNAL store's `.git`, and
|
|
96
|
+
// both isInsideGitRepo/nearestGitRoot misread it as a crossed nested-child-
|
|
97
|
+
// repo boundary (#2843's guard). `fs.lstatSync` does not dereference its
|
|
98
|
+
// final path component, so it answers "is `d` ITSELF a symlink" (#4815).
|
|
99
|
+
//
|
|
100
|
+
// Deliberately scoped to a symlink named `.planning`, NOT any symlinked
|
|
101
|
+
// directory: a nested child repo reached through some other symlinked
|
|
102
|
+
// ancestor is still a real repo boundary, and skipping it would reopen
|
|
103
|
+
// #2843 through a different trigger.
|
|
104
|
+
function isSymlinkedPlanningDir(d) {
|
|
105
|
+
if (node_path_1.default.basename(d) !== '.planning')
|
|
106
|
+
return false;
|
|
107
|
+
try {
|
|
108
|
+
return node_fs_1.default.lstatSync(d).isSymbolicLink();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Walk upward, mirroring isInsideGitRepo from the CJS reference.
|
|
115
|
+
function isInsideGitRepo(candidateParent) {
|
|
116
|
+
let d = resolvedStart;
|
|
117
|
+
while (d !== fsRoot) {
|
|
118
|
+
try {
|
|
119
|
+
if (!isSymlinkedPlanningDir(d) && node_fs_1.default.existsSync(d + node_path_1.default.sep + '.git'))
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
if (d === candidateParent)
|
|
126
|
+
break;
|
|
127
|
+
const next = node_path_1.default.dirname(d);
|
|
128
|
+
if (next === d)
|
|
129
|
+
break;
|
|
130
|
+
d = next;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
// #2843: nearest ancestor (including `from` itself) that contains a `.git`,
|
|
135
|
+
// bounded by `upTo` (exclusive). Returns the git-repo root, or null if none
|
|
136
|
+
// exists before `upTo` / the filesystem root. Used to detect a NESTED child
|
|
137
|
+
// repo whose root is strictly below a candidate ancestor `.planning/` — in
|
|
138
|
+
// that case the caller's repo boundary sits between start and the ancestor,
|
|
139
|
+
// so trusting the ancestor's `.planning/` would silently cross into a
|
|
140
|
+
// different project. (No `git` subprocess — fs walk only, matching
|
|
141
|
+
// isInsideGitRepo's deliberate no-spawn contract.)
|
|
142
|
+
function nearestGitRoot(from, upTo) {
|
|
143
|
+
let d = from;
|
|
144
|
+
while (d !== fsRoot) {
|
|
145
|
+
if (d === upTo)
|
|
146
|
+
break;
|
|
147
|
+
try {
|
|
148
|
+
if (!isSymlinkedPlanningDir(d) && node_fs_1.default.existsSync(d + node_path_1.default.sep + '.git'))
|
|
149
|
+
return d;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
const next = node_path_1.default.dirname(d);
|
|
155
|
+
if (next === d)
|
|
156
|
+
break;
|
|
157
|
+
d = next;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
let dir = resolvedStart;
|
|
162
|
+
let depth = 0;
|
|
163
|
+
while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
|
164
|
+
const parent = node_path_1.default.dirname(dir);
|
|
165
|
+
if (parent === dir)
|
|
166
|
+
break;
|
|
167
|
+
if (isHome(parent))
|
|
168
|
+
break;
|
|
169
|
+
const parentPlanning = parent + node_path_1.default.sep + '.planning';
|
|
170
|
+
let parentPlanningIsDir = false;
|
|
171
|
+
try {
|
|
172
|
+
parentPlanningIsDir = node_fs_1.default.existsSync(parentPlanning) && node_fs_1.default.statSync(parentPlanning).isDirectory();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
parentPlanningIsDir = false;
|
|
176
|
+
}
|
|
177
|
+
if (parentPlanningIsDir) {
|
|
178
|
+
const configPath = parentPlanning + node_path_1.default.sep + 'config.json';
|
|
179
|
+
let matched = false;
|
|
180
|
+
try {
|
|
181
|
+
// Bounded read (#5759): a FIFO/device/oversized config throws and is skipped like a
|
|
182
|
+
// missing one. An absent file (null) becomes '' so JSON.parse throws into the same catch.
|
|
183
|
+
const raw = readConfigFileBounded(configPath) ?? '';
|
|
184
|
+
const config = JSON.parse(raw);
|
|
185
|
+
if (config && typeof config === 'object') {
|
|
186
|
+
const cfg = config;
|
|
187
|
+
const subReposValue = cfg['sub_repos'] ??
|
|
188
|
+
(cfg['planning'] && typeof cfg['planning'] === 'object'
|
|
189
|
+
? cfg['planning']['sub_repos']
|
|
190
|
+
: undefined);
|
|
191
|
+
const subRepos = Array.isArray(subReposValue) ? subReposValue : [];
|
|
192
|
+
if (subRepos.length > 0) {
|
|
193
|
+
const relPath = node_path_1.default.relative(parent, resolvedStart);
|
|
194
|
+
const topSegment = relPath.split(node_path_1.default.sep)[0];
|
|
195
|
+
if (subRepos.includes(topSegment)) {
|
|
196
|
+
return { root: parent, source: 'workspace' };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (cfg['multiRepo'] === true && isInsideGitRepo(parent)) {
|
|
200
|
+
matched = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// config.json missing or unparseable — fall through to .git heuristic.
|
|
206
|
+
}
|
|
207
|
+
if (matched)
|
|
208
|
+
return { root: parent, source: 'ancestor' };
|
|
209
|
+
// Heuristic (3): parent has .planning/ and we're inside a git repo.
|
|
210
|
+
// Before returning, check if any further ancestor has sub_repos that explicitly
|
|
211
|
+
// claims our startDir — explicit sub_repos config takes precedence over the
|
|
212
|
+
// implicit .git signal. (#1422)
|
|
213
|
+
if (isInsideGitRepo(parent)) {
|
|
214
|
+
// #2843: do NOT cross a git-repo boundary. If the caller is inside its
|
|
215
|
+
// OWN nested repo whose root is strictly below `parent`, trusting
|
|
216
|
+
// `parent`'s .planning/ would silently resolve to a DIFFERENT project.
|
|
217
|
+
// isInsideGitRepo only proved SOME .git exists between start and parent;
|
|
218
|
+
// verify that .git is parent's own (or absent between), not a nested
|
|
219
|
+
// child repo. If nearestGitRoot finds a .git strictly below parent,
|
|
220
|
+
// the boundary is crossed — fall through (do not return parent).
|
|
221
|
+
if (nearestGitRoot(resolvedStart, parent) !== null) {
|
|
222
|
+
dir = parent;
|
|
223
|
+
depth += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
// Lookahead: walk ancestors above `parent` to find a sub_repos claim.
|
|
227
|
+
let ancestor = node_path_1.default.dirname(parent);
|
|
228
|
+
let ancestorDepth = 0;
|
|
229
|
+
while (ancestor !== fsRoot && !isHome(ancestor) && ancestorDepth < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
|
230
|
+
const ancestorPlanning = ancestor + node_path_1.default.sep + '.planning';
|
|
231
|
+
try {
|
|
232
|
+
if (node_fs_1.default.existsSync(ancestorPlanning) && node_fs_1.default.statSync(ancestorPlanning).isDirectory()) {
|
|
233
|
+
const ancestorConfig = ancestor + node_path_1.default.sep + '.planning' + node_path_1.default.sep + 'config.json';
|
|
234
|
+
const rawA = readConfigFileBounded(ancestorConfig) ?? ''; // #5759: bounded; '' makes JSON.parse throw into the catch
|
|
235
|
+
const cfgA = JSON.parse(rawA);
|
|
236
|
+
const subReposValueA = cfgA['sub_repos'] ??
|
|
237
|
+
(cfgA['planning'] && typeof cfgA['planning'] === 'object'
|
|
238
|
+
? cfgA['planning']['sub_repos']
|
|
239
|
+
: undefined);
|
|
240
|
+
const subReposA = Array.isArray(subReposValueA) ? subReposValueA : [];
|
|
241
|
+
if (subReposA.length > 0) {
|
|
242
|
+
const relPathA = node_path_1.default.relative(ancestor, resolvedStart);
|
|
243
|
+
const topSegmentA = relPathA.split(node_path_1.default.sep)[0];
|
|
244
|
+
if (subReposA.includes(topSegmentA)) {
|
|
245
|
+
return { root: ancestor, source: 'workspace' };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// ignore — config missing or unparseable, keep walking
|
|
252
|
+
}
|
|
253
|
+
const nextAncestor = node_path_1.default.dirname(ancestor);
|
|
254
|
+
if (nextAncestor === ancestor)
|
|
255
|
+
break;
|
|
256
|
+
ancestor = nextAncestor;
|
|
257
|
+
ancestorDepth += 1;
|
|
258
|
+
}
|
|
259
|
+
return { root: parent, source: 'ancestor' };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
dir = parent;
|
|
263
|
+
depth += 1;
|
|
264
|
+
}
|
|
265
|
+
// Heuristic (4): nearest ancestor .planning/ — last resort before fallback.
|
|
266
|
+
// Runs only after heuristics (1)–(3) have been exhausted without a match,
|
|
267
|
+
// ensuring sub_repos / multiRepo / .git-based resolution always wins when
|
|
268
|
+
// applicable. Walks upward again within the same FIND_PROJECT_ROOT_MAX_DEPTH
|
|
269
|
+
// bound; returns the nearest ancestor directory that contains a .planning/
|
|
270
|
+
// subdirectory so config resolves correctly when invoked from a plain
|
|
271
|
+
// descendant of a single-repo project. (#1414)
|
|
272
|
+
let dir2 = resolvedStart;
|
|
273
|
+
let depth2 = 0;
|
|
274
|
+
while (dir2 !== fsRoot && depth2 < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
|
275
|
+
const parent2 = node_path_1.default.dirname(dir2);
|
|
276
|
+
if (parent2 === dir2)
|
|
277
|
+
break;
|
|
278
|
+
try {
|
|
279
|
+
const candidatePlanning = parent2 + node_path_1.default.sep + '.planning';
|
|
280
|
+
if (node_fs_1.default.existsSync(candidatePlanning) && node_fs_1.default.statSync(candidatePlanning).isDirectory()) {
|
|
281
|
+
// #2843: do not cross a git-repo boundary. If the caller is inside its
|
|
282
|
+
// own nested repo (a .git strictly below parent2), parent2's .planning/
|
|
283
|
+
// belongs to a DIFFERENT project — keep walking is wrong; stop and fall
|
|
284
|
+
// through to the startDir fallback instead of silently resolving to the
|
|
285
|
+
// ancestor project. (Reached only when no .git exists anywhere in the
|
|
286
|
+
// chain per the triage, but guard defensively.)
|
|
287
|
+
if (nearestGitRoot(resolvedStart, parent2) !== null) {
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
return { root: parent2, source: 'ancestor' };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// ignore fs errors and continue walking
|
|
295
|
+
}
|
|
296
|
+
if (isHome(parent2))
|
|
297
|
+
break;
|
|
298
|
+
dir2 = parent2;
|
|
299
|
+
depth2 += 1;
|
|
300
|
+
}
|
|
301
|
+
return { root: startDir, source: 'none' };
|
|
302
|
+
}
|
|
303
|
+
const GIT_TIMED_OUT_WARNING = 'git timed out while resolving the project context; roots fall back to what git had answered or the start directory';
|
|
304
|
+
const GIT_UNAVAILABLE_WARNING = 'git could not be run while resolving the project context; roots fall back to what git had answered or the start directory';
|
|
305
|
+
const GIT_ERROR_WARNING = 'git failed while resolving the project context; roots fall back to the start directory';
|
|
306
|
+
const GIT_WORKTREE_LIST_WARNING = 'git worktree list failed while resolving the project context; the main checkout is taken to be the current checkout';
|
|
307
|
+
function lexicalFacts(start, reason, degraded, warnings) {
|
|
308
|
+
return { worktreeRoot: start, repoRoot: start, gitCommonDir: null, reason, degraded, warnings };
|
|
309
|
+
}
|
|
310
|
+
function readGitFacts(start, execGit) {
|
|
311
|
+
const run = (args) => {
|
|
312
|
+
try {
|
|
313
|
+
return execGit(args, { cwd: start, timeout: DEFAULT_GIT_TIMEOUT_MS });
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
// The seam never throws; an injected stand-in might. Same degradation as a spawn failure.
|
|
317
|
+
return { exitCode: 1, stdout: '', stderr: '', signal: null, error: err instanceof Error ? err : new Error(String(err)), timedOut: false };
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const rev = run(['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir']);
|
|
321
|
+
if (rev.timedOut)
|
|
322
|
+
return lexicalFacts(start, 'git_timed_out', true, [GIT_TIMED_OUT_WARNING]);
|
|
323
|
+
if (rev.error)
|
|
324
|
+
return lexicalFacts(start, 'git_unavailable', true, [GIT_UNAVAILABLE_WARNING]);
|
|
325
|
+
if (rev.exitCode !== 0) {
|
|
326
|
+
// Only git's own "not a git repository" is a claim about the directory; any other
|
|
327
|
+
// failure (dubious ownership, corrupt repo) says nothing, so it degrades.
|
|
328
|
+
if (/not a git repository/i.test(String(rev.stderr || '')))
|
|
329
|
+
return lexicalFacts(start, 'not_git_repo', false, []);
|
|
330
|
+
return lexicalFacts(start, 'git_error', true, [GIT_ERROR_WARNING]);
|
|
331
|
+
}
|
|
332
|
+
const [topText = '', gitDirText = '', commonText = ''] = String(rev.stdout || '')
|
|
333
|
+
.split(/\r?\n/)
|
|
334
|
+
.map((line) => line.trim());
|
|
335
|
+
if (topText === '')
|
|
336
|
+
return lexicalFacts(start, 'not_git_repo', false, []);
|
|
337
|
+
const worktreeRoot = node_path_1.default.resolve(start, topText);
|
|
338
|
+
const gitCommonDir = commonText !== '' ? node_path_1.default.resolve(start, commonText) : null;
|
|
339
|
+
const linked = gitDirText !== '' && gitCommonDir !== null && node_path_1.default.resolve(start, gitDirText) !== gitCommonDir;
|
|
340
|
+
// A main checkout is its own repo root: no second git process.
|
|
341
|
+
if (!linked) {
|
|
342
|
+
return { worktreeRoot, repoRoot: worktreeRoot, gitCommonDir, reason: 'ok', degraded: false, warnings: [] };
|
|
343
|
+
}
|
|
344
|
+
const listed = run(['worktree', 'list', '--porcelain']);
|
|
345
|
+
if (listed.timedOut)
|
|
346
|
+
return { worktreeRoot, repoRoot: worktreeRoot, gitCommonDir, reason: 'git_timed_out', degraded: true, warnings: [GIT_TIMED_OUT_WARNING] };
|
|
347
|
+
if (listed.error)
|
|
348
|
+
return { worktreeRoot, repoRoot: worktreeRoot, gitCommonDir, reason: 'git_unavailable', degraded: true, warnings: [GIT_UNAVAILABLE_WARNING] };
|
|
349
|
+
if (listed.exitCode !== 0) {
|
|
350
|
+
return { worktreeRoot, repoRoot: worktreeRoot, gitCommonDir, reason: 'worktree_list_failed', degraded: true, warnings: [GIT_WORKTREE_LIST_WARNING] };
|
|
351
|
+
}
|
|
352
|
+
const firstNonBare = parseWorktreeEntries(listed.stdout).find((entry) => !entry.bare);
|
|
353
|
+
if (firstNonBare) {
|
|
354
|
+
return { worktreeRoot, repoRoot: node_path_1.default.resolve(start, firstNonBare.path), gitCommonDir, reason: 'ok', degraded: false, warnings: [] };
|
|
355
|
+
}
|
|
356
|
+
return { worktreeRoot, repoRoot: worktreeRoot, gitCommonDir, reason: 'bare_main_worktree', degraded: false, warnings: [] };
|
|
357
|
+
}
|
|
358
|
+
// ─── Resolver ─────────────────────────────────────────────────────────────────
|
|
359
|
+
function sameCheckout(a, b, realpath) {
|
|
360
|
+
if (a === b)
|
|
361
|
+
return true;
|
|
362
|
+
try {
|
|
363
|
+
return realpath(a) === realpath(b);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function resolveProjectContext(startDir, opts = {}) {
|
|
370
|
+
const execGit = opts.deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
371
|
+
const realpath = opts.deps?.fs?.realpathSync ?? node_fs_1.default.realpathSync.native;
|
|
372
|
+
const explicit = opts.explicit ? node_path_1.default.resolve(opts.explicit) : null;
|
|
373
|
+
const start = explicit ?? node_path_1.default.resolve(startDir);
|
|
374
|
+
const facts = readGitFacts(start, execGit);
|
|
375
|
+
const isLinkedWorktree = !sameCheckout(facts.worktreeRoot, facts.repoRoot, realpath);
|
|
376
|
+
let projectRoot = start;
|
|
377
|
+
let source = 'none';
|
|
378
|
+
if (explicit) {
|
|
379
|
+
projectRoot = explicit;
|
|
380
|
+
source = 'explicit';
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const walked = walkUpForPlanning(start);
|
|
384
|
+
if (walked.source !== 'none') {
|
|
385
|
+
projectRoot = walked.root;
|
|
386
|
+
source = walked.source;
|
|
387
|
+
}
|
|
388
|
+
else if (isLinkedWorktree) {
|
|
389
|
+
const fromMain = walkUpForPlanning(facts.repoRoot);
|
|
390
|
+
if (fromMain.source !== 'none') {
|
|
391
|
+
projectRoot = fromMain.root;
|
|
392
|
+
source = 'main-checkout';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const lexicalPlanning = node_path_1.default.join(projectRoot, '.planning');
|
|
397
|
+
let planningRoot = lexicalPlanning;
|
|
398
|
+
try {
|
|
399
|
+
planningRoot = realpath(lexicalPlanning);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// no `.planning` on disk (source: none) or unreadable: the lexical path stands
|
|
403
|
+
}
|
|
404
|
+
const provenance = Object.freeze({
|
|
405
|
+
source,
|
|
406
|
+
reason: facts.reason,
|
|
407
|
+
degraded: facts.degraded,
|
|
408
|
+
warnings: Object.freeze([...facts.warnings]),
|
|
409
|
+
});
|
|
410
|
+
return Object.freeze({
|
|
411
|
+
projectRoot,
|
|
412
|
+
planningRoot,
|
|
413
|
+
worktreeRoot: facts.worktreeRoot,
|
|
414
|
+
repoRoot: facts.repoRoot,
|
|
415
|
+
gitCommonDir: facts.gitCommonDir,
|
|
416
|
+
isLinkedWorktree,
|
|
417
|
+
provenance,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Transitional cwd for SKIP_ROOT_RESOLUTION commands and the workstream
|
|
422
|
+
* resolution input (Phase 2 deletes this as it migrates skip commands to named
|
|
423
|
+
* fields). Equivalent to the retired `resolveMainWorktreeCwd` remap for
|
|
424
|
+
* non-bare, non-submodule layouts; for bare-main and submodule layouts it uses
|
|
425
|
+
* the first non-bare `git worktree list` entry (the old `dirname(git-common-dir)`
|
|
426
|
+
* pointed at a non-checkout there). The remap: the start
|
|
427
|
+
* directory when it owns `.planning/`; otherwise, inside a linked worktree,
|
|
428
|
+
* that worktree's top level when it owns `.planning/`, else the main checkout;
|
|
429
|
+
* outside a linked worktree the start directory unchanged.
|
|
430
|
+
*/
|
|
431
|
+
function legacyDispatchCwd(ctx, startDir) {
|
|
432
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(startDir, '.planning')))
|
|
433
|
+
return startDir;
|
|
434
|
+
if (!ctx.isLinkedWorktree)
|
|
435
|
+
return startDir;
|
|
436
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(ctx.worktreeRoot, '.planning')))
|
|
437
|
+
return ctx.worktreeRoot;
|
|
438
|
+
return ctx.repoRoot;
|
|
439
|
+
}
|