@opengsd/gsd-core 1.14.0 → 1.15.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 (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /**
3
+ * Undo Commit Selection — issue #4661 (absorbed into epic #4906, Phase 5).
4
+ *
5
+ * Pure scope-matching logic for `/gsd:undo --phase N` / `--plan N-M`, replacing
6
+ * the two hand-rolled `git log --oneline | grep -E ...` pipelines that used to
7
+ * live in `gsd-core/workflows/undo.md`. Those greps were unanchored substring
8
+ * matches against a live regex built from unvalidated user input — four
9
+ * documented bug classes (see #4661):
10
+ *
11
+ * 1. The id was interpolated raw into an ERE: `.` and `+` are live
12
+ * metacharacters there, so a malformed or merely differently-shaped id
13
+ * silently matched the WRONG phase.
14
+ * 2. The grep was unanchored, so a commit that only MENTIONS a scope
15
+ * (`docs(99-01): explain feat(03-01): commit convention`) was wrongly
16
+ * selected as a DECLARATION of it.
17
+ * 3. Phase-mode's `\):` suffix and plan-mode's missing suffix disagreed on
18
+ * the same commit (`feat(03-01)!: breaking change`).
19
+ * 4. The "obvious" fix — anchoring `git log --oneline`'s record start —
20
+ * is a trap: it silently drops `fixup! ...` / `Revert "..."` wrapper
21
+ * commits (a worse failure, a silent partial revert) and returns zero
22
+ * matches under `color.ui=always` (ANSI codes precede the sha).
23
+ *
24
+ * This module closes all four by parsing each commit subject through the
25
+ * SAME anchored conventional-commit grammar the changelog/PR-title gate uses
26
+ * (`HEADER_RE`, reused verbatim from `scripts/release-notes/conventional-title.cjs`
27
+ * — see the require() below), then comparing the extracted scope to the
28
+ * caller-supplied target id by exact STRING EQUALITY. No regex is built from
29
+ * user input at any point past `validatePhaseNumber` (src/security.cts),
30
+ * which the CLI caller (`gsd-tools.cjs query select-revert-commits`) runs
31
+ * BEFORE this module ever sees the id.
32
+ *
33
+ * Deliberately no `fs`/`child_process` in this file: it operates purely on
34
+ * `{sha, subject}` records the caller already fetched (e.g. via `git log
35
+ * --format=%H%x00%s`), so it is unit-testable without touching git or the
36
+ * filesystem.
37
+ *
38
+ * KNOWN LIMIT (documented, not a defect): `unwrapSubject` unwraps ONE level
39
+ * of a `fixup! `/`squash! `/`Revert "..."` wrapper. A doubly-wrapped subject
40
+ * (e.g. `Revert "fixup! feat(03-01): work"`) is treated as unparseable (no
41
+ * declared scope) after that single unwrap — it is NOT selected, never
42
+ * mis-selected. This bounds the module to the wrapper shapes git itself
43
+ * produces (`fixup!`/`squash!` from `--fixup`/`--squash`, `Revert "..."` from
44
+ * `git revert`), each of which wraps a single ordinary commit subject, not
45
+ * another wrapper.
46
+ */
47
+ var __importDefault = (this && this.__importDefault) || function (mod) {
48
+ return (mod && mod.__esModule) ? mod : { "default": mod };
49
+ };
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.selectCommitsByDeclaredScope = selectCommitsByDeclaredScope;
52
+ const node_path_1 = __importDefault(require("node:path"));
53
+ // Reuse the exact HEADER_RE the changelog classifier and PR-title CI gate
54
+ // already depend on (#1549) — never fork a second copy of this grammar. A
55
+ // bare `require()` (not a TS `import ... = require(...)`) is used
56
+ // deliberately: `scripts/` sits outside this build's `rootDir` ("src", see
57
+ // tsconfig.build.json) and carries no `.d.ts`, so a type-checked import-equals
58
+ // would fail module resolution (TS7016) under `strict`/`noImplicitAny`. A
59
+ // plain `require()` resolves through Node's ambient `NodeRequire` type
60
+ // (`(id: string) => any`) instead, sidestepping that check entirely — the
61
+ // cast below is the only place the resulting shape is asserted.
62
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
63
+ const conventionalTitle = require(node_path_1.default.resolve(__dirname, '..', '..', '..', 'scripts', 'release-notes', 'conventional-title.cjs'));
64
+ const { HEADER_RE } = conventionalTitle;
65
+ // ─── Subject unwrapping (Known limit: exactly ONE level) ──────────────────
66
+ const FIXUP_PREFIX = 'fixup! ';
67
+ const SQUASH_PREFIX = 'squash! ';
68
+ const REVERT_RE = /^Revert "(.*)"$/;
69
+ /**
70
+ * Strip exactly one `fixup! `/`squash! `/`Revert "..."` wrapper layer off a
71
+ * commit subject, returning the (possibly) unwrapped remainder. Never
72
+ * recurses — a doubly-wrapped subject is returned with only its outermost
73
+ * layer removed, which is intentionally still unparseable by `HEADER_RE` (see
74
+ * module doc comment).
75
+ */
76
+ function unwrapSubject(subject) {
77
+ if (subject.startsWith(FIXUP_PREFIX))
78
+ return subject.slice(FIXUP_PREFIX.length);
79
+ if (subject.startsWith(SQUASH_PREFIX))
80
+ return subject.slice(SQUASH_PREFIX.length);
81
+ const revertMatch = REVERT_RE.exec(subject);
82
+ if (revertMatch)
83
+ return revertMatch[1];
84
+ return subject;
85
+ }
86
+ /**
87
+ * Parse a (possibly wrapped) commit subject into its declared conventional-
88
+ * commit scope, or `null` when the subject carries no parseable scope: no
89
+ * clean `type[(scope)][!]:` header at all, or a header with no `(scope)`
90
+ * group. Never regex-matches against untrusted `targetId` — this function
91
+ * only ever consumes the commit's OWN subject text.
92
+ */
93
+ function parseDeclaredScope(subject) {
94
+ const unwrapped = unwrapSubject(subject);
95
+ const m = HEADER_RE.exec(unwrapped);
96
+ if (!m)
97
+ return null;
98
+ const scopeWithParens = m[2]; // e.g. "(03-01)" — includes parens, or undefined
99
+ if (!scopeWithParens)
100
+ return null;
101
+ // HEADER_RE's scope group is `\([^)]*\)` — always at least the two paren
102
+ // characters when present, so slicing them off is safe unconditionally.
103
+ return scopeWithParens.slice(1, -1);
104
+ }
105
+ /**
106
+ * Select commits whose DECLARED conventional-commit scope identifies
107
+ * `targetId`, by exact string equality (`'plan'` mode) or exact-or-phase-
108
+ * prefixed equality (`'phase'` mode). `targetId` is assumed ALREADY
109
+ * VALIDATED/NORMALIZED by the caller (e.g. via `validatePhaseNumber` from
110
+ * `src/security.cts`) — this function performs no validation of its own, and
111
+ * never builds a regex from it (string equality / `startsWith` only), so a
112
+ * malformed or metacharacter-laden `targetId` cannot cause a wildcard-style
113
+ * over-match the way the retired grep pipelines could.
114
+ */
115
+ function selectCommitsByDeclaredScope(commits, targetId, mode = 'plan') {
116
+ const phasePrefix = `${targetId}-`;
117
+ const classified = commits.map(({ sha, subject }) => {
118
+ const parsedScope = parseDeclaredScope(subject);
119
+ let matched = false;
120
+ if (parsedScope !== null) {
121
+ matched = mode === 'phase'
122
+ ? parsedScope === targetId || parsedScope.startsWith(phasePrefix)
123
+ : parsedScope === targetId;
124
+ }
125
+ return { sha, subject, parsedScope, matched };
126
+ });
127
+ const selected = classified
128
+ .filter((c) => c.matched)
129
+ .map(({ sha, subject }) => ({ sha, subject }));
130
+ return { selected, classified };
131
+ }
@@ -51,7 +51,7 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
51
51
  const security_cjs_1 = require("./security.cjs");
52
52
  const { output, error } = io;
53
53
  const { extractPhaseToken, scopeToPhase } = phaseId;
54
- const { extractFrontmatter } = frontmatterMod;
54
+ const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatterMod;
55
55
  const { normalizeLineEndings } = coreUtilsMod;
56
56
  const { SCOPE } = planningScopeMod;
57
57
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -102,8 +102,14 @@ const VERIFICATION_ROUTING_TABLE = {
102
102
  },
103
103
  stale: {
104
104
  status: 'stale',
105
- next_action: 'Verification is stale. Re-run verify-work before transition.',
106
- next_command: '',
105
+ // #4682: staleness means covered source files changed after the verifier
106
+ // last ran — the only remedy is re-running the verifier.
107
+ // /gsd-verify-work never rewrites VERIFICATION.md, so advising it from
108
+ // here was an advice loop. execute-phase resumes at the verification
109
+ // gates and re-runs the verifier (its resume tree routes a stale report
110
+ // to re-verification), which regenerates VERIFICATION.md and its digest.
111
+ next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
112
+ next_command: 'execute-phase',
107
113
  },
108
114
  // INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
109
115
  // the file has no parseable frontmatter status. Never emitted by the verifier.
@@ -112,6 +118,15 @@ const VERIFICATION_ROUTING_TABLE = {
112
118
  next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
113
119
  next_command: 'execute-phase',
114
120
  },
121
+ // #4806: the report EXISTS but its frontmatter is not parseable YAML —
122
+ // fundamentally different from "missing" (the verify step DID run; re-running
123
+ // execute-phase cannot fix a YAML typo). Consumers treat any non-'passed'
124
+ // status as blocking, so this value fails safe while telling the truth.
125
+ unparseable: {
126
+ status: 'unparseable',
127
+ next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
128
+ next_command: '',
129
+ },
115
130
  // INTERNAL SENTINEL: constructed when the file has a status value not in
116
131
  // VERIFIER_STATUSES. Never emitted by the verifier.
117
132
  unknown: {
@@ -174,8 +189,95 @@ function canonicalizeCoveredFiles(files) {
174
189
  * Bump on any change to the digest's input shape (path list, hashing order,
175
190
  * per-file hash algorithm) so an old stored digest can never collide with a
176
191
  * differently-computed new one — a version mismatch is just a mismatch.
192
+ *
193
+ * Version history:
194
+ * v1 (#4155) — every covered path's whole bytes, uniformly.
195
+ * v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
196
+ * excluded from the hash by construction.
197
+ *
198
+ * A stored digest names its own version (`v<N>:sha256:…`), and
199
+ * `readVerificationStatus` recomputes under the STORED version rather than
200
+ * this constant — so bumping it does not flip every already-verified phase
201
+ * to `stale` on upgrade. A legacy v1 report keeps v1 semantics, shared
202
+ * documents included, until it is re-fingerprinted; only a version outside
203
+ * `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
177
204
  */
178
- const FINGERPRINT_VERSION = 1;
205
+ const FINGERPRINT_VERSION = 2;
206
+ const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2]);
207
+ /**
208
+ * #4623: the planning roots whose DIRECT children are repo-wide planning
209
+ * documents, as project-root-relative posix paths. Always `.planning`; plus
210
+ * the phase's OWN planning root when a phase directory is known — the parent
211
+ * of its `phases/` directory, which is how `planningDir` lays out every
212
+ * scope (`.planning`, `.planning/<project>`, `.planning/workstreams/<ws>`,
213
+ * `.planning/<project>/workstreams/<ws>`; `planning-workspace.cts`). Derived
214
+ * from the phase's position rather than from a list of layouts so a
215
+ * workstream-scoped `ROADMAP.md` is recognised without this function
216
+ * knowing what a workstream is, and so `.planning/research/notes.md` is
217
+ * NOT mistaken for one — lexically the two are indistinguishable from
218
+ * `.planning/<project>/ROADMAP.md`. A phase directory that does not sit
219
+ * under the project root (unit fixtures at a bare tmpdir) contributes no
220
+ * extra root.
221
+ */
222
+ function sharedPlanningRoots(projectRoot, phaseDir) {
223
+ const roots = ['.planning'];
224
+ if (phaseDir) {
225
+ const phasesDir = node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
226
+ const planningDir = node_path_1.default.dirname(phasesDir);
227
+ const rel = normalizeRel(node_path_1.default.relative(node_path_1.default.resolve(projectRoot), planningDir));
228
+ // Two structural checks, both load-bearing: the phase dir's PARENT must be
229
+ // the `phases/` directory `planningDir` lays every scope out with, and the
230
+ // derived root must sit inside `.planning/`. Without them any accepted
231
+ // directory — `<root>/src/phases/01-fake` — would nominate `src` as a
232
+ // planning root and silently drop real implementation evidence from the
233
+ // digest (found by the cross-AI review of this change). A shape that fails
234
+ // either check contributes no extra root; `.planning` itself is already
235
+ // present.
236
+ if (node_path_1.default.basename(phasesDir) === 'phases' &&
237
+ rel.startsWith('.planning/') &&
238
+ !rel.includes('/../') &&
239
+ !roots.includes(rel)) {
240
+ roots.push(rel);
241
+ }
242
+ }
243
+ return roots;
244
+ }
245
+ /**
246
+ * #4623: a covered path names a repo-wide planning document when it sits
247
+ * DIRECTLY under one of `sharedPlanningRoots` — `ROADMAP.md`,
248
+ * `REQUIREMENTS.md`, `STATE.md`, `PROJECT.md`, `MILESTONES.md`,
249
+ * `config.json`, … — as opposed to a phase's own artifacts under
250
+ * `<root>/phases/<phase>/` or a research note under `.planning/research/`.
251
+ * Every phase rewrites these as ordinary bookkeeping (a roadmap checkbox, a
252
+ * requirement's traceability cell, STATE.md's position), so hashing their
253
+ * whole bytes into one phase's digest coupled every phase's staleness to
254
+ * every other phase's close — and to its OWN close, since `phase.complete`
255
+ * and `requirements mark-complete` write them after the verifier has
256
+ * already run.
257
+ *
258
+ * Defined by position, not by a name list, so the set cannot drift as new
259
+ * top-level planning documents appear (the tree already carries a dozen).
260
+ * `rel` is expected posix-normalized (`canonicalizeCoveredFiles`), so a
261
+ * `./.planning/ROADMAP.md` spelling has already collapsed to the bare form.
262
+ */
263
+ function isSharedPlanningDoc(rel, roots = ['.planning']) {
264
+ if (rel === '' || rel.endsWith('/'))
265
+ return false;
266
+ return roots.includes(node_path_1.default.posix.dirname(rel));
267
+ }
268
+ /**
269
+ * #4623: the fingerprint version a stored `covered_digest` was computed
270
+ * under, or `null` when the prefix is absent, malformed, or names a version
271
+ * this build cannot recompute (an unknown version is a mismatch by
272
+ * construction — the fail-closed shape `FINGERPRINT_VERSION`'s doc promises).
273
+ */
274
+ function parseFingerprintVersion(digest) {
275
+ const m = /^v(\d+):sha256:/.exec(digest);
276
+ if (!m)
277
+ return null;
278
+ const version = Number(m[1]);
279
+ return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
280
+ }
179
281
  /**
180
282
  * #4155: recompute the deterministic content fingerprint over a verifier's
181
283
  * declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
@@ -216,14 +318,23 @@ const FINGERPRINT_VERSION = 1;
216
318
  * confinement work (against `projectRoot`, the correct boundary for this
217
319
  * data), so no security property is lost by bypassing a narrower seam here.
218
320
  */
219
- function computeCoveredDigest(projectRoot, coveredFiles) {
321
+ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
322
+ // #4623: `version` selects the input shape to hash under — the CURRENT
323
+ // one for a fresh fingerprint (the CLI verb), or the STORED one when
324
+ // `readVerificationStatus` recomputes against a report's own digest.
325
+ // `opts.phaseDir` lets v2 recognise the phase's own planning root
326
+ // (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
327
+ if (!KNOWN_FINGERPRINT_VERSIONS.has(version))
328
+ return null;
220
329
  const uniqueSorted = canonicalizeCoveredFiles(coveredFiles);
221
330
  if (uniqueSorted.length === 0)
222
331
  return null;
332
+ const sharedRoots = version >= 2 ? sharedPlanningRoots(projectRoot, opts.phaseDir) : [];
333
+ let hashed = 0;
223
334
  // Canonicalize the root ONCE — every candidate's realpath is checked against
224
335
  // this, not the possibly-symlinked `projectRoot` argument itself. Always via
225
336
  // the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER
226
- // derived (findProjectRoot), not attacker-influenced covered-input data —
337
+ // derived (resolveProjectRoot), not attacker-influenced covered-input data —
227
338
  // routing it through a caller-scoped containment seam (e.g. #4155's
228
339
  // containmentEnforcingVerificationFs, confined to `.planning/`, a proper
229
340
  // SUBSET of `projectRoot`) would reject the root itself and fail every
@@ -262,6 +373,15 @@ function computeCoveredDigest(projectRoot, coveredFiles) {
262
373
  const st = node_fs_1.default.statSync(real);
263
374
  if (!st.isFile())
264
375
  return null;
376
+ // #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
377
+ // every other covered path — confined, present, a regular file; the
378
+ // fail-closed contract above is unchanged — but its bytes contribute
379
+ // nothing to the digest. It may stay declared in `covered_files` (the
380
+ // verifier's instructions long said to list the mapped requirement,
381
+ // and every report already written does); its bookkeeping churn can
382
+ // no longer read as drift.
383
+ if (isSharedPlanningDoc(rel, sharedRoots))
384
+ continue;
265
385
  bytes = node_fs_1.default.readFileSync(real);
266
386
  }
267
387
  catch {
@@ -269,12 +389,19 @@ function computeCoveredDigest(projectRoot, coveredFiles) {
269
389
  }
270
390
  const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
271
391
  parts.push(`${rel}\n${fileHash}\n`);
392
+ hashed++;
272
393
  }
394
+ // #4623 (v2+): a declaration made ONLY of shared planning documents has no
395
+ // evidence in it at all — a constant digest over the header would satisfy
396
+ // the fingerprint pair while grounding the verification in nothing. Fail
397
+ // closed, the same way an empty declaration does.
398
+ if (version >= 2 && hashed === 0)
399
+ return null;
273
400
  const aggregate = node_crypto_1.default
274
401
  .createHash('sha256')
275
- .update(`v${FINGERPRINT_VERSION}\n${parts.join('')}`, 'utf-8')
402
+ .update(`v${version}\n${parts.join('')}`, 'utf-8')
276
403
  .digest('hex');
277
- return `v${FINGERPRINT_VERSION}:sha256:${aggregate}`;
404
+ return `v${version}:sha256:${aggregate}`;
278
405
  }
279
406
  /**
280
407
  * #4155: the content fingerprint only recomputes digests for paths the
@@ -687,6 +814,16 @@ function readVerificationStatus(phaseDir, opts = {}) {
687
814
  // same root cause as the false-clean class fixed elsewhere in #3707-CR.
688
815
  const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
689
816
  fm = extractFrontmatter(content, filePath);
817
+ // #4806: an unparseable frontmatter block is NOT "missing" — the file
818
+ // exists and verification ran. Report a distinct status so the caller is
819
+ // sent to fix the YAML, not to re-run execute-phase.
820
+ if (fm[FRONTMATTER_UNPARSEABLE] === true) {
821
+ return {
822
+ status: 'unparseable',
823
+ next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
824
+ next_command: '',
825
+ };
826
+ }
690
827
  const statusVal = fm['status'];
691
828
  // status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
692
829
  // only accept string values — arrays and objects are not valid status values.
@@ -741,9 +878,19 @@ function readVerificationStatus(phaseDir, opts = {}) {
741
878
  // short-circuit in turn: the live-directory re-scan (for a plan/summary
742
879
  // added AFTER verification and never declared in covered_files) only
743
880
  // runs once the digest itself has already matched.
881
+ //
882
+ // #4623: recompute under the STORED digest's own version, not the
883
+ // current constant — a v1 report written before the shared-document
884
+ // exclusion keeps v1 semantics rather than going stale on upgrade. An
885
+ // unknown version parses to `null`, which `computeCoveredDigest`
886
+ // refuses (returns `null`), so the compare below fails closed.
887
+ const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
888
+ ? parseFingerprintVersion(coveredDigestVal)
889
+ : null;
744
890
  isStale =
745
891
  !hasWellFormedFingerprint ||
746
- computeCoveredDigest((0, project_root_cjs_1.findProjectRoot)(phaseDir), coveredFilesVal) !== coveredDigestVal ||
892
+ storedVersion === null ||
893
+ computeCoveredDigest((0, project_root_cjs_1.resolveProjectRoot)(phaseDir), coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
747
894
  !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
748
895
  }
749
896
  else {
@@ -761,7 +908,10 @@ function readVerificationStatus(phaseDir, opts = {}) {
761
908
  return {
762
909
  status: entry.status,
763
910
  next_action: entry.next_action,
764
- next_command: projectNextCommand('verify-work', runtime, phaseArg),
911
+ // #4682: execute-phase resumes at the verification gates and re-runs
912
+ // the verifier, regenerating VERIFICATION.md and its digest — the same
913
+ // routing the `missing` sentinel has used since #2868.
914
+ next_command: projectNextCommand('execute-phase', runtime, phaseArg),
765
915
  };
766
916
  }
767
917
  // 3. Route — exclude internal sentinels from raw-file lookup (they are
@@ -898,6 +1048,69 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
898
1048
  }
899
1049
  output({ verification_file: verificationPath }, raw, verificationPath);
900
1050
  }
1051
+ /**
1052
+ * #4623: parse the argv tokens after `verification.fingerprint <phaseDir>`
1053
+ * into a covered-file list. The router hands over a raw positional slice,
1054
+ * so every `--files`-style form other `gsd-tools` verbs accept (`commit
1055
+ * --files a b`, `docs/CLI-TOOLS.md`) used to reach `computeCoveredDigest`
1056
+ * with the literal token `--files` — or an unsplit `"a,b"` — as a covered
1057
+ * path, and the whole command failed closed with "a covered file is
1058
+ * missing, unreadable, or escapes the project root". On the reporting
1059
+ * project that message convinced two people the digest was permanently
1060
+ * unrecomputable.
1061
+ *
1062
+ * Accepted, all equivalent and freely mixed:
1063
+ * - bare positionals `a b` (the documented form, unchanged)
1064
+ * - a single flag `--files a`
1065
+ * - a comma-separated value `--files a,b` (also `--files=a,b`)
1066
+ * - a repeated flag `--files a --files b`
1067
+ *
1068
+ * Only a `--files` VALUE is comma-split: a bare positional keeps its bytes,
1069
+ * so the documented form's behaviour on a comma-bearing filename is
1070
+ * unchanged. Any other `--flag` is an explicit usage error, never a path —
1071
+ * a mis-typed flag must not fail as "file missing" again. (`--raw` never
1072
+ * reaches here; the CLI entry point splices it out before routing.)
1073
+ */
1074
+ function parseFingerprintFileArgs(tokens) {
1075
+ const files = [];
1076
+ const EMPTY_VALUE = '--files requires at least one path for verification.fingerprint (a path, or a comma-separated list)';
1077
+ const splitList = (value) => value
1078
+ .split(',')
1079
+ .map((s) => s.trim())
1080
+ .filter((s) => s.length > 0);
1081
+ for (let i = 0; i < tokens.length; i++) {
1082
+ const token = tokens[i];
1083
+ if (token === '--files') {
1084
+ const value = tokens[i + 1];
1085
+ if (value === undefined || value.startsWith('--')) {
1086
+ return { error: '--files requires a value for verification.fingerprint (a path, or a comma-separated list)' };
1087
+ }
1088
+ const list = splitList(value);
1089
+ // An empty or all-comma value is a usage error, never a silent no-op —
1090
+ // the caller would otherwise meet the generic zero-files error and go
1091
+ // looking for a missing path.
1092
+ if (list.length === 0)
1093
+ return { error: EMPTY_VALUE };
1094
+ files.push(...list);
1095
+ i++;
1096
+ }
1097
+ else if (token.startsWith('--files=')) {
1098
+ const list = splitList(token.slice('--files='.length));
1099
+ if (list.length === 0)
1100
+ return { error: EMPTY_VALUE };
1101
+ files.push(...list);
1102
+ }
1103
+ else if (token.startsWith('--')) {
1104
+ return {
1105
+ error: `unknown flag ${token} for verification.fingerprint (covered files are bare positionals or --files <a[,b]>, repeatable)`,
1106
+ };
1107
+ }
1108
+ else {
1109
+ files.push(token);
1110
+ }
1111
+ }
1112
+ return { files };
1113
+ }
901
1114
  /**
902
1115
  * CLI command handler (#4155): compute the covered-input fingerprint the
903
1116
  * verifier embeds in VERIFICATION.md frontmatter (`covered_files`,
@@ -913,7 +1126,13 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
913
1126
  * @param cwd - Current working directory.
914
1127
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd);
915
1128
  * its project root is the base covered paths resolve against.
916
- * @param files - Covered-input paths, relative to the project root.
1129
+ * Must be an existing directory (#4623): with the
1130
+ * phase dir omitted, the first covered file used to be
1131
+ * taken as the phase dir and the rest hashed — a
1132
+ * plausible digest over the wrong set, at exit 0.
1133
+ * @param fileArgs - The argv tokens after the phase dir, parsed by
1134
+ * `parseFingerprintFileArgs`: covered-input paths
1135
+ * relative to the project root, bare or via `--files`.
917
1136
  * @param raw - Whether to emit raw (non-JSON) output: just the
918
1137
  * `covered_digest` string, so `VAR=$(gsd_run query
919
1138
  * verification.fingerprint "$PHASE_DIR" ... --raw)` is
@@ -921,17 +1140,34 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
921
1140
  * from the caller's own input list in that mode, so
922
1141
  * only the computed digest needs a raw form.
923
1142
  */
924
- function cmdVerificationFingerprint(cwd, phaseDirArg, files, raw) {
1143
+ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
925
1144
  if (!phaseDirArg) {
926
1145
  error('phase directory required for verification.fingerprint');
927
1146
  return;
928
1147
  }
1148
+ const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
1149
+ let phaseDirIsDir = false;
1150
+ try {
1151
+ phaseDirIsDir = node_fs_1.default.statSync(phaseDir).isDirectory();
1152
+ }
1153
+ catch {
1154
+ // not found → not a directory
1155
+ }
1156
+ if (!phaseDirIsDir) {
1157
+ error(`phase directory not found: ${phaseDirArg} — verification.fingerprint takes the phase directory first, then the covered files`);
1158
+ return;
1159
+ }
1160
+ const parsed = parseFingerprintFileArgs(fileArgs);
1161
+ if ('error' in parsed) {
1162
+ error(parsed.error);
1163
+ return;
1164
+ }
1165
+ const files = parsed.files;
929
1166
  if (files.length === 0) {
930
1167
  error('at least one covered file required for verification.fingerprint');
931
1168
  return;
932
1169
  }
933
- const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
934
- const projectRoot = (0, project_root_cjs_1.findProjectRoot)(phaseDir);
1170
+ const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
935
1171
  // canonicalizeCoveredFiles here is for the emitted `covered_files` field —
936
1172
  // computeCoveredDigest canonicalizes its own `coveredFiles` argument
937
1173
  // internally too (it must, for callers like readVerificationStatus that
@@ -939,8 +1175,21 @@ function cmdVerificationFingerprint(cwd, phaseDirArg, files, raw) {
939
1175
  // already-canonical list keeps that internal pass a cheap no-op rather
940
1176
  // than a second meaningfully different canonicalization.
941
1177
  const uniqueSorted = canonicalizeCoveredFiles(files);
942
- const digest = computeCoveredDigest(projectRoot, uniqueSorted);
1178
+ const digest = computeCoveredDigest(projectRoot, uniqueSorted, FINGERPRINT_VERSION, { phaseDir });
943
1179
  if (digest === null) {
1180
+ // #4623: name the one null that is NOT a bad path — a declaration made
1181
+ // only of shared planning documents hashes nothing under v2, and the
1182
+ // generic message below would send the caller looking for a missing file
1183
+ // that is not missing. Discriminated AFTER the v2 attempt, and only when a
1184
+ // v1 pass over the same list (which hashes, and therefore validates, every
1185
+ // path) succeeds: an all-shared list with a missing or directory member is
1186
+ // a bad path first, and gets the generic message.
1187
+ const sharedRoots = sharedPlanningRoots(projectRoot, phaseDir);
1188
+ if (uniqueSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
1189
+ computeCoveredDigest(projectRoot, uniqueSorted, 1) !== null) {
1190
+ error(`could not compute fingerprint — every covered file is a repo-wide planning document (direct children of ${sharedRoots.join(', ')} never enter the digest); declare the phase's own artifacts and implementation files`);
1191
+ return;
1192
+ }
944
1193
  error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
945
1194
  return;
946
1195
  }
@@ -958,5 +1207,9 @@ module.exports = {
958
1207
  cmdVerificationStatus,
959
1208
  cmdVerificationResolveFile,
960
1209
  computeCoveredDigest,
1210
+ sharedPlanningRoots,
1211
+ isSharedPlanningDoc,
1212
+ parseFingerprintVersion,
1213
+ parseFingerprintFileArgs,
961
1214
  cmdVerificationFingerprint,
962
1215
  };
@@ -32,6 +32,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
32
32
  const node_fs_1 = __importDefault(require("node:fs"));
33
33
  const node_path_1 = __importDefault(require("node:path"));
34
34
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
35
+ const security_cjs_1 = require("./security.cjs");
35
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
36
37
  const phaseIdMod = require("./phase-id.cjs");
37
38
  const { stripProjectCodePrefix, extractPhaseToken, comparePhaseNum } = phaseIdMod;
@@ -380,6 +381,25 @@ function splitSegments(cmd) {
380
381
  .map(s => s.trim())
381
382
  .filter(s => s.length > 0);
382
383
  }
384
+ /**
385
+ * Decode entity-escaped ampersands (`&amp;` → `&`) — #4730, the grounding
386
+ * gate's share of the #3611 defect. Planners emit `<automated>` bodies with
387
+ * `&amp;&amp;` as the chain operator, and the executing agent reads the
388
+ * decoded (rendered) form. Without this decode, `splitSegments` cuts
389
+ * `&amp;&amp;` at its semicolons and a `cd` target absorbs the trailing
390
+ * `&amp` fragment (`rawTarget: "src &amp"`), reporting an existing directory
391
+ * as a `missing_dir` blocker. Applied to the command text inside
392
+ * `resolveVerifyCommandTarget` — before any segment splitting or target
393
+ * resolution, after `result.command` has captured the text verbatim — so the
394
+ * escaped and literal forms of the same command produce identical verdicts.
395
+ * Kept module-private beside `splitSegments`, mirroring the sibling
396
+ * `src/verify.cts`'s own private `decodeEntityAmps` (#3611): the two gates
397
+ * are separate modules that independently parse `<automated>` text, and
398
+ * neither imports the other.
399
+ */
400
+ function decodeEntityAmps(s) {
401
+ return s.replace(/&amp;/g, '&');
402
+ }
383
403
  /** Strip a single matching pair of surrounding quotes, if present. */
384
404
  function stripQuotes(s) {
385
405
  if (s.length >= 2) {
@@ -438,6 +458,16 @@ function stripLeadingDotSlash(s) {
438
458
  * without touching the filesystem. A climb that names a concrete sibling (e.g.
439
459
  * `cd ../../frontend`, the exact #2401 shape) still names something checkable
440
460
  * and falls through to the normal filesystem probe below.
461
+ *
462
+ * An ABSOLUTE target outside the project root takes the same `outside_root`
463
+ * exit (#4767). It is more ambiguous across worktrees, not less: it is pinned
464
+ * to exactly one checkout, and when a planner copies the orchestrator's cwd
465
+ * into `<automated>` that checkout is the main tree — so under worktree
466
+ * isolation the command exists, runs, and passes against code the worktree
467
+ * changed and the main tree did not. Existence is therefore not evidence for
468
+ * an absolute target outside the root, and the filesystem is not consulted.
469
+ * Containment goes through `tryWithinRootLexical` (#4636) — lexical because the
470
+ * probe is read-only and must not depend on the target existing.
441
471
  */
442
472
  function isPureAncestorClimb(rel) {
443
473
  if (rel.length === 0)
@@ -501,7 +531,10 @@ function resolveVerifyCommandTarget(command, options) {
501
531
  if (typeof command !== 'string')
502
532
  return result;
503
533
  result.command = command;
504
- const trimmed = command.trim();
534
+ // #4730: decode entity-escaped ampersands BEFORE segment splitting (see
535
+ // decodeEntityAmps) so the chain operator is visible to the splitter and
536
+ // the verdict matches what the executing agent's decoded form grounds to.
537
+ const trimmed = decodeEntityAmps(command.trim());
505
538
  if (trimmed === '')
506
539
  return result;
507
540
  // Nyquist "MISSING — Wave 0 must create …" sentinel; Dimension 8 owns it.
@@ -550,7 +583,18 @@ function resolveVerifyCommandTarget(command, options) {
550
583
  result.form = form;
551
584
  result.rawTarget = rawTarget;
552
585
  result.target = target;
553
- if (!isAbs) {
586
+ if (isAbs) {
587
+ // #4767: an absolute target outside projectRoot is `outside_root`, same
588
+ // as the bare climb — see isPureAncestorClimb's doc for why existence is
589
+ // not evidence here and the filesystem is deliberately not consulted.
590
+ if ((0, security_cjs_1.tryWithinRootLexical)(target, base) === null) {
591
+ result.status = 'ok';
592
+ result.severity = 'warning';
593
+ result.reason = 'outside_root';
594
+ return result;
595
+ }
596
+ }
597
+ else {
554
598
  const rel = node_path_1.default.relative(base, target);
555
599
  if (isPureAncestorClimb(rel)) {
556
600
  result.status = 'ok';