@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
@@ -23,6 +23,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
23
23
  const node_os_1 = __importDefault(require("node:os"));
24
24
  const node_path_1 = __importDefault(require("node:path"));
25
25
  const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
26
+ const security_cjs_1 = require("./security.cjs");
26
27
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
27
28
  const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
28
29
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
@@ -111,7 +112,7 @@ function previousOwnedCorpusFiles(configDir, prefix) {
111
112
  }
112
113
  function pruneEmptyCorpusParents(start, stop) {
113
114
  let current = node_path_1.default.dirname(start);
114
- while (current !== stop && current.startsWith(stop + node_path_1.default.sep)) {
115
+ while (current !== stop && current.startsWith(stop + node_path_1.default.sep)) { // allow-handrolled-containment: ancestor-walk loop condition, not a containment gate
115
116
  if (installFs().readdirSync(current).length > 0)
116
117
  return;
117
118
  installFs().rmdirSync(current);
@@ -347,8 +348,11 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
347
348
  const resolvedFullPath = node_path_1.default.resolve(fullPath);
348
349
  // (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
349
350
  // destSubpath string that escapes the install root via '..' is rejected
350
- // regardless of user opt-in state (ADR-1239 Phase B threat (a)).
351
- if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
351
+ // regardless of user opt-in state (ADR-1239 Phase B threat (a)). Lexical
352
+ // (ADR-4650 decision 6): this function's whole purpose is to DETECT
353
+ // symlinks between root and target, so resolving them here would erase what
354
+ // it measures.
355
+ if ((0, security_cjs_1.tryWithinRootLexical)(resolvedFullPath, resolvedRoot) === null) {
352
356
  return true;
353
357
  }
354
358
  // #2393 (security-review finding): realpathSync fully resolves all symlink
@@ -1492,6 +1496,15 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1492
1496
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
1493
1497
  content = processAttribution(content, resolveAttribution(runtime));
1494
1498
  const skillDir = node_path_1.default.join(dest, skillName);
1499
+ // isPathConfined is lexical and cannot see a symlink. mkdirSync({recursive:true})
1500
+ // does NOT throw when skillDir already exists as a symlink to a directory, so a
1501
+ // pre-planted link would redirect the SKILL.md write outside `dest`. Refuse to
1502
+ // write through a link (epic #4636; mirrors retired-artifact-cleanup.cts:77).
1503
+ try {
1504
+ if (installFs().lstatSync(skillDir).isSymbolicLink())
1505
+ continue;
1506
+ }
1507
+ catch { /* ENOENT: not created yet — the normal case */ }
1495
1508
  installFs().mkdirSync(skillDir, { recursive: true });
1496
1509
  installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
1497
1510
  // #2322 HIGH-3 parity: persist the capability-owned marker so a later
@@ -740,6 +740,11 @@ function readInstalledCapabilitySkill(stem, registry) {
740
740
  return null;
741
741
  const skillPath = node_path_1.default.join(capDir, relSkillPath);
742
742
  try {
743
+ // statSync follows symlinks; isPathConfined is lexical and cannot see one.
744
+ // Refuse to read through a link so an outside file's content cannot be
745
+ // installed as a capability skill (epic #4636).
746
+ if (node_fs_1.default.lstatSync(skillPath).isSymbolicLink())
747
+ return null;
743
748
  if (!node_fs_1.default.statSync(skillPath).isFile())
744
749
  return null;
745
750
  return { capId, content: node_fs_1.default.readFileSync(skillPath, 'utf8') };
@@ -858,6 +863,15 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
858
863
  if (!(0, external_descriptor_trust_cjs_1.isPathConfined)(skillName, stageDir))
859
864
  continue; // defense-in-depth
860
865
  const destDir = node_path_1.default.join(stageDir, skillName);
866
+ // isPathConfined is lexical and cannot see a symlink. mkdirSync({recursive:true})
867
+ // does NOT throw when destDir already exists as a symlink to a directory, so a
868
+ // pre-planted link would redirect the SKILL.md write outside `stageDir`. Refuse
869
+ // to write through a link (epic #4636; mirrors retired-artifact-cleanup.cts:77).
870
+ try {
871
+ if (installFs().lstatSync(destDir).isSymbolicLink())
872
+ continue;
873
+ }
874
+ catch { /* ENOENT: not created yet — the normal case */ }
861
875
  installFs().mkdirSync(destDir, { recursive: true });
862
876
  installFs().writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), found.content);
863
877
  // #2322 HIGH-3: persist the capability-owned marker so a later prune
@@ -17,6 +17,7 @@ const installer_migration_authoring_cjs_1 = require("./installer-migration-autho
17
17
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
18
  const clock_cjs_1 = require("./clock.cjs");
19
19
  const install_scope_cjs_1 = require("./install-scope.cjs");
20
+ const security_cjs_1 = require("./security.cjs");
20
21
  // #2874 (ADR-58 cleanup phase): this file is the ~1200-line migration
21
22
  // plan/apply/rollback/lock/journal engine — almost none of it is on the
22
23
  // installRuntimeArtifacts call tree. Only `readInstallManifest` and
@@ -122,8 +123,17 @@ function evaluateRemoveEmptyDir(configDir, fullPath) {
122
123
  catch {
123
124
  return 'left-in-place';
124
125
  }
125
- if (resolvedTarget === resolvedRoot || !resolvedTarget.startsWith(resolvedRoot + node_path_1.default.sep)) {
126
- // Refuses both "target IS configDir" and "target escaped configDir".
126
+ // `resolvedTarget === resolvedRoot` is a DELIBERATE ADDITIONAL rejection,
127
+ // separate from the containment decision: `tryWithinRootLexical` treats
128
+ // target === root as CONTAINED, but removing the config root itself is
129
+ // never in scope for this action (see the doc comment above) — this arm
130
+ // prevents `rmdirSync` from ever being asked to remove `configDir` itself.
131
+ // Kept as its own check per ADR-4650 decision 6 (a wrapper may add its own
132
+ // conditions on top of the canonical predicate, never invert it).
133
+ if (resolvedTarget === resolvedRoot)
134
+ return 'left-in-place';
135
+ if ((0, security_cjs_1.tryWithinRootLexical)(resolvedTarget, resolvedRoot) === null) {
136
+ // Refuses "target escaped configDir".
127
137
  return 'left-in-place';
128
138
  }
129
139
  let entries;
@@ -598,11 +608,30 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO
598
608
  }
599
609
  }
600
610
  }
611
+ // DELIBERATELY LEXICAL — the RESOLUTION policy stays lexical, never realpath
612
+ // (`assertWithinRoot` / `tryWithinRoot`, src/security.cts).
613
+ //
614
+ // Reviewed under epic #4636 Phase 3 and reverted after the remote matrix proved
615
+ // the realpath collapse wrong. This module's contract is that a symlinked
616
+ // managed path is treated AS A LINK and never dereferenced — it is snapshotted
617
+ // as a link, restored as a link, and backed up as a link. The realpath-based
618
+ // predicate dereferences exactly the symlinks this module exists to preserve
619
+ // and then rejects them for escaping configDir
620
+ // ("migration path escapes configDir: extensions/gsd.cjs"). Four tests in
621
+ // tests/installer-migrations.test.cjs pin that behavior.
622
+ //
623
+ // The containment DECISION now routes through the canonical LEXICAL predicate
624
+ // (`tryWithinRootLexical`, ADR-4650 decision 6) — only the comparison moved;
625
+ // the lexical policy itself remains this module's own required choice, and
626
+ // the thrown message / returned `fullPath` are unchanged.
627
+ //
628
+ // `normalizeRelPath` is the pre-gate: it throws on absolute paths and on any
629
+ // '..' segment BEFORE this runs, so the check below is defense-in-depth over
630
+ // already-traversal-free input rather than the primary boundary.
601
631
  function ensureInsideConfig(configDir, relPath) {
602
632
  const normalized = normalizeRelPath(relPath);
603
633
  const fullPath = node_path_1.default.resolve(configDir, normalized);
604
- const root = node_path_1.default.resolve(configDir);
605
- if (fullPath !== root && !fullPath.startsWith(root + node_path_1.default.sep)) {
634
+ if ((0, security_cjs_1.tryWithinRootLexical)(fullPath, configDir) === null) {
606
635
  throw new Error(`migration path escapes configDir: ${relPath}`);
607
636
  }
608
637
  return { normalized, fullPath };
@@ -224,6 +224,9 @@ function resolveLoopHooks(input) {
224
224
  active.consumes = consumes;
225
225
  if (onError !== undefined)
226
226
  active.onError = onError;
227
+ // #4209 DISP-02: only a literal `true` projects; absent/false stay inert.
228
+ if (hook['supportsReviewerLanes'] === true)
229
+ active.supportsReviewerLanes = true;
227
230
  activeHooks.push(active);
228
231
  }
229
232
  // Process contributions
@@ -422,17 +425,20 @@ function sanitizeLoadFailReason(reason) {
422
425
  .slice(0, 300);
423
426
  return cleaned || '(no reason given)';
424
427
  }
425
- function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
426
- if (!point) {
427
- coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
428
- return;
429
- }
430
- // --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
431
- const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
432
- if (activeCapId !== undefined && activeCapId === '') {
433
- coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
434
- return;
435
- }
428
+ /**
429
+ * The full config/registry/capability-state resolution `cmdLoopRenderHooks` performs, minus its
430
+ * CLI-only output formatting — extracted so an in-process caller (e.g. `review-lane dispatch-step`
431
+ * self-verifying a `supportsReviewerLanes` trait) can reach the SAME resolution `gsd_run loop
432
+ * render-hooks <point> --raw` would give it, without spawning a subprocess and re-parsing its
433
+ * stdout (which was subject to `io.cjs`'s `@file:` overflow protocol on the rendered-string
434
+ * envelope — a bug class this in-process call cannot hit, since it never touches that envelope
435
+ * or its rendering at all).
436
+ *
437
+ * Throws on an invalid `point` (mirrors `resolveLoopHooks`); callers convert to their own error
438
+ * channel. Emits the same loud stderr load-failure warnings `cmdLoopRenderHooks` always has,
439
+ * regardless of caller — a skipped gate must never be silently invisible.
440
+ */
441
+ function resolveActiveHooksForPoint(cwd, point, options = {}) {
436
442
  const runtimeConfigDir = typeof options['configDir'] === 'string'
437
443
  ? options['configDir']
438
444
  : undefined;
@@ -468,15 +474,7 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
468
474
  for (const cap of state.capabilities || []) {
469
475
  capabilityStatesById.set(cap.id, cap);
470
476
  }
471
- let resolved;
472
- try {
473
- resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
474
- }
475
- catch (err) {
476
- const msg = (err instanceof Error) ? err.message : String(err);
477
- coreError(msg);
478
- return;
479
- }
477
+ const resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
480
478
  // ── ADR-1244 D2: load-failed capability gates FAIL OPEN with a loud warning ────
481
479
  // Decision (#2009): a capability that failed to LOAD must not block the loop.
482
480
  // The prior behavior injected a BLOCKING synthetic gate (blocking:true,
@@ -525,24 +523,44 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
525
523
  for (const w of loadFailWarnings) {
526
524
  process.stderr.write(`gsd: warning — ${w}\n`);
527
525
  }
528
- // --active-cap mode: print exactly 'true' or 'false' with no envelope
526
+ // Surface capability-state warnings and the #2009 load-failure fail-open warnings together
527
+ // (in addition to the stderr emission above, which is the channel host workflows actually see).
528
+ const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings];
529
+ return { point: resolved.point, activeHooks: resolved.activeHooks, warnings: combinedWarnings };
530
+ }
531
+ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
532
+ if (!point) {
533
+ coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
534
+ return;
535
+ }
536
+ // --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
537
+ const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
538
+ if (activeCapId !== undefined && activeCapId === '') {
539
+ coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
540
+ return;
541
+ }
542
+ let result;
543
+ try {
544
+ result = resolveActiveHooksForPoint(cwd, point, options);
545
+ }
546
+ catch (err) {
547
+ const msg = (err instanceof Error) ? err.message : String(err);
548
+ coreError(msg);
549
+ return;
550
+ }
529
551
  if (activeCapId !== undefined) {
530
- const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
552
+ const isActive = result.activeHooks.some((h) => h.capId === activeCapId);
531
553
  process.stdout.write(isActive ? 'true\n' : 'false\n');
532
554
  return;
533
555
  }
534
- const rendered = renderLoopHooks(resolved);
556
+ const rendered = renderLoopHooks({ point: result.point, activeHooks: result.activeHooks });
535
557
  const envelope = {
536
- point: resolved.point,
537
- activeHooks: resolved.activeHooks,
558
+ point: result.point,
559
+ activeHooks: result.activeHooks,
538
560
  rendered,
539
561
  };
540
- // Surface capability-state warnings and the #2009 load-failure fail-open
541
- // warnings together in the structured `warnings` channel (in addition to the
542
- // stderr emission above, which is the channel host workflows actually see).
543
- const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings];
544
- if (combinedWarnings.length > 0) {
545
- envelope.warnings = combinedWarnings;
562
+ if (result.warnings.length > 0) {
563
+ envelope.warnings = result.warnings;
546
564
  }
547
565
  coreOutput(envelope, raw);
548
566
  }
@@ -550,6 +568,7 @@ module.exports = {
550
568
  resolveLoopHooks,
551
569
  renderLoopHooks,
552
570
  cmdLoopRenderHooks,
571
+ resolveActiveHooksForPoint,
553
572
  // Exported for tests
554
573
  _getNestedConfigValue,
555
574
  _resolveActivationValue,
@@ -402,8 +402,8 @@ function classifyUnindexedUri(uri) {
402
402
  }
403
403
  /** Second, independent gate (design "Hostile inputs" gate 2): re-validate an INDEXED entry's relPath against the catalog root, catching a symlink planted after the index was built. */
404
404
  function readIndexedResource(catalog, entry) {
405
- const check = (0, security_cjs_1.validatePath)(entry.relPath, catalog.root);
406
- if (!check.safe) {
405
+ const contained = (0, security_cjs_1.tryWithinRoot)(entry.relPath, catalog.root);
406
+ if (contained === null) {
407
407
  fail(exports.REASON.TRAVERSAL_REFUSED, `indexed resource escapes catalog root: ${entry.relPath}`);
408
408
  }
409
409
  let raw;
@@ -826,7 +826,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
826
826
  // never disagree with what a real run actually archives. Absent
827
827
  // --archive-quick this stays `[]` and nothing on disk is touched either
828
828
  // way (dry-run always returns before any mutation below).
829
- const quickDirsToArchive = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
829
+ const quickDirsToArchive = options.archiveQuick ? listQuickTaskDirsForArchive(cwd).map((d) => d.name) : [];
830
830
  const dryRunResult = {
831
831
  dry_run: true,
832
832
  version,
@@ -1482,6 +1482,16 @@ function writeQuickArchiveReadme(archiveQuickDir) {
1482
1482
  * written three times, and only the real-run copy applied `requireSafePath`,
1483
1483
  * so a dry-run preview could list a directory the real run would silently
1484
1484
  * skip).
1485
+ *
1486
+ * Returns the proven `ContainedPath` alongside each entry's bare `name`
1487
+ * (`no-unconfined-path-join`'s `discardedContainmentResult` arm — a bare
1488
+ * statement call to `requireSafePath` throws away the exact answer it just
1489
+ * computed). The two dry-run previews only need `name` for display;
1490
+ * `archiveQuickTaskDirectories` deliberately does NOT reuse `abs` for its
1491
+ * rename — it re-derives and re-validates independently as TOCTOU
1492
+ * defense-in-depth (see its own comment), so `abs` exists here only to make
1493
+ * this function's own discard explicit, not to be trusted downstream as a
1494
+ * stale-safe proof.
1485
1495
  */
1486
1496
  function listQuickTaskDirsForArchive(cwd) {
1487
1497
  const planningBase = planningPaths(cwd).planning;
@@ -1494,19 +1504,20 @@ function listQuickTaskDirsForArchive(cwd) {
1494
1504
  // .planning/quick absent or unreadable — nothing to select.
1495
1505
  return [];
1496
1506
  }
1497
- const names = [];
1507
+ const results = [];
1498
1508
  for (const entry of sourceEntries) {
1499
1509
  if (!entry.isDirectory())
1500
1510
  continue; // excludes symlinks too — see MAJOR 3 note above
1511
+ let abs;
1501
1512
  try {
1502
- (0, security_cjs_1.requireSafePath)(node_path_1.default.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
1513
+ abs = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(quickDir, entry.name), planningBase, 'quick task dir', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1503
1514
  }
1504
1515
  catch {
1505
1516
  continue; // symlink/escape attempt — never a candidate, in preview OR real run
1506
1517
  }
1507
- names.push(entry.name);
1518
+ results.push({ name: entry.name, abs });
1508
1519
  }
1509
- return names.sort();
1520
+ return results.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1510
1521
  }
1511
1522
  /**
1512
1523
  * #2142: move each DIRECTORY entry under `.planning/quick/` into
@@ -1555,7 +1566,7 @@ function archiveQuickTaskDirectories(cwd, version) {
1555
1566
  // #2142 MAJOR 5 (review): dirNames is the SAME selection
1556
1567
  // `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
1557
1568
  // the real run, so it cannot disagree with what a preview reported.
1558
- const dirNames = listQuickTaskDirsForArchive(cwd);
1569
+ const dirNames = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
1559
1570
  if (dirNames.length === 0) {
1560
1571
  // Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
1561
1572
  // stray files, or every entry excluded by the selection rule) must not
@@ -1576,7 +1587,7 @@ function archiveQuickTaskDirectories(cwd, version) {
1576
1587
  // rename are two separate filesystem observations, and an entry
1577
1588
  // that was a safe real directory at selection time could in theory
1578
1589
  // be swapped for a symlink before this loop reaches it.
1579
- safeSrc = (0, security_cjs_1.requireSafePath)(src, planningBase, 'quick task dir', { allowAbsolute: true });
1590
+ safeSrc = (0, security_cjs_1.requireSafePath)(src, planningBase, 'quick task dir', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
1580
1591
  }
1581
1592
  catch {
1582
1593
  continue; // symlink/escape attempt — skip, not archived
@@ -1669,7 +1680,7 @@ function cmdQuickArchive(cwd, version, options, raw) {
1669
1680
  // `cmdMilestoneComplete`'s own dry-run preview and the real
1670
1681
  // `archiveQuickTaskDirectories` both use, so all three can never disagree.
1671
1682
  if (options.dryRun) {
1672
- const quickDirsToArchive = listQuickTaskDirsForArchive(cwd);
1683
+ const quickDirsToArchive = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
1673
1684
  output({
1674
1685
  dry_run: true,
1675
1686
  version,
@@ -128,6 +128,67 @@ function _resolveRuntimeTier(config, tier) {
128
128
  overrides: config['model_profile_overrides'],
129
129
  });
130
130
  }
131
+ /**
132
+ * #4192 — Resolve the claude-runtime TIER OVERRIDE model for (config, tier).
133
+ *
134
+ * Step 3's runtime-aware resolution deliberately skips the claude runtime to
135
+ * preserve the alias-native posture (#1156/#2297): with no user override, the
136
+ * resolver must keep returning bare tier aliases, and the builtin claude tier
137
+ * map (`opus → claude-opus-4-8`, …) must never force full-ID emission on every
138
+ * default install. But `model_profile_overrides.<runtime>.<tier>` is a
139
+ * documented override point (docs/CONFIGURATION.md § Runtime-Aware Profiles)
140
+ * that `workflows/settings-advanced.md` actively writes for claude-runtime
141
+ * users — and #4192 Finding 1 measured the key inert on this runtime.
142
+ *
143
+ * This helper reads ONLY the user's override entry for the effective claude
144
+ * runtime and tier — never the builtin claude tier map — so an install with no
145
+ * override is byte-identical to before the fix. The runtime is resolved the
146
+ * same way steps 1-3 resolve it (config['runtime'], defaulting to 'claude'),
147
+ * NOT via resolveActiveRuntime (GSD_RUNTIME/marker): the value policy must
148
+ * key off the config the operator wrote, matching mapClaudeOverrideForRuntime.
149
+ *
150
+ * Value policy mirrors the model_overrides path (#2041/#4192): an override
151
+ * value that maps to a current tier alias collapses to that alias
152
+ * (byte-equivalent resolution, alias-form emission); anything else — a pinned
153
+ * older generation (`claude-opus-4-7`), a bare alias/tier repoint (`sonnet`),
154
+ * or a non-Claude vendor id (`openai/o3`) — is emitted verbatim as pinned.
155
+ * Malformed entries (no usable `model` string) return null so the caller falls
156
+ * through to normal alias resolution (ADR-443 D1: invalid values fall through).
157
+ */
158
+ function resolveClaudeTierOverrideModel(configRuntime, tier, overrides) {
159
+ if (!tier || tier === 'inherit')
160
+ return null;
161
+ const effectiveRuntime = configRuntime || 'claude';
162
+ if (effectiveRuntime !== 'claude')
163
+ return null; // non-claude runtimes resolve at step 3
164
+ const overridesMap = overrides;
165
+ if (!overridesMap || typeof overridesMap !== 'object')
166
+ return null;
167
+ // Own-property guards throughout: both levels are config-supplied plain
168
+ // objects, so a prototype-chain key ("constructor", "toString") must not
169
+ // resolve an inherited member instead of falling through (same hardening as
170
+ // every other config-keyed lookup in this module).
171
+ const runtimeEntry = Object.hasOwn(overridesMap, effectiveRuntime)
172
+ ? overridesMap[effectiveRuntime]
173
+ : undefined;
174
+ if (!runtimeEntry || typeof runtimeEntry !== 'object')
175
+ return null;
176
+ const userRaw = Object.hasOwn(runtimeEntry, tier) ? runtimeEntry[tier] : undefined;
177
+ if (userRaw === undefined || userRaw === null)
178
+ return null;
179
+ const entry = typeof userRaw === 'string'
180
+ ? { model: userRaw }
181
+ : userRaw;
182
+ if (!entry || typeof entry !== 'object')
183
+ return null;
184
+ const model = entry['model'];
185
+ if (typeof model !== 'string' || model.length === 0)
186
+ return null;
187
+ if (Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, model)) {
188
+ return CLAUDE_POLICY_ID_TO_ALIAS[model];
189
+ }
190
+ return model;
191
+ }
131
192
  // Reverse of the Claude tier-default IDs, plus the Fable alias which Claude
132
193
  // Code's Agent tool accepts but which is not a GSD model-profile tier (#1133).
133
194
  const CLAUDE_POLICY_ID_TO_ALIAS = {
@@ -154,7 +215,10 @@ function warnModelPolicyUnmappable(agentType, policyModel, tier) {
154
215
  function _resetModelPolicyWarningCacheForTests() {
155
216
  _modelPolicyUnmappableWarned.clear();
156
217
  }
157
- // Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041).
218
+ // Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041 /
219
+ // #4192). #2041 originally warned that such a value was being DROPPED to tier
220
+ // resolution; #4192 keeps the warn-once breadcrumb but changes the behavior to
221
+ // a verbatim pass-through, so the text now describes the pass-through.
158
222
  const _modelOverrideUnmappableWarned = new Set();
159
223
  function warnModelOverrideUnmappable(agentType, overrideValue) {
160
224
  const key = `${agentType}::${overrideValue}`;
@@ -165,8 +229,9 @@ function warnModelOverrideUnmappable(agentType, overrideValue) {
165
229
  // full to stderr/logs (#2041 security review). MUST go to stderr — resolve-
166
230
  // model's JSON result is parsed from stdout.
167
231
  const safe = overrideValue.length > 64 ? overrideValue.slice(0, 64) + '…' : overrideValue;
168
- process.stderr.write(`gsd: warning — model_overrides value "${safe}" for ${agentType} ` +
169
- `has no Claude agent alias; falling through to tier resolution.\n`);
232
+ process.stderr.write(`gsd: warning — model_overrides value "${safe}" for ${agentType} is a fully-qualified ` +
233
+ `Claude model ID with no tier alias; passing it through verbatim. Claude Code setups ` +
234
+ `whose Agent tool accepts only tier aliases will not honor it. (#4192)\n`);
170
235
  }
171
236
  // Test-only: reset the model_overrides warn-dedupe cache between cases (#2041).
172
237
  function _resetModelOverrideWarningCacheForTests() {
@@ -175,12 +240,24 @@ function _resetModelOverrideWarningCacheForTests() {
175
240
  /**
176
241
  * #2041 — Map a `model_overrides` value to its Claude Agent-tool alias on the
177
242
  * claude runtime, mirroring the `model_policy` path (#1144). Claude Code's
178
- * Agent tool `model` parameter documents only tier aliases (opus/sonnet/haiku/
179
- * fable); a full Claude model ID returned verbatim is silently dropped by the
180
- * spawner. Returns the value to return verbatim, or null to signal "fall
181
- * through to normal tier/dynamic-routing resolution" (used when a Claude full
182
- * ID has no alias — matches model_policy's warn-and-fall-through). Non-Claude
183
- * runtimes and non-Claude values always pass through verbatim.
243
+ * Agent tool `model` parameter documents tier aliases (opus/sonnet/haiku/
244
+ * fable) as the always-accepted form. Returns the value to emit verbatim, or
245
+ * null to signal "fall through to normal tier/dynamic-routing resolution".
246
+ * Non-Claude runtimes and non-Claude values always pass through verbatim.
247
+ *
248
+ * #4192 — an unmappable `claude-*` value (a pinned generation that is not the
249
+ * current catalog default, e.g. `claude-opus-4-7`) is now PASSED THROUGH
250
+ * VERBATIM with a warn-once stderr breadcrumb, instead of being dropped to
251
+ * tier resolution. #2041's drop was correct when the value was plausibly a
252
+ * mis-typed current default, but for an explicit pin it silently UNPINNED the
253
+ * operator's choice — the resolver would report a tier the config never asked
254
+ * for, the exact "profile can misrepresent what actually runs" defect #4192
255
+ * files. The documented contract ("any fully-qualified model ID",
256
+ * docs/CONFIGURATION.md § Per-Agent Overrides,
257
+ * gsd-core/references/model-profiles.md § Per-Agent Overrides) is restored:
258
+ * the pin is resolved as configured. Values that DO map to a current tier
259
+ * alias still collapse to that alias — byte-equivalent resolution, the #2041
260
+ * protection preserved — and a mappable pin never warns.
184
261
  *
185
262
  * Hardening (code+security review): a `typeof` guard preserves the pre-fix
186
263
  * no-crash behavior if a malformed config surfaces a non-string value, and an
@@ -204,8 +281,9 @@ function mapClaudeOverrideForRuntime(override, configRuntime, agentType) {
204
281
  if (model_catalog_cjs_1.CLAUDE_AGENT_ALIASES.has(override))
205
282
  return override;
206
283
  if (override.startsWith('claude-')) {
284
+ // #4192: explicit generation pin — resolve as configured (see docblock).
207
285
  warnModelOverrideUnmappable(agentType, override);
208
- return null;
286
+ return override;
209
287
  }
210
288
  return override;
211
289
  }
@@ -467,6 +545,19 @@ function resolveModelInternal(cwd, agentType) {
467
545
  && (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(resolveActiveRuntime(config)))) {
468
546
  return '';
469
547
  }
548
+ // 4.5. Claude-runtime tier override (#4192 Finding 1). Sits AFTER the omit
549
+ // gate so an explicit project `resolve_model_ids:"omit"` still wins (#2297:
550
+ // explicit project omit is honored regardless of runtime), and BEFORE the
551
+ // alias return so a pinned generation is not re-collapsed to a tier alias or
552
+ // re-materialized to the LATEST catalog id by step 5's
553
+ // `resolve_model_ids:true` path. Fires ONLY when the user wrote a
554
+ // `model_profile_overrides.claude.<tier>` entry for this tier — see
555
+ // resolveClaudeTierOverrideModel for why the builtin map stays out.
556
+ if (tier && tier !== 'inherit') {
557
+ const claudeOverrideModel = resolveClaudeTierOverrideModel(configRuntime, tier, config['model_profile_overrides']);
558
+ if (claudeOverrideModel !== null)
559
+ return claudeOverrideModel;
560
+ }
470
561
  // 5. Profile lookup (Claude-native default).
471
562
  if (!agentModels) {
472
563
  return profile === 'quality' ? 'opus'
@@ -126,7 +126,13 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
126
126
  if (args.includes('--dry-run')) {
127
127
  return makeInvalidArgs('--dry-run', 'phase insert does not support --dry-run');
128
128
  }
129
- phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
129
+ // #4569: --sibling opts into joining afterPhase's parent decimal level
130
+ // instead of nesting one level deeper. Filtered out like other
131
+ // boolean flags (see `remove`'s --force handling above) so it never
132
+ // leaks into the free-text description.
133
+ const sibling = args.includes('--sibling');
134
+ const insertArgs = args.slice(2).filter(token => token !== '--sibling');
135
+ phase.cmdPhaseInsert(cwd, insertArgs[0], insertArgs.slice(1).join(' '), raw, sibling ? 'sibling' : 'nested');
130
136
  return { ok: true, data: null };
131
137
  },
132
138
  remove: (_ctx) => {