@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
@@ -61,6 +61,13 @@ const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
61
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports
62
62
  const verificationMod = require("./verification.cjs");
63
63
  const { isPhaseComplete } = verificationMod;
64
+ // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
65
+ // (phase-lifecycle.cts) — reused for the completed-phases numerator floor so
66
+ // this scan cannot grow a second ROADMAP parser. Pure computation module (no
67
+ // I/O), so it introduces no cycle on this path.
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
70
+ const { deriveProgressFromRoadmap } = phaseLifecycleMod;
64
71
  // eslint-disable-next-line @typescript-eslint/no-require-imports
65
72
  const planningScopeMod = require("./planning-scope.cjs");
66
73
  const { SCOPE } = planningScopeMod;
@@ -286,7 +293,7 @@ function cmdStateGet(cwd, section, raw) {
286
293
  // Try to find markdown section or field
287
294
  const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
288
295
  // Check for **field:** value (bold format)
289
- const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
296
+ const boldPattern = new RegExp(`^[ \\t]*\\*\\*${fieldEscaped}:\\*\\*[ \\t]*(.*)`, 'im');
290
297
  const boldMatch = content.match(boldPattern);
291
298
  if (boldMatch) {
292
299
  output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
@@ -314,13 +321,10 @@ function readTextArgOrFile(cwd, value, filePath, label) {
314
321
  return value;
315
322
  // Path traversal guard: ensure file resolves within project directory
316
323
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
317
- const { validatePath } = require('./security.cjs');
318
- const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
319
- if (!pathCheck.safe) {
320
- throw new Error(`${label} path rejected: ${pathCheck.error}`);
321
- }
324
+ const { assertWithinRoot, PathAcceptance } = require('./security.cjs');
325
+ const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
322
326
  try {
323
- return node_fs_1.default.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
327
+ return node_fs_1.default.readFileSync(contained, 'utf-8').trimEnd();
324
328
  }
325
329
  catch {
326
330
  throw new Error(`${label} file not found: ${filePath}`);
@@ -489,7 +493,31 @@ function cmdStateUpdate(cwd, field, value) {
489
493
  // (#3345's direction) — reported separately from `updated` because this
490
494
  // command's contract is a single-field boolean, not a per-field array.
491
495
  const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
492
- updated = reconciled.includes(field);
496
+ // #4488: `updateCore` itself already told us whether it matched the field
497
+ // (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
498
+ // real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
499
+ // question ("what changed on disk") and, per its own docstring, reports
500
+ // `[]` whenever `preWriteState.fm` is `undefined`. That happens in two
501
+ // known cases, both of which mean "no snapshot was ever captured", not
502
+ // "nothing happened": (a) `readModifyWriteStateMd`'s #948 no-op guard
503
+ // fires because the transform's output was byte-identical to the input —
504
+ // the requested value already equals what's on disk, so the field WAS
505
+ // found and there was simply nothing left to change; (b)
506
+ // `applyPostSyncPreservation`'s `isUnparseableFrontmatter` early return —
507
+ // the ORIGINAL frontmatter block was malformed, so preservation never
508
+ // runs, yet `readModifyWriteStateMd` still persists the transform's raw
509
+ // output via `platformWriteSync`. In neither case did preservation
510
+ // discard or rewrite what the transform wrote, so trusting the
511
+ // transform's own `updated` signal here is never a false positive.
512
+ // Collapsing either case into the same `false` as "field not found" is
513
+ // the #4488 bug — `explainUpdateFailure` then reports a message that is
514
+ // actively false (it tells the caller to add a line that is already
515
+ // there). Every other `false` origin (case-D fallback did not apply, or
516
+ // the transform genuinely found nothing) is unaffected: there
517
+ // `preWriteState.fm` is defined (a normal sync ran) or `updated` was
518
+ // already false before this line.
519
+ const noopBecauseAlreadyCorrect = updated && preWriteState.fm === undefined;
520
+ updated = reconciled.includes(field) || noopBecauseAlreadyCorrect;
493
521
  const preserved = reconciled.filter((f) => f !== field);
494
522
  if (updated) {
495
523
  // #3699 case D: surfaced so a caller can tell "wrote the body source" from
@@ -1560,6 +1588,18 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1560
1588
  }
1561
1589
  }
1562
1590
  function cmdStateRecordSession(cwd, options, raw) {
1591
+ // #4186: a bare invocation is a usage error, not a heartbeat write. The
1592
+ // pre-#4186 handler accepted zero arguments and still refreshed
1593
+ // `Last session` / `Last Date` / `last_updated` — a caller probing the
1594
+ // command's signature (the way other subcommands encourage) silently
1595
+ // mutated STATE.md. Mirrors `state update`'s required-arg guard
1596
+ // (cmdStateUpdate: `error('field and value required for state update')`),
1597
+ // including its ordering: validation precedes the STATE.md existence
1598
+ // check. Either flag suffices — `--resume-file` alone carries an explicit
1599
+ // value the handler must persist.
1600
+ if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
1601
+ error('stopped-at or resume-file required for state record-session');
1602
+ }
1563
1603
  const statePath = planningPaths(cwd).state;
1564
1604
  if (!node_fs_1.default.existsSync(statePath)) {
1565
1605
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -2512,7 +2552,10 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
2512
2552
  // own comment on that field). Folding this consumer onto the raw
2513
2553
  // summaries-met flag was the exact "consolidate two of three and
2514
2554
  // leave the third" gap §7.4's forcing function rules out.
2515
- if (isPhaseComplete(phaseDir).value.complete)
2555
+ // #612: `phaseConvention` threaded so a bracket phase dir resolves
2556
+ // and scopes its verification report like its legacy twin — the
2557
+ // read-side half of the same thread cmdStateSync gets below.
2558
+ if (isPhaseComplete(phaseDir, { convention: phaseConvention }).value.complete)
2516
2559
  diskCompletedPhases++;
2517
2560
  }
2518
2561
  // Count phase headings from ROADMAP — single source of truth for
@@ -2619,6 +2662,34 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
2619
2662
  // write silently clobbered the three stored siblings with the
2620
2663
  // under-scoped disk numbers.
2621
2664
  const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
2665
+ // #4129: floor the completed-phases numerator at the ROADMAP's own
2666
+ // milestone Complete-row count. The disk numerator counts ONLY
2667
+ // phase dirs whose *-VERIFICATION.md routes `passed` (isPhaseComplete,
2668
+ // #2957 disk-strict — the gate stays untouched), so a completed
2669
+ // phase whose verification reads `stale` (a SUMMARY committed or
2670
+ // edited after it, #2348 clean-commit-time clock) or `missing`
2671
+ // (pre-verification era, hand-flipped ROADMAP row) drops out of the
2672
+ // count forever — while every other surface (the ROADMAP row
2673
+ // `phase complete` just flipped, the body `Completed Phases` field
2674
+ // completePhaseCore derives from deriveProgressFromRoadmap) still
2675
+ // asserts the phase complete. max(disk, ROADMAP) keeps the disk
2676
+ // signal for gap detection (a verification-passed phase whose ROADMAP
2677
+ // row is not yet flipped still counts) while never UNDER-counting
2678
+ // what the ROADMAP asserts. Scoped exactly like the denominator:
2679
+ // the same milestone window (roadmapScope), the same
2680
+ // safeToUseRoadmapCount gate, and never under the #3354/#3573
2681
+ // withhold — a whole-document Complete-row count must not leak
2682
+ // through an untrustworthy scope. Reuses deriveProgressFromRoadmap
2683
+ // (phase-lifecycle.cts, the one owner of "read the Progress table")
2684
+ // — no second ROADMAP parser here. A ROADMAP without a canonical
2685
+ // `## Progress` table resolves no table → floor inert (disk count
2686
+ // stands), the owner's own answer to "what is countable".
2687
+ const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
2688
+ ? deriveProgressFromRoadmap(roadmapScope).completedPhases
2689
+ : null;
2690
+ const flooredCompletedPhases = roadmapCompletedPhases !== null
2691
+ ? Math.max(diskCompletedPhases, roadmapCompletedPhases)
2692
+ : diskCompletedPhases;
2622
2693
  return {
2623
2694
  // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2624
2695
  // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
@@ -2629,7 +2700,7 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
2629
2700
  ? null
2630
2701
  : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
2631
2702
  milestoneBounded,
2632
- completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
2703
+ completedPhases: diskCountsWithheld ? null : flooredCompletedPhases,
2633
2704
  totalPlans: diskCountsWithheld ? null : diskTotalPlans,
2634
2705
  completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
2635
2706
  phaseDirScope,
@@ -2722,19 +2793,19 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
2722
2793
  progressPercent = parseInt(pctMatch[1], 10);
2723
2794
  }
2724
2795
  let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2725
- // #3578: normalizeStateStatus matches 'complete' as a case-insensitive
2726
- // SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
2727
- // the body (`Phase ${N} complete`) collapses to the milestone-level
2728
- // 'completed' status even when other phases remain open. Phase-level
2729
- // prose must never decide milestone-level status — completedPhases /
2730
- // totalPhases / diskScope, already derived above from a disk scan, are
2731
- // the authority on whether the MILESTONE is actually done. Only override
2732
- // when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
2733
- // raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
2734
- // pattern below deliberately excludes "All phases complete" (no `\S+`
2735
- // phase token) and milestone-close prose like "v1.0 milestone complete"
2736
- // (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
2737
- // disk scope, both counts are finite numbers, and a positive
2796
+ // #3578: the declared status vocabulary (#4186) recognizes
2797
+ // `Phase ${N} complete` (state.cts's own phase-completion write) and maps
2798
+ // it to `completed`, so the phase-completion prose still collapses to the
2799
+ // milestone-level status even when other phases remain open — this guard
2800
+ // demotes it back. Phase-level prose must never decide milestone-level
2801
+ // status — completedPhases / totalPhases / diskScope, already derived above
2802
+ // from a disk scan, are the authority on whether the MILESTONE is actually
2803
+ // done. Only override when: (a) normalizeStateStatus actually landed on
2804
+ // 'completed'; (b) the raw prose is UNAMBIGUOUSLY phase-completion prose —
2805
+ // the anchored pattern below deliberately excludes "All phases complete"
2806
+ // (no `\S+` phase token) and milestone-close prose like "v1.0 milestone
2807
+ // complete" (no leading "phase"); and (c) the counters are trustworthy (a
2808
+ // COMPLETE disk scope, both counts are finite numbers, and a positive
2738
2809
  // denominator) and affirmatively disagree with 'completed'. In every
2739
2810
  // other case normalizedStatus is left exactly as normalizeStateStatus
2740
2811
  // returned it.
@@ -2996,6 +3067,75 @@ function readStoredTotalPlans(existingFm) {
2996
3067
  function readStoredCompletedPlans(existingFm) {
2997
3068
  return readStoredProgressCounter(existingFm, 'completed_plans');
2998
3069
  }
3070
+ /**
3071
+ * #4129: is this authoritativeFm value a PARTIAL progress intent? The #2736
3072
+ * seam was string-only (names); #4129 extends it with one object direction —
3073
+ * the `progress` key carrying the sub-keys a transition resolved
3074
+ * authoritatively (completePhase's ROADMAP-derived completed_phases/percent).
3075
+ * Anything else keeps the seam's existing contract untouched.
3076
+ */
3077
+ function isPartialProgressIntent(value) {
3078
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3079
+ }
3080
+ /**
3081
+ * #4129: merge a PARTIAL progress intent (see isPartialProgressIntent) into a
3082
+ * frontmatter object's `progress` block. Sub-keys are accepted only when they
3083
+ * are a declared `progress.*` row in FIELD_CLASSIFICATION — the single policy
3084
+ * source (ADR-3408 §8.5) decides which leaves exist; an intent may not invent
3085
+ * one. Returns whether anything changed.
3086
+ *
3087
+ * `completedOnlyRaise` (both application sites use it): completed counters
3088
+ * apply only when strictly greater than what is already in the block, so no
3089
+ * intent can LOWER a count another trustworthy signal already established —
3090
+ * at the pre-preservation site the disk derivation's own count (a
3091
+ * verification-passed phase whose ROADMAP row drifted behind), at the
3092
+ * post-preservation re-assert the #2969 monotonic property preservation just
3093
+ * enforced. `percent` follows its sibling: it is applied when a completed
3094
+ * counter moved this call (the intent percent was computed from the intent
3095
+ * counters and is coherent with them) or when the block has no percent to
3096
+ * lose (a repair, never a regression of an upstream withhold — the withhold
3097
+ * nulled percent upstream precisely so no write would re-assert one over
3098
+ * untrustworthy counts; here the intent's own counts ARE the trustworthy
3099
+ * source, the post-completion ROADMAP).
3100
+ */
3101
+ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
3102
+ const current = fm['progress'];
3103
+ const base = isPartialProgressIntent(current)
3104
+ ? { ...current }
3105
+ : {};
3106
+ let changed = false;
3107
+ let completedMoved = false;
3108
+ for (const [subkey, value] of Object.entries(intent)) {
3109
+ if (typeof value !== 'number' || !Number.isFinite(value))
3110
+ continue;
3111
+ if (!getFieldClassification(`progress.${subkey}`))
3112
+ continue;
3113
+ const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
3114
+ if (isCompletedCounter && opts.completedOnlyRaise) {
3115
+ const currentNum = (0, state_document_cjs_1.toFiniteNumber)(base[subkey]);
3116
+ if (currentNum !== null && currentNum >= value)
3117
+ continue;
3118
+ }
3119
+ if (isCompletedCounter && !Object.is(base[subkey], value))
3120
+ completedMoved = true;
3121
+ if (!Object.is(base[subkey], value)) {
3122
+ base[subkey] = value;
3123
+ changed = true;
3124
+ }
3125
+ }
3126
+ // percent: applied only when a completed counter moved (coherent with the
3127
+ // counters that just landed) or when no percent exists to contradict.
3128
+ const intentPercent = intent['percent'];
3129
+ if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || (0, state_document_cjs_1.toFiniteNumber)(base['percent']) === null)) {
3130
+ if (!Object.is(base['percent'], intentPercent)) {
3131
+ base['percent'] = intentPercent;
3132
+ changed = true;
3133
+ }
3134
+ }
3135
+ if (changed)
3136
+ fm['progress'] = base;
3137
+ return changed;
3138
+ }
2999
3139
  function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
3000
3140
  // Read existing frontmatter BEFORE stripping — it may contain values
3001
3141
  // that the body no longer has (e.g., Status field removed by an agent).
@@ -3201,11 +3341,22 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
3201
3341
  // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
3202
3342
  // the final word on a field the transition just resolved. The prose parser
3203
3343
  // remains the fallback for genuinely unknown prose only.
3344
+ // #4129: the `progress` key carries a PARTIAL block (the object direction of
3345
+ // this seam — see applyAuthoritativeProgressSubkeys) for the same reason:
3346
+ // completePhase holds the POST-completion ROADMAP, and the disk scan this
3347
+ // function drives reads the PRE-completion one. The intent is applied as a
3348
+ // FLOOR here too (completedOnlyRaise): a derivation that already counted
3349
+ // MORE completed phases than the ROADMAP table asserts (verification-passed
3350
+ // phases whose table rows drifted behind) must not be lowered by the intent
3351
+ // — the two signals agree on direction (up), never on subtraction.
3204
3352
  if (authoritativeFm) {
3205
3353
  for (const [key, value] of Object.entries(authoritativeFm)) {
3206
3354
  if (typeof value === 'string' && value.trim().length > 0) {
3207
3355
  derivedFm[key] = value;
3208
3356
  }
3357
+ else if (key === 'progress' && isPartialProgressIntent(value)) {
3358
+ applyAuthoritativeProgressSubkeys(derivedFm, value, { completedOnlyRaise: true });
3359
+ }
3209
3360
  }
3210
3361
  }
3211
3362
  // #3257: propagate full-line frontmatter comments from the extracted source onto the
@@ -3782,6 +3933,10 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3782
3933
  // (equal), so the #1695 restore fires and would put the stale pre-transition
3783
3934
  // name back over the authoritative one. Intent beats both the prose
3784
3935
  // re-derivation and the curated restore — the transition just resolved it.
3936
+ // #4129: for the `progress` key the re-assert is a FLOOR, not an override —
3937
+ // the #2969 monotonic property preservation just enforced (completed
3938
+ // counters never move down) must not be undone by the intent, so completed
3939
+ // sub-keys apply only-raise here (see applyAuthoritativeProgressSubkeys).
3785
3940
  let authoritativeReasserted = false;
3786
3941
  if (authoritativeFm) {
3787
3942
  for (const [key, value] of Object.entries(authoritativeFm)) {
@@ -3789,6 +3944,11 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3789
3944
  preservation.postFm[key] = value;
3790
3945
  authoritativeReasserted = true;
3791
3946
  }
3947
+ else if (key === 'progress' && isPartialProgressIntent(value)) {
3948
+ if (applyAuthoritativeProgressSubkeys(preservation.postFm, value, { completedOnlyRaise: true })) {
3949
+ authoritativeReasserted = true;
3950
+ }
3951
+ }
3792
3952
  }
3793
3953
  }
3794
3954
  let finalContent = syncedContent;
@@ -4454,6 +4614,26 @@ function cmdStateJson(cwd, raw) {
4454
4614
  * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
4455
4615
  */
4456
4616
  function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4617
+ // #4138: `--phase` is this verb's one required argument, and an invocation
4618
+ // that names no phase must fail closed BEFORE any read-modify-write runs —
4619
+ // previously the missing flag flowed through as null and the transition
4620
+ // serialised `String(null)` into the body (`Phase: null — EXECUTING`,
4621
+ // `Status: Executing Phase null`, `last_activity_desc: Phase null execution
4622
+ // started`) while the post-sync frontmatter rebuild dropped current_phase /
4623
+ // current_phase_name entirely, so a single argument-less call un-set the
4624
+ // phase identity. The guard mirrors the sibling usage errors that already
4625
+ // exit non-zero (`state update`'s "field and value required", the router's
4626
+ // "unexpected positional argument" / "Invalid --plans value"), NOT
4627
+ // `cmdStateMilestoneSwitch`'s `output({error})` form, which exits 0 — the
4628
+ // issue's Expected is explicit: "Exit non-zero with a usage message and
4629
+ // write nothing." Empty and whitespace-only values are the same missing
4630
+ // argument (CONTRIBUTING.md CLI matrix); a flag-shaped `--phase --name x`
4631
+ // resolves to null in parseNamedArgs and lands here too. Runs before the
4632
+ // STATE.md existence check so argument validation always precedes I/O, and
4633
+ // before claimMilestonePhase so no phase-"null" milestone claim is taken.
4634
+ if (phaseNumber == null || String(phaseNumber).trim() === '') {
4635
+ error('phase required (--phase <N>)');
4636
+ }
4457
4637
  const statePath = planningPaths(cwd).state;
4458
4638
  if (!node_fs_1.default.existsSync(statePath)) {
4459
4639
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -4467,7 +4647,11 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4467
4647
  // #1230 post-sync preservation, and the no-op write guard.
4468
4648
  const intent = {
4469
4649
  kind: 'beginPhase',
4470
- phaseNumber,
4650
+ // The guard above made this non-null/non-empty; `error` is never-returning
4651
+ // at runtime but this module's destructured io binding does not narrow CFA,
4652
+ // so the narrowed fact is restated once (cmdStateUpdate's `field as string`
4653
+ // idiom, state.cts:782).
4654
+ phaseNumber: phaseNumber,
4471
4655
  phaseName: phaseName ?? null,
4472
4656
  planCount: planCount ?? null,
4473
4657
  };
@@ -4763,6 +4947,12 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
4763
4947
  * Updates Status to "Ready to execute", Total Plans, Last Activity.
4764
4948
  */
4765
4949
  function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4950
+ // #4383: mirror begin-phase's command-boundary guard. A missing phase must
4951
+ // fail before even looking up STATE.md so no invalid invocation can enter
4952
+ // the read-modify-write path and serialize a null/blank phase identity.
4953
+ if (phaseNumber == null || String(phaseNumber).trim() === '') {
4954
+ error('phase required (--phase <N>)');
4955
+ }
4766
4956
  const statePath = planningPaths(cwd).state;
4767
4957
  if (!node_fs_1.default.existsSync(statePath)) {
4768
4958
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -4778,7 +4968,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4778
4968
  // still owns the lock, the #1230 preservation, and the no-op write guard.
4779
4969
  const intent = {
4780
4970
  kind: 'plannedPhase',
4781
- phaseNumber,
4971
+ phaseNumber: phaseNumber,
4782
4972
  phaseName: phaseName ?? null,
4783
4973
  planCount: planCount ?? null,
4784
4974
  };
@@ -5134,7 +5324,10 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5134
5324
  // ("verification passed" drift), not a false S007.
5135
5325
  const files = node_fs_1.default.readdirSync(phaseDirPath);
5136
5326
  const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
5137
- const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
5327
+ // #612: `validateConvention` threaded (already resolved above for
5328
+ // `phaseKeyFromDir`) so the S006/S007 scan scopes bracket dirs by
5329
+ // their real token instead of the include-everything fail-safe.
5330
+ const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName, validateConvention);
5138
5331
  for (const vf of verificationFiles) {
5139
5332
  try {
5140
5333
  const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
@@ -5325,7 +5518,10 @@ function cmdStateSync(cwd, options, raw) {
5325
5518
  // was a second, independent consumer of the same raw field the initial
5326
5519
  // migration missed — without it, `state sync` and `state json` disagreed
5327
5520
  // on completed_phases for the identical disk state.
5328
- if (isPhaseComplete(dirPath).value.complete)
5521
+ // #612: `syncConvention` threaded — the write-side half of
5522
+ // buildStateFrontmatter's thread above, so `state sync` and `state json`
5523
+ // keep agreeing on completed_phases under the bracket convention.
5524
+ if (isPhaseComplete(dirPath, { convention: syncConvention }).value.complete)
5329
5525
  diskCompletedPhases++;
5330
5526
  // Track the highest phase with incomplete plans (or any plans)
5331
5527
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
@@ -364,14 +364,25 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
364
364
  try {
365
365
  for (const kind of layout.kinds) {
366
366
  let staged;
367
- if (kind.kind === 'agents') {
367
+ // #4211: kimi-agents is an AGENT kind — kimiAgentsKind.stage() forwards
368
+ // agentCtx into stageAgentsForRuntimeWithConverter exactly as agentsKind
369
+ // does, and createRuntimeArtifactInstallPlan hands every kind the
370
+ // context. Staging it bare here dropped the path-prefix rewrites and the
371
+ // attribution trailer from Kimi's generated subagents, and (under an
372
+ // unmodified `full` profile) staged only the skill-referenced subset the
373
+ // install path stages with `skills: '*'`.
374
+ if (kind.kind === 'agents' || kind.kind === 'kimi-agents') {
368
375
  const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' } : resolved;
369
376
  staged = kind.stage(agentProfile, agentCtx);
370
377
  }
371
378
  else {
372
379
  staged = kind.stage(resolved);
373
380
  }
374
- if (kind.kind === 'skills') {
381
+ // #4211: kimi-agents takes the skill-body rewrite too —
382
+ // createRuntimeArtifactInstallPlan routes `skills` and `kimi-agents`
383
+ // through rewriteStagedSkillBodies together, so omitting it here left
384
+ // Kimi's surface-materialized prompts with unrewritten paths.
385
+ if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
375
386
  runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
376
387
  runtime: layout.runtime,
377
388
  configDir: layout.configDir,
@@ -572,6 +583,53 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest, runtime) {
572
583
  // (no agentFileExtension declared) keep the staged filename verbatim.
573
584
  const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
574
585
  const isRenamedAgents = !!_agentExt && kindName === 'agents';
586
+ if (kindName === 'kimi-agents') {
587
+ // #4211: Kimi's managed tree is `gsd.yaml` + `gsd.md` + `subagents/gsd-*.{yaml,md}`
588
+ // (runtime-artifact-layout.cts kimiAgentsKind), and install copies it
589
+ // RECURSIVELY (_copyStaged in src/install-engine.cts). Surface apply fell
590
+ // through to the flat command/agent branch below, which reads only `*.md`
591
+ // at the top level: the YAML half and the whole subagents/ subtree were
592
+ // dropped, and `gsd.md` was written as `gsdgsd.md` (the flat branch
593
+ // re-applies kind.prefix to a name that already carries it). A surface
594
+ // change could therefore corrupt Kimi's installed artifacts while still
595
+ // reporting success.
596
+ node_fs_1.default.cpSync(stagedDir, destDir, { recursive: true });
597
+ // Prune GSD-owned files the new surface no longer stages, with exactly the
598
+ // ownership rule install's _removeGsdEntries applies to this kind: the two
599
+ // root files, and `gsd-`-prefixed .yaml/.md under subagents/. Everything
600
+ // else in the directory is user-owned and is preserved.
601
+ const _rootStaged = new Set(node_fs_1.default.readdirSync(stagedDir));
602
+ for (const fileName of ['gsd.yaml', 'gsd.md']) {
603
+ if (!_rootStaged.has(fileName)) {
604
+ try {
605
+ node_fs_1.default.rmSync(node_path_1.default.join(destDir, fileName), { force: true });
606
+ }
607
+ catch { /* ignore */ }
608
+ }
609
+ }
610
+ const _stagedSubagentsDir = node_path_1.default.join(stagedDir, 'subagents');
611
+ const _destSubagentsDir = node_path_1.default.join(destDir, 'subagents');
612
+ const _stagedSubagents = node_fs_1.default.existsSync(_stagedSubagentsDir)
613
+ ? new Set(node_fs_1.default.readdirSync(_stagedSubagentsDir))
614
+ : new Set();
615
+ if (node_fs_1.default.existsSync(_destSubagentsDir)) {
616
+ for (const entry of node_fs_1.default.readdirSync(_destSubagentsDir, { withFileTypes: true })) {
617
+ if (!entry.isFile())
618
+ continue;
619
+ if (!entry.name.startsWith('gsd-'))
620
+ continue;
621
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md'))
622
+ continue;
623
+ if (_stagedSubagents.has(entry.name))
624
+ continue;
625
+ try {
626
+ node_fs_1.default.rmSync(node_path_1.default.join(_destSubagentsDir, entry.name), { force: true });
627
+ }
628
+ catch { /* ignore */ }
629
+ }
630
+ }
631
+ return;
632
+ }
575
633
  if (kindName === 'skills') {
576
634
  // Skills kind: work with directories, not files.
577
635
  // Each staged entry is a directory named ${prefix}${stem}.
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const security_cjs_1 = require("./security.cjs");
14
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
16
  const ioMod = require("./io.cjs");
16
17
  const { output, error, ERROR_REASON } = ioMod;
@@ -108,12 +109,14 @@ function routeResolveContent({ args, cwd, raw }, deps = {}) {
108
109
  return;
109
110
  }
110
111
  const projectRoot = node_path_1.default.resolve(cwd || process.cwd());
111
- const resolvedPlanPath = node_path_1.default.resolve(projectRoot, plan);
112
- const rel = node_path_1.default.relative(projectRoot, resolvedPlanPath);
113
- if (rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`)) {
112
+ // Lexical containment (ADR-4650): this path is validated before existence is
113
+ // checked below, so realpath resolution is neither available nor required.
114
+ const contained = (0, security_cjs_1.tryWithinRootLexical)(plan, projectRoot);
115
+ if (contained === null) {
114
116
  error(`Plan file is outside project scope: ${plan}`, ERROR_REASON.USAGE);
115
117
  return;
116
118
  }
119
+ const resolvedPlanPath = contained;
117
120
  if (!node_fs_1.default.existsSync(resolvedPlanPath)) {
118
121
  error(`Plan file not found: ${plan}`, ERROR_REASON.USAGE);
119
122
  return;
@@ -173,11 +176,14 @@ function routeTaskCommand({ args, cwd, raw }) {
173
176
  else if (args[2]) {
174
177
  const projectRoot = node_path_1.default.resolve(cwd || process.cwd());
175
178
  const requestedPath = args[2];
176
- const resolvedTaskPath = node_path_1.default.resolve(projectRoot, requestedPath);
177
- const rel = node_path_1.default.relative(projectRoot, resolvedTaskPath);
178
- if (rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`)) {
179
+ // Lexical containment (ADR-4650): validated before existence is checked below.
180
+ // `error()` here does not return/throw (preserved from before this migration),
181
+ // so resolvedTaskPath must still be computed identically on the rejected path.
182
+ const contained = (0, security_cjs_1.tryWithinRootLexical)(requestedPath, projectRoot);
183
+ if (contained === null) {
179
184
  error(`Task file is outside project scope: ${requestedPath}`, ERROR_REASON.USAGE);
180
185
  }
186
+ const resolvedTaskPath = contained ?? node_path_1.default.resolve(projectRoot, requestedPath);
181
187
  if (!node_fs_1.default.existsSync(resolvedTaskPath)) {
182
188
  error(`Task file not found: ${requestedPath}`, ERROR_REASON.USAGE);
183
189
  }
@@ -322,7 +322,7 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
322
322
  if (!filePath) {
323
323
  error('UAT file required: use uat render-checkpoint --file <path>');
324
324
  }
325
- const resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'UAT file', { allowAbsolute: true });
325
+ const resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'UAT file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
326
326
  if (!node_fs_1.default.existsSync(resolvedPath)) {
327
327
  error(`UAT file not found: ${filePath}`);
328
328
  }
@@ -127,6 +127,23 @@ function preferFirst(entries, preferred) {
127
127
  const rest = entries.filter(([rt]) => rt !== preferred);
128
128
  return [...pref, ...rest];
129
129
  }
130
+ // GLOBAL probe: absolute env candidates first (in preferFirst order, first
131
+ // hasInstall hit wins), then $HOME-relative. Single resolver shared by the
132
+ // preferredConfigDir fast path's same-path dedup and the full cascade (#4197),
133
+ // so both compare against the global dir the resolution would actually select —
134
+ // an env-directed candidate, not necessarily the $HOME-relative pathname.
135
+ function resolveGlobalCandidate(fs, env, home, preferred) {
136
+ for (const [rt, absdir] of preferFirst(envRuntimeDirs({ env, home }), preferred)) {
137
+ if (hasInstall(fs, absdir))
138
+ return { runtime: rt, dir: node_path_1.default.resolve(absdir) };
139
+ }
140
+ for (const [rt, reldir] of preferFirst(exports.RUNTIME_DIRS, preferred)) {
141
+ const cand = node_path_1.default.resolve(home, reldir);
142
+ if (hasInstall(fs, cand))
143
+ return { runtime: rt, dir: cand };
144
+ }
145
+ return { runtime: '', dir: '' };
146
+ }
130
147
  /**
131
148
  * Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }.
132
149
  */
@@ -137,11 +154,17 @@ function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = ''
137
154
  // Fast path: a validated preferredConfigDir (custom --config-dir install).
138
155
  if (preferredConfigDir && hasInstall(fs, preferredConfigDir)) {
139
156
  const resolvedPref = node_path_1.default.resolve(preferredConfigDir);
157
+ // Same-path dedup the cascade applies (#4197): a preferred dir that IS the
158
+ // selected global install (an env candidate or the $HOME-relative dir) is
159
+ // GLOBAL even when cwd === $HOME also makes it the cwd-relative match.
160
+ const { dir: globalDir } = resolveGlobalCandidate(fs, env, home, preferred);
140
161
  let scope = 'GLOBAL';
141
- for (const [, reldir] of exports.RUNTIME_DIRS) {
142
- if (node_path_1.default.resolve(cwd, reldir) === resolvedPref) {
143
- scope = 'LOCAL';
144
- break;
162
+ if (resolvedPref !== globalDir) {
163
+ for (const [, reldir] of exports.RUNTIME_DIRS) {
164
+ if (node_path_1.default.resolve(cwd, reldir) === resolvedPref) {
165
+ scope = 'LOCAL';
166
+ break;
167
+ }
145
168
  }
146
169
  }
147
170
  return {
@@ -151,7 +174,6 @@ function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = ''
151
174
  gsdDir: preferredConfigDir,
152
175
  };
153
176
  }
154
- const orderedEnv = preferFirst(envRuntimeDirs({ env, home }), preferred);
155
177
  const orderedRuntime = preferFirst(exports.RUNTIME_DIRS, preferred);
156
178
  // LOCAL probe (relative to cwd).
157
179
  let localRuntime = '', localDir = '';
@@ -163,25 +185,9 @@ function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = ''
163
185
  break;
164
186
  }
165
187
  }
166
- // GLOBAL probe: absolute env candidates first, then $HOME-relative.
167
- let globalRuntime = '', globalDir = '';
168
- for (const [rt, absdir] of orderedEnv) {
169
- if (hasInstall(fs, absdir)) {
170
- globalRuntime = rt;
171
- globalDir = node_path_1.default.resolve(absdir);
172
- break;
173
- }
174
- }
175
- if (!globalRuntime) {
176
- for (const [rt, reldir] of orderedRuntime) {
177
- const cand = node_path_1.default.resolve(home, reldir);
178
- if (hasInstall(fs, cand)) {
179
- globalRuntime = rt;
180
- globalDir = cand;
181
- break;
182
- }
183
- }
184
- }
188
+ // GLOBAL probe: absolute env candidates first, then $HOME-relative — the
189
+ // same resolver the fast path dedups against.
190
+ const { runtime: globalRuntime, dir: globalDir } = resolveGlobalCandidate(fs, env, home, preferred);
185
191
  const localValid = trustedVersionAt(fs, localDir);
186
192
  const isLocal = !!localValid && (!globalDir || localDir !== globalDir);
187
193
  if (isLocal) {
@@ -1262,16 +1262,21 @@
1262
1262
  state.result += _result;
1263
1263
  }
1264
1264
  }
1265
+ function chargeMergeWork(state) {
1266
+ state.totalMergeKeys++;
1267
+ if (state.maxTotalMergeKeys !== -1 && state.totalMergeKeys > state.maxTotalMergeKeys) {
1268
+ throwError(state, "merge keys exceeded maxTotalMergeKeys (" + state.maxTotalMergeKeys + ")");
1269
+ }
1270
+ }
1265
1271
  function mergeMappings(state, destination, source, overridableKeys) {
1266
1272
  if (!common2.isObject(source)) {
1267
1273
  throwError(state, "cannot merge mappings; the provided source object is unacceptable");
1268
1274
  }
1275
+ chargeMergeWork(state);
1269
1276
  var sourceKeys = Object.keys(source);
1270
1277
  for (var index = 0, quantity = sourceKeys.length; index < quantity; index += 1) {
1271
1278
  var key = sourceKeys[index];
1272
- if (state.maxTotalMergeKeys !== -1 && ++state.totalMergeKeys > state.maxTotalMergeKeys) {
1273
- throwError(state, "merge keys exceeded maxTotalMergeKeys (" + state.maxTotalMergeKeys + ")");
1274
- }
1279
+ chargeMergeWork(state);
1275
1280
  if (!_hasOwnProperty.call(destination, key)) {
1276
1281
  setProperty(destination, key, source[key]);
1277
1282
  overridableKeys[key] = true;
@@ -1299,6 +1304,9 @@
1299
1304
  }
1300
1305
  if (keyTag === "tag:yaml.org,2002:merge") {
1301
1306
  if (Array.isArray(valueNode)) {
1307
+ if (valueNode.length > 100) {
1308
+ throwError(state, "abnormal merge sequence size");
1309
+ }
1302
1310
  for (var _index = 0, _quantity = valueNode.length; _index < _quantity; _index += 1) {
1303
1311
  mergeMappings(state, _result, valueNode[_index], overridableKeys);
1304
1312
  }