@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
@@ -459,9 +459,18 @@ function normalizeNodePath(execPath, opts) {
459
459
  // survives the upgrade. Derive <prefix> from the path itself (more reliable
460
460
  // than HOMEBREW_PREFIX env — the path IS the install location) so every layout
461
461
  // is covered by one branch instead of one per known prefix (#2185).
462
+ //
463
+ // #4137: rewrite only when the symlink exists; otherwise fall through to the
464
+ // raw execPath, exactly like the mise and volta branches. A keg-only/versioned
465
+ // formula (node@24 installed but never `brew link`ed) has no <prefix>/bin/node
466
+ // at all, so the unconditional rewrite handed every managed hook a path that
467
+ // fails at invocation — a rewrite must never turn a working keg path into an
468
+ // immediately broken one.
462
469
  const homebrewMatch = normalizedForMatch.match(/^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i);
463
470
  if (homebrewMatch) {
464
- return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
471
+ const homebrewStable = `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
472
+ if (existsSync(homebrewStable))
473
+ return homebrewStable;
465
474
  }
466
475
  // mise pins a concrete node version at <data>/installs/node/<ver>/bin/node
467
476
  // (Windows: <data>/installs/node/<ver>/node.exe). Node realpaths
@@ -802,16 +811,52 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
802
811
  });
803
812
  return { content: updated, changed };
804
813
  }
814
+ function _installEngineSymlinkGuard() {
815
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
816
+ const mod = require('./install-engine.cjs');
817
+ return mod;
818
+ }
805
819
  function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
806
820
  const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
807
821
  const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
808
822
  const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
809
823
  const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
810
824
  const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
825
+ // #2586 Major 2: every Codex hooks.json writer funnels through this one
826
+ // function, and atomicWriteFileSync's final step is a rename(2) onto
827
+ // `hooksJsonPath` — which, when that path is a symlink, REPLACES the
828
+ // symlink with a plain file rather than writing through it. Refuse (with
829
+ // the same GSD_ALLOW_SYMLINKED_DEST opt-in every other install call site
830
+ // honors) before reading or writing, so a symlinked hooks.json is neither
831
+ // silently destroyed nor left the caller no escape hatch.
832
+ const symlinkGuard = _installEngineSymlinkGuard();
833
+ // The path this function actually reads/writes. Defaults to the nominal
834
+ // hooks.json path; reassigned below to the symlink's real target when the
835
+ // opt-in is active, so the write lands on the file the user's symlink
836
+ // points at instead of clobbering the symlink itself (see note below).
837
+ let effectiveHooksJsonPath = hooksJsonPath;
838
+ if (node_fs_1.default.existsSync(hooksJsonPath) && node_fs_1.default.lstatSync(hooksJsonPath).isSymbolicLink()) {
839
+ if (symlinkGuard.hasExistingSymlinkBetween(targetDir, hooksJsonPath, {
840
+ allowOptInFollow: symlinkGuard.isSymlinkedDestOptIn(),
841
+ })) {
842
+ throw new Error(`hooks.json at "${hooksJsonPath}" contains a symlink the install root "${targetDir}" does not trust — ` +
843
+ 'refusing to read or write it. If this is an intentional user-owned symlink layout, re-run with ' +
844
+ 'GSD_ALLOW_SYMLINKED_DEST=1.');
845
+ }
846
+ // hasExistingSymlinkBetween returned false only because the opt-in is
847
+ // active (a symlinked leaf always trips it otherwise) — so this IS a
848
+ // symlink and we are cleared to follow it. atomicWriteFileSync's final
849
+ // step is a rename(2) onto its target, which REPLACES an existing
850
+ // symlink at that path rather than writing through it; resolving to the
851
+ // real path here makes the read AND the write operate on the symlink's
852
+ // target, leaving the symlink itself untouched, matching what "follow"
853
+ // is supposed to mean.
854
+ effectiveHooksJsonPath = node_fs_1.default.realpathSync(hooksJsonPath);
855
+ }
811
856
  let parsed = {};
812
857
  let currentContent = null;
813
- if (node_fs_1.default.existsSync(hooksJsonPath)) {
814
- const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
858
+ if (node_fs_1.default.existsSync(effectiveHooksJsonPath)) {
859
+ const raw = node_fs_1.default.readFileSync(effectiveHooksJsonPath, 'utf8');
815
860
  currentContent = raw;
816
861
  if (raw.trim()) {
817
862
  try {
@@ -845,6 +890,12 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
845
890
  }
846
891
  parsed['hooks'] = hookTable;
847
892
  const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
893
+ // Minor 5 (#2586 review): an event key the user already had, already
894
+ // holding an empty array, must survive removal as an empty array — not be
895
+ // deleted outright. Deleting is only correct when OUR removal is what
896
+ // emptied a previously non-empty array. Tracked before the loop below can
897
+ // mutate anything.
898
+ const wasArrayEmpty = Array.isArray(hookTable[eventName]) && eventEntries.length === 0;
848
899
  let removedLegacy = false;
849
900
  const sanitizedEntries = [];
850
901
  for (const entry of eventEntries) {
@@ -886,6 +937,11 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
886
937
  if (sanitizedEntries.length > 0) {
887
938
  hookTable[eventName] = sanitizedEntries;
888
939
  }
940
+ else if (wasArrayEmpty) {
941
+ // Nothing of ours was ever here to remove — preserve the user's own
942
+ // empty array exactly as found (Minor 5).
943
+ hookTable[eventName] = [];
944
+ }
889
945
  else {
890
946
  delete hookTable[eventName];
891
947
  }
@@ -898,7 +954,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
898
954
  const changed = currentContent !== nextContent;
899
955
  const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
900
956
  if (shouldWrite) {
901
- atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
957
+ atomicWriteFileSync(effectiveHooksJsonPath, nextContent, 'utf8');
902
958
  }
903
959
  return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
904
960
  }
@@ -1015,6 +1071,136 @@ function removeCodexHooksJsonEvent(targetDir, eventName) {
1015
1071
  function removeCodexHooksJsonSessionStart(targetDir) {
1016
1072
  return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
1017
1073
  }
1074
+ // Literal, version-stable markers every shipped gsd-context-monitor.js
1075
+ // carries. Stable across the {{GSD_VERSION}} and runtime-path substitutions
1076
+ // the Codex copy step applies (#2586 design doc "Ownership check" — a raw
1077
+ // content hash would differ per runtime/version by construction, so a marker
1078
+ // check is used instead of manifest-membership, which has a bootstrap gap on
1079
+ // the exact case that matters most: a pre-#2586 install's manifest never
1080
+ // recorded this file at all).
1081
+ const CODEX_CONTEXT_MONITOR_OWNERSHIP_MARKERS = [
1082
+ '#!/usr/bin/env node',
1083
+ '// gsd-hook-version:',
1084
+ '// Context Monitor - PostToolUse/AfterTool hook',
1085
+ ];
1086
+ function isGsdOwnedCodexContextMonitorScript(filePath) {
1087
+ let content;
1088
+ try {
1089
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1090
+ }
1091
+ catch {
1092
+ return false;
1093
+ }
1094
+ // The .cmd shim (buildCodexHookWindowsShimIR) is a tiny generated batch
1095
+ // wrapper, not the JS file itself — it never carries the JS markers above,
1096
+ // so it gets its own narrower, still-specific signature: the exact
1097
+ // "@ECHO OFF" / "@SETLOCAL" preamble the shim generator emits, invoking a
1098
+ // script path that ends in gsd-context-monitor.js.
1099
+ if (filePath.endsWith('.cmd')) {
1100
+ return content.startsWith('@ECHO OFF') && content.includes('@SETLOCAL')
1101
+ && /gsd-context-monitor\.js/.test(content);
1102
+ }
1103
+ return CODEX_CONTEXT_MONITOR_OWNERSHIP_MARKERS.every((marker) => content.includes(marker));
1104
+ }
1105
+ /**
1106
+ * Scan every event in hooks.json for a surviving reference to the
1107
+ * context-monitor script or its Windows .cmd shim, by basename — not scoped
1108
+ * to CODEX_EXTENDED_HOOK_EVENTS, so a user who hand-registered it under an
1109
+ * unrelated event key is still detected as "referenced" and the script is
1110
+ * preserved.
1111
+ */
1112
+ function hooksJsonReferencesCodexContextMonitor(targetDir) {
1113
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
1114
+ if (!node_fs_1.default.existsSync(hooksJsonPath))
1115
+ return false;
1116
+ let raw;
1117
+ try {
1118
+ raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
1119
+ }
1120
+ catch {
1121
+ return true; // unreadable — conservatively assume referenced, never delete
1122
+ }
1123
+ if (!raw.trim())
1124
+ return false;
1125
+ let parsed;
1126
+ try {
1127
+ parsed = JSON.parse(raw);
1128
+ }
1129
+ catch {
1130
+ return true; // unparseable — conservatively assume referenced
1131
+ }
1132
+ if (!parsed || typeof parsed !== 'object')
1133
+ return false;
1134
+ const hooks = parsed['hooks'];
1135
+ const table = hooks && typeof hooks === 'object' && !Array.isArray(hooks)
1136
+ ? hooks
1137
+ : parsed;
1138
+ for (const key of Object.keys(table)) {
1139
+ const entries = table[key];
1140
+ if (!Array.isArray(entries))
1141
+ continue;
1142
+ for (const entry of entries) {
1143
+ if (!entry || typeof entry !== 'object')
1144
+ continue;
1145
+ const entryHooks = entry['hooks'];
1146
+ const hookList = Array.isArray(entryHooks) ? entryHooks : [entry];
1147
+ for (const hook of hookList) {
1148
+ if (!hook || typeof hook !== 'object')
1149
+ continue;
1150
+ const values = [
1151
+ hook['command'],
1152
+ hook['commandWindows'],
1153
+ ];
1154
+ for (const value of values) {
1155
+ if (typeof value === 'string' && /gsd-context-monitor(\.js|\.cmd)?/.test(value)) {
1156
+ return true;
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ }
1162
+ return false;
1163
+ }
1164
+ /**
1165
+ * #2586 must-have #4/#8: after hooks.json registrations for
1166
+ * CODEX_EXTENDED_HOOK_EVENTS have been reconciled away (by the caller, via
1167
+ * removeCodexHooksJsonEvent), delete `hooks/gsd-context-monitor.js` and its
1168
+ * `.cmd` shim ONLY when (a) no surviving hooks.json registration under ANY
1169
+ * event still references either basename, and (b) the on-disk file carries
1170
+ * GSD's own ownership markers (a user's hand-edited or unrelated file at that
1171
+ * path is left alone). Each file is deleted independently — a failure
1172
+ * deleting one is reported as a warning and never rolls back the (already
1173
+ * safe, already-written) hooks.json deregistration the caller performed
1174
+ * first.
1175
+ */
1176
+ function cleanupOrphanedCodexContextMonitorScript(targetDir) {
1177
+ const result = { deleted: [], warnings: [], stillReferenced: false };
1178
+ if (hooksJsonReferencesCodexContextMonitor(targetDir)) {
1179
+ result.stillReferenced = true;
1180
+ return result;
1181
+ }
1182
+ const candidates = [
1183
+ node_path_1.default.join(targetDir, 'hooks', 'gsd-context-monitor.js'),
1184
+ node_path_1.default.join(targetDir, 'hooks', 'gsd-context-monitor.cmd'),
1185
+ ];
1186
+ for (const candidate of candidates) {
1187
+ if (!node_fs_1.default.existsSync(candidate))
1188
+ continue;
1189
+ if (!isGsdOwnedCodexContextMonitorScript(candidate))
1190
+ continue;
1191
+ try {
1192
+ node_fs_1.default.unlinkSync(candidate);
1193
+ result.deleted.push(candidate);
1194
+ }
1195
+ catch (err) {
1196
+ result.warnings.push({
1197
+ path: candidate,
1198
+ reason: err && err.message ? err.message : String(err),
1199
+ });
1200
+ }
1201
+ }
1202
+ return result;
1203
+ }
1018
1204
  function buildHookCommand(configDir, hookName, opts) {
1019
1205
  if (!opts)
1020
1206
  opts = {};
@@ -2692,6 +2878,9 @@ module.exports = {
2692
2878
  removeCodexHooksJsonEvent,
2693
2879
  removeCodexHooksJsonSessionStart,
2694
2880
  buildCodexHookWindowsShimIR,
2881
+ cleanupOrphanedCodexContextMonitorScript,
2882
+ isGsdOwnedCodexContextMonitorScript,
2883
+ hooksJsonReferencesCodexContextMonitor,
2695
2884
  // Codex TOML
2696
2885
  buildCodexHookBlock,
2697
2886
  rewriteLegacyCodexHookBlock,
@@ -22,10 +22,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
22
22
  return (mod && mod.__esModule) ? mod : { "default": mod };
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.MARKDOWN_LINK_PATTERNS = exports.INJECTION_PATTERNS = void 0;
26
- exports.validatePath = validatePath;
25
+ exports.MARKDOWN_LINK_PATTERNS = exports.INJECTION_PATTERNS = exports.PathAcceptance = void 0;
26
+ exports.isContainedIn = isContainedIn;
27
27
  exports.loadTrustedGlobalRoots = loadTrustedGlobalRoots;
28
+ exports.assertWithinRoot = assertWithinRoot;
29
+ exports.tryWithinRoot = tryWithinRoot;
28
30
  exports.requireSafePath = requireSafePath;
31
+ exports.tryWithinRootLexical = tryWithinRootLexical;
32
+ exports.assertWithinRootLexical = assertWithinRootLexical;
29
33
  exports.scanForInjection = scanForInjection;
30
34
  exports.sanitizeForPrompt = sanitizeForPrompt;
31
35
  exports.sanitizeForDisplay = sanitizeForDisplay;
@@ -39,6 +43,31 @@ const node_fs_1 = __importDefault(require("node:fs"));
39
43
  const node_os_1 = __importDefault(require("node:os"));
40
44
  const node_path_1 = __importDefault(require("node:path"));
41
45
  // ─── Path Traversal Prevention ──────────────────────────────────────────────
46
+ /**
47
+ * THE containment comparison — the single place this repo decides whether an
48
+ * already-resolved path lies inside an already-resolved root (ADR-4650).
49
+ *
50
+ * Separator-aware on purpose: comparing the bare strings would accept a
51
+ * sibling that merely shares a prefix (`<root>-evil` against `<root>`), so both
52
+ * sides get a trailing separator before the prefix test. `target === root` is
53
+ * contained.
54
+ *
55
+ * `pathImpl` lets a caller supply `path.win32` / `path.posix` instead of the
56
+ * ambient module, so win32 separator semantics are testable off Windows.
57
+ *
58
+ * Exported for callers that have ALREADY resolved both operands themselves
59
+ * and need only this comparison step (e.g. a caller that owns its own
60
+ * `fs.realpathSync` calls to preserve an exists-vs-escaped tri-state). A
61
+ * caller that has NOT resolved its operands must NOT reach for this function
62
+ * directly — the comparison alone is not a containment check — and should use
63
+ * `assertWithinRoot` / `tryWithinRoot` (or the `assertWithinRootLexical` /
64
+ * `tryWithinRootLexical` pair) instead.
65
+ */
66
+ function isContainedIn(resolvedTarget, resolvedRoot, pathImpl = node_path_1.default) {
67
+ if (resolvedTarget === resolvedRoot)
68
+ return true;
69
+ return (resolvedTarget + pathImpl.sep).startsWith(resolvedRoot + pathImpl.sep); // allow-handrolled-containment: this IS the canonical comparison every other site routes through
70
+ }
42
71
  /**
43
72
  * Validate that a file path resolves within an allowed base directory.
44
73
  * Prevents path traversal attacks via ../ sequences, symlinks, or absolute paths.
@@ -122,9 +151,7 @@ function validatePath(filePath, baseDir, opts = {}) {
122
151
  }
123
152
  }
124
153
  }
125
- const normalizedBase = resolvedBase + node_path_1.default.sep;
126
- const normalizedPath = resolvedPath + node_path_1.default.sep;
127
- if (resolvedPath !== resolvedBase && !normalizedPath.startsWith(normalizedBase)) {
154
+ if (!isContainedIn(resolvedPath, resolvedBase)) {
128
155
  return {
129
156
  safe: false,
130
157
  resolved: resolvedPath,
@@ -204,17 +231,109 @@ function loadTrustedGlobalRoots(config) {
204
231
  }
205
232
  return result;
206
233
  }
234
+ /**
235
+ * Named acceptance policy for what kind of candidate path is even considered.
236
+ *
237
+ * This replaces the old per-call-site `{ allowAbsolute: true }` boolean flag.
238
+ * At a call site, `{ allowAbsolute: true }` reads as "containment is relaxed
239
+ * here" — which is FALSE. An absolute path that resolves OUTSIDE the root is
240
+ * still rejected; the flag only ever controlled whether an absolute candidate
241
+ * was considered at all. `AbsoluteInsideRoot` states the real contract: an
242
+ * absolute candidate is accepted for consideration, but containment is
243
+ * enforced exactly as it is for a relative one.
244
+ */
245
+ exports.PathAcceptance = {
246
+ /** Relative candidates only; an absolute candidate is rejected outright. */
247
+ RelativeOnly: 'relative-only',
248
+ /**
249
+ * An absolute candidate is accepted — but ONLY if it still resolves inside the
250
+ * root. Containment is NOT relaxed by this policy; an absolute path outside the
251
+ * root is rejected exactly as a traversal is. This is the distinction the old
252
+ * `{ allowAbsolute: true }` flag failed to make at its call sites.
253
+ */
254
+ AbsoluteInsideRoot: 'absolute-inside-root',
255
+ };
207
256
  /**
208
257
  * Validate a file path and throw on traversal attempt.
209
258
  * Convenience wrapper around validatePath for use in CLI commands.
210
259
  */
211
- function requireSafePath(filePath, baseDir, label, opts = {}) {
212
- const result = validatePath(filePath, baseDir, opts);
260
+ function assertWithinRoot(candidate, root, label, policy = exports.PathAcceptance.RelativeOnly) {
261
+ const result = validatePath(candidate, root, { allowAbsolute: policy === exports.PathAcceptance.AbsoluteInsideRoot });
213
262
  if (!result.safe) {
214
263
  throw new Error(`${label || 'Path'} validation failed: ${result.error}`);
215
264
  }
216
265
  return result.resolved;
217
266
  }
267
+ /**
268
+ * Validate a file path and return null on traversal attempt (no throw).
269
+ *
270
+ * Returns exactly `null` when unsafe — never `''`, never `result.resolved`.
271
+ * `validatePath` populates `resolved` with the ESCAPING path on the
272
+ * traversal branch, so returning it here would reproduce the defect this
273
+ * narrowing exists to remove.
274
+ */
275
+ function tryWithinRoot(candidate, root, policy = exports.PathAcceptance.RelativeOnly) {
276
+ const result = validatePath(candidate, root, { allowAbsolute: policy === exports.PathAcceptance.AbsoluteInsideRoot });
277
+ if (!result.safe) {
278
+ return null;
279
+ }
280
+ return result.resolved;
281
+ }
282
+ /**
283
+ * Validate a file path and throw on traversal attempt.
284
+ * Convenience wrapper around validatePath for use in CLI commands.
285
+ *
286
+ * Delegates to assertWithinRoot so there is one implementation beneath both
287
+ * names; its declared return type is ContainedPath (a branded string, still
288
+ * assignable to string) so existing callers keep compiling untouched.
289
+ */
290
+ function requireSafePath(filePath, baseDir, label, policy = exports.PathAcceptance.RelativeOnly) {
291
+ return assertWithinRoot(filePath, baseDir, label, policy);
292
+ }
293
+ /**
294
+ * LEXICAL containment — `path.resolve` only, never any filesystem access.
295
+ *
296
+ * Shares `isContainedIn` with the realpath-based predicate, so there is ONE
297
+ * containment decision in this repo; these differ only in how a path is
298
+ * RESOLVED before that decision, never in the decision itself (ADR-4650
299
+ * decisions 1 and 6).
300
+ *
301
+ * Use this — and say why at the call site — only where a symlink must be
302
+ * PRESERVED rather than resolved, or where the target legitimately does not
303
+ * exist yet. Three such cases exist: a destination validated before the
304
+ * `mkdirSync` that creates it, a migration that snapshots and restores a
305
+ * symlinked path AS A LINK, and a restore gate that refuses links outright.
306
+ * Everywhere else the realpath-based `assertWithinRoot` / `tryWithinRoot` is
307
+ * the correct predicate, because a lexical check CANNOT SEE A SYMLINK: a
308
+ * caller relying on one for a write-confinement guarantee must pair it with
309
+ * its own symlink refusal.
310
+ *
311
+ * `candidate` is resolved RELATIVE TO `root` (so an absolute candidate is
312
+ * taken as-is, matching `path.resolve` semantics). `target === root` is
313
+ * contained.
314
+ *
315
+ * DELIBERATELY ABSENT: no NUL-byte rejection here. The existing lexical
316
+ * callers do not reject NUL at this layer (one of them checks NUL itself,
317
+ * separately), and adding it here would change their behavior. Callers that
318
+ * need it keep their own check.
319
+ */
320
+ function tryWithinRootLexical(candidate, root, opts = {}) {
321
+ const p = opts.pathImpl || node_path_1.default;
322
+ if (typeof candidate !== 'string' || candidate === '')
323
+ return null;
324
+ if (typeof root !== 'string' || root === '')
325
+ return null;
326
+ const rootResolved = p.resolve(root);
327
+ const targetResolved = p.resolve(root, candidate);
328
+ return isContainedIn(targetResolved, rootResolved, p) ? targetResolved : null;
329
+ }
330
+ function assertWithinRootLexical(candidate, root, label, opts = {}) {
331
+ const contained = tryWithinRootLexical(candidate, root, opts);
332
+ if (contained === null) {
333
+ throw new Error(`${label || 'Path'} validation failed: lexical containment check failed`);
334
+ }
335
+ return contained;
336
+ }
218
337
  // ─── Prompt Injection Detection ────────────────────────────────────────────────────
219
338
  /**
220
339
  * Patterns that indicate prompt injection attempts in user-supplied text.
@@ -8,7 +8,7 @@
8
8
  * from the prior hand-written .cjs; only types are added.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.KNOWN_STATUS_PATTERNS = exports.KNOWN_TEMPLATE_DEFAULTS = void 0;
11
+ exports.KNOWN_STATUS_PATTERNS = exports.KNOWN_TEMPLATE_DEFAULTS = exports.STATUS_ANCHORED_PATTERNS = exports.STATUS_EXACT_TOKENS = void 0;
12
12
  exports.toFiniteNumber = toFiniteNumber;
13
13
  exports.isUnfilledFieldValue = isUnfilledFieldValue;
14
14
  exports.leadingCalendarDate = leadingCalendarDate;
@@ -364,7 +364,7 @@ function stateFieldContinuation(content, fieldName) {
364
364
  // this locates exactly the line whose value it returned. The pipe-table rung
365
365
  // is deliberately absent: a `| Field | value |` row is bounded by its closing
366
366
  // pipe and cannot wrap.
367
- const match = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i').exec(content) ??
367
+ const match = new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im').exec(content) ??
368
368
  new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im').exec(content);
369
369
  if (!match)
370
370
  return null;
@@ -396,8 +396,9 @@ function stateFieldContinuation(content, fieldName) {
396
396
  }
397
397
  function stateExtractField(content, fieldName) {
398
398
  const escaped = (0, pattern_cjs_1.escapeRegex)(fieldName);
399
- // Bold inline format: **FieldName:** value
400
- const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
399
+ // Bold line-start format: **FieldName:** value. Leading same-line whitespace
400
+ // matches the writer's established indented-field tolerance.
401
+ const boldPattern = new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im');
401
402
  const boldMatch = content.match(boldPattern);
402
403
  if (boldMatch)
403
404
  return boldMatch[1].trim();
@@ -540,7 +541,22 @@ function stateReplaceField(content, fieldName, newValue) {
540
541
  // `(.*)` captured the following line and the rebuild discarded it — the #4010
541
542
  // data-loss. ADR-3180 §7.7 makes stateExtractField the same-line-confined owner;
542
543
  // this aligns the writer to it.
543
- const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*[ \\t]*)(.*)`, 'i');
544
+ //
545
+ // #4243: the bold form is also ANCHORED to line start, with same-line leading
546
+ // whitespace only. The pre-fix pattern carried no `^` and no `m` flag, so a
547
+ // bold label quoted MID-SENTENCE inside prose — an Accumulated Context bullet
548
+ // mentioning `**Status:**` — captured the rewrite and destroyed the rest of
549
+ // its line, silently, whenever a whole-body caller fed this function every
550
+ // section (beginPhaseCore's tryField, advancePlanCore's Status/Current Plan
551
+ // writes). The plain branch below was always line-anchored; only the bold
552
+ // branch lagged. Anchoring reuses #4010's same-line confinement idiom (the
553
+ // leading class is `[ \t]*`, deliberately NOT the `\s*` the issue suggested —
554
+ // `^\s*\*\*` can consume the newlines before the label into the match and
555
+ // drop them on rebuild) and #4186's recognition-by-anchoring discipline: a
556
+ // write target must BE the whole declared line shape, never a substring
557
+ // guess inside prose. `$` is explicit-and-inert (`.` never crosses line
558
+ // terminators) and documents that the match ends at end-of-line.
559
+ const boldPattern = new RegExp(`^([ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*)(.*)$`, 'im');
544
560
  if (boldPattern.test(content)) {
545
561
  return content.replace(boldPattern, (_match, prefix) => joinFieldReplacement(prefix, newValue));
546
562
  }
@@ -595,31 +611,117 @@ function stateReplaceFieldInSession(content, primary, fallback, value) {
595
611
  const target = hasCanonicalSession ? isSession : isSessionContinuity;
596
612
  return (0, markdown_sectionizer_cjs_1.withSection)(content, target, (sectionBody) => stateReplaceFieldWithFallback(sectionBody, primary, fallback, value));
597
613
  }
614
+ /**
615
+ * #4186: the DECLARED raw-status vocabulary `normalizeStateStatus` recognizes.
616
+ * Keys are whole-field values, compared against the caller's input after
617
+ * lowercasing, trimming, and collapsing internal whitespace runs to single
618
+ * spaces — so case and whitespace variants the vocabulary documents
619
+ * (`EXECUTING PHASE 5`, ` Paused `, `In progress`) keep normalizing.
620
+ * Values are members of `STATUS_LIFECYCLE_ENUM` (`src/state-md-schema.cts`)
621
+ * — the set the normalizer maps recognized input ONTO.
622
+ *
623
+ * The mapping preserves the PRE-#4186 branch ORDER's observable artifacts for
624
+ * every value the old substring chain recognized: `Planning complete` →
625
+ * `planning` (the `planning` branch outranked `complete`) and
626
+ * `Phase complete — ready for verification` → `verifying` (`verif` outranked
627
+ * `complete`; pinned by tests/state.test.cjs's advance-plan case-5 comment).
628
+ *
629
+ * Everything else — prose that merely CONTAINS a status word — falls through
630
+ * to the caller's raw value (the recorded lenient fallback, #3873 phase-3
631
+ * row 26). A token guessed from a substring inside a sentence is worse than
632
+ * a visible paragraph: the paragraph is visibly prose, the wrong token is
633
+ * not (a `.planning/` path in Italian prose silently produced
634
+ * `status: planning`; `verificata` produced `verifying`; `completezza`
635
+ * produced `completed`).
636
+ */
637
+ exports.STATUS_EXACT_TOKENS = Object.freeze({
638
+ paused: 'paused',
639
+ stopped: 'paused',
640
+ executing: 'executing',
641
+ 'in progress': 'executing',
642
+ 'ready to execute': 'executing',
643
+ planning: 'planning',
644
+ 'ready to plan': 'planning',
645
+ 'planning complete': 'planning',
646
+ discussing: 'discussing',
647
+ verifying: 'verifying',
648
+ completed: 'completed',
649
+ done: 'completed',
650
+ complete: 'completed',
651
+ 'phase complete': 'completed',
652
+ // advance-plan's phase-complete write (state-transition.cts:1812) — maps
653
+ // to `verifying`, preserving the pre-#4186 branch order where `verif`
654
+ // outranked `complete`.
655
+ 'phase complete — ready for verification': 'verifying',
656
+ 'all phases complete': 'completed',
657
+ // Legacy bare terminal form. ADR-2207/#2204 removed it from every WRITER
658
+ // (phase verbs write `All phases complete`; milestone close writes
659
+ // `<version> milestone complete`) — kept here as READER recognition so a
660
+ // legacy STATE.md still normalizes, exactly the way KNOWN_TEMPLATE_DEFAULTS
661
+ // keeps the other legacy Status strings.
662
+ 'milestone complete': 'completed',
663
+ unknown: 'unknown',
664
+ });
665
+ /**
666
+ * #4186: ANCHORED patterns for handler-written raw statuses whose text
667
+ * carries a variable component (a phase number, a milestone version, a
668
+ * #1070 completion glyph). Each pattern is matched against the same
669
+ * normalized key as `STATUS_EXACT_TOKENS` (lowercased, trimmed,
670
+ * whitespace-collapsed) and must match the WHOLE value — never a substring —
671
+ * mirroring how `KNOWN_STATUS_PATTERNS` anchors its template-default checks.
672
+ * `Executing Phase 5 — final stretch` (executor-appended prose) matches
673
+ * NOTHING and passes through verbatim, the same discipline #1070 applies to
674
+ * "Complete but needs manual QA".
675
+ */
676
+ exports.STATUS_ANCHORED_PATTERNS = Object.freeze([
677
+ // begin-phase / planned transitions write `Executing Phase ${N}`.
678
+ [/^executing phase\s+\S+$/, 'executing'],
679
+ [/^planning phase\s+\S+$/, 'planning'],
680
+ [/^verifying phase\s+\S+$/, 'verifying'],
681
+ // phase-complete verbs write `Phase ${N} complete` (state.cts) — the exact
682
+ // shape the #3578 demote guard then re-checks against the disk counters.
683
+ [/^phase\s+\S+\s+complete$/, 'completed'],
684
+ // milestoneCompleteCore writes `${version} milestone complete` (terminal,
685
+ // ADR-2207); the version label is a single token (milestone.cts's charset
686
+ // validation admits letters, digits, '.', '-', '_').
687
+ [/^\S+\s+milestone complete$/, 'completed'],
688
+ // #1070: LLM executors may write "Complete ✓" or bare "Complete" when
689
+ // finishing a phase.
690
+ [/^complete\s*[✓✔✅☑]?$/, 'completed'],
691
+ ]);
692
+ /**
693
+ * Normalize a raw `Status` body-field value to the canonical status token.
694
+ *
695
+ * #4186: recognition is ANCHORED — the whole field value (lowercased,
696
+ * trimmed, whitespace-collapsed) must be a member of the declared vocabulary
697
+ * (`STATUS_EXACT_TOKENS` / `STATUS_ANCHORED_PATTERNS` above). The pre-#4186
698
+ * implementation ran a first-match-wins chain of SUBSTRING tests over the
699
+ * free-prose field, so any prose merely CONTAINING a trigger word was
700
+ * silently rewritten to a credible wrong token: a `.planning/...` path
701
+ * mentioned in a non-English status line landed on `planning` (the trigger
702
+ * word lives in the directory name and outranked the `verif`/`complete`
703
+ * branches), Italian `verifica*` landed on `verifying`, `completezza` and
704
+ * `fasi complete` landed on `completed`. The lenient FALLBACK is unchanged
705
+ * and recorded (#3873 phase-3 row 26): an unrecognized value passes through
706
+ * verbatim — visible prose, never a guessed token.
707
+ *
708
+ * `pausedAt` keeps its documented force (issue #4186: intended behavior): a
709
+ * truthy value yields `paused` regardless of the prose.
710
+ */
598
711
  function normalizeStateStatus(status, pausedAt) {
599
- let normalizedStatus = status || 'unknown';
600
- const statusLower = (status || '').toLowerCase();
601
- if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
602
- normalizedStatus = 'paused';
603
- }
604
- else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
605
- normalizedStatus = 'executing';
606
- }
607
- else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
608
- normalizedStatus = 'planning';
609
- }
610
- else if (statusLower.includes('discussing')) {
611
- normalizedStatus = 'discussing';
612
- }
613
- else if (statusLower.includes('verif')) {
614
- normalizedStatus = 'verifying';
615
- }
616
- else if (statusLower.includes('complete') || statusLower.includes('done')) {
617
- normalizedStatus = 'completed';
618
- }
619
- else if (statusLower.includes('ready to execute')) {
620
- normalizedStatus = 'executing';
712
+ if (pausedAt)
713
+ return 'paused';
714
+ if (!status)
715
+ return 'unknown';
716
+ const key = status.trim().toLowerCase().replace(/\s+/g, ' ');
717
+ const exact = exports.STATUS_EXACT_TOKENS[key];
718
+ if (exact)
719
+ return exact;
720
+ for (const [pattern, token] of exports.STATUS_ANCHORED_PATTERNS) {
721
+ if (pattern.test(key))
722
+ return token;
621
723
  }
622
- return normalizedStatus;
724
+ return status;
623
725
  }
624
726
  /**
625
727
  * ADR-3180 §7.6 rule 4 (#3217): `scope` is the `listMilestonePhaseDirs`-owner