@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
@@ -89,7 +89,7 @@ function coerceTruthToString(t) {
89
89
  return '';
90
90
  }
91
91
  // ─── countPhasePlansAndSummaries ──────────────────────────────────────────────
92
- function countPhasePlansAndSummaries(phaseDir) {
92
+ function countPhasePlansAndSummaries(phaseDir, convention) {
93
93
  const { planCount, summaryCount } = scanPhasePlans(phaseDir);
94
94
  // hasContext and hasResearch are not plan-scan concerns — read the directory
95
95
  // once and share the listing for all non-plan metadata that cmdRoadmapAnalyze needs.
@@ -112,7 +112,9 @@ function countPhasePlansAndSummaries(phaseDir) {
112
112
  // summaryCount above stay on scanPhasePlans's own unscoped listing since a
113
113
  // PLAN/SUMMARY leading number is a plan sequence number, not a phase
114
114
  // number. Mirrors core-utils.cts's getPhaseFileStats.
115
- const scopedFiles = scopeToPhase(phaseFiles, node_path_1.default.basename(phaseDir));
115
+ // #612: `convention` threaded from the one caller (which already threads it
116
+ // into matchPhaseDirs) so a bracket dir scopes by its real token.
117
+ const scopedFiles = scopeToPhase(phaseFiles, node_path_1.default.basename(phaseDir), convention);
116
118
  return {
117
119
  planCount,
118
120
  summaryCount,
@@ -349,8 +351,17 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
349
351
  // #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
350
352
  // that get-phase/execute-phase already resolve. An optional leading letter prefix
351
353
  // ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
354
+ // #4478: line-anchored (`^ {0,3}`, `/m`) — unanchored, `#{2,4}` matched a
355
+ // `### Phase N:`-shaped mention ANYWHERE `exec()`'s scan reached: mid-sentence
356
+ // prose, inside a blockquote, inside an inline code span (backtick-quoted on
357
+ // the same line, not a fenced block `tokenizeHeadings` would exclude). Any
358
+ // such line minted a phantom phase entry, inflating phase_count and able to
359
+ // collide on a phase NUMBER with a real heading nearby. `{0,3}` leading
360
+ // spaces mirrors `tokenizeHeadings`'s own CommonMark ATX-heading tolerance
361
+ // (src/markdown-sectionizer.cts:453) so a legitimately-indented heading that
362
+ // matched before this fix still matches after it.
352
363
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
353
- const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
364
+ const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
354
365
  // The capturing intro inserts the bracket id at group 1 only under the
355
366
  // bracket convention; the token and name shift by the same offset.
356
367
  const G = convention === 'bracket' ? 1 : 0;
@@ -372,7 +383,13 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
372
383
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
373
384
  // recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
374
385
  // (e.g. B7) are also recognised.
375
- const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i'));
386
+ // #4478 follow-up (independent code review on this same fix): ` {0,3}` after
387
+ // the literal `\n` mirrors phasePattern's own new leading-space tolerance
388
+ // above -- without it, a legitimately-indented (1-3 space) NEXT phase
389
+ // heading was invisible to this boundary lookup, letting the PRIOR phase's
390
+ // goal/mode/depends_on extraction bleed across the section boundary into
391
+ // the next phase's own body.
392
+ const nextHeader = restOfContent.match(new RegExp(`\\n {0,3}#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i'));
376
393
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
377
394
  const section = content.slice(sectionStart, sectionEnd);
378
395
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -420,7 +437,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
420
437
  // convention into that owner rather than reviving the primitive `.find()`.
421
438
  const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0];
422
439
  if (dirMatch) {
423
- const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
440
+ const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch), convention);
424
441
  planCount = counts.planCount;
425
442
  summaryCount = counts.summaryCount;
426
443
  hasContext = counts.hasContext;
@@ -433,7 +450,10 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
433
450
  // NOT a precondition, so a zero-plan phase with a passing
434
451
  // `*-VERIFICATION.md` reports complete here too, not just via
435
452
  // `phase.complete`.
436
- const completionResult = isPhaseComplete(node_path_1.default.join(phasesDir, dirMatch));
453
+ // #612: `convention` (a parameter of this function, same thread as
454
+ // matchPhaseDirs above) rides into completion so a bracket phase dir
455
+ // resolves and scopes its verification report like its legacy twin.
456
+ const completionResult = isPhaseComplete(node_path_1.default.join(phasesDir, dirMatch), { convention });
437
457
  if (completionResult.value.complete)
438
458
  diskStatus = 'complete';
439
459
  else if (summaryCount > 0)
@@ -841,7 +861,11 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
841
861
  // the same phase (ADR-3180 §7.4's headline: one predicate for the read
842
862
  // path and the write path).
843
863
  const phaseDir = node_path_1.default.join(cwd, phaseInfo.directory);
844
- const completionResult = isPhaseComplete(phaseDir);
864
+ // ADR-3180 §7.4 read/write-path symmetry with the threaded site at ~583:
865
+ // thread convention here too, so this write path's completion reading
866
+ // agrees with the read path's under the bracket convention.
867
+ const convention = resolvePhaseIdConvention(cwd);
868
+ const completionResult = isPhaseComplete(phaseDir, { convention });
845
869
  const verificationResult = completionResult.value.verification;
846
870
  // #2648 precedent, applied at this write site (ADR-3180 §7.4 / #3186):
847
871
  // `isPhaseComplete` deliberately carries NO plan-count precondition — the
@@ -872,6 +896,23 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
872
896
  }
873
897
  // Wrap entire read-modify-write in lock to prevent concurrent corruption
874
898
  let updated = false;
899
+ // #4247: the refusal flag. The write/report decision below must be keyed to
900
+ // "a writable roadmap representation of THIS phase was found", never to "any
901
+ // byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the
902
+ // roadmapper's own summary-checklist form) every phase-targeted grammar
903
+ // below requires an ATX `#{2,4} Phase N` heading and therefore finds
904
+ // nothing; the Progress-table row is the only other writable target, and
905
+ // when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed
906
+ // `Phase 68` cell — deliberately unrecognized on the read side too,
907
+ // `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used
908
+ // to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark
909
+ // anywhere in the document) and report `updated: true` while the phase's
910
+ // own row stayed untouched — with the file-global write then letting the
911
+ // platform write seam's markdown normalization inject blank lines around
912
+ // other phases' bullets, splitting hand-wrapped sentences mid-entry. The
913
+ // refusal below declines with the analyzer's own `missing_phase_details`
914
+ // vocabulary and leaves ROADMAP.md byte-identical.
915
+ let missingPhaseDetails = false;
875
916
  withPlanningLock(cwd, () => {
876
917
  // #3957 (B9.4): captured BEFORE any transform runs, so the write/report
877
918
  // decision below reflects whether the transforms actually changed
@@ -881,6 +922,32 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
881
922
  const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
882
923
  let roadmapContent = originalContent;
883
924
  const phasePattern = phaseMarkdownRegexSource(phaseNum);
925
+ // #4247: ONE local source for the ATX phase-heading anchor that every
926
+ // section-scoped writer below (`planCountPattern`,
927
+ // `insertRowsPatternA|B`) starts with — extracted so the target-detection
928
+ // gate below reads the SAME grammar the writers anchor on, and a future
929
+ // edit to one cannot drift from the other three copies.
930
+ const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
931
+ // #4247: target detection runs against the ORIGINAL content's active
932
+ // (post-</details>) region — the same milestone scoping every writer
933
+ // below applies — so the gate asks "does the file carry a writable phase
934
+ // representation" rather than "did some regex fire mid-transform".
935
+ const gateDetailsClose = originalContent.lastIndexOf('</details>');
936
+ const gateActiveRegion = gateDetailsClose === -1
937
+ ? originalContent
938
+ : originalContent.slice(gateDetailsClose + '</details>'.length);
939
+ // Heading target: the exact grammar `planCountPattern` /
940
+ // `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
941
+ const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion);
942
+ // Checklist target: when the phase is complete, its own checklist bullet
943
+ // (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
944
+ // checkbox stamp below updates it. Same grammar as that writer, widened
945
+ // one notch to `[ x]` so an ALREADY-checked bullet still counts as a
946
+ // found target: an idempotent re-run then takes the honest
947
+ // "no changes were needed" decline instead of this refusal.
948
+ const checklistTargetFound = isComplete && new RegExp(`-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i').test(gateActiveRegion);
949
+ // Table-row target: set by the row-scoped cell updates below.
950
+ let tableRowFound = false;
884
951
  // Progress table row: update Plans Complete/Status/Completed columns BY
885
952
  // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
886
953
  // Milestone-column presence) via the markdown-table seam (ADR-2143 §7) —
@@ -899,11 +966,15 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
899
966
  roadmapContent = editProgressTableSlice(roadmapContent, (scoped) => {
900
967
  let text = scoped;
901
968
  const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
902
- if (plansResult.ok)
969
+ if (plansResult.ok) {
903
970
  text = plansResult.value;
971
+ tableRowFound = true;
972
+ }
904
973
  const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
905
- if (statusResult.ok)
974
+ if (statusResult.ok) {
906
975
  text = statusResult.value;
976
+ tableRowFound = true;
977
+ }
907
978
  // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
908
979
  // Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
909
980
  // a no-op updateTableCell write (its own tolerant row scan) rather than
@@ -918,8 +989,10 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
918
989
  }
919
990
  return ' ';
920
991
  });
921
- if (completedResult.ok)
992
+ if (completedResult.ok) {
922
993
  text = completedResult.value;
994
+ tableRowFound = true;
995
+ }
923
996
  return text;
924
997
  });
925
998
  // Update plan count in phase detail section.
@@ -960,7 +1033,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
960
1033
  // `_match` unchanged. An untouched first line cannot orphan its own
961
1034
  // continuation on the next line, since the pattern never spans past
962
1035
  // `\n` in the first place.
963
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`, 'i');
1036
+ const planCountPattern = new RegExp(`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`, 'i');
964
1037
  const planCountText = isComplete
965
1038
  ? `${summaryCount}/${planCount} plans complete`
966
1039
  : `${summaryCount}/${planCount} plans executed`;
@@ -1040,8 +1113,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1040
1113
  //
1041
1114
  // Pattern A: anchor to bare `Plans:` header (preferred).
1042
1115
  // Pattern B: fallback to bold summary when no bare header exists.
1043
- const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
1044
- const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
1116
+ const insertRowsPatternA = new RegExp(`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
1117
+ const insertRowsPatternB = new RegExp(`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
1045
1118
  const sortedMissing = [...missingPlans].sort();
1046
1119
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
1047
1120
  const inserter = (match) => `${match}\n${newRows}`;
@@ -1081,10 +1154,24 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1081
1154
  // `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
1082
1155
  // gate. Previously this wrote and reported `updated: true`
1083
1156
  // unconditionally, even on an idempotent re-run that changed nothing.
1084
- if (roadmapContent !== originalContent) {
1157
+ //
1158
+ // #4247: ...but ONLY when a writable representation of THIS phase was
1159
+ // found. Without the target gate, a byte delta from an unrelated
1160
+ // transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate
1161
+ // and produced a success-shaped `updated: true` while the phase's own
1162
+ // row stayed untouched — and the file-global write let the platform
1163
+ // write seam's markdown normalization reflow unrelated entries. When no
1164
+ // target exists the command refuses: no write at all, so ROADMAP.md is
1165
+ // left byte-identical, and the caller gets a typed
1166
+ // `missing_phase_details` decline instead of a false green.
1167
+ const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
1168
+ if (phaseRepresentationFound && roadmapContent !== originalContent) {
1085
1169
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, roadmapContent);
1086
1170
  updated = true;
1087
1171
  }
1172
+ if (!phaseRepresentationFound) {
1173
+ missingPhaseDetails = true;
1174
+ }
1088
1175
  });
1089
1176
  const computed = {
1090
1177
  phase: phaseNum,
@@ -1097,6 +1184,13 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
1097
1184
  if (updated) {
1098
1185
  output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
1099
1186
  }
1187
+ else if (missingPhaseDetails) {
1188
+ // #4247: honest refusal — the reason names the real condition (the
1189
+ // analyzer's `missing_phase_details` vocabulary), never "already
1190
+ // reflects", which was false: the ROADMAP was never able to record this
1191
+ // phase's progress in the first place.
1192
+ declineNoOp(raw, 'updated', 'missing_phase_details', `roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`, computed);
1193
+ }
1100
1194
  else {
1101
1195
  declineNoOp(raw, 'updated', "no changes were needed — ROADMAP.md already reflects this phase's plan/summary counts and status", "roadmap update-plan-progress skipped — no changes were needed; ROADMAP.md already reflects this phase's plan/summary counts and status.", computed);
1102
1196
  }
@@ -1011,7 +1011,7 @@ function buildKimiAgentArtifacts({ rootAgent = '', subagents = [], requestedSuba
1011
1011
  * @param {boolean} [isGlobal=false] - Whether this is a global install
1012
1012
  */
1013
1013
  function convertClaudeToAntigravityContent(content, isGlobal = false) {
1014
- let c = content;
1014
+ let c = filterRuntimeNotesForTarget(content, 'antigravity');
1015
1015
  if (isGlobal) {
1016
1016
  // #3738: global skills install under ~/.gemini/config/skills (the dir AGY
1017
1017
  // scans for global discovery), so skills-path references must divert there
@@ -1103,7 +1103,7 @@ function convertSlashCommandsToCursorSkillMentions(content) {
1103
1103
  return content.replace(/gsd:/gi, 'gsd-');
1104
1104
  }
1105
1105
  function convertClaudeToCursorMarkdown(content) {
1106
- let converted = convertSlashCommandsToCursorSkillMentions(content);
1106
+ let converted = convertSlashCommandsToCursorSkillMentions(filterRuntimeNotesForTarget(content, 'cursor'));
1107
1107
  // Replace tool name references in body text
1108
1108
  converted = converted.replace(/\bBash\(/g, 'Shell(');
1109
1109
  converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
@@ -1203,7 +1203,7 @@ function convertSlashCommandsToWindsurfSkillMentions(content) {
1203
1203
  return content.replace(/gsd:/gi, 'gsd-');
1204
1204
  }
1205
1205
  function convertClaudeToWindsurfMarkdown(content) {
1206
- let converted = convertSlashCommandsToWindsurfSkillMentions(content);
1206
+ let converted = convertSlashCommandsToWindsurfSkillMentions(filterRuntimeNotesForTarget(content, 'windsurf'));
1207
1207
  // Replace tool name references in body text
1208
1208
  converted = converted.replace(/\bBash\(/g, 'Shell(');
1209
1209
  converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
@@ -1362,7 +1362,7 @@ function convertSlashCommandsToAugmentSkillMentions(content) {
1362
1362
  return content.replace(/gsd:/gi, 'gsd-');
1363
1363
  }
1364
1364
  function convertClaudeToAugmentMarkdown(content) {
1365
- let converted = convertSlashCommandsToAugmentSkillMentions(content);
1365
+ let converted = convertSlashCommandsToAugmentSkillMentions(filterRuntimeNotesForTarget(content, 'augment'));
1366
1366
  converted = converted.replace(/\bBash\(/g, 'launch-process(');
1367
1367
  converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
1368
1368
  converted = converted.replace(/\bRead\(/g, 'view(');
@@ -1440,7 +1440,7 @@ function convertSlashCommandsToTraeSkillMentions(content) {
1440
1440
  });
1441
1441
  }
1442
1442
  function convertClaudeToTraeMarkdown(content) {
1443
- let converted = convertSlashCommandsToTraeSkillMentions(content);
1443
+ let converted = convertSlashCommandsToTraeSkillMentions(filterRuntimeNotesForTarget(content, 'trae'));
1444
1444
  converted = converted.replace(/\bBash\(/g, 'Shell(');
1445
1445
  converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
1446
1446
  // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task"
@@ -1548,7 +1548,7 @@ function convertSlashCommandsToCodebuddySkillMentions(content) {
1548
1548
  });
1549
1549
  }
1550
1550
  function convertClaudeToCodebuddyMarkdown(content) {
1551
- let converted = convertSlashCommandsToCodebuddySkillMentions(content);
1551
+ let converted = convertSlashCommandsToCodebuddySkillMentions(filterRuntimeNotesForTarget(content, 'codebuddy'));
1552
1552
  // CodeBuddy uses the same tool names as Claude Code (Bash, Edit, Read, Write, etc.)
1553
1553
  // No tool name conversion needed
1554
1554
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
@@ -1625,7 +1625,7 @@ function convertClaudeCommandToCodebuddyCommand(content, commandName) {
1625
1625
  }
1626
1626
  // ── Cline converters ────────────────────────────────────────────────────────
1627
1627
  function convertClaudeToCliineMarkdown(content) {
1628
- let converted = content;
1628
+ let converted = filterRuntimeNotesForTarget(content, 'cline');
1629
1629
  // Cline uses the same tool names as Claude Code — no tool name conversion needed
1630
1630
  converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.clinerules`');
1631
1631
  converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
@@ -1712,7 +1712,7 @@ function rewriteBareGsdToolsCommandsForCodex(content) {
1712
1712
  .replace(/((?:&&|\|\||[;|])\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`);
1713
1713
  }
1714
1714
  function convertClaudeToCodexMarkdown(content) {
1715
- let converted = convertSlashCommandsToCodexSkillMentions(content);
1715
+ let converted = convertSlashCommandsToCodexSkillMentions(filterRuntimeNotesForTarget(content, 'codex'));
1716
1716
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
1717
1717
  // Remove /clear references — Codex has no equivalent command
1718
1718
  // Handle backtick-wrapped: `\/clear` then: → (removed)
@@ -1901,9 +1901,24 @@ function frontmatterScalar(key, value) {
1901
1901
  ? `${key} "${frontmatterModule.escapeDoubleQuotedScalar(value)}"`
1902
1902
  : `${key} ${value}`;
1903
1903
  }
1904
+ const RUNTIME_NOTE_AUDIENCE_BY_HEADING = new Map([
1905
+ ['copilot (vs code)', 'copilot'],
1906
+ ]);
1907
+ function filterRuntimeNotesForTarget(content, targetRuntime) {
1908
+ return content.replace(/<runtime_note(?:\s+runtime=["']([^"']+)["'])?>([\s\S]*?)<\/runtime_note>/g, (whole, declaredAudience, inner) => {
1909
+ if (declaredAudience && declaredAudience.toLowerCase() !== targetRuntime)
1910
+ return '';
1911
+ const remaining = inner.replace(/(?:^|\n)[ \t]*\*\*([^*\n]+):\*\*[^\n]*(?:\n(?![ \t]*\n)[^\n]*)*/g, (section, heading) => {
1912
+ const audience = RUNTIME_NOTE_AUDIENCE_BY_HEADING.get(heading.trim().toLowerCase());
1913
+ return audience && audience !== targetRuntime ? '' : section;
1914
+ });
1915
+ const body = remaining.trim();
1916
+ return body ? `<runtime_note>\n${body}\n</runtime_note>` : '';
1917
+ });
1918
+ }
1904
1919
  function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null, variant = null } = {}) {
1905
1920
  // Replace tool name references in content (applies to all files)
1906
- let convertedContent = content;
1921
+ let convertedContent = filterRuntimeNotesForTarget(content, 'opencode');
1907
1922
  convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
1908
1923
  convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
1909
1924
  convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
@@ -2064,7 +2079,7 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
2064
2079
  // (#2093).
2065
2080
  function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
2066
2081
  // Replace tool name references in content (applies to all files)
2067
- let convertedContent = content;
2082
+ let convertedContent = filterRuntimeNotesForTarget(content, 'kilo');
2068
2083
  convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
2069
2084
  convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
2070
2085
  convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
@@ -2945,6 +2960,7 @@ function restoreClaudeGlobalAtRefTilde(content, pathPrefix) {
2945
2960
  function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
2946
2961
  const dirName = getDirName(runtime);
2947
2962
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
2963
+ content = filterRuntimeNotesForTarget(content, runtime);
2948
2964
  // #1521: stamp runtime identity + use_worktrees=false for every non-Claude runtime
2949
2965
  // before brand-specific path rewrites, so the replace operates on the pristine
2950
2966
  // source line and is idempotent regardless of subsequent path substitutions.
@@ -3613,6 +3629,7 @@ module.exports = {
3613
3629
  neutralizeAgentReferences,
3614
3630
  convertClaudeCommandToOpencodeSkill,
3615
3631
  convertClaudeCommandToKiloSkill,
3632
+ filterRuntimeNotesForTarget,
3616
3633
  // #2087 — opencode/kilo command-frontmatter converters, exported so the
3617
3634
  // layout-driven `convertedCommandsKind` can resolve them by name (routes the
3618
3635
  // opencode/kilo command install through the engine instead of the bespoke path).
@@ -9,6 +9,7 @@
9
9
  // In .cts (CommonJS output) files, `require` is available as a global.
10
10
  const _require = require;
11
11
  const path = _require('node:path');
12
+ const { tryWithinRootLexical } = _require('./security.cjs');
12
13
  // #2870: InstallScope is owned by install-scope.cts, not re-declared here.
13
14
  // `isGlobalScope` centralizes the `scope === 'global'` boolean projection
14
15
  // this module needs at `_computePathPrefix`'s `isGlobal: boolean` boundary
@@ -33,11 +34,19 @@ function assertDestWithinConfigHome(configDir, destSubpath) {
33
34
  throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`);
34
35
  }
35
36
  const root = path.resolve(configDir);
36
- const resolved = path.resolve(configDir, destSubpath);
37
- if (resolved === root || !resolved.startsWith(root + path.sep)) {
37
+ // `resolved === root` is a DELIBERATE ADDITIONAL rejection, separate from
38
+ // the containment decision: `tryWithinRootLexical` treats target === root
39
+ // as CONTAINED, but a destSubpath of "" (or one that resolves to configDir
40
+ // itself) must never be accepted here — this is the strict-subpath
41
+ // requirement Phase B of ADR-1239 imposes on third-party descriptors, and
42
+ // it prevents a descriptor from writing at configHome itself. Kept as its
43
+ // own check per ADR-4650 decision 6 (a wrapper may add its own conditions
44
+ // on top of the canonical predicate, never invert it).
45
+ const contained = tryWithinRootLexical(destSubpath, configDir);
46
+ if (contained === null || contained === root) {
38
47
  throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`);
39
48
  }
40
- return resolved;
49
+ return contained;
41
50
  }
42
51
  function errorMessage(err) {
43
52
  if (err instanceof Error)
@@ -24,6 +24,7 @@ const node_os_1 = __importDefault(require("node:os"));
24
24
  // unless the top-level installRuntimeArtifacts call injected a `deps.fs`.
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const installFsAdapter = require("./install-fs-adapter.cjs");
27
+ const security_cjs_1 = require("./security.cjs");
27
28
  const { installFs, mkInstallTempDir } = installFsAdapter;
28
29
  // Reuse the install manifest's existing parser and streamed SHA-256
29
30
  // classification instead of deriving a second integrity implementation here.
@@ -101,10 +102,15 @@ function isReadableDirectory(candidate, routed) {
101
102
  }
102
103
  }
103
104
  function isPhysicallyConfinedTo(root, candidate) {
105
+ // ADR-4650 decision 6: lexical family on already-realpath'd operands — the
106
+ // surrounding try/catch must survive verbatim, since a non-existent
107
+ // candidate throwing out of realpathSync (not `tryWithinRootLexical`, which
108
+ // would accept it) is exactly the "incomplete manifest" signal this
109
+ // function's callers depend on.
104
110
  try {
105
111
  const physicalRoot = installFs().realpathSync(root);
106
112
  const physicalCandidate = installFs().realpathSync(candidate);
107
- return physicalCandidate === physicalRoot || physicalCandidate.startsWith(physicalRoot + node_path_1.default.sep);
113
+ return (0, security_cjs_1.tryWithinRootLexical)(physicalCandidate, physicalRoot) !== null;
108
114
  }
109
115
  catch {
110
116
  return false;
@@ -167,9 +173,11 @@ function installedManifestIsComplete(runtimeConfigDir, required) {
167
173
  const parts = key.split('/');
168
174
  if (parts.some((part) => part === '' || part === '.' || part === '..'))
169
175
  return false;
170
- const candidate = node_path_1.default.resolve(runtimeConfigDir, ...parts);
171
- const root = node_path_1.default.resolve(runtimeConfigDir);
172
- if (!candidate.startsWith(root + node_path_1.default.sep))
176
+ // ADR-4650 decision 6: lexical family — the object is lstat'd (never
177
+ // stat'd) and refused if it is a symlink just below, so this gate must
178
+ // refuse rather than resolve.
179
+ const candidate = (0, security_cjs_1.tryWithinRootLexical)(parts.join('/'), runtimeConfigDir);
180
+ if (candidate === null || candidate === node_path_1.default.resolve(runtimeConfigDir))
173
181
  return false;
174
182
  const stat = io.lstatSync(candidate);
175
183
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -217,7 +225,7 @@ function providersShareRequiredRoots(left, right, required) {
217
225
  const overlap = (leftPath, rightPath) => {
218
226
  const relative = node_path_1.default.relative(leftPath, rightPath);
219
227
  return relative === '' ||
220
- (relative !== '..' && !relative.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(relative));
228
+ (relative !== '..' && !relative.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(relative)); // allow-handrolled-containment: bidirectional physical-root overlap/identity check between two providers for dedup detection — not a security confinement gate on untrusted input
221
229
  };
222
230
  const physicalLeft = canonicalize(leftFs, leftRoot);
223
231
  const physicalRight = canonicalize(rightFs, rightRoot);