@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -438,8 +438,9 @@ function dispatchCapabilityCommand({ command, args, cwd, raw, error, registry, r
438
438
  // Step 2: confinement check — belt-and-suspenders even after the basename
439
439
  // validation above. Resolved path must be inside libDir (not equal to it,
440
440
  // and must start with libDir + sep so "libDir-suffix" can't sneak through).
441
- const resolved = path.resolve(libDir, m);
442
- if (resolved === libDir || !resolved.startsWith(libDir + path.sep)) {
441
+ const { tryWithinRootLexical } = require('./lib/security.cjs');
442
+ const resolved = tryWithinRootLexical(m, libDir);
443
+ if (resolved === null || resolved === path.resolve(libDir)) {
443
444
  throw new Error('capability module path escapes bin/lib/: ' + JSON.stringify(m));
444
445
  }
445
446
  // Step 3: require the resolved absolute path — the SAME representation that
@@ -515,18 +516,19 @@ function defaultRequireFromInstallRoot(installRoot, m) {
515
516
  if (typeof m !== 'string' || !/^[A-Za-z0-9._-]+\.cjs$/.test(m)) {
516
517
  throw new Error('capability module must be a bare .cjs basename: ' + JSON.stringify(m));
517
518
  }
518
- // Realpath the root so a symlinked ancestor can't widen confinement.
519
- const realRoot = fs.realpathSync(installRoot);
520
- const resolved = path.resolve(realRoot, m);
521
- if (resolved === realRoot || !resolved.startsWith(realRoot + path.sep)) {
519
+ const { tryWithinRoot, tryWithinRootLexical, PathAcceptance } = require('./lib/security.cjs');
520
+ // Lexical containment check: a symlinked ancestor can't widen confinement.
521
+ const lexical = tryWithinRootLexical(m, installRoot);
522
+ if (lexical === null || lexical === path.resolve(installRoot)) {
522
523
  throw new Error('capability module path escapes its install root: ' + JSON.stringify(m));
523
524
  }
524
- // The module file itself must not be a symlink pointing outside the root.
525
- const realResolved = fs.realpathSync(resolved);
526
- if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) {
525
+ // Realpath/symlink check: the module file itself must not be a symlink
526
+ // pointing outside the root.
527
+ const real = tryWithinRoot(m, installRoot, PathAcceptance.RelativeOnly);
528
+ if (real === null) {
527
529
  throw new Error('capability module resolves outside its install root (symlink): ' + JSON.stringify(m));
528
530
  }
529
- return require(realResolved);
531
+ return require(real);
530
532
  }
531
533
 
532
534
  /**
@@ -784,7 +786,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
784
786
  error,
785
787
  output: output,
786
788
  });
787
- if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
789
+ if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw, { dryRun: args.includes('--dry-run') });
788
790
  }
789
791
 
790
792
  function routeConfigSetModelProfile({ args, cwd, raw }) {
@@ -950,15 +952,27 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
950
952
  function routeCommit({ args, cwd, raw, error }) {
951
953
  const amend = args.includes('--amend');
952
954
  const noVerify = args.includes('--no-verify');
953
- const filesIndex = args.indexOf('--files');
955
+ // #4208: `--files` and `--files-removed` are two path lists, each
956
+ // running from its flag to the NEXT LIST FLAG. A boolean flag
957
+ // inside a list (`--files a --amend b`) is skipped, not a
958
+ // terminator: that is what the previous slice-to-end collection
959
+ // did (it filtered `--` tokens and kept everything else), and a
960
+ // list that stopped at any `--` token silently dropped `b`
961
+ // (review of #4253). The previous form could not carry a second
962
+ // list flag at all, which is the only thing that changed.
963
+ // A REPEATED list flag (`--files a --files b`) merges, as the old
964
+ // slice-to-end parse merged it: every occurrence contributes its
965
+ // run, and none of them ends another's silently.
966
+ const firstListFlag = args.findIndex((a, i) => i > 0 && COMMIT_LIST_FLAGS.has(a));
954
967
  // Collect all positional args between command name and first flag,
955
968
  // then join them — handles both quoted ("multi word msg") and
956
969
  // unquoted (multi word msg) invocations from different shells
957
- const endIndex = filesIndex !== -1 ? filesIndex : args.length;
970
+ const endIndex = firstListFlag !== -1 ? firstListFlag : args.length;
958
971
  const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
959
972
  const message = messageArgs.join(' ') || undefined;
960
- const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
961
- commands.cmdCommit(cwd, message, files, raw, amend, noVerify);
973
+ const files = collectListFlagValues(args, '--files');
974
+ const filesRemoved = collectListFlagValues(args, '--files-removed');
975
+ commands.cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved);
962
976
  }
963
977
 
964
978
  function routeCheckCommit({ args, cwd, raw, error }) {
@@ -1322,7 +1336,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1322
1336
  const fsx = require('node:fs');
1323
1337
  const os = require('node:os');
1324
1338
  const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
1325
- const { resolveLanePlan, resolveLaneEffort } = require('./lib/review-lane-invocation.cjs');
1339
+ const { resolveLanePlan, resolveLaneEffort, resolveLaneBudget } = require('./lib/review-lane-invocation.cjs');
1326
1340
  const modelCatalog = require('./lib/model-catalog.cjs');
1327
1341
  const runner = require('./lib/review-lane-runner.cjs');
1328
1342
  const cfgLoader = require('./lib/config-loader.cjs');
@@ -1347,8 +1361,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1347
1361
  // `plan`/`invoke` are the only subs that need the expensive plan-building path
1348
1362
  // below; `sections`/`flags` return earlier still. Anything else errors here, before
1349
1363
  // any of that work starts.
1350
- if (!['plan', 'invoke', 'sections', 'flags'].includes(sub)) {
1351
- error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1364
+ if (!['plan', 'invoke', 'sections', 'flags', 'dispatch-step', 'explicit-from-argv'].includes(sub)) {
1365
+ error("Usage: review-lane <plan|invoke|sections|flags|dispatch-step|explicit-from-argv> [--selected a,b] [--run-dir D] [--repo-root R]");
1352
1366
  return;
1353
1367
  }
1354
1368
  const runDir = flag('--run-dir') || '.';
@@ -1370,6 +1384,86 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1370
1384
  return cur;
1371
1385
  };
1372
1386
 
1387
+ // Shared by `invoke` and `dispatch-step` (#4209) — both need the SAME bounded
1388
+ // spawn/http/fs seam `runLane` requires (RunnerDeps). Factored out so the two
1389
+ // callers can never disagree about how a lane's binary is resolved or how its
1390
+ // process is bounded; a fix to either reaches both.
1391
+ const buildLaneRunnerDeps = () => ({
1392
+ spawn: (binary, argv, opts) => {
1393
+ // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1394
+ // as .cmd shims. spawnSync with a bare name + shell:false fails with
1395
+ // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1396
+ // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1397
+ // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1398
+ //
1399
+ // #3275: descriptors declare BARE names, so the gate above never saw an
1400
+ // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1401
+ // (the same one `hasBinary` uses, so probe and spawn can never disagree
1402
+ // about what the lane's binary is). POSIX keeps the bare name: Node's own
1403
+ // PATH search already worked there, and the #3275 acceptance contract
1404
+ // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1405
+ // falls back to the declared name so the ENOENT still surfaces (#3086).
1406
+ // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1407
+ // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1408
+ // reaches every spawn site instead of only this one.
1409
+ //
1410
+ // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1411
+ // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1412
+ // from the same resolver, so probe and spawn must agree on the exact file.
1413
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1414
+ const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1415
+ const r = cp.spawnSync(spawnBinary, spawnArgv, {
1416
+ input: opts.input,
1417
+ encoding: 'utf8',
1418
+ timeout: opts.timeoutMs,
1419
+ killSignal: 'SIGKILL',
1420
+ maxBuffer: 64 * 1024 * 1024,
1421
+ shell: false, // argv array only — never a shell string (no interpolation of config values)
1422
+ // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1423
+ // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1424
+ // into the orchestrating session or into the next lane.
1425
+ ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1426
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1427
+ });
1428
+ return {
1429
+ status: r.status,
1430
+ stdout: r.stdout || '',
1431
+ stderr: r.stderr || '',
1432
+ errorCode: r.error && r.error.code ? r.error.code : undefined,
1433
+ };
1434
+ },
1435
+ httpJson: async (url, opts) => {
1436
+ try {
1437
+ const res = await fetch(url, {
1438
+ method: opts.method,
1439
+ headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
1440
+ body: opts.body,
1441
+ signal: AbortSignal.timeout(opts.timeoutMs),
1442
+ });
1443
+ return { ok: res.ok, status: res.status, body: await res.text() };
1444
+ } catch (e) {
1445
+ return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
1446
+ }
1447
+ },
1448
+ readFile: (p) => fsx.readFileSync(p, 'utf8'),
1449
+ writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
1450
+ exists: (p) => fsx.existsSync(p),
1451
+ // PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
1452
+ // all (a probe that costs a process is a probe you avoid running, which is how the original
1453
+ // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1454
+ // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1455
+ //
1456
+ // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1457
+ // above. Two private copies of "what is this declared binary?" is how the
1458
+ // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1459
+ // so a lane reported available for a spawn that could never start. One
1460
+ // resolver, both seams — if one changes, the other changes with it.
1461
+ hasBinary: (name) => resolveSpawnBinary(name) !== null,
1462
+ configGet,
1463
+ homeDir: os.homedir(),
1464
+ warn: (m) => process.stderr.write(`${m}\n`),
1465
+ });
1466
+
1373
1467
  const selected = (flag('--selected') || '')
1374
1468
  .split(',').map((s) => s.trim()).filter(Boolean);
1375
1469
  // ADR-2782 D8 (#2927): the lane map is first-party ∪ INSTALLED overlay
@@ -1397,6 +1491,25 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1397
1491
  const laneBySlug = new Map(mergedLanes.map((l) => [l.slug, l]));
1398
1492
  const chosen = selected.length ? selected : mergedLanes.map((l) => l.slug);
1399
1493
 
1494
+ // #4209 RQ-02: match a workflow's raw CLI argv (e.g. `--codex`, `--agy`) against the SAME
1495
+ // merged first-party+installed-overlay roster this whole function already built above,
1496
+ // instead of a workflow re-deriving its own copy of `loadRegistry`/`mergeReviewerLanes` via
1497
+ // an inline `node -e` (a rename-only duplicate of the block starting at `mergedLanes =
1498
+ // REVIEWER_LANES` above — `code-review-flags.cjs`'s own header states "this is the canonical
1499
+ // flag-parsing surface — do not replicate inline bash parsing" for exactly this reason).
1500
+ // Everything after `--` is a candidate flag; matched lane slugs print sorted and comma-joined.
1501
+ if (sub === 'explicit-from-argv') {
1502
+ const sepIdx = args.indexOf('--');
1503
+ const candidateArgs = new Set(sepIdx === -1 ? [] : args.slice(sepIdx + 1));
1504
+ const slugs = [];
1505
+ for (const lane of mergedLanes) {
1506
+ const flags = Array.isArray(lane.flags) ? lane.flags : [];
1507
+ if (flags.some((f) => candidateArgs.has(f))) slugs.push(lane.slug);
1508
+ }
1509
+ process.stdout.write([...new Set(slugs)].sort().join(','));
1510
+ return;
1511
+ }
1512
+
1400
1513
  if (sub === 'sections') {
1401
1514
  const rows = chosen
1402
1515
  .map((s) => laneBySlug.get(s))
@@ -1454,26 +1567,135 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1454
1567
  };
1455
1568
  const effortFor = (lane) => resolveLaneEffort(lane, configGet, renderLaneEffort);
1456
1569
 
1457
- /**
1458
- * Per-lane prompt budget (#2797 semantics, preserved exactly).
1459
- *
1460
- * `-1` is the UNSET sentinel and falls back to the central `review.max_prompt_tokens`, because
1461
- * `0` is a legitimate value meaning "do not trim this lane". Treating 0 as unset would silently
1462
- * switch a user who deliberately disabled trimming onto the global budget.
1463
- *
1464
- * Only the budget VALUE is resolved here. Assembly and trimming stay in `prompt-budget`, which
1465
- * already owns that machinery and is already tested; the workflow calls it and hands the
1466
- * trimmed file back via `--prompt-file`. Re-implementing it inside the runner would fork a
1467
- * tested surface for no gain.
1468
- */
1469
- const budgetFor = (lane) => {
1470
- if (!lane.promptBudgetKey) return null;
1471
- const per = configGet(lane.promptBudgetKey);
1472
- const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
1473
- if (isNum(per) && per !== -1) return per;
1474
- const global = configGet('review.max_prompt_tokens');
1475
- return isNum(global) ? global : null;
1476
- };
1570
+ // Per-lane prompt budget: `resolveLaneBudget` (review-lane-invocation.cjs) owns the #2797
1571
+ // resolution semantics (shared with src/reviewer-step-dispatch.cts, #4209 R3 — was two
1572
+ // verbatim copies). Only the budget VALUE is resolved here; assembly/trimming stay in
1573
+ // `prompt-budget`, which the workflow calls, handing the trimmed file back via `--prompt-file`.
1574
+ const budgetFor = (lane) => resolveLaneBudget(lane, configGet);
1575
+
1576
+ // #4209 (ADR-2782 seam) — the ONE interpreter route for a step that declared
1577
+ // `supportsReviewerLanes: true`. Wires `dispatchReviewerLanes` (src/reviewer-step-dispatch.cts)
1578
+ // to the SAME plan/invoke machinery `plan`/`invoke` above use, so a step opting in gets exact
1579
+ // parity with hand-driven `review-lane plan|invoke` rather than a second implementation.
1580
+ // Canonical file paths travel on stdin, never argv (see gsd-core/workflows/code-review.md's
1581
+ // "Files travel on stdin" note) — a 50+-file scope with long paths approaches the Windows
1582
+ // execFileSync argv ceiling, and stdin has no such bound.
1583
+ //
1584
+ // Returns EARLY, like `sections`/`flags` above, rather than falling into the `plans` builder
1585
+ // below: that builder spawns one `effortFor` child process PER LANE IN THE ROSTER (chosen
1586
+ // defaults to every merged lane when nothing is selected), which would burn ~12 wasted spawns
1587
+ // on every dispatch-step call whether or not anything was actually selected. `dispatchReviewerLanes`
1588
+ // builds its own per-SELECTED-lane plan below instead, bounded by the (typically 0-3) explicitly
1589
+ // requested slugs, not the whole roster.
1590
+ if (sub === 'dispatch-step') {
1591
+ const { dispatchReviewerLanes } = require('./lib/reviewer-step-dispatch.cjs');
1592
+ const explicitFlags = (flag('--explicit') || '').split(',').map((s) => s.trim()).filter(Boolean);
1593
+ const depth = flag('--depth') || '';
1594
+ const baseSha = flag('--base-sha') || '';
1595
+ // #4209: this command IS the reusable capability/step-dispatch trait check — see
1596
+ // gsd-core/references/loop-hook-dispatch.md for what supportsReviewerLanes means and why
1597
+ // this is the one place it's resolved. Calls the SAME resolver `loop render-hooks` uses,
1598
+ // `resolveActiveHooksForPoint`, directly in-process — no subprocess, no JSON re-parse, and
1599
+ // no exposure to `io.cjs`'s `@file:` overflow protocol (which only applies to the
1600
+ // rendered-string envelope this path never touches).
1601
+ const capId = flag('--cap-id') || '';
1602
+ const point = flag('--point') || '';
1603
+ let trait = false;
1604
+ if (capId && point) {
1605
+ try {
1606
+ const { resolveActiveHooksForPoint } = loopResolver;
1607
+ const { activeHooks } = resolveActiveHooksForPoint(cwd, point);
1608
+ trait = activeHooks.some((h) => h && h.capId === capId && h.supportsReviewerLanes === true);
1609
+ } catch (e) {
1610
+ process.stderr.write(`Warning: reviewer-lane trait resolution failed for --cap-id ${capId} --point ${point}: ${e && e.message ? e.message : String(e)} — treating as not enabled.\n`);
1611
+ trait = false;
1612
+ }
1613
+ } else if (capId || point) {
1614
+ // #4209 RQ-03: exactly one of the two was passed — a caller with NO capability-step
1615
+ // context at all (neither flag) is the legitimate, silent no-op documented above, but a
1616
+ // caller that named a capability without its point (or vice versa) is misconfigured, not
1617
+ // opted out, and that must not look identical to a correct opt-out on the wire.
1618
+ process.stderr.write(`Warning: --cap-id and --point must both be given to resolve the reviewer-lane trait (got --cap-id=${JSON.stringify(capId)} --point=${JSON.stringify(point)}) — treating as not enabled.\n`);
1619
+ }
1620
+ // No piped stdin (interactive TTY): fail closed to empty paths instead of blocking
1621
+ // indefinitely on a TTY EOF the caller never sends.
1622
+ let stdinPaths = '';
1623
+ if (!process.stdin.isTTY) {
1624
+ try { stdinPaths = fsx.readFileSync(0, 'utf8'); } catch { stdinPaths = ''; }
1625
+ }
1626
+ const paths = stdinPaths.split('\n').map((s) => s.trim()).filter(Boolean);
1627
+
1628
+ // Reuse the exact effort-aware, per-lane plan `plan` builds above (DISP-03: "planned
1629
+ // through the existing `review-lane plan` interface") rather than the interpreter's
1630
+ // simpler default plan callback, which does not resolve per-host effort.
1631
+ const planFn = (lane, ctx) => {
1632
+ const effort = effortFor(lane.slug);
1633
+ return resolveLanePlan({
1634
+ lane, configGet: ctx.configGet, runDir: ctx.runDir, repoRoot: ctx.repoRoot,
1635
+ effortArgs: effort.argv, effortValue: effort.value,
1636
+ });
1637
+ };
1638
+
1639
+ const runnerDeps = buildLaneRunnerDeps();
1640
+ const invokeFn = async (lane, plan) => {
1641
+ let consentedHost;
1642
+ if (plan.transport === 'openai-http') {
1643
+ try {
1644
+ const consent = require('./lib/capability-consent.cjs');
1645
+ const projectRoot = require('./lib/project-root.cjs').consentProjectRoot(cwd);
1646
+ const capId = String(lane.slug).replace(/_/g, '-');
1647
+ consentedHost = consent.readConsentedReviewerHost({ projectRoot, id: capId });
1648
+ } catch { consentedHost = undefined; }
1649
+ }
1650
+ // DISP-04/05: every selected lane is invoked through this SAME `runner.runLane` seam
1651
+ // `invoke` uses, exactly once (the interpreter's own for-loop over `selection.selected`
1652
+ // never revisits a slug).
1653
+ return runner.runLane(plan, runnerDeps, { consentedHost, explicitlyRequested: true, repoRoot });
1654
+ };
1655
+
1656
+ // `resolveReviewerSelection` only selects an explicit flag present in `detected`
1657
+ // (ADR-2782 D4: absent-safe governs discovery, never explicit selection — a slug the
1658
+ // roster does not declare is rejected here as an explicit-selection error). REAL
1659
+ // host availability (is the CLI actually installed?) is a separate, already-owned
1660
+ // check inside `runner.runLane`'s `probeLane` at invoke time below — duplicating a
1661
+ // second `command -v` probe here would let the two disagree about what "available"
1662
+ // means, which is the exact defect class `resolveSpawnBinary` was consolidated to
1663
+ // prevent (#3275).
1664
+ //
1665
+ // GUARDED ON explicitFlags.length, not unconditional: `resolveReviewerSelection`'s
1666
+ // precedence chain (explicit > --all > review.default_reviewers > all detected) ends,
1667
+ // when none of the first three apply, in `selected = [...detected]` — the SAME
1668
+ // "no flags means every detected reviewer" default `/gsd:review` intentionally uses.
1669
+ // Source review's COMP-01 contract is the opposite: no reviewer-lane flag means inert,
1670
+ // unchanged from before #4209. Passing a non-empty
1671
+ // `detected` unconditionally would silently opt every dispatch-step call with no
1672
+ // `--explicit` into planning+invoking the WHOLE roster via that fallback branch. An
1673
+ // empty `detected` when nothing was asked for makes that fallback resolve to
1674
+ // `[...[]]` = `[]`, so `dispatchReviewerLanes` hits its own `NO_LANES_SELECTED`
1675
+ // early-return before any plan/invoke call — the same fast, inert no-op the caller
1676
+ // gets from an absent `supportsReviewerLanes` trait.
1677
+ const rosterSlugs = explicitFlags.length > 0 ? [...laneBySlug.keys()] : [];
1678
+
1679
+ const dispatchResult = await dispatchReviewerLanes(
1680
+ {
1681
+ trait,
1682
+ selection: { explicitFlags, detected: rosterSlugs },
1683
+ repoRoot,
1684
+ paths,
1685
+ depth,
1686
+ baseSha,
1687
+ runDir,
1688
+ },
1689
+ {
1690
+ getLane: (slug) => laneBySlug.get(slug),
1691
+ configGet,
1692
+ plan: planFn,
1693
+ invoke: invokeFn,
1694
+ },
1695
+ );
1696
+ output(dispatchResult, raw);
1697
+ return;
1698
+ }
1477
1699
 
1478
1700
  const plans = chosen.map((slug) => {
1479
1701
  const lane = laneBySlug.get(slug);
@@ -1508,7 +1730,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1508
1730
  }
1509
1731
 
1510
1732
  if (sub !== 'invoke') {
1511
- error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1733
+ error("Usage: review-lane <plan|invoke|sections|flags|dispatch-step|explicit-from-argv> [--selected a,b] [--run-dir D] [--repo-root R]");
1512
1734
  return;
1513
1735
  }
1514
1736
 
@@ -1522,81 +1744,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1522
1744
 
1523
1745
  // EVERY spawn bounded — `DEFECT.UNBOUNDED-SUBPROCESS` (CONTEXT.md:772). A frozen sync spawn
1524
1746
  // cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
1525
- const deps = {
1526
- spawn: (binary, argv, opts) => {
1527
- // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1528
- // as .cmd shims. spawnSync with a bare name + shell:false fails with
1529
- // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1530
- // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1531
- // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1532
- //
1533
- // #3275: descriptors declare BARE names, so the gate above never saw an
1534
- // extension — resolve through the shared PATH+PATHEXT resolver FIRST
1535
- // (the same one `hasBinary` uses, so probe and spawn can never disagree
1536
- // about what the lane's binary is). POSIX keeps the bare name: Node's own
1537
- // PATH search already worked there, and the #3275 acceptance contract
1538
- // holds macOS/Linux behavior unchanged. A name that resolves to nothing
1539
- // falls back to the declared name so the ENOENT still surfaces (#3086).
1540
- // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
1541
- // private copies here; `projectSpawnInvocation` owns them, so a fix to either
1542
- // reaches every spawn site instead of only this one.
1543
- //
1544
- // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
1545
- // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
1546
- // from the same resolver, so probe and spawn must agree on the exact file.
1547
- const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
1548
- const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
1549
- const r = cp.spawnSync(spawnBinary, spawnArgv, {
1550
- input: opts.input,
1551
- encoding: 'utf8',
1552
- timeout: opts.timeoutMs,
1553
- killSignal: 'SIGKILL',
1554
- maxBuffer: 64 * 1024 * 1024,
1555
- shell: false, // argv array only — never a shell string (no interpolation of config values)
1556
- // #2483: a lane's declared env pairs merged OVER this process's environment, for this
1557
- // child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
1558
- // into the orchestrating session or into the next lane.
1559
- ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
1560
- ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
1561
- });
1562
- return {
1563
- status: r.status,
1564
- stdout: r.stdout || '',
1565
- stderr: r.stderr || '',
1566
- errorCode: r.error && r.error.code ? r.error.code : undefined,
1567
- };
1568
- },
1569
- httpJson: async (url, opts) => {
1570
- try {
1571
- const res = await fetch(url, {
1572
- method: opts.method,
1573
- headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
1574
- body: opts.body,
1575
- signal: AbortSignal.timeout(opts.timeoutMs),
1576
- });
1577
- return { ok: res.ok, status: res.status, body: await res.text() };
1578
- } catch (e) {
1579
- return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
1580
- }
1581
- },
1582
- readFile: (p) => fsx.readFileSync(p, 'utf8'),
1583
- writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
1584
- exists: (p) => fsx.existsSync(p),
1585
- // PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
1586
- // all (a probe that costs a process is a probe you avoid running, which is how the original
1587
- // Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
1588
- // Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
1589
- //
1590
- // #3275: the scan lives in `resolveSpawnBinary` now, SHARED with `deps.spawn`
1591
- // above. Two private copies of "what is this declared binary?" is how the
1592
- // defect hid: the probe resolved WITH PATHEXT while spawn resolved WITHOUT,
1593
- // so a lane reported available for a spawn that could never start. One
1594
- // resolver, both seams — if one changes, the other changes with it.
1595
- hasBinary: (name) => resolveSpawnBinary(name) !== null,
1596
- configGet,
1597
- homeDir: os.homedir(),
1598
- warn: (m) => process.stderr.write(`${m}\n`),
1599
- };
1747
+ const deps = buildLaneRunnerDeps();
1600
1748
 
1601
1749
  // ADR-1517 reviewer instances resolve THROUGH a lane rather than being lanes themselves
1602
1750
  // (ADR-2782 D8), so they reuse this seam with three substitutions instead of duplicating the
@@ -3147,6 +3295,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3147
3295
  const RESTORE_OUTCOME = Object.freeze({
3148
3296
  ELIGIBLE: 'eligible',
3149
3297
  RESTORED: 'restored',
3298
+ ALREADY_PRESENT: 'already_present',
3150
3299
  SKIPPED_DESTINATION_MANAGED: 'skipped_destination_managed',
3151
3300
  SKIPPED_DESTINATION_EXISTS: 'skipped_destination_exists',
3152
3301
  SKIPPED_COPY_FAILED: 'skipped_copy_failed',
@@ -3205,18 +3354,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3205
3354
  return out;
3206
3355
  }
3207
3356
 
3208
- // Why these three checks rather than security.cjs's `validatePath`: that seam
3209
- // resolves symlinks with realpathSync and then tests containment, so a link
3210
- // whose target sits inside the config dir passes. For a restore that is still
3211
- // wrong — writing through any link overwrites whatever it points at instead
3212
- // of materializing a regular file at the backed-up path. These checks reject
3213
- // links outright, which is strictly stricter than validatePath, not a
3214
- // reimplementation of it. Do not "simplify" this to validatePath.
3357
+ // Why these three checks rather than security.cjs's `assertWithinRoot` /
3358
+ // `tryWithinRoot`: that seam resolves symlinks with realpathSync and then
3359
+ // tests containment, so a link whose target sits inside the config dir
3360
+ // passes. For a restore that is still wrong — writing through any link
3361
+ // overwrites whatever it points at instead of materializing a regular file
3362
+ // at the backed-up path. These checks reject links outright, which is
3363
+ // strictly stricter than assertWithinRoot/tryWithinRoot, not a
3364
+ // reimplementation of them. Do not "simplify" this to assertWithinRoot or
3365
+ // tryWithinRoot. Reviewed under epic #4636 Phase 3: the containment
3366
+ // DECISION now routes through the canonical lexical predicate
3367
+ // (`tryWithinRootLexical`, ADR-4650 decision 6); isInsideDir below still
3368
+ // treats target === root as NOT contained via its own extra `!==` check
3369
+ // (unlike every other containment implementation in this repo, which
3370
+ // treats target === root as contained) — that condition is this gate's
3371
+ // own and is layered on top of the shared predicate, not folded into it.
3215
3372
 
3216
3373
  /** True when `target` resolves strictly inside `root`. */
3217
3374
  function isInsideDir(root, target) {
3218
- const rel = path.relative(path.resolve(root), path.resolve(target));
3219
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
3375
+ // Containment decision: canonical lexical predicate (ADR-4650 decision 6).
3376
+ // The extra `!==` condition is this gate's own: a restore must never
3377
+ // target the config directory itself, only something strictly inside it.
3378
+ if (path.resolve(target) === path.resolve(root)) return false;
3379
+ const { tryWithinRootLexical } = require('./lib/security.cjs');
3380
+ return tryWithinRootLexical(target, root) !== null;
3220
3381
  }
3221
3382
 
3222
3383
  /**
@@ -3414,9 +3575,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3414
3575
  }
3415
3576
 
3416
3577
  // An identical destination is a no-op restore, not a conflict: re-running
3417
- // the restore after a successful one must stay quiet and idempotent.
3578
+ // the restore after a successful one must stay quiet and idempotent. It
3579
+ // gets its own outcome so it is excluded from eligible_count — the update
3580
+ // workflow drives its restore question off that count (#4558).
3581
+ let destExists = false;
3418
3582
  let destDiffers = false;
3419
3583
  if (fs.existsSync(destPath)) {
3584
+ destExists = true;
3420
3585
  try {
3421
3586
  destDiffers = !fs.readFileSync(destPath).equals(fs.readFileSync(srcPath));
3422
3587
  } catch {
@@ -3431,6 +3596,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3431
3596
  entries.push({ path: relPath, outcome: RESTORE_OUTCOME.SKIPPED_DESTINATION_EXISTS, warnings });
3432
3597
  continue;
3433
3598
  }
3599
+ if (destExists) {
3600
+ entries.push({ path: relPath, outcome: RESTORE_OUTCOME.ALREADY_PRESENT, warnings });
3601
+ continue;
3602
+ }
3434
3603
 
3435
3604
  if (!apply) {
3436
3605
  entries.push({ path: relPath, outcome: RESTORE_OUTCOME.ELIGIBLE, warnings });
@@ -4146,6 +4315,31 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4146
4315
  * declares that file "All OS-facing I/O; single platform seam", and a private
4147
4316
  * duplicate here is what made it untrue.
4148
4317
  */
4318
+ const COMMIT_LIST_FLAGS = new Set(['--files', '--files-removed']);
4319
+
4320
+ // #4208 review: hoisted out of routeCommit's closure so the parser is reachable
4321
+ // from a test. It is the whole of the two-list argument contract, and its edge
4322
+ // cases (a boolean flag inside a run, a repeated list flag, either order) were
4323
+ // already the subject of a review round -- a parser that only the CLI can reach
4324
+ // can only be tested by example, one spawn at a time.
4325
+ //
4326
+ // Every occurrence of `flag` contributes a run; a run ends at the next LIST
4327
+ // flag and skips boolean flags on the way, so no token strictly between one
4328
+ // list flag and the next is ever dropped. Repeated runs of the same flag merge,
4329
+ // as the pre-#4208 slice-to-end parse merged them.
4330
+ function collectListFlagValues(args, flag) {
4331
+ const values = [];
4332
+ args.forEach((a, i) => {
4333
+ if (a !== flag) return;
4334
+ for (const b of args.slice(i + 1)) {
4335
+ if (COMMIT_LIST_FLAGS.has(b)) break;
4336
+ if (b.startsWith('--')) continue;
4337
+ values.push(b);
4338
+ }
4339
+ });
4340
+ return values;
4341
+ }
4342
+
4149
4343
  function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
4150
4344
  const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
4151
4345
  return resolveExecutableBinary(name, { platform, env });
@@ -4187,6 +4381,21 @@ const HOST_COMMAND_ROUTERS = {
4187
4381
  // rather than a family — ADR-2346 promotes to a family only at >=3.
4188
4382
  'estimate-check': ({ args, cwd, raw }) => estimateCli.cmdEstimateCheck(cwd, args.slice(1), raw),
4189
4383
  'estimate-calibration': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibration(cwd, args.slice(1), raw),
4384
+ // #3418: writes `last_mapped_commit` into every codebase-map document that
4385
+ // exists, closing the loop drift.cjs was built for. A LEAF verb rather than a
4386
+ // `verify` subcommand on purpose -- the verify family is read-only by
4387
+ // contract and this one mutates; ADR-2346 promotes a leaf to a family only at
4388
+ // >=3 verbs, and this is one.
4389
+ 'stamp-codebase-map': ({ args, cwd, raw, error }) => {
4390
+ const { files } = parseNamedArgsOrExit(args, { valueFlags: ['files'], positionals: 1 }, error);
4391
+ // A value flag with no value parses to `null`, same as an absent one, so
4392
+ // presence is read off `args`: a bare `--files` (an unquoted empty shell
4393
+ // variable) must hit the empty-filter refusal, not widen to all seven.
4394
+ const only = args.includes('--files')
4395
+ ? String(files ?? '').split(',').map((f) => f.trim()).filter(Boolean)
4396
+ : undefined;
4397
+ verify.cmdStampCodebaseMap(cwd, raw, only);
4398
+ },
4190
4399
  'estimate-calibrate': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibrate(cwd, args.slice(1), raw),
4191
4400
  'config-new-project': routeConfigNewProject,
4192
4401
  'config-path': routeConfigPath,
@@ -4485,7 +4694,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <fiel
4485
4694
  'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, planning, profile-questionnaire, ' +
4486
4695
  'profile-sample, progress, project-instruction-file, prompt-budget, quick-batch, quick-tasks-append, quick-tasks-migrate, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, runtime-identity, scaffold, smart-entry, state, ' +
4487
4696
  'config-set-model-profile, dispatch-capacity, dispatch-isolation, dispatch-should-flatten, inspect-dispatch-isolation, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-agent, resolve-dispatch-type, ' +
4488
- 'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
4697
+ 'resolve-execution, review-lane, skill-manifest, skills-root, stamp-codebase-map, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
4489
4698
  'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
4490
4699
  'Global flags:\n' +
4491
4700
  ' --raw Emit raw output without post-processing\n' +
@@ -5050,6 +5259,10 @@ module.exports = {
5050
5259
  // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
5051
5260
  // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
5052
5261
  resolveSpawnBinary,
5262
+ // #4208 review: exported for tests — the two-list commit parser is otherwise
5263
+ // reachable only by spawning the CLI, which a property test cannot afford.
5264
+ collectListFlagValues,
5265
+ COMMIT_LIST_FLAGS,
5053
5266
  // #3714 follow-up: exported for tests — the dispatch model-pin VALUE
5054
5267
  // policy (charset accept/render parity, max-length boundary, leading-char
5055
5268
  // anchor) is otherwise unreachable from outside the dispatchOverlayCapabilityCommand closure.
@@ -402,7 +402,7 @@ function parseCliArgs(argv) {
402
402
  }
403
403
  function main(argv) {
404
404
  const opts = parseCliArgs(argv);
405
- const safePath = (0, security_cjs_1.requireSafePath)(opts.input, node_path_1.default.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
405
+ const safePath = (0, security_cjs_1.requireSafePath)(opts.input, node_path_1.default.resolve(opts.projectDir), 'ADR input path', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
406
406
  const content = node_fs_1.default.readFileSync(safePath, 'utf8');
407
407
  const parsed = parseAdrMarkdown(content, { sourcePath: opts.input ?? undefined, format: opts.format });
408
408
  process.stdout.write(JSON.stringify(parsed, null, 2));
@@ -31,7 +31,8 @@ exports.CANONICAL_EXACT = new Set([
31
31
  'STATE-ARCHIVE.md', // state.cts's cmdStatePrune writes this at the .planning/ root
32
32
  'milestone.lock', // #3311: milestone (phase + session) claim (src/milestone-lock.cts); persistent, unlike the transient STATE.md.lock/WAITING.json
33
33
  'state.json', // #3227: machine-readable state contract published at step boundaries (src/state-contract.cts)
34
- 'skill-manifest.json', // init.cts routeSkillManifest --write (project-scoped planning root, #3964)
34
+ 'skill-manifest.json', // init.cts cmdSkillManifest --write (project-scoped planning root, #3964)
35
+ 'PATTERNS.md', // #4282: graduated cross-phase patterns (workflows/graduation.md, `patterns` target) -- distinct from the per-phase NN-PATTERNS.md (templates/README.md)
35
36
  ]);
36
37
  // Pattern-match canonical file names (regex tests on the basename)
37
38
  // Each pattern includes the name of the workflow that produces it as a comment.