@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.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. 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
- const raw = node_fs_1.default.readFileSync(configPath, 'utf-8');
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
- node = JSON.parse(String(node_fs_1.default.readFileSync(configPath, 'utf8')));
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
- const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
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
- const decisionStat = node_fs_1.default.statSync(lockPath);
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() - decisionStat.mtimeMs;
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 decisionStat.dev === 'number' && typeof decisionStat.ino === 'number' &&
496
- confirmStat.dev === decisionStat.dev && confirmStat.ino === decisionStat.ino &&
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
+ }