@pareto4/pp-core 1.11.0 → 1.12.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 (256) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  39. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  40. package/pp-core/bin/lib/clusters.cjs +2 -3
  41. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  42. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  43. package/pp-core/bin/lib/commands.cjs +21 -6
  44. package/pp-core/bin/lib/compose-bindings.cjs +128 -15
  45. package/pp-core/bin/lib/config-schema.cjs +16 -0
  46. package/pp-core/bin/lib/config.cjs +142 -27
  47. package/pp-core/bin/lib/core-utils.cjs +43 -7
  48. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  49. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  50. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  51. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  52. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  53. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  54. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  55. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  56. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  57. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  58. package/pp-core/bin/lib/init-contract.cjs +213 -33
  59. package/pp-core/bin/lib/init-interactive.cjs +20 -1
  60. package/pp-core/bin/lib/init.cjs +124 -134
  61. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  62. package/pp-core/bin/lib/install-run.cjs +536 -0
  63. package/pp-core/bin/lib/io.cjs +15 -0
  64. package/pp-core/bin/lib/loop-resolver.cjs +24 -0
  65. package/pp-core/bin/lib/milestone.cjs +345 -48
  66. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  67. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  68. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  69. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  70. package/pp-core/bin/lib/phase.cjs +247 -21
  71. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  72. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  73. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  74. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  75. package/pp-core/bin/lib/profile-output.cjs +11 -3
  76. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  77. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  78. package/pp-core/bin/lib/roadmap.cjs +141 -39
  79. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  80. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  81. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  82. package/pp-core/bin/lib/state-document.cjs +124 -7
  83. package/pp-core/bin/lib/state.cjs +784 -257
  84. package/pp-core/bin/lib/template.cjs +22 -6
  85. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  86. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  87. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  88. package/pp-core/bin/lib/vault.cjs +555 -23
  89. package/pp-core/bin/lib/verify.cjs +53 -31
  90. package/pp-core/bin/lib/workstream.cjs +1 -1
  91. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  92. package/pp-core/bin/portable-timeout.sh +89 -0
  93. package/pp-core/bin/pp-tools.cjs +158 -8
  94. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  95. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  96. package/pp-core/references/autonomous-best-effort.md +461 -0
  97. package/pp-core/references/autonomous-lifecycle.md +146 -0
  98. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  99. package/pp-core/references/checkpoints.md +1 -1
  100. package/pp-core/references/discovery-id-conventions.md +202 -0
  101. package/pp-core/references/discovery-rebaseline.md +254 -0
  102. package/pp-core/references/discovery-research-track.md +283 -0
  103. package/pp-core/references/discovery-traceability.md +400 -0
  104. package/pp-core/references/loop-hook-dispatch.md +7 -0
  105. package/pp-core/references/mandatory-initial-read.md +1 -1
  106. package/pp-core/references/planner-gap-closure.md +1 -0
  107. package/pp-core/references/planner-human-verify-mode.md +1 -1
  108. package/pp-core/references/planner-load-graph-context.md +1 -1
  109. package/pp-core/references/planner-plan-graph.md +66 -0
  110. package/pp-core/references/planner-tdd.md +1 -0
  111. package/pp-core/references/planning-config.md +8 -0
  112. package/pp-core/references/sketch-tooling.md +4 -0
  113. package/pp-core/references/tdd.md +1 -0
  114. package/pp-core/templates/claude-md.md +1 -1
  115. package/pp-core/templates/phase-prompt.md +6 -2
  116. package/pp-core/templates/prototype-review/review.js +303 -0
  117. package/pp-core/templates/state.md +6 -0
  118. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  119. package/pp-core/workflows/add-backlog.md +1 -1
  120. package/pp-core/workflows/add-phase.md +1 -1
  121. package/pp-core/workflows/add-tests.md +1 -1
  122. package/pp-core/workflows/add-todo.md +1 -1
  123. package/pp-core/workflows/ai-integration-phase.md +2 -2
  124. package/pp-core/workflows/audit-fix.md +12 -16
  125. package/pp-core/workflows/audit-milestone.md +1 -1
  126. package/pp-core/workflows/audit-uat.md +1 -1
  127. package/pp-core/workflows/autonomous.md +68 -306
  128. package/pp-core/workflows/check-todos.md +1 -1
  129. package/pp-core/workflows/cleanup.md +1 -1
  130. package/pp-core/workflows/code-review-fix.md +1 -1
  131. package/pp-core/workflows/code-review.md +19 -7
  132. package/pp-core/workflows/complete-milestone.md +41 -60
  133. package/pp-core/workflows/debug.md +1 -1
  134. package/pp-core/workflows/diagnose-issues.md +1 -1
  135. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  136. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  137. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  138. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  139. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  140. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  141. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  153. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  154. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  155. package/pp-core/workflows/discovery-sprint.md +79 -1714
  156. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  157. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  158. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  159. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  160. package/pp-core/workflows/discovery-to-build.md +21 -238
  161. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  162. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  163. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  164. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  165. package/pp-core/workflows/discuss-phase.md +9 -2
  166. package/pp-core/workflows/do.md +4 -4
  167. package/pp-core/workflows/docs-update.md +1 -1
  168. package/pp-core/workflows/edit-phase.md +1 -1
  169. package/pp-core/workflows/eval-review.md +1 -1
  170. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  171. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  172. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  173. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  174. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  175. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  176. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  177. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  178. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  179. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  180. package/pp-core/workflows/execute-phase.md +102 -216
  181. package/pp-core/workflows/execute-plan.md +6 -2
  182. package/pp-core/workflows/explore.md +1 -1
  183. package/pp-core/workflows/extract-learnings.md +1 -1
  184. package/pp-core/workflows/forensics.md +4 -4
  185. package/pp-core/workflows/graduation.md +1 -1
  186. package/pp-core/workflows/health.md +1 -1
  187. package/pp-core/workflows/help/modes/brief.md +1 -0
  188. package/pp-core/workflows/help/modes/default.md +2 -1
  189. package/pp-core/workflows/help/modes/full.md +12 -5
  190. package/pp-core/workflows/import.md +1 -1
  191. package/pp-core/workflows/ingest-docs.md +1 -1
  192. package/pp-core/workflows/init.md +18 -2
  193. package/pp-core/workflows/insert-phase.md +1 -1
  194. package/pp-core/workflows/list-workspaces.md +1 -1
  195. package/pp-core/workflows/manager.md +3 -3
  196. package/pp-core/workflows/map-codebase.md +2 -2
  197. package/pp-core/workflows/milestone-summary.md +4 -4
  198. package/pp-core/workflows/mvp-phase.md +1 -1
  199. package/pp-core/workflows/new-milestone.md +7 -8
  200. package/pp-core/workflows/new-project.md +6 -1
  201. package/pp-core/workflows/new-workspace.md +1 -1
  202. package/pp-core/workflows/next.md +2 -2
  203. package/pp-core/workflows/note.md +2 -2
  204. package/pp-core/workflows/pause-work.md +1 -1
  205. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  206. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  207. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  208. package/pp-core/workflows/plan-phase.md +43 -167
  209. package/pp-core/workflows/plan-review-convergence.md +2 -2
  210. package/pp-core/workflows/plant-seed.md +1 -1
  211. package/pp-core/workflows/pr-branch.md +1 -1
  212. package/pp-core/workflows/profile-user.md +1 -1
  213. package/pp-core/workflows/progress.md +3 -3
  214. package/pp-core/workflows/quick.md +3 -1
  215. package/pp-core/workflows/remove-phase.md +1 -1
  216. package/pp-core/workflows/remove-workspace.md +1 -1
  217. package/pp-core/workflows/resume-project.md +6 -12
  218. package/pp-core/workflows/review.md +1 -1
  219. package/pp-core/workflows/scan.md +1 -1
  220. package/pp-core/workflows/secure-phase.md +1 -1
  221. package/pp-core/workflows/settings-advanced.md +1 -1
  222. package/pp-core/workflows/settings-integrations.md +1 -1
  223. package/pp-core/workflows/settings.md +1 -1
  224. package/pp-core/workflows/ship.md +25 -8
  225. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  226. package/pp-core/workflows/sketch.md +2 -1
  227. package/pp-core/workflows/spec-phase.md +1 -1
  228. package/pp-core/workflows/spike-wrap-up.md +1 -1
  229. package/pp-core/workflows/spike.md +1 -1
  230. package/pp-core/workflows/stats.md +2 -2
  231. package/pp-core/workflows/thread.md +1 -1
  232. package/pp-core/workflows/transition.md +5 -9
  233. package/pp-core/workflows/ui-phase.md +23 -2
  234. package/pp-core/workflows/ui-review.md +1 -1
  235. package/pp-core/workflows/ultraplan-phase.md +2 -2
  236. package/pp-core/workflows/validate-phase.md +1 -1
  237. package/pp-core/workflows/vault-write-back.md +1 -1
  238. package/pp-core/workflows/verify-phase.md +29 -26
  239. package/pp-core/workflows/verify-work.md +14 -10
  240. package/scripts/base64-scan.sh +7 -41
  241. package/scripts/changeset/cli.cjs +25 -5
  242. package/scripts/dev-env.cjs +462 -0
  243. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  244. package/scripts/lib/size-budget.cjs +311 -0
  245. package/scripts/lint-neutrality-names.cjs +694 -0
  246. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  247. package/scripts/lint-org-coordinates.cjs +426 -0
  248. package/scripts/lint-skill-deps.cjs +48 -9
  249. package/scripts/lint-test-file-count.cjs +17 -2
  250. package/scripts/prompt-injection-scan.sh +41 -20
  251. package/scripts/sync-manifest-versions.cjs +41 -7
  252. package/scripts/sync-runtime-launcher.cjs +106 -1
  253. package/scripts/update-size-baseline.cjs +274 -30
  254. package/scripts/workflow-size.cjs +61 -9
  255. package/commands/pp-build-handoff.md +0 -46
  256. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -0,0 +1,1995 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery screen-inventory enforcement engine (discovery-v2 Phase 4,
4
+ * TRACE-02 / TRACE-03 / TRACE-04 + GUARD-01, D-49..D-59).
5
+ *
6
+ * Read-only structural validator over the per-engagement requirement-to-screen
7
+ * inventory `.planning/discovery/_screens.md` (D-49), the feedback-round log
8
+ * `.planning/discovery/_feedback.md` (D-56/D-57/D-58), the engine-computed MVP
9
+ * coverage assertion (D-51) and the build-tree instrumentation scan (D-54),
10
+ * aggregated by `checkScreenInventory` behind `pp-tools discovery screen-check`.
11
+ *
12
+ * The aggregator is PURE COMPOSITION: every rule it enforces is a NAMED,
13
+ * EXPORTED function declared below, so no fault class ever waited on the
14
+ * aggregator to become emittable or testable, and the aggregator adds no
15
+ * violation code, grammar rule or resolution of its own.
16
+ *
17
+ * Never-blocks contract (D-43): unprocessed feedback is not a fault — a round
18
+ * whose every Processing cell is `Open` exits 0 and reports those rows under
19
+ * `open_feedback`, so a still-running feedback loop can never block Session 13
20
+ * sign-off. Only STRUCTURAL faults set `ok` false, and the outstanding-work
21
+ * report is emitted on the failure path too.
22
+ *
23
+ * Why the contract lives in code (D-52): `/pp-discovery-to-build` and
24
+ * `/pp-ui-phase` each add a CALL plus a checklist row, not a second copy of the
25
+ * rule. Prose in two workflows drifts; a parser does not.
26
+ *
27
+ * Why the reason codes are a runtime VALUE (D-60): the reference
28
+ * `pp-core/references/discovery-traceability.md` and the sketch emitter are both
29
+ * pinned against `VIOLATION_REASONS` / `INSTRUMENTATION_LITERALS` by test rather
30
+ * than by manual diff. That is what makes the single-home claim mechanical
31
+ * instead of aspirational — the same repair Phase 3 made for the research track.
32
+ *
33
+ * Fail-loud (D-53), one bullet per fault class this plan emits:
34
+ * - missing_section — `## Screens` or `## No screen (still MVP)` absent
35
+ * - bad_id — a screens row whose first cell is not `SCR-\d{2}`, or a no-screen
36
+ * row whose first cell is not a requirement ID. Reported by artifact plus
37
+ * row number ONLY; the cell has passed no validation, so nothing about it is
38
+ * safe to quote (T-04-04)
39
+ * - duplicate_id — a second screens row claiming an ID already seen, or a
40
+ * second no-screen bucket row claiming a requirement the bucket already
41
+ * holds. The first row keeps the slot and the later one is REPORTED rather
42
+ * than dropped: its cell may contradict the one already recorded. Also a
43
+ * REPEATED `## Screens` / `## No screen (still MVP)` heading, which costs
44
+ * the gate a whole table; the heading named is this module's own literal
45
+ * - sequence_gap — the observed screen numbers are not exactly `01..max`; the
46
+ * reported id is the MISSING one, so a deleted row cannot lapse (D-41)
47
+ * - bad_surface_status — a surface cell that is neither the required keyword
48
+ * nor the deferred keyword with a parenthesised reason. A BARE deferred
49
+ * keyword lands here, per D-55: the reason is structured, not prose to be
50
+ * regexed out later
51
+ * - deferred_without_reason — the deferred shape with an empty or
52
+ * whitespace-only reason
53
+ * - no_screen_without_reason — a no-screen bucket row with an empty reason
54
+ * - unknown_requirement_id — a requirement-ID token on a screen that either
55
+ * does not match the family regex (the parser's arm) or matches it and
56
+ * names no row of `_requirements.md` (`resolveRequirementRefs`, guarded on
57
+ * a parseable requirements table). BOTH arms exist: for a long while only
58
+ * the first did, and a code named for resolution that only ever meant
59
+ * "malformed shape" is a trap for the next reader
60
+ * - requirements_unparseable — `_requirements.md` fails its own gate; reported
61
+ * ONCE, naming the owning gate, never by re-enumerating rollup's errors
62
+ * - artifact_unreadable — an artifact the directory listing named but that
63
+ * could not be read. Deliberately distinguished from the graceful absent
64
+ * arm: "could not read it, so it's fine" is a fail-open on a gate whose
65
+ * whole purpose is structural verification
66
+ * - bad_anchor — a feedback Anchor cell that is not `SCR-<nn> · <element>`
67
+ * (D-58). The separator is the one D-27/D-37/D-45 established; there is no
68
+ * second spelling
69
+ * - bad_verdict — a Verdict cell outside the closed four-word D-58 vocabulary
70
+ * - bad_processing — a Processing cell that is neither `Open`, nor
71
+ * `No change`, nor a closed citation of an existing decision / `DG-` / `RS-`
72
+ * ID (D-57). A BARE or prose-only cell lands here rather than in a code of
73
+ * its own: D-57 calls it one structural error, not two
74
+ * - unknown_screen_id — a feedback anchor naming a screen the inventory does
75
+ * not contain. An ABSENT `_screens.md` yields an empty map, so every anchor
76
+ * fails rather than passing vacuously — a feedback log without an inventory
77
+ * is exactly the silent-omission failure GUARD-01 exists to catch
78
+ * - unknown_processing_id — a `DG-`/`RS-` citation that does not resolve
79
+ * against a PRESENT `_risks.md` / `_research.md`
80
+ * - uncovered_mvp_requirement — an `MVP`-disposition requirement that appears
81
+ * on no screen and in no no-screen bucket row (D-51)
82
+ * - screens_markers — the computed-coverage marker pair is missing, doubled or
83
+ * out of order. A structural property of `_screens.md` alone, so it is
84
+ * checked even when `_requirements.md` is not there to compute against
85
+ * - screens_mismatch — the text between the markers differs from the machine
86
+ * render. Hand-writing a coverage claim is the 105-vs-106 drift D-05..D-08
87
+ * exist to kill, and this phase does not reintroduce it
88
+ * - instrumentation_leaked — a build-tree file carrying one of the pinned
89
+ * `INSTRUMENTATION_LITERALS`. GUARD-01 asks for the absence of discovery
90
+ * instrumentation to be ASSERTED, not assumed, so the fault names the
91
+ * repo-relative path, the 1-based line number and the matched literal — and
92
+ * NEVER the surrounding source line. D-54 asks for what was found and where,
93
+ * not for the code (T-04-15). Suppressible per exact path through
94
+ * `_scan-allow.txt`, so a known false positive is a recorded decision
95
+ * rather than a reason to disable the whole gate
96
+ * - scan_budget_exceeded — the walk hit its file-count or byte cap. Reported
97
+ * as a loud fault rather than returned as a partial pass: a bounded scan
98
+ * that truncates quietly is a fail-open, which is the defect class this
99
+ * engine exists to close
100
+ *
101
+ * Deliberately NOT validated — the non-rules, stated here because a parser
102
+ * contract is only honest when it says what it does NOT check:
103
+ * - the Role and Purpose cells of `_screens.md`. Role is a grouping column
104
+ * (D-50) and Purpose is free text; neither has a closed grammar, so neither
105
+ * is checked and neither is ever echoed
106
+ * - the round heading's DATE and LABEL. `ROUND_HEADING_RE` validates the round
107
+ * NUMBER and nothing else; the ISO date and the free-text label are matched
108
+ * for shape only and their content is never inspected
109
+ * - the feedback log's `Feedback` cell. It is verbatim client comment on a
110
+ * prototype — free text by definition, so it is never validated and never
111
+ * echoed
112
+ * - a DUPLICATE Anchor inside one round. This is a deliberate NON-rule, not an
113
+ * oversight: the same element legitimately attracts two comments in a round,
114
+ * and D-57 makes row identity Anchor + round precisely so no ID family has
115
+ * to be minted for feedback rows (D-32). A negative test pins it, so a later
116
+ * change that tightens it fails loudly rather than quietly
117
+ * - a decision-ID citation in a Processing cell, which resolves by SHAPE ONLY
118
+ * and is never looked up — the same posture as the research track's
119
+ * `Closed (D-<ss>-<n>)` evidence rule
120
+ * - the NO-SCREEN BUCKET's requirement cell, which resolves by SHAPE only and
121
+ * is never looked up in `_requirements.md`, unlike a screen's join cell.
122
+ * Stated rather than left implicit, and it is a decision with a reason: a
123
+ * bucket key can only ever make a REAL requirement look covered, and
124
+ * `computeCoverage` walks the requirements table asking `bucketSet.has(id)`,
125
+ * so a key naming no requirement matches no row, reaches no count, and
126
+ * cannot produce a false pass. There is no fail-open here to close
127
+ *
128
+ * Graceful arms (D-42 posture, applied to every artifact this engine reads): no
129
+ * `.planning/discovery/` directory at all, and `_screens.md` simply absent, both
130
+ * check clean. A `DG-`/`RS-` citation whose register artifact is ABSENT passes
131
+ * on shape alone rather than failing for the register's absence. An ABSENT or
132
+ * unparseable `_requirements.md` skips coverage computation entirely — no
133
+ * `uncovered_mvp_requirement`, no `screens_mismatch` — AND skips requirement-ID
134
+ * resolution, so a screen's citations still pass on shape alone: resolving
135
+ * against a table that could not be read would fail every citation on every
136
+ * screen and turn a missing artifact into an inventory full of faults. The
137
+ * `screens_markers` structural check still runs. An artifact that EXISTS but
138
+ * cannot be read is a fault, not an absence, in every one of those arms.
139
+ *
140
+ * The build-tree scan takes that same graceful arm and it is GATED, not merely
141
+ * quiet: `scanBuildTree` runs only when at least one of `DISCOVERY_ARTIFACTS`
142
+ * was actually found under `.planning/discovery/`. A project with no discovery
143
+ * package never walks its own tree at all, so a codebase that uses the
144
+ * `data-req` attribute for its own unrelated purposes is untouched by this gate.
145
+ * That gate is the bound on the one cost D-54 accepted explicitly (T-04-18), and
146
+ * it is a property of the aggregator, so the scan itself stays unconditional and
147
+ * drivable on its own.
148
+ *
149
+ * The gate reads ARTIFACT NAMES, never `present.size`. `present` is every entry
150
+ * the directory listing returned — subdirectories, editor scratch files and the
151
+ * `.DS_Store` macOS writes on mere Finder access — so a size test made the
152
+ * bound "the folder has something in it" rather than "a discovery package
153
+ * exists", and opening a folder was enough to promote an empty one into a
154
+ * whole-repo walk.
155
+ *
156
+ * The one cost D-54 accepted also has a way to DISCHARGE it, which is not the
157
+ * same thing as a bound on it: an optional `_scan-allow.txt` in the discovery
158
+ * package records repo-relative paths whose `instrumentation_leaked` hit is a
159
+ * known false positive. With a hard block at the handoff and no allowlist, a
160
+ * single unavoidable hit made the gate unpassable with no remedy but deleting
161
+ * the file — and an operator with no way to record "this one is fine" does not
162
+ * leave the gate red, they turn the gate off.
163
+ *
164
+ * Content-free contract (T-04-04): output names artifact paths, `<file>:<row>`
165
+ * locators, IDs that have already passed their family regex, field names and
166
+ * snake_case reason codes — and nothing else. Every remaining author-supplied
167
+ * detail is whitespace-collapsed and capped at `ECHO_MAX_CHARS` by
168
+ * `sanitizeEcho`, the single chokepoint inside `formatViolation`. The bound is a
169
+ * confidentiality control, not a formatting preference: screen names, purposes
170
+ * and deferral reasons are derived from client documents (D-24).
171
+ *
172
+ * COLUMN CONTRACT — decided here, in the first plan that parses each artifact,
173
+ * so it is not emergent. `_screens.md` carries two tables plus a computed block:
174
+ * `## Screens` → | ID | Role | Purpose | Surface | Requirements |
175
+ * `## No screen (still MVP)` → | Requirement | Reason |
176
+ * `## Coverage` → the marker pair, machine-owned
177
+ * `_feedback.md` carries one table per round:
178
+ * `## Round <n> — <date> — <label>` → | Anchor | Verdict | Feedback | Processing |
179
+ * The bootstrap template in `references/discovery-traceability.md` and Session
180
+ * 4's instruction block must both match these positions; the positions are
181
+ * exported as named indices below rather than being spelled as bare integers at
182
+ * their use sites.
183
+ */
184
+ var __importDefault = (this && this.__importDefault) || function (mod) {
185
+ return (mod && mod.__esModule) ? mod : { "default": mod };
186
+ };
187
+ const node_fs_1 = __importDefault(require("node:fs"));
188
+ const node_path_1 = __importDefault(require("node:path"));
189
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
190
+ const io = require("./io.cjs");
191
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
192
+ const rollup = require("./discovery-rollup.cjs");
193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
194
+ const table = require("./discovery-table.cjs");
195
+ // The `DG-`/`RS-` register grammar is owned by the research-track gate (D-42).
196
+ // This engine RESOLVES citations against those registers and re-implements none
197
+ // of their validation — a parse fault there is that gate's to report, not this
198
+ // one's to launder into a screens fault.
199
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
200
+ const researchTrack = require("./discovery-research-track.cjs");
201
+ const { splitTableCells, SEPARATOR_ROW_RE, boundedSequenceMax } = table;
202
+ const { output, error, ERROR_REASON } = io;
203
+ // Exported under its own name as well as through `ERROR_REASON`: the gate's
204
+ // identity is cited by the workflow steps that run it and by the reference, so
205
+ // it lives in one place rather than being re-spelled as a string literal.
206
+ const SCREEN_CHECK_FAILED = io.ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED;
207
+ // ─── Types ────────────────────────────────────────────────────────────────────
208
+ /**
209
+ * The closed set of reason codes this engine can emit, in declaration order.
210
+ *
211
+ * Declared IN FULL here even though this plan only emits ten of them. Later
212
+ * plans in this phase add the emission paths and their tests; the coverage audit
213
+ * asserts every code is emitted by at least one fixture. Declaration ORDER is
214
+ * part of the contract — the tuple is pinned with a `deepEqual`, so a code moved
215
+ * is a code the reference table has to move too.
216
+ *
217
+ * Exported as a runtime VALUE, not merely as a type, for the D-60 reason given
218
+ * in the module comment: `ViolationReason` is derived from the tuple, so a code
219
+ * added to one is a code added to both.
220
+ */
221
+ const VIOLATION_REASONS = Object.freeze([
222
+ 'bad_id',
223
+ 'duplicate_id',
224
+ 'sequence_gap',
225
+ 'missing_section',
226
+ 'bad_surface_status',
227
+ 'deferred_without_reason',
228
+ 'no_screen_without_reason',
229
+ 'unknown_requirement_id',
230
+ 'uncovered_mvp_requirement',
231
+ 'screens_markers',
232
+ 'screens_mismatch',
233
+ 'bad_anchor',
234
+ 'unknown_screen_id',
235
+ 'bad_verdict',
236
+ 'bad_processing',
237
+ 'unknown_processing_id',
238
+ 'instrumentation_leaked',
239
+ 'scan_budget_exceeded',
240
+ 'requirements_unparseable',
241
+ 'artifact_unreadable',
242
+ ]);
243
+ /**
244
+ * The two literals D-54's build-tree scan looks for, and the two the `/pp-sketch`
245
+ * emitter must produce.
246
+ *
247
+ * Declaring them HERE — in the engine that both consumes and enforces them — is
248
+ * what makes D-60's single-home claim mechanical: the sketch surface and the
249
+ * reference are compared against this tuple by test, so a renamed attribute or
250
+ * toggle id cannot drift into a scan that quietly finds nothing.
251
+ */
252
+ const INSTRUMENTATION_LITERALS = Object.freeze([
253
+ 'data-req=',
254
+ 'sketch-req-overlay',
255
+ ]);
256
+ // ─── Constants ────────────────────────────────────────────────────────────────
257
+ const PLANNING_DIR = '.planning';
258
+ const DISCOVERY_SUBDIR = 'discovery';
259
+ const SCREENS_FILE = '_screens.md';
260
+ const FEEDBACK_FILE = '_feedback.md';
261
+ const REQUIREMENTS_FILE = '_requirements.md';
262
+ // Read-only, for citation resolution only. Their grammar belongs to
263
+ // `discovery research-check` (D-42) and is not re-implemented here.
264
+ const RISKS_FILE = '_risks.md';
265
+ const RESEARCH_FILE = '_research.md';
266
+ /**
267
+ * The optional, auditable suppression list for `instrumentation_leaked`
268
+ * (T-04-18). Repo-relative POSIX paths, one per line; `#` comments and blank
269
+ * lines are ignored, and there are no globs — an exact path is a decision a
270
+ * reviewer can read, a pattern is a hole nobody can size.
271
+ *
272
+ * NOT a discovery artifact this gate validates, and deliberately not a member of
273
+ * `DISCOVERY_ARTIFACTS` below: an allowlist alone must never be what promotes a
274
+ * project into a whole-repo walk. It lives under `.planning/discovery/` because
275
+ * that directory is committed, which is what makes the suppression a recorded
276
+ * decision rather than a local workaround — and because `.planning` is in
277
+ * `SCAN_SKIP_DIRS`, so the list can never allowlist itself into invisibility.
278
+ */
279
+ const SCAN_ALLOW_FILE = '_scan-allow.txt';
280
+ /**
281
+ * The artifacts whose presence means "this project has a discovery package".
282
+ *
283
+ * The build-tree scan gates on THIS, not on `present.size > 0`. `present` is
284
+ * `new Set(fs.readdirSync(dir))` — every name in the directory, including
285
+ * subdirectories, editor scratch files and `.DS_Store`, which macOS creates on
286
+ * mere Finder access. The documented bound (see the module comment) is "a
287
+ * project with no discovery package never walks its own tree"; the bound as
288
+ * implemented was "a project whose `.planning/discovery/` directory is empty or
289
+ * absent", and a single metadata file silently promoted the one into the other.
290
+ * A gate whose reach depends on whether someone opened a folder is not a bound.
291
+ */
292
+ const DISCOVERY_ARTIFACTS = Object.freeze([
293
+ SCREENS_FILE, FEEDBACK_FILE, REQUIREMENTS_FILE, RISKS_FILE, RESEARCH_FILE,
294
+ ]);
295
+ const SCREENS_HEADING = '## Screens';
296
+ const NO_SCREEN_HEADING = '## No screen (still MVP)';
297
+ const COVERAGE_HEADING = '## Coverage';
298
+ /** D-51/D-06: the computed coverage block is replaced verbatim from command output. */
299
+ const COVERAGE_OPEN = '<!-- screens:auto -->';
300
+ const COVERAGE_CLOSE = '<!-- /screens:auto -->';
301
+ /**
302
+ * The plan for the coverage mechanism names this pair `SCREENS_OPEN` /
303
+ * `SCREENS_CLOSE`, by analogy with rollup's `ROLLUP_OPEN` / `ROLLUP_CLOSE`. The
304
+ * constants already existed under the `COVERAGE_*` spelling, so these are
305
+ * ALIASES of the same two strings rather than a second pair — one marker
306
+ * spelling, two names, and the equality is pinned by test.
307
+ */
308
+ const SCREENS_OPEN = COVERAGE_OPEN;
309
+ const SCREENS_CLOSE = COVERAGE_CLOSE;
310
+ // Column positions of the three tables (see the COLUMN CONTRACT note in the
311
+ // module comment). Named rather than spelled inline so the reference and the
312
+ // bootstrap template have something concrete to agree with.
313
+ const SCREEN_COL = Object.freeze({ ID: 0, ROLE: 1, PURPOSE: 2, SURFACE: 3, REQUIREMENTS: 4 });
314
+ const NO_SCREEN_COL = Object.freeze({ ID: 0, REASON: 1 });
315
+ const FEEDBACK_COL = Object.freeze({ ANCHOR: 0, VERDICT: 1, FEEDBACK: 2, PROCESSING: 3 });
316
+ // Anchored, quantifier-free, applied to a SINGLE already-split cell — never
317
+ // unanchored across whole-file untrusted content (T-03-02 discipline carried
318
+ // forward as T-04-05). A backtracking pattern here would be a denial-of-service
319
+ // surface, and the cells are client-derived.
320
+ /** D-50: screen IDs are a flat, global, zero-padded 2-digit sequence. */
321
+ const SCR_ID_RE = /^SCR-\d{2}$/;
322
+ /** D-51: the join cell names requirement IDs, whose family shape is fixed by D-01. */
323
+ const REQ_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
324
+ /**
325
+ * D-55 surface-status grammar. The cell is EXACTLY the required keyword, or the
326
+ * deferred keyword with a parenthesised reason. Same trick as `Closed
327
+ * (<evidence>)` (D-35) and `Dropped (D-<ss>-<n>)`: the reason is structured, not
328
+ * prose to be regexed out later.
329
+ */
330
+ const SURFACE_STATUS = Object.freeze({ REQUIRED: 'REQUIRED', DEFERRED: 'DEFERRED' });
331
+ /**
332
+ * The capture is `(.*)` rather than `(.+)` on purpose: an EMPTY parenthesised
333
+ * reason must reach `deferred_without_reason`, which says what is wrong, rather
334
+ * than falling through to `bad_surface_status`, which does not. A bare
335
+ * `DEFERRED` with no parentheses fails this pattern and IS a
336
+ * `bad_surface_status`, per D-55.
337
+ */
338
+ const DEFERRED_REASON_RE = /^DEFERRED \((.*)\)$/;
339
+ /** The em dash alone is a permitted "no requirements on this row" marker. */
340
+ const EM_DASH = '—';
341
+ /**
342
+ * D-58 verdict vocabulary: four lowercase English WORDS, not emoji — the same
343
+ * posture D-03 and D-34 took for the sibling registers. Declaration order is the
344
+ * contract and is pinned by test.
345
+ */
346
+ const VERDICTS = Object.freeze(['change', 'keep', 'drop', 'question']);
347
+ /**
348
+ * D-57 Processing grammar. The cell is EXACTLY one of these two literals, or a
349
+ * closed citation of an existing ID. Same trick as `Closed (<evidence>)` (D-35):
350
+ * "nothing came of this" is an explicit closed value, because many rows
351
+ * legitimately produce no decision — a `keep` verdict changes nothing, and a
352
+ * `question` may seed a `DG-`/`RS-` item instead. That is why an EMPTY cell is
353
+ * a fault rather than a shorthand for `No change`.
354
+ */
355
+ const PROCESSING = Object.freeze({ OPEN: 'Open', NO_CHANGE: 'No change' });
356
+ // D-57: the three citation shapes a Processing cell may take. Anchored,
357
+ // quantifier-free, applied to a SINGLE already-split cell (T-04-13).
358
+ const DECISION_CITE_RE = /^D-\d{2}-\d+$/;
359
+ // D-57: a data-gathering item in `_risks.md`.
360
+ const DG_CITE_RE = /^DG-\d{2}$/;
361
+ // D-57: a research-backlog item in `_research.md`.
362
+ const RS_CITE_RE = /^RS-\d{2}$/;
363
+ /**
364
+ * D-56 round heading. Second-level heading, the word Round, a decimal round
365
+ * number, then an em-dash-separated ISO date and an em-dash-separated free-text
366
+ * label.
367
+ *
368
+ * ONLY the round number is validated. The date and the label are matched for
369
+ * SHAPE and their content is never inspected — stated here so the reference's
370
+ * parser-contract table can say so honestly rather than implying a date check
371
+ * this engine does not perform.
372
+ *
373
+ * The round number is BOUNDED to two digits, like every other discovery ID
374
+ * family (`SCR-\d{2}`, `DG-\d{2}`, `RS-\d{2}`). An unbounded `\d+` let a plain
375
+ * typo — the ISO date written into the round-number slot, `## Round 20250115` —
376
+ * drive `validateRoundSequence` into allocating twenty million gap violations
377
+ * and abort the process on a V8 heap fatal, which is not a JavaScript exception
378
+ * and therefore not something the CLI's try/catch can turn into `{ ok, reason }`.
379
+ */
380
+ const ROUND_HEADING_RE = /^## Round (\d{1,2}) — [^—]+ — .+$/;
381
+ /** A heading that CLAIMS the round family. A claimed-but-malformed one is a fault. */
382
+ const ROUND_CLAIM_RE = /^## Round\b/;
383
+ /**
384
+ * D-58 anchor grammar: screen ID, the ` · ` separator D-27/D-37/D-45
385
+ * established, then a non-empty element anchor. One spelling of the separator
386
+ * across the whole discovery surface — a second would be a join that silently
387
+ * matches nothing.
388
+ */
389
+ const ANCHOR_SEP = ' · ';
390
+ const ANCHOR_RE = /^(SCR-\d{2}) · (.+)$/;
391
+ /**
392
+ * The confidentiality bound on every echoed detail. Not a formatting preference:
393
+ * inventory prose is derived from client documents (D-24), so an unbounded echo
394
+ * is a disclosure. Same value as the research-track sibling, deliberately.
395
+ */
396
+ const ECHO_MAX_CHARS = 24;
397
+ /** A pathological artifact must not produce an unbounded report. */
398
+ const MAX_REPORTED_VIOLATIONS = 50;
399
+ // ─── Parsing ──────────────────────────────────────────────────────────────────
400
+ /**
401
+ * Collect the pipe-table row lines belonging to the section opening at `start`.
402
+ *
403
+ * Modelled on the rollup parser's `sectionRows` but LOCAL to this module and
404
+ * deliberately not shared with it: rollup's stop condition names the rollup
405
+ * marker and is specific to the `_requirements.md` template, and the research
406
+ * track's whole-file `registerRows` treats `## ` as a section START (D-32).
407
+ * Neither is interchangeable with this one, which stops at the next second-level
408
+ * heading or at the coverage open marker. Only the SPLITTER is shared (D-60).
409
+ *
410
+ * The header row is identified POSITIONALLY — it is the row immediately before
411
+ * the table's FIRST separator — not by header text and not by "any row followed
412
+ * by a separator-shaped line". Those last two are not the same rule, and the
413
+ * difference was a silent-omission hole: `SEPARATOR_ROW_RE` matches the
414
+ * extremely common `| --- | --- | --- |` idiom ANYWHERE in the document, so a
415
+ * stray separator (an author appending a second table under one heading, a
416
+ * pasted separator, a `| --- |` placeholder row) deleted the data row above it
417
+ * from the entire gate — its faults were never reported, its ID never
418
+ * registered, and its sequence number never counted, so not even
419
+ * `sequence_gap` noticed. A gate whose stated purpose is that nothing is
420
+ * silently omitted must not have a one-line trick that omits a row.
421
+ *
422
+ * A SECOND separator therefore STOPS the collection rather than swallowing the
423
+ * row above it. Stopping is the conservative reading: the rows that were
424
+ * collected are still validated, and a second table under one heading is
425
+ * outside the template this parser is written against.
426
+ *
427
+ * Split from `screenSectionRows` (which resolves a heading by TEXT) because the
428
+ * feedback log's sections are found by PATTERN — every `## Round …` heading, in
429
+ * order — and two headings can carry the same text. One collector, two ways of
430
+ * locating the section it starts from; the alternative was a second copy of the
431
+ * stop condition, and a stop condition that exists twice drifts.
432
+ */
433
+ function sectionRowsFrom(lines, start) {
434
+ const rows = [];
435
+ let seenSeparator = false;
436
+ for (let i = start + 1; i < lines.length; i += 1) {
437
+ const line = lines[i];
438
+ const trimmed = line.trim();
439
+ if (trimmed.startsWith('## ') || trimmed === COVERAGE_OPEN)
440
+ break;
441
+ if (!trimmed.includes('|'))
442
+ continue;
443
+ if (SEPARATOR_ROW_RE.test(line)) {
444
+ // The FIRST separator ends the header, so the row before it — and only
445
+ // that row — is the header. A LATER separator starts a new table in the
446
+ // same section: stop, rather than silently swallowing the data row above
447
+ // it as the previous lookahead did.
448
+ if (seenSeparator)
449
+ break;
450
+ seenSeparator = true;
451
+ if (rows.length > 0)
452
+ rows.pop();
453
+ continue;
454
+ }
455
+ rows.push({ line, num: i + 1 });
456
+ }
457
+ return rows;
458
+ }
459
+ /**
460
+ * Locate a section by its exact heading TEXT and collect its rows. Returns null
461
+ * when the heading itself is absent, which the caller turns into
462
+ * `missing_section`.
463
+ *
464
+ * EVERY matching heading is counted, not just the first, and that is the whole
465
+ * reason this returns a record rather than a bare row list. `findIndex` took the
466
+ * first match and `sectionRowsFrom` stops at the next `## ` — which, when the
467
+ * document carries the heading twice, is the SECOND copy of the same heading. So
468
+ * the second table's rows were never collected and never validated: no `bad_id`,
469
+ * no `duplicate_id`, no `bad_surface_status`, no sequence number, and no
470
+ * violation of any kind saying a table had been skipped. One duplicated heading
471
+ * deleted a whole table from a gate whose entire purpose is that nothing is
472
+ * silently omitted — the same silent-omission class as the stray separator that
473
+ * `sectionRowsFrom` above was repaired for, and not one of the module's declared
474
+ * non-rules.
475
+ *
476
+ * The extra headings are REPORTED and their tables are still not read. Stopping
477
+ * at the first section is the conservative reading, exactly as it is for a
478
+ * second separator: a second table under one heading is outside the template
479
+ * this parser is written against, and guessing at a merge would invent sequence
480
+ * and duplicate semantics nobody specified. What changed is that the omission
481
+ * now has a name.
482
+ */
483
+ function screenSectionRows(lines, heading) {
484
+ const starts = [];
485
+ for (let i = 0; i < lines.length; i += 1) {
486
+ if (lines[i].trim() === heading)
487
+ starts.push(i);
488
+ }
489
+ if (starts.length === 0)
490
+ return null;
491
+ return {
492
+ rows: sectionRowsFrom(lines, starts[0]),
493
+ duplicateHeadings: starts.slice(1).map(i => i + 1),
494
+ };
495
+ }
496
+ // `boundedSequenceMax` and its `MAX_SEQUENCE_SPAN` ceiling used to live here,
497
+ // private to this module. They now live in the `discovery-table.cjs` leaf and
498
+ // are shared with `discovery-research-track.cts`, which had been left with a
499
+ // bare `Math.max(...seqs)` when this engine was hardened (04-SECURITY AR-04-02).
500
+ // Being private was also why neutering the ceiling or restoring the spread left
501
+ // the suite green (M4/M5): no test could reach the fold. It is exported and
502
+ // pinned there now.
503
+ /**
504
+ * D-41 sequence integrity. `id` is the MISSING id — that is what makes
505
+ * non-lapse mechanical: a deleted row leaves a hole the parser names, so history
506
+ * is never the enforcement mechanism. An inventory with zero rows has no gaps.
507
+ */
508
+ function validateScreenSequence(seqs) {
509
+ const violations = [];
510
+ if (seqs.length === 0)
511
+ return violations;
512
+ const seen = new Set(seqs);
513
+ const max = boundedSequenceMax(seqs);
514
+ if (max === null) {
515
+ // Unreachable through `SCR_ID_RE` (two digits, so max is 99) and stated as
516
+ // such: this is the independent floor, not the primary guard. One bounded
517
+ // fault instead of a gap list nobody could read.
518
+ return [{ artifact: SCREENS_FILE, reason: 'bad_id' }];
519
+ }
520
+ for (let n = 1; n <= max; n += 1) {
521
+ if (!seen.has(n)) {
522
+ violations.push({
523
+ artifact: SCREENS_FILE,
524
+ id: `SCR-${String(n).padStart(2, '0')}`,
525
+ reason: 'sequence_gap',
526
+ });
527
+ }
528
+ }
529
+ return violations;
530
+ }
531
+ /**
532
+ * D-55 surface-status validation for one already-split cell.
533
+ *
534
+ * The three arms are exhaustive and ordered so the most specific fault wins:
535
+ * exactly the required keyword is clean; the deferred shape with an empty or
536
+ * whitespace-only reason is `deferred_without_reason`; anything else — including
537
+ * a BARE deferred keyword — is `bad_surface_status`, and only that arm echoes,
538
+ * through the sanitiser.
539
+ */
540
+ function validateSurfaceStatus(cell, num) {
541
+ const value = cell.trim();
542
+ if (value === SURFACE_STATUS.REQUIRED)
543
+ return [];
544
+ const match = DEFERRED_REASON_RE.exec(value);
545
+ if (match === null) {
546
+ return [{
547
+ artifact: SCREENS_FILE,
548
+ line: num,
549
+ reason: 'bad_surface_status',
550
+ field: 'surface_status',
551
+ cell: value,
552
+ }];
553
+ }
554
+ if (match[1].trim() === '') {
555
+ return [{ artifact: SCREENS_FILE, line: num, reason: 'deferred_without_reason' }];
556
+ }
557
+ return [];
558
+ }
559
+ /**
560
+ * D-51 join cell: the requirement IDs a screen serves. An em dash alone is
561
+ * permitted and contributes nothing. Every other cell is split on commas and
562
+ * each token must match the family regex.
563
+ *
564
+ * A token that does not is `unknown_requirement_id` with the TOKEN echoed
565
+ * through the sanitiser — the token, not the surrounding cell, because the
566
+ * bounded echo is what lets an author find the typo without the report carrying
567
+ * the row's prose.
568
+ *
569
+ * RESOLUTION against `_requirements.md` (does this ID exist?) is NOT done here —
570
+ * that needs the other artifact, so the parser stays pure and the rule lives in
571
+ * `resolveRequirementRefs`, which the aggregator calls. It emits the SAME code:
572
+ * a token of the right shape naming nothing and a token of the wrong shape are
573
+ * both "this is not a requirement I can find", and splitting them would only
574
+ * split the fixture set that proves the rule.
575
+ */
576
+ function parseRequirementCell(cell, num) {
577
+ const ids = [];
578
+ const violations = [];
579
+ const value = cell.trim();
580
+ if (value === '' || value === EM_DASH)
581
+ return { ids, violations };
582
+ for (const raw of value.split(',')) {
583
+ const token = raw.trim();
584
+ if (token === '')
585
+ continue;
586
+ if (!REQ_ID_RE.test(token)) {
587
+ violations.push({
588
+ artifact: SCREENS_FILE,
589
+ line: num,
590
+ reason: 'unknown_requirement_id',
591
+ cell: token,
592
+ });
593
+ continue;
594
+ }
595
+ ids.push(token);
596
+ }
597
+ return { ids, violations };
598
+ }
599
+ /**
600
+ * Parse `_screens.md` into the screen inventory and the no-screen bucket.
601
+ *
602
+ * Fail-loud (D-53), never warn-and-skip: every fault class runs before the
603
+ * return, so an author sees the whole picture in one pass rather than peeling
604
+ * one fault at a time.
605
+ *
606
+ * Both collections are `Map`s, never plain object literals: the keys are parsed
607
+ * from untrusted content, and a `__proto__`-shaped key must not be able to write
608
+ * into derived state (T-03-03 discipline carried forward as T-04-06). The screen
609
+ * key has passed `SCR_ID_RE` and the bucket key has passed `REQ_ID_RE`, so
610
+ * neither can actually be `__proto__` today — the `Map` is the structural
611
+ * guarantee that stays true if a regex is ever loosened.
612
+ */
613
+ function parseScreenInventory(content) {
614
+ const screens = new Map();
615
+ const noScreen = new Map();
616
+ const violations = [];
617
+ if (typeof content !== 'string' || content === '') {
618
+ return { screens, noScreen, violations };
619
+ }
620
+ const lines = content.split(/\r?\n/);
621
+ const screenSection = screenSectionRows(lines, SCREENS_HEADING);
622
+ const bucketSection = screenSectionRows(lines, NO_SCREEN_HEADING);
623
+ // An absent heading is one violation naming the heading's own artifact, and
624
+ // that section contributes no rows. The sibling section is still parsed —
625
+ // reporting only the first missing heading would be warn-and-skip by another
626
+ // name.
627
+ if (screenSection === null) {
628
+ violations.push({ artifact: SCREENS_FILE, reason: 'missing_section', cell: SCREENS_HEADING });
629
+ }
630
+ if (bucketSection === null) {
631
+ violations.push({ artifact: SCREENS_FILE, reason: 'missing_section', cell: NO_SCREEN_HEADING });
632
+ }
633
+ // A REPEATED heading is the mirror of an absent one and is reported for the
634
+ // same reason: it costs the gate a whole table. One violation per extra
635
+ // heading, located by its own source row so the author can find the copy.
636
+ //
637
+ // The heading is carried in `id` rather than in `cell`, and that is safe
638
+ // BECAUSE it is not author data at all: both values are closed literals
639
+ // declared in this module — `SCREENS_HEADING` and `NO_SCREEN_HEADING`, never
640
+ // the text as it was written in the document — so naming one discloses
641
+ // nothing and cannot smuggle prose past the echo bound. It still routes
642
+ // through `sanitizeEcho` at the chokepoint, because the invariant that every
643
+ // rendered detail passes it stays literally true or it is not an invariant.
644
+ for (const [section, heading] of [
645
+ [screenSection, SCREENS_HEADING],
646
+ [bucketSection, NO_SCREEN_HEADING],
647
+ ]) {
648
+ for (const num of section?.duplicateHeadings ?? []) {
649
+ violations.push({ artifact: SCREENS_FILE, id: heading, reason: 'duplicate_id', line: num });
650
+ }
651
+ }
652
+ const seqs = [];
653
+ for (const { line, num } of screenSection?.rows ?? []) {
654
+ const cells = splitTableCells(line);
655
+ const id = cells[SCREEN_COL.ID] ?? '';
656
+ const idOk = SCR_ID_RE.test(id);
657
+ const duplicate = idOk && screens.has(id);
658
+ if (!idOk) {
659
+ // Reported by POSITION, never by content: the first cell has passed no
660
+ // validation at all, so there is nothing about it that is safe to quote.
661
+ violations.push({ artifact: SCREENS_FILE, line: num, reason: 'bad_id' });
662
+ }
663
+ else if (duplicate) {
664
+ // The ID is validated, so naming it is safe — and naming it is the only
665
+ // way an author can find the second row.
666
+ violations.push({ artifact: SCREENS_FILE, id, reason: 'duplicate_id', line: num });
667
+ }
668
+ // The cell validators run REGARDLESS of the ID's fate, and that is the
669
+ // whole point. Both ID arms used to `continue` past this block, so a row
670
+ // with a typo'd ID and a malformed Surface cell reported one fault; the
671
+ // author fixed the ID, re-ran, and only then met the second. That is
672
+ // exactly the peeling-one-fault-at-a-time this parser says it does not do
673
+ // (D-53). Surface status and the requirements join are properties of their
674
+ // OWN cells — neither is derived from the ID, and neither becomes
675
+ // meaningless because the ID is wrong — so there is nothing to defer.
676
+ violations.push(...validateSurfaceStatus(cells[SCREEN_COL.SURFACE] ?? '', num));
677
+ const requirements = parseRequirementCell(cells[SCREEN_COL.REQUIREMENTS] ?? '', num);
678
+ violations.push(...requirements.violations);
679
+ // Only a row with a valid, unseen ID joins the inventory and the sequence.
680
+ // That much is genuinely ID-dependent: a malformed ID has no sequence
681
+ // number to contribute, and a duplicate must not overwrite the row that
682
+ // claimed the slot first. Reporting more is not the same as recording more.
683
+ if (!idOk || duplicate)
684
+ continue;
685
+ seqs.push(Number(id.slice('SCR-'.length)));
686
+ screens.set(id, {
687
+ id,
688
+ seq: Number(id.slice('SCR-'.length)),
689
+ role: cells[SCREEN_COL.ROLE] ?? '',
690
+ purpose: cells[SCREEN_COL.PURPOSE] ?? '',
691
+ surface: (cells[SCREEN_COL.SURFACE] ?? '').trim(),
692
+ requirementIds: requirements.ids,
693
+ line: num,
694
+ });
695
+ }
696
+ violations.push(...validateScreenSequence(seqs));
697
+ for (const { line, num } of bucketSection?.rows ?? []) {
698
+ const cells = splitTableCells(line);
699
+ const id = cells[NO_SCREEN_COL.ID] ?? '';
700
+ if (!REQ_ID_RE.test(id)) {
701
+ violations.push({ artifact: SCREENS_FILE, line: num, reason: 'bad_id' });
702
+ continue;
703
+ }
704
+ // Ordered BEFORE the empty-reason check so the MORE SPECIFIC fault is the
705
+ // first thing an author reads. A second row claiming a requirement the
706
+ // bucket already holds used to be dropped in silence — first row kept,
707
+ // second row's reason (potentially contradicting it) discarded with no
708
+ // violation at all. That was an oversight rather than a decision: the
709
+ // screens table above and the feedback log's rounds both raise
710
+ // `duplicate_id`, and the non-rules block never claimed this one as open.
711
+ const duplicate = noScreen.has(id);
712
+ if (duplicate) {
713
+ // The ID has passed its family regex, so naming it is safe — and naming
714
+ // it is the only way an author can find the second row.
715
+ violations.push({ artifact: SCREENS_FILE, id, reason: 'duplicate_id', line: num });
716
+ }
717
+ const reason = (cells[NO_SCREEN_COL.REASON] ?? '').trim();
718
+ if (reason === '') {
719
+ // The ID has passed its family regex, so naming it is safe; the empty
720
+ // cell is what is being reported, and there is nothing in it to echo.
721
+ // Still checked on a DUPLICATE row, for the same D-53 reason the screens
722
+ // table validates the cells of a duplicate row: the two faults are
723
+ // independent, and reporting only one makes the author run the gate twice.
724
+ violations.push({ artifact: SCREENS_FILE, id, reason: 'no_screen_without_reason', line: num });
725
+ }
726
+ if (!duplicate)
727
+ noScreen.set(id, { id, reason, line: num });
728
+ }
729
+ return { screens, noScreen, violations };
730
+ }
731
+ // ─── Feedback log (TRACE-02, D-56 / D-57 / D-58) ──────────────────────────────
732
+ /**
733
+ * D-41 sequence integrity for feedback rounds. Rows are never deleted (D-56), so
734
+ * a dropped round leaves a hole the parser names. Round numbers are NEVER
735
+ * renumbered: renumbering is what turns a deleted round into a round that never
736
+ * existed, and the round history is the audit trail.
737
+ */
738
+ function validateRoundSequence(seqs) {
739
+ const violations = [];
740
+ if (seqs.length === 0)
741
+ return violations;
742
+ const seen = new Set(seqs);
743
+ const max = boundedSequenceMax(seqs);
744
+ if (max === null) {
745
+ // Unreachable through `ROUND_HEADING_RE` (two digits, so max is 99). Kept
746
+ // because the grammar is one edit away from being widened again, and the
747
+ // failure mode this floor prevents is a process abort rather than a report.
748
+ return [{ artifact: FEEDBACK_FILE, reason: 'bad_id' }];
749
+ }
750
+ for (let n = 1; n <= max; n += 1) {
751
+ if (!seen.has(n)) {
752
+ violations.push({ artifact: FEEDBACK_FILE, id: `Round ${n}`, reason: 'sequence_gap' });
753
+ }
754
+ }
755
+ return violations;
756
+ }
757
+ /**
758
+ * D-57 Processing-cell grammar for one already-split cell.
759
+ *
760
+ * Clean when the cell is exactly `Open`, exactly `No change`, or a single
761
+ * citation matching one of the three closed shapes. Everything else — including
762
+ * an EMPTY or whitespace-only cell — is one `bad_processing`. D-57 calls a bare
763
+ * or prose-only Processing cell a structural error, singular, so there is no
764
+ * separate empty-cell code: an author who left the cell blank and an author who
765
+ * wrote a sentence in it have made the same mistake, and inventing a second code
766
+ * would only split the fixture set that proves the rule.
767
+ *
768
+ * RESOLUTION of a `DG-`/`RS-` citation against its register is NOT done here —
769
+ * that needs the other artifacts, so it lives in `resolveCrossArtifact` and the
770
+ * parser stays pure.
771
+ */
772
+ function validateProcessing(cell, num) {
773
+ const value = cell.trim();
774
+ if (value === PROCESSING.OPEN || value === PROCESSING.NO_CHANGE)
775
+ return [];
776
+ if (DECISION_CITE_RE.test(value) || DG_CITE_RE.test(value) || RS_CITE_RE.test(value))
777
+ return [];
778
+ return [{
779
+ artifact: FEEDBACK_FILE,
780
+ line: num,
781
+ reason: 'bad_processing',
782
+ field: 'processing',
783
+ cell: value,
784
+ }];
785
+ }
786
+ /**
787
+ * Parse `_feedback.md` into its rounds and rows.
788
+ *
789
+ * Fail-loud (D-53) and never warn-and-skip, including at the HEADING level: a
790
+ * heading that claims the round family but does not match the grammar is
791
+ * reported and its section skipped, rather than silently ignored. That widening
792
+ * is the same repair 03-08 made to the `_sparring.md` heading grammar — a
793
+ * malformed heading that a parser passes over is a whole section of the audit
794
+ * trail the gate never looked at.
795
+ *
796
+ * The heading fault is `bad_id` located by artifact plus row number ONLY. The
797
+ * heading text has passed no validation, so there is nothing about it that is
798
+ * safe to quote (T-04-10).
799
+ *
800
+ * A heading that never claims the Round family is ignored outright: other
801
+ * second-level sections in the document are not this parser's business.
802
+ *
803
+ * `rounds` is a `Map` keyed by the validated integer round number (T-04-06
804
+ * discipline: derived state is never keyed into a plain object literal).
805
+ */
806
+ function parseFeedbackLog(content) {
807
+ const rounds = new Map();
808
+ const rows = [];
809
+ const violations = [];
810
+ if (typeof content !== 'string' || content === '')
811
+ return { rounds, rows, violations };
812
+ const lines = content.split(/\r?\n/);
813
+ const seqs = [];
814
+ for (let i = 0; i < lines.length; i += 1) {
815
+ const heading = lines[i].trim();
816
+ if (!ROUND_CLAIM_RE.test(heading))
817
+ continue;
818
+ const num = i + 1;
819
+ const match = ROUND_HEADING_RE.exec(heading);
820
+ if (match === null) {
821
+ violations.push({ artifact: FEEDBACK_FILE, line: num, reason: 'bad_id' });
822
+ continue;
823
+ }
824
+ const round = Number(match[1]);
825
+ if (rounds.has(round)) {
826
+ // The round number has passed the grammar, so naming it is safe — and
827
+ // naming it is the only way an author finds the second section.
828
+ violations.push({ artifact: FEEDBACK_FILE, id: `Round ${round}`, reason: 'duplicate_id', line: num });
829
+ continue;
830
+ }
831
+ rounds.set(round, { round, line: num });
832
+ seqs.push(round);
833
+ for (const row of sectionRowsFrom(lines, i)) {
834
+ const cells = splitTableCells(row.line);
835
+ const anchor = (cells[FEEDBACK_COL.ANCHOR] ?? '').trim();
836
+ const anchorMatch = ANCHOR_RE.exec(anchor);
837
+ if (anchorMatch === null) {
838
+ violations.push({
839
+ artifact: FEEDBACK_FILE,
840
+ line: row.num,
841
+ reason: 'bad_anchor',
842
+ field: 'anchor',
843
+ cell: anchor,
844
+ });
845
+ }
846
+ const verdict = (cells[FEEDBACK_COL.VERDICT] ?? '').trim();
847
+ if (!VERDICTS.includes(verdict)) {
848
+ violations.push({
849
+ artifact: FEEDBACK_FILE,
850
+ line: row.num,
851
+ reason: 'bad_verdict',
852
+ field: 'verdict',
853
+ cell: verdict,
854
+ });
855
+ }
856
+ const processing = (cells[FEEDBACK_COL.PROCESSING] ?? '').trim();
857
+ violations.push(...validateProcessing(processing, row.num));
858
+ // Every row is kept, including one whose anchor failed: the cross-artifact
859
+ // resolver skips rows with no validated screen ID, and dropping the row
860
+ // here would make a second fault on the same row invisible.
861
+ rows.push({
862
+ screenId: anchorMatch === null ? '' : anchorMatch[1],
863
+ elementAnchor: anchorMatch === null ? '' : anchorMatch[2],
864
+ verdict,
865
+ feedback: cells[FEEDBACK_COL.FEEDBACK] ?? '',
866
+ processing,
867
+ round,
868
+ line: row.num,
869
+ });
870
+ }
871
+ }
872
+ violations.push(...validateRoundSequence(seqs));
873
+ return { rounds, rows, violations };
874
+ }
875
+ // ─── Artifact loading ─────────────────────────────────────────────────────────
876
+ /**
877
+ * Resolve the discovery package for a project root and hand back a reader.
878
+ *
879
+ * The `present.has(file)` early return is what discriminates the two conditions,
880
+ * and it must stay that way: ABSENT is the graceful arm and is silent, while
881
+ * PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling symlink) is a fault.
882
+ * Re-collapsing them would let the gate exit 0 having validated nothing at all —
883
+ * a fail-open on a structural verifier, and the failure mode GUARD-01 exists to
884
+ * close. An unreadable artifact must not be counted as checked either: saying it
885
+ * was checked would be a second, quieter overstatement.
886
+ *
887
+ * This is a NAMED, EXPORTED function rather than a closure buried inside an
888
+ * aggregator because later plans in this phase compose it — they need a name to
889
+ * call and a test needs a name to drive.
890
+ */
891
+ function loadDiscoveryArtifacts(cwd) {
892
+ const violations = [];
893
+ const dir = node_path_1.default.join(cwd, PLANNING_DIR, DISCOVERY_SUBDIR);
894
+ let names = [];
895
+ try {
896
+ names = node_fs_1.default.readdirSync(dir);
897
+ }
898
+ catch {
899
+ names = []; // no discovery package at all — graceful arm
900
+ }
901
+ const present = new Set(names);
902
+ const readArtifact = (file) => {
903
+ if (!present.has(file))
904
+ return null;
905
+ try {
906
+ return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
907
+ }
908
+ catch {
909
+ violations.push({ artifact: file, reason: 'artifact_unreadable' });
910
+ return null;
911
+ }
912
+ };
913
+ return { dir, present, readArtifact, violations };
914
+ }
915
+ /**
916
+ * Read `_requirements.md` through the loaded package and hand back its rows.
917
+ *
918
+ * `_requirements.md` is owned by `discovery rollup`; this gate consumes that
919
+ * single exported reader and NOTHING else. A grammar fault there is reported
920
+ * ONCE, by name, pointing at the owning gate — the underlying errors are NOT
921
+ * re-enumerated here, because two gates reporting the same fault in two
922
+ * vocabularies is how an author learns to ignore both.
923
+ *
924
+ * An ABSENT `_requirements.md` is silent, yields no rows, and leaves `status`
925
+ * absent; the requirement-resolution checks that need it are skipped by the
926
+ * aggregator rather than failing. Not having the artifact is not a fault of this
927
+ * gate.
928
+ */
929
+ function resolveRequirements(loaded) {
930
+ const violations = [];
931
+ const content = loaded.readArtifact(REQUIREMENTS_FILE);
932
+ if (content === null)
933
+ return { rows: [], status: 'absent', violations };
934
+ const parsed = rollup.parseRequirementsArtifact(content);
935
+ if (!parsed.ok) {
936
+ violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
937
+ return { rows: [], status: 'unparseable', violations };
938
+ }
939
+ const rows = parsed.rows.map((r) => ({
940
+ id: r.id,
941
+ disposition: r.disposition,
942
+ }));
943
+ return { rows, status: 'ok', violations };
944
+ }
945
+ // ─── Cross-artifact resolution (D-58 anchors, D-57 citations) ─────────────────
946
+ /**
947
+ * Collect the register IDs of a citation register, or null when the artifact is
948
+ * absent.
949
+ *
950
+ * The read goes through `loaded.readArtifact`, so the absent-versus-unreadable
951
+ * discrimination is INHERITED rather than re-derived — an unreadable register
952
+ * pushes `artifact_unreadable` onto the loader's own violation list and returns
953
+ * null here, which is the correct outcome: this gate must not resolve citations
954
+ * against a register it could not read, and it must not pretend it did.
955
+ *
956
+ * Parsing goes through the research track's exported register parsers. Their
957
+ * violations are DISCARDED on purpose: a grammar fault in `_risks.md` belongs to
958
+ * `discovery research-check` (D-42), and laundering it into a screens fault is
959
+ * the two-gates-two-vocabularies failure `requirements_unparseable` already
960
+ * avoids for `_requirements.md`.
961
+ */
962
+ function registerIds(loaded, file, parse) {
963
+ const content = loaded.readArtifact(file);
964
+ if (content === null)
965
+ return null;
966
+ return new Set(parse(content).rows.map(r => r.id));
967
+ }
968
+ /**
969
+ * Resolve a parsed feedback log against the screen inventory and the citation
970
+ * registers. Returns a violation array and nothing else.
971
+ *
972
+ * A NAMED, EXPORTED function rather than a step inside `parseFeedbackLog`, for
973
+ * two reasons that pull the same way: the parser stays pure (it is handed a
974
+ * string and reads no filesystem), and both cross-artifact fault classes are
975
+ * emittable and testable in the wave that introduces them rather than waiting on
976
+ * the aggregator. The aggregator CALLS this; it adds no resolution rule of its
977
+ * own.
978
+ *
979
+ * `unknown_screen_id` (D-58, T-04-11): the screen ID left of the ` · ` separator
980
+ * must be a key of the inventory map. When `_feedback.md` is present while
981
+ * `_screens.md` is ABSENT the map is empty and EVERY anchor fails — that is the
982
+ * correct fail-loud reading, not an over-report: a feedback log without an
983
+ * inventory is precisely the silent-omission failure GUARD-01 exists to catch,
984
+ * and a gate that passed it would be verifying nothing.
985
+ *
986
+ * `unknown_processing_id` (D-57): a `DG-`/`RS-` citation resolves against
987
+ * `_risks.md` / `_research.md`. When the register artifact is ABSENT, resolution
988
+ * is SKIPPED and the citation passes on shape alone — the D-42 graceful arm. A
989
+ * decision-ID citation is NEVER looked up and always passes on shape, matching
990
+ * the research track's `Closed (D-<ss>-<n>)` evidence rule. Both of those are
991
+ * "not mechanically checked beyond shape", and the reference says so.
992
+ *
993
+ * Each register is read at most once, whatever the row count.
994
+ */
995
+ function resolveCrossArtifact(screens, rows, loaded) {
996
+ const violations = [];
997
+ let risks;
998
+ let research;
999
+ for (const row of rows) {
1000
+ // A row whose anchor failed the grammar already carries `bad_anchor`; it has
1001
+ // no validated screen ID, so there is nothing here to resolve.
1002
+ if (row.screenId !== '' && !screens.has(row.screenId)) {
1003
+ violations.push({
1004
+ artifact: FEEDBACK_FILE,
1005
+ line: row.line,
1006
+ reason: 'unknown_screen_id',
1007
+ id: row.screenId,
1008
+ });
1009
+ }
1010
+ if (DG_CITE_RE.test(row.processing)) {
1011
+ if (risks === undefined)
1012
+ risks = registerIds(loaded, RISKS_FILE, researchTrack.parseRiskRegister);
1013
+ if (risks !== null && !risks.has(row.processing)) {
1014
+ violations.push({
1015
+ artifact: FEEDBACK_FILE,
1016
+ line: row.line,
1017
+ reason: 'unknown_processing_id',
1018
+ id: row.processing,
1019
+ });
1020
+ }
1021
+ }
1022
+ else if (RS_CITE_RE.test(row.processing)) {
1023
+ if (research === undefined) {
1024
+ research = registerIds(loaded, RESEARCH_FILE, researchTrack.parseResearchBacklog);
1025
+ }
1026
+ if (research !== null && !research.has(row.processing)) {
1027
+ violations.push({
1028
+ artifact: FEEDBACK_FILE,
1029
+ line: row.line,
1030
+ reason: 'unknown_processing_id',
1031
+ id: row.processing,
1032
+ });
1033
+ }
1034
+ }
1035
+ }
1036
+ return violations;
1037
+ }
1038
+ /**
1039
+ * Resolve every requirement ID a screen cites against `_requirements.md` (D-51).
1040
+ *
1041
+ * This is the OTHER half of `unknown_requirement_id`, and until it existed the
1042
+ * code did not do what its name said. `parseRequirementCell` checks a token's
1043
+ * SHAPE and nothing else, and two comments in this module used to promise that
1044
+ * resolution was "the aggregator's job (a later plan)". No later plan did it:
1045
+ * `requirementIds` was read in exactly one place — `computeCoverage`, to build
1046
+ * the placed set — and was never compared against the requirements table at all.
1047
+ * A screen citing `REQ-999` where no such requirement existed checked clean, so
1048
+ * a reason code named `unknown_requirement_id` only ever meant "malformed
1049
+ * shape". Coverage compensated partly, and only for MVP rows, by resurfacing the
1050
+ * real requirement as `uncovered_mvp_requirement`; a `Later` or `Dropped`
1051
+ * requirement mistyped on a screen was invisible in every arm. A reason code
1052
+ * that does not do what its name says is a trap for the next reader.
1053
+ *
1054
+ * A NAMED, EXPORTED function rather than a block inside `checkScreenInventory`,
1055
+ * for the reason `resolveCrossArtifact` is one: the aggregator stays pure
1056
+ * composition and adds no resolution of its own, and this rule is drivable and
1057
+ * testable without going through it.
1058
+ *
1059
+ * GUARDED on `status === 'ok'`, which preserves the documented graceful arm
1060
+ * exactly: an ABSENT or UNPARSEABLE `_requirements.md` yields no known set, and
1061
+ * resolving against an empty one would fail every citation on every screen and
1062
+ * turn a missing artifact into an inventory full of faults. `absent` is silent
1063
+ * by design and `unparseable` is already reported once, by name, against the
1064
+ * gate that owns it.
1065
+ *
1066
+ * The NO-SCREEN BUCKET's requirement cell is deliberately NOT resolved here, and
1067
+ * that is a stated non-rule rather than an omission. A bucket key can only ever
1068
+ * make a REAL requirement look covered, and `computeCoverage` iterates the
1069
+ * requirements table and asks `bucketSet.has(row.id)` — so an ID that is in no
1070
+ * requirements table matches no row, contributes to no count, and cannot produce
1071
+ * a false pass of the coverage assertion. There is no fail-open to close.
1072
+ */
1073
+ function resolveRequirementRefs(screens, requirements) {
1074
+ const violations = [];
1075
+ if (requirements.status !== 'ok')
1076
+ return violations;
1077
+ const known = new Set(requirements.rows.map(r => r.id));
1078
+ for (const row of screens.values()) {
1079
+ for (const id of row.requirementIds) {
1080
+ if (known.has(id))
1081
+ continue;
1082
+ violations.push({
1083
+ artifact: SCREENS_FILE,
1084
+ line: row.line,
1085
+ reason: 'unknown_requirement_id',
1086
+ // The token has already passed `REQ_ID_RE`, so naming it is safe — and
1087
+ // naming it is the only way an author finds which citation is wrong.
1088
+ cell: id,
1089
+ });
1090
+ }
1091
+ }
1092
+ return violations;
1093
+ }
1094
+ // ─── MVP coverage (TRACE-03, D-51) ────────────────────────────────────────────
1095
+ /** The fixed metric labels of the computed block. A closed vocabulary, like the reason codes. */
1096
+ const COVERAGE_LABELS = Object.freeze({
1097
+ MVP: 'MVP requirements',
1098
+ PLACED: 'Placed on a screen',
1099
+ BUCKETED: 'In the no-screen bucket',
1100
+ UNCOVERED: 'Uncovered',
1101
+ UNCOVERED_IDS: 'Uncovered IDs',
1102
+ });
1103
+ /**
1104
+ * Compute "no requirement without a place" (D-51).
1105
+ *
1106
+ * COMPUTED, never sampled. The motivating failure for this whole phase is a
1107
+ * percentage visual gate that passed over a CURATED baseline list while an
1108
+ * entire prototype surface had been silently dropped: a percentage over a
1109
+ * curated list cannot detect an omission, because the omitted thing is missing
1110
+ * from the denominator too. So the denominator here is `_requirements.md` — the
1111
+ * artifact the author does not get to curate for this check — and the union is
1112
+ * taken FROM the screens inventory INTO it (D-46's join direction; the
1113
+ * requirements grammar is not widened and no Screen column is added to it).
1114
+ *
1115
+ * Only `MVP`-disposition rows participate. `Later` and `Dropped` requirements
1116
+ * need no place, so they appear in no count — a negative test pins that, because
1117
+ * quietly widening the denominator is how this check would start reporting
1118
+ * uncoverable work.
1119
+ *
1120
+ * The placed and bucket sets are `Set`s, never plain objects: the members come
1121
+ * from parsed content (T-04-06).
1122
+ */
1123
+ function computeCoverage(screens, noScreen, requirementRows) {
1124
+ const placedSet = new Set();
1125
+ for (const row of screens.values()) {
1126
+ for (const id of row.requirementIds)
1127
+ placedSet.add(id);
1128
+ }
1129
+ const bucketSet = new Set(noScreen.keys());
1130
+ const uncovered = [];
1131
+ let mvp = 0;
1132
+ let placed = 0;
1133
+ let bucketed = 0;
1134
+ for (const row of requirementRows) {
1135
+ if (row.disposition !== rollup.DISPOSITIONS.MVP)
1136
+ continue;
1137
+ mvp += 1;
1138
+ const onScreen = placedSet.has(row.id);
1139
+ const inBucket = bucketSet.has(row.id);
1140
+ if (onScreen)
1141
+ placed += 1;
1142
+ if (inBucket)
1143
+ bucketed += 1;
1144
+ if (!onScreen && !inBucket)
1145
+ uncovered.push(row.id);
1146
+ }
1147
+ uncovered.sort();
1148
+ return { mvp, placed, bucketed, uncovered };
1149
+ }
1150
+ /**
1151
+ * Render the machine-owned coverage block (D-51/D-06) — the exact text that
1152
+ * belongs between the markers. The workflow replaces the block with this
1153
+ * VERBATIM, exactly as it does with the rollup totals.
1154
+ *
1155
+ * CONTENT-FREE BY CONSTRUCTION (T-04-09): the output is integers, requirement
1156
+ * IDs that have already passed `REQ_ID_RE`, the fixed labels above, table pipes
1157
+ * and an em dash — and nothing else. No Requirement text cell, no screen name,
1158
+ * no purpose ever reaches it, which is what lets the whole block be carried in a
1159
+ * `screens_mismatch` violation's `expected` field without breaching the
1160
+ * content-free contract. A test asserts the property against a whitelist rather
1161
+ * than trusting this comment.
1162
+ */
1163
+ function renderCoverageBlock(summary) {
1164
+ return [
1165
+ '| Metric | Count |',
1166
+ '|--------|-------|',
1167
+ `| ${COVERAGE_LABELS.MVP} | ${summary.mvp} |`,
1168
+ `| ${COVERAGE_LABELS.PLACED} | ${summary.placed} |`,
1169
+ `| ${COVERAGE_LABELS.BUCKETED} | ${summary.bucketed} |`,
1170
+ `| ${COVERAGE_LABELS.UNCOVERED} | ${summary.uncovered.length} |`,
1171
+ '',
1172
+ `${COVERAGE_LABELS.UNCOVERED_IDS}: ${summary.uncovered.length > 0 ? summary.uncovered.join(', ') : EM_DASH}`,
1173
+ ].join('\n');
1174
+ }
1175
+ /** Whitespace-normalize for block comparison: trim lines, collapse runs, drop blanks. */
1176
+ function normalizeBlock(text) {
1177
+ return text
1178
+ .split(/\r?\n/)
1179
+ .map(l => l.trim().replace(/\s+/g, ' '))
1180
+ .filter(l => l.length > 0)
1181
+ .join('\n');
1182
+ }
1183
+ function countOccurrences(haystack, needle) {
1184
+ return haystack.split(needle).length - 1;
1185
+ }
1186
+ /**
1187
+ * Enforce the coverage assertion against what is actually written in
1188
+ * `_screens.md`. Returns a violation array and nothing else.
1189
+ *
1190
+ * A NAMED, EXPORTED function for the same reason `resolveCrossArtifact` is: all
1191
+ * three of its fault classes are emittable and testable in the wave that
1192
+ * introduces them, and the aggregator CALLS this rather than reimplementing it.
1193
+ *
1194
+ * `requirementsStatus` is passed EXPLICITLY rather than inferred, so the
1195
+ * graceful arm is a decision this function makes rather than one every caller
1196
+ * has to remember to make. Order of the three rules is load-bearing:
1197
+ *
1198
+ * 1. MARKER STRUCTURE, first and unconditionally. Exactly one open marker,
1199
+ * exactly one close marker, close after open. This is a structural property
1200
+ * of `_screens.md` ALONE, so it is checked even when `_requirements.md` is
1201
+ * absent — a document with no computed block is a document whose coverage
1202
+ * claim was never verified, whatever the other artifacts say. (The `## Coverage`
1203
+ * heading itself is deliberately not separately reported: `parseScreenInventory`
1204
+ * does not check it, so there is no second report to collide with.)
1205
+ * 2. UNCOVERED MVP REQUIREMENTS, one violation per ID. The ID has passed
1206
+ * `REQ_ID_RE`, so naming it is safe — and naming it is the whole point.
1207
+ * 3. NORMALIZED DRIFT COMPARISON. A hand-edited block is `screens_mismatch`,
1208
+ * exactly as a hand-edited rollup total is `rollup_mismatch`. This is the
1209
+ * 105-vs-106 drift class D-05..D-08 exist to kill, closed for coverage.
1210
+ *
1211
+ * D-42 GRACEFUL ARM: when `_requirements.md` is ABSENT or `unparseable`, rules 2
1212
+ * and 3 are skipped entirely — there is nothing to compute against, and a gate
1213
+ * that reported drift against an empty computation would be dressing up a
1214
+ * missing sibling artifact to look like a coverage fault. Rule 1 still runs.
1215
+ */
1216
+ function enforceCoverage(screensContent, summary, requirementsStatus) {
1217
+ const violations = [];
1218
+ const content = typeof screensContent === 'string' ? screensContent : '';
1219
+ const openCount = countOccurrences(content, SCREENS_OPEN);
1220
+ const closeCount = countOccurrences(content, SCREENS_CLOSE);
1221
+ if (openCount !== 1 || closeCount !== 1) {
1222
+ violations.push({
1223
+ artifact: SCREENS_FILE,
1224
+ reason: 'screens_markers',
1225
+ detail: `expected exactly one ${SCREENS_OPEN} and one ${SCREENS_CLOSE} (found ${openCount}/${closeCount})`,
1226
+ });
1227
+ // Without exactly one of each there is no block to compare; a drift report
1228
+ // on top of a marker report would be the same fault in two vocabularies.
1229
+ return violations;
1230
+ }
1231
+ const openIdx = content.indexOf(SCREENS_OPEN);
1232
+ const closeIdx = content.indexOf(SCREENS_CLOSE);
1233
+ if (closeIdx < openIdx) {
1234
+ violations.push({
1235
+ artifact: SCREENS_FILE,
1236
+ reason: 'screens_markers',
1237
+ detail: 'close marker precedes open marker',
1238
+ });
1239
+ return violations;
1240
+ }
1241
+ if (requirementsStatus !== 'ok')
1242
+ return violations;
1243
+ for (const id of summary.uncovered) {
1244
+ violations.push({ artifact: SCREENS_FILE, id, reason: 'uncovered_mvp_requirement' });
1245
+ }
1246
+ const expected = normalizeBlock(renderCoverageBlock(summary));
1247
+ const actual = normalizeBlock(content.slice(openIdx + SCREENS_OPEN.length, closeIdx));
1248
+ if (expected !== actual) {
1249
+ violations.push({
1250
+ artifact: SCREENS_FILE,
1251
+ reason: 'screens_mismatch',
1252
+ expected,
1253
+ // Arbitrary author text between two markers. Bounded HERE, at
1254
+ // construction, so an inspector of the violation object cannot reach an
1255
+ // unbounded echo either (T-04-09).
1256
+ actual: sanitizeEcho(actual),
1257
+ });
1258
+ }
1259
+ return violations;
1260
+ }
1261
+ // ─── Build-tree instrumentation scan (GUARD-01, D-54) ─────────────────────────
1262
+ /**
1263
+ * Directory basenames the walk never enters.
1264
+ *
1265
+ * The OMISSIONS carry as much weight as the entries. A build output directory is
1266
+ * deliberately NOT skipped — a build tree is precisely the thing GUARD-01
1267
+ * asserts about, so skipping `dist`/`build`/`out` would make the whole scan a
1268
+ * ceremony. What is skipped is the three trees whose contents this project did
1269
+ * not author for this engagement: the dependency tree, the git object store, and
1270
+ * `.planning` itself, whose discovery artifacts and templates legitimately spell
1271
+ * the literals out — walking those would report the specification as a leak.
1272
+ *
1273
+ * `Object.freeze` seals the object's own properties; a `Set`'s membership lives
1274
+ * in an internal slot, so this is a declaration of intent backed by the ordinary
1275
+ * no-mutation convention, with the membership pinned by test rather than by the
1276
+ * freeze.
1277
+ */
1278
+ const SCAN_SKIP_DIRS = Object.freeze(new Set([
1279
+ 'node_modules', '.git', PLANNING_DIR,
1280
+ // Caches and vendored trees. NOT build output: these inflate the file count
1281
+ // without ever shipping, and every visited file counts against
1282
+ // `SCAN_MAX_FILES` regardless of extension (the count precedes the
1283
+ // extension filter). A locally-built Next.js tree alone puts thousands of
1284
+ // files into `.next/cache`, none of which a browser will ever honour a
1285
+ // `data-req` attribute from. `.gitignore` is deliberately NOT consulted —
1286
+ // that would make the scan's reach depend on a file the engagement edits.
1287
+ '.next', '.nuxt', '.turbo', '.cache', 'coverage',
1288
+ '.venv', 'venv', '__pycache__', 'vendor', 'target',
1289
+ ]));
1290
+ /**
1291
+ * The file extensions the scan actually READS. An allowlist rather than a
1292
+ * denylist, because the bound has to hold for file types nobody has thought of
1293
+ * yet: a lockfile, a source map or a binary asset cannot carry a discovery
1294
+ * annotation that a browser would honour, and reading them is pure cost.
1295
+ *
1296
+ * A file outside this list still counts against the file cap — it was visited,
1297
+ * and pretending otherwise would let a tree of a million images walk forever.
1298
+ */
1299
+ const SCAN_EXTENSIONS = Object.freeze(new Set([
1300
+ '.html', '.htm',
1301
+ '.js', '.jsx', '.mjs', '.cjs',
1302
+ '.ts', '.tsx',
1303
+ '.vue', '.svelte', '.astro',
1304
+ '.css',
1305
+ ]));
1306
+ /**
1307
+ * The two axes of the walk's budget (T-04-14).
1308
+ *
1309
+ * No analog exists in this codebase — no other discovery engine walks outside
1310
+ * `.planning/discovery/` — so both numbers are invented here and are deliberately
1311
+ * explicit rather than emergent. They are generous enough that a real front-end
1312
+ * build tree passes, and finite enough that a pathological or hostile tree
1313
+ * cannot turn a structural gate into an unbounded read.
1314
+ *
1315
+ * SIZED AGAINST A REAL REPOSITORY, not against a fixture. The first numbers
1316
+ * (5,000 files / 20 MB) were guesses, and measured against THIS repository —
1317
+ * counting exactly as `scanBuildTree` counts, with `SCAN_SKIP_DIRS` applied —
1318
+ * they came to 3,022 files and 18,487,659 bytes: 60% of the file cap and 92%
1319
+ * of the byte cap. The repository that ships this engine was within 1.5 MB of
1320
+ * tripping its own gate, and `scan_budget_exceeded` sets `ok` false at the
1321
+ * handoff, which is the workstream's single deliberate HARD BLOCK. An operator
1322
+ * who hit it had no remedy but to delete files. A cap an operator cannot raise
1323
+ * turns an honest truncation into an unpassable gate, so `SCAN_FLOOR` below
1324
+ * pins these against a stated realistic floor and the CLI accepts a bounded
1325
+ * override.
1326
+ */
1327
+ const SCAN_MAX_FILES = 50000;
1328
+ const SCAN_MAX_BYTES = 200000000;
1329
+ /**
1330
+ * The realistic floor the shipped caps must clear, exported so a test can pin
1331
+ * it. Stated as its own constant rather than as a magic number in an assertion:
1332
+ * the point is that a future edit cannot quietly re-lower the caps back under
1333
+ * what an ordinary repository needs, and a floor nobody can read is a floor
1334
+ * nobody will respect.
1335
+ */
1336
+ const SCAN_FLOOR = Object.freeze({ files: 20000, bytes: 100000000 });
1337
+ /**
1338
+ * The locator a whole-tree fault is reported against. The repo root has no
1339
+ * filename, and an empty locator would render as a bare `: scan_budget_exceeded`.
1340
+ */
1341
+ const SCAN_ROOT_LABEL = '.';
1342
+ /**
1343
+ * The ceiling an operator-supplied bound may not exceed, per axis. The override
1344
+ * exists to RAISE a cap that was set too low, never to remove the bound: a walk
1345
+ * with no finite ceiling on either axis is the unbounded read T-04-14 exists to
1346
+ * prevent, and `--max-files 1e21` would be exactly that wearing a number. Both
1347
+ * ceilings sit two orders of magnitude above the shipped defaults, which is
1348
+ * headroom for a repository nobody has built yet and still a hard stop.
1349
+ */
1350
+ const SCAN_ABSOLUTE_MAX = Object.freeze({ files: 5000000, bytes: 20000000000 });
1351
+ /**
1352
+ * One operator-supplied budget bound, or `null` when it is not a plain positive
1353
+ * integer at or below `ceiling`.
1354
+ *
1355
+ * Deliberately stricter than `Number()`: `parseInt` would accept `50000abc`,
1356
+ * and `Number` accepts `0x10`, `1e9`, `Infinity` and whitespace-padded forms.
1357
+ * An anchored digit run is the whole grammar, so there is nothing to reason
1358
+ * about. Zero is rejected too — a zero cap makes the very first file trip the
1359
+ * budget, which reads as a misunderstanding rather than as an intent.
1360
+ */
1361
+ function parseScanBound(value, ceiling) {
1362
+ if (typeof value !== 'string' || !/^\d{1,11}$/.test(value))
1363
+ return null;
1364
+ const parsed = Number(value);
1365
+ if (parsed < 1 || parsed > ceiling)
1366
+ return null;
1367
+ return parsed;
1368
+ }
1369
+ /**
1370
+ * The single spelling of the usage line. Declared once because it is emitted
1371
+ * from two arms below and the cross-surface literal audit compares the command
1372
+ * name against the workflows — two copies would be two things to drift.
1373
+ */
1374
+ const SCREEN_CHECK_USAGE = 'Usage: pp-tools discovery screen-check [--max-files <n>] [--max-bytes <n>] [--require-processed]';
1375
+ /**
1376
+ * The truncation fault. Names WHICH axis was crossed, the count reached and the
1377
+ * cap, because "the scan gave up" without saying where is not actionable.
1378
+ *
1379
+ * `detail` is machine-rendered from this module's own integers and fixed
1380
+ * phrases, never from author content, which is what makes it emittable verbatim.
1381
+ */
1382
+ function scanBudgetViolation(axis, reached, cap) {
1383
+ return {
1384
+ artifact: SCAN_ROOT_LABEL,
1385
+ reason: 'scan_budget_exceeded',
1386
+ detail: `${axis} cap ${cap} crossed at ${reached}; the scan stopped and nothing beyond that point was checked`,
1387
+ };
1388
+ }
1389
+ /**
1390
+ * The FIRST pinned literal in a file, or null.
1391
+ *
1392
+ * First, not all: one generated bundle can carry thousands of annotations, and a
1393
+ * report that listed every one would bury the other files under a single build
1394
+ * artifact. The per-file cap is one hit; the report's total stays the number of
1395
+ * FILES with hits, which is the number an operator acts on.
1396
+ *
1397
+ * The matched literal is returned so the caller can name it. It is a
1398
+ * closed-vocabulary token from the frozen `INSTRUMENTATION_LITERALS` tuple, so
1399
+ * naming it is safe — unlike the line it was found on, which is application
1400
+ * source and is never carried anywhere (T-04-15).
1401
+ */
1402
+ function firstInstrumentationHit(content) {
1403
+ const lines = content.split(/\r?\n/);
1404
+ for (let i = 0; i < lines.length; i += 1) {
1405
+ for (const literal of INSTRUMENTATION_LITERALS) {
1406
+ if (lines[i].includes(literal))
1407
+ return { line: i + 1, literal };
1408
+ }
1409
+ }
1410
+ return null;
1411
+ }
1412
+ /** Repo-relative, POSIX-separated, so a report reads the same on every platform. */
1413
+ function relativePosix(root, full) {
1414
+ return node_path_1.default.relative(root, full).split(node_path_1.default.sep).join('/');
1415
+ }
1416
+ /**
1417
+ * Read the optional `_scan-allow.txt` and hand back the repo-relative paths a
1418
+ * known `instrumentation_leaked` false positive has been recorded against.
1419
+ *
1420
+ * WHY THIS EXISTS. D-54 accepted a false-positive cost explicitly, but accepting
1421
+ * a cost is not the same as providing a way to discharge it. With no suppression
1422
+ * mechanism and a hard block at the handoff, ONE unavoidable hit made the gate
1423
+ * unpassable with no remedy but deleting the file — and the hits are not
1424
+ * hypothetical: the repository that ships this engine matches `data-req=` in its
1425
+ * own compiled output and in its own test fixtures, and in a consumer repo the
1426
+ * equivalents are documentation, a config file or a test that merely mentions
1427
+ * the attribute. An operator with no way to record "this one is fine" does not
1428
+ * leave the gate red; they turn the gate off, which costs every other finding.
1429
+ *
1430
+ * WHY IT IS SHAPED THIS WAY. Exact repo-relative paths only, no globs: an exact
1431
+ * path is a decision a reviewer can read and a pattern is a hole nobody can
1432
+ * size. `#` comments are supported precisely so the WHY can sit next to the
1433
+ * path. Split on `/\r?\n/` rather than on `'\n'`, so a list edited on Windows
1434
+ * does not leave a trailing carriage return welded to every path and silently
1435
+ * match nothing.
1436
+ *
1437
+ * The read goes through `loaded.readArtifact`, so the absent-versus-unreadable
1438
+ * discrimination is INHERITED: an absent list is the ordinary case and is
1439
+ * silent, while a list that exists and cannot be read is `artifact_unreadable`
1440
+ * and suppresses nothing. Failing loud with everything reported is the correct
1441
+ * direction for an unreadable SUPPRESSION file.
1442
+ *
1443
+ * A STALE entry — a path that is allowlisted but no longer matches anything — is
1444
+ * a deliberate non-rule. Reporting it would need a reason code of its own, and
1445
+ * an unused allowlist entry cannot cause the gate to miss a leak; it is untidy,
1446
+ * not unsound.
1447
+ */
1448
+ function loadScanAllowlist(loaded) {
1449
+ const content = loaded.readArtifact(SCAN_ALLOW_FILE);
1450
+ if (content === null)
1451
+ return new Set();
1452
+ const allowed = new Set();
1453
+ for (const raw of content.split(/\r?\n/)) {
1454
+ const line = raw.trim();
1455
+ if (line === '' || line.startsWith('#'))
1456
+ continue;
1457
+ allowed.add(line);
1458
+ }
1459
+ return allowed;
1460
+ }
1461
+ /**
1462
+ * Assert that no discovery instrumentation survives into the build tree (D-54,
1463
+ * GUARD-01). Returns a violation array and nothing else.
1464
+ *
1465
+ * GUARD-01 asks for the absence to be ASSERTED rather than assumed, and D-54
1466
+ * makes that a deterministic scan for two pinned literals — no Playwright, no
1467
+ * generated per-project code, consistent with the ROADMAP fence.
1468
+ *
1469
+ * Every bound is explicit:
1470
+ * - the walk is ITERATIVE over a stack, never recursive, so tree depth cannot
1471
+ * exhaust the call stack;
1472
+ * - entries are read with `lstatSync` and a SYMLINK is skipped whatever it
1473
+ * points at, which bounds the walk and removes the directory-cycle class
1474
+ * outright rather than defending against it with a visited-set;
1475
+ * - every entry is resolved and confirmed strictly beneath the resolved
1476
+ * project root before any stat or read, the same posture the roll-up
1477
+ * traversal guard takes (T-04-16);
1478
+ * - `SCAN_SKIP_DIRS` keeps the walk out of the dependency tree;
1479
+ * - `SCAN_EXTENSIONS` bounds what is read;
1480
+ * - the file and byte caps stop the walk.
1481
+ *
1482
+ * ON CROSSING A CAP the return is the budget violation ALONE. Whatever hits had
1483
+ * accumulated are dropped, deliberately: a truncated scan has not established
1484
+ * anything about the tree, and a result that mixed real findings into a partial
1485
+ * pass would invite exactly the reading "it found two, so it looked" — which is
1486
+ * the silent fail-open this phase exists to close. One loud fault that says the
1487
+ * scan did not complete is the honest report, and the gate exits non-zero on it
1488
+ * either way.
1489
+ *
1490
+ * GATING is the CALLER's job and is documented in the module comment: this
1491
+ * function is unconditional so it stays drivable on its own, and the aggregator
1492
+ * runs it only when a discovery artifact was found.
1493
+ */
1494
+ function scanBuildTree(cwd, options = {}) {
1495
+ const maxFiles = options.maxFiles ?? SCAN_MAX_FILES;
1496
+ const maxBytes = options.maxBytes ?? SCAN_MAX_BYTES;
1497
+ const allow = options.allow ?? new Set();
1498
+ const violations = [];
1499
+ const root = node_path_1.default.resolve(cwd);
1500
+ const stack = [root];
1501
+ let files = 0;
1502
+ let bytes = 0;
1503
+ while (stack.length > 0) {
1504
+ const dir = stack.pop();
1505
+ if (dir === undefined)
1506
+ break;
1507
+ let names;
1508
+ try {
1509
+ names = node_fs_1.default.readdirSync(dir);
1510
+ }
1511
+ catch {
1512
+ // A directory that cannot be listed bounds the walk here. It is not a
1513
+ // discovery artifact, so it is not `artifact_unreadable`: that code names
1514
+ // a file the gate was asked to validate and could not, and inventing a
1515
+ // structural fault out of an unreadable build directory would report the
1516
+ // operator's filesystem permissions as a traceability failure.
1517
+ continue;
1518
+ }
1519
+ // Sorted so the walk is deterministic. Which file a CAPPED scan stops at
1520
+ // must not depend on filesystem enumeration order, or the same tree would
1521
+ // produce different reports on different machines.
1522
+ names.sort();
1523
+ for (const name of names) {
1524
+ const full = node_path_1.default.resolve(dir, name);
1525
+ // Containment guard (T-04-16): refuse anything that does not resolve
1526
+ // strictly beneath the project root, BEFORE any stat or read.
1527
+ if (!full.startsWith(root + node_path_1.default.sep))
1528
+ continue;
1529
+ let stat;
1530
+ try {
1531
+ stat = node_fs_1.default.lstatSync(full);
1532
+ }
1533
+ catch {
1534
+ continue;
1535
+ }
1536
+ if (stat.isSymbolicLink())
1537
+ continue;
1538
+ if (stat.isDirectory()) {
1539
+ if (!SCAN_SKIP_DIRS.has(name))
1540
+ stack.push(full);
1541
+ continue;
1542
+ }
1543
+ // Sockets, FIFOs and devices are neither walked nor counted: they are not
1544
+ // files a build emits, and reading one can block.
1545
+ if (!stat.isFile())
1546
+ continue;
1547
+ files += 1;
1548
+ if (files > maxFiles)
1549
+ return [scanBudgetViolation('file', files, maxFiles)];
1550
+ if (!SCAN_EXTENSIONS.has(node_path_1.default.extname(name).toLowerCase()))
1551
+ continue;
1552
+ // An allowlisted path is still VISITED and still counted against the file
1553
+ // cap — suppressing a finding must not also shrink the walk, or the
1554
+ // budget an operator raised would silently mean something else. Only the
1555
+ // read and the report are skipped.
1556
+ if (allow.has(relativePosix(root, full)))
1557
+ continue;
1558
+ // Checked BEFORE the read, so a single enormous file cannot cross the cap
1559
+ // by being read first and counted afterwards.
1560
+ if (bytes + stat.size > maxBytes) {
1561
+ return [scanBudgetViolation('byte', bytes + stat.size, maxBytes)];
1562
+ }
1563
+ bytes += stat.size;
1564
+ let content;
1565
+ try {
1566
+ content = node_fs_1.default.readFileSync(full, 'utf-8');
1567
+ }
1568
+ catch {
1569
+ continue;
1570
+ }
1571
+ const hit = firstInstrumentationHit(content);
1572
+ if (hit === null)
1573
+ continue;
1574
+ violations.push({
1575
+ // The repo-relative path IS the report. D-54 accepted the
1576
+ // false-positive cost on the explicit condition that the fault says
1577
+ // plainly what was found and where, so the path is carried in full — a
1578
+ // path truncated to the echo bound would locate nothing. It is a
1579
+ // locator, in the same class as the `<artifact>:<row>` locators the
1580
+ // content-free contract already emits, not client prose.
1581
+ artifact: relativePosix(root, full),
1582
+ line: hit.line,
1583
+ reason: 'instrumentation_leaked',
1584
+ // The matched literal, from this module's own frozen tuple. The SOURCE
1585
+ // LINE it sat on is never carried (T-04-15).
1586
+ detail: hit.literal,
1587
+ });
1588
+ }
1589
+ }
1590
+ return violations;
1591
+ }
1592
+ // ─── Output ───────────────────────────────────────────────────────────────────
1593
+ /**
1594
+ * Every control character the whitespace collapse below does NOT handle: the C0
1595
+ * plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
1596
+ *
1597
+ * Stripped BEFORE the collapse, and this ordering is the whole point.
1598
+ * JavaScript `\s` matches only the five C0 whitespace characters plus the
1599
+ * Unicode space separators; it does NOT match ESC (`\x1b`), BEL (`\x07`),
1600
+ * BS (`\x08`) or NUL. `formatViolation` output reaches `error()`, which in the
1601
+ * default non-`--json-errors` mode writes `'Error: ' + message` to fd 2
1602
+ * VERBATIM, which is the mode a human operator reads in a terminal. A complete
1603
+ * OSC-52 clipboard-write sequence fits inside `ECHO_MAX_CHARS`, and an
1604
+ * UNTERMINATED OSC string consumes the report text after it and continues into
1605
+ * the next violation's cell. `_screens.md` and `_feedback.md` are derived from
1606
+ * client documents (D-24): treating them as untrusted for LENGTH while
1607
+ * treating them as trusted for BYTES was the gap.
1608
+ */
1609
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
1610
+ /**
1611
+ * Bound and flatten an author-supplied value before it reaches violation output.
1612
+ * Strip the control planes, collapse whitespace, trim, and cap at
1613
+ * `ECHO_MAX_CHARS`, appending an ellipsis when the value was longer.
1614
+ *
1615
+ * Both the strip and the collapse are load-bearing and must run BEFORE the
1616
+ * slice: together they guarantee a cell can never inject a newline, carriage
1617
+ * return, tab or terminal escape sequence into the one-line-per-violation
1618
+ * report format, regardless of where the slice happens to land.
1619
+ */
1620
+ function sanitizeEcho(value) {
1621
+ // A non-string is not author data this function can meaningfully bound, and
1622
+ // default stringification would emit `[object Object]` — so it renders as
1623
+ // nothing rather than as a placeholder that looks like content.
1624
+ const raw = typeof value === 'string' ? value : '';
1625
+ const collapsed = raw.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
1626
+ if (collapsed.length <= ECHO_MAX_CHARS)
1627
+ return collapsed;
1628
+ return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
1629
+ }
1630
+ /**
1631
+ * One content-free line per violation, two-space indented (T-04-04).
1632
+ *
1633
+ * This is the SINGLE chokepoint for echoed detail: every author-supplied value
1634
+ * below routes through `sanitizeEcho`, so a reason code added to the union by a
1635
+ * later plan inherits the confidentiality bound by construction rather than by
1636
+ * the author of that code remembering it. Reason codes, field names and IDs that
1637
+ * have already passed their family regex are deliberately NOT sanitised — all
1638
+ * three are closed sets declared in this module, not author data.
1639
+ *
1640
+ * `bad_id` renders as locator-plus-reason only. Its cell has passed no
1641
+ * validation at all, so there is nothing about it that is safe to quote; the
1642
+ * `<artifact>:<row>` locator finds the row instead.
1643
+ */
1644
+ function formatViolation(v) {
1645
+ const where = v.artifact ?? '';
1646
+ const locator = v.line === undefined ? where : `${where}:${v.line}`;
1647
+ let detail = '';
1648
+ switch (v.reason) {
1649
+ case 'bad_id':
1650
+ detail = '';
1651
+ break;
1652
+ case 'missing_section':
1653
+ detail = sanitizeEcho(v.cell);
1654
+ break;
1655
+ case 'bad_surface_status':
1656
+ case 'bad_anchor':
1657
+ case 'bad_verdict':
1658
+ case 'bad_processing':
1659
+ // One arm for every closed-enum fault: the value IS echoed, because a
1660
+ // closed-vocabulary fault an author cannot see the value of is not
1661
+ // actionable — but it is echoed BOUNDED, through the chokepoint.
1662
+ detail = `${v.field}: ${sanitizeEcho(v.cell)}`;
1663
+ break;
1664
+ case 'unknown_requirement_id':
1665
+ detail = sanitizeEcho(v.cell);
1666
+ break;
1667
+ case 'screens_markers':
1668
+ case 'instrumentation_leaked':
1669
+ case 'scan_budget_exceeded':
1670
+ // `detail` is machine-rendered from this module's own marker literals, the
1671
+ // frozen instrumentation tuple and integers — never author content — so it
1672
+ // is emitted verbatim. For `instrumentation_leaked` that is the matched
1673
+ // literal and nothing else: the file and line are already in the locator,
1674
+ // and the source line they name is never carried at all (T-04-15).
1675
+ detail = v.detail ?? '';
1676
+ break;
1677
+ case 'screens_mismatch':
1678
+ // `actual` was already bounded at construction; routing it through the
1679
+ // chokepoint again is idempotent and keeps the invariant "every
1680
+ // author-supplied value in this function passes sanitizeEcho" literally
1681
+ // true, so a later reader cannot mistake the pre-sanitised field for an
1682
+ // exemption. `expected` is deliberately NOT rendered here: it is a
1683
+ // multi-line block, and this format is one line per violation.
1684
+ detail = `block drift; actual: ${sanitizeEcho(v.actual)}`;
1685
+ break;
1686
+ case 'requirements_unparseable':
1687
+ detail = 'run: discovery rollup --check';
1688
+ break;
1689
+ default:
1690
+ detail = sanitizeEcho(v.id ?? '');
1691
+ }
1692
+ return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
1693
+ }
1694
+ /**
1695
+ * Render a violation list, capped, with the TRUE total.
1696
+ *
1697
+ * A pathological artifact must not produce an unbounded report, but a capped
1698
+ * report that also capped the COUNT would understate the problem — and
1699
+ * understating is the failure mode this whole phase exists to close. So the cap
1700
+ * applies to the rendered lines only; the tail line carries how many were
1701
+ * suppressed and the caller keeps `violations.length` as the true total.
1702
+ *
1703
+ * The research track applies this same idiom inline in its CLI adapter. It is a
1704
+ * named function here because the screens CLI adapter arrives in a later plan
1705
+ * and the cap is a contract that has to be testable before its only caller
1706
+ * exists — the same reason `formatViolation` is exported.
1707
+ */
1708
+ function renderViolations(violations) {
1709
+ const total = violations.length;
1710
+ const rendered = violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
1711
+ if (total > MAX_REPORTED_VIOLATIONS) {
1712
+ rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
1713
+ }
1714
+ return rendered;
1715
+ }
1716
+ /**
1717
+ * Run every fault class this engine owns, in one pass, for a project root
1718
+ * (D-52). Collect-all-then-fail: every check runs, violations accumulate, the
1719
+ * result returns once. There is no warn-and-skip anywhere in this function.
1720
+ *
1721
+ * PURE COMPOSITION. Every fault class reported here is emitted by a function
1722
+ * declared and tested above; this aggregator introduces no violation code, no
1723
+ * grammar rule and no resolution of its own. That is deliberate and it is the
1724
+ * single-home doctrine applied to itself — a second copy of a resolution is
1725
+ * exactly the drift this phase exists to prevent, and the earlier waves' tests
1726
+ * pin the first copy.
1727
+ *
1728
+ * NEVER-BLOCKS (D-43), carried forward from the research track: unprocessed
1729
+ * feedback is not a fault — a round whose every Processing cell is `Open` exits
1730
+ * 0 and reports those rows under `open_feedback`, so a still-running feedback
1731
+ * loop can never block sign-off. Only STRUCTURAL faults set `ok` false.
1732
+ *
1733
+ * The `_screens.md` ABSENT arm skips coverage enforcement entirely. A document
1734
+ * that does not exist has no markers that could be missing, and reporting
1735
+ * `screens_markers` against it would collapse the absent arm into a fault — the
1736
+ * conflation `loadDiscoveryArtifacts` exists to keep apart. The anchor join is
1737
+ * NOT skipped in that arm, because an empty inventory map failing every anchor
1738
+ * is the correct GUARD-01 reading rather than an over-report.
1739
+ *
1740
+ * `artifacts_checked` names the three artifacts this gate VALIDATES, and only
1741
+ * those. `_risks.md` and `_research.md` are consulted for citation resolution,
1742
+ * never checked — their grammar belongs to `discovery research-check` (D-42) —
1743
+ * so listing them here would be the same overstatement that keeping an
1744
+ * unreadable artifact out of the list avoids.
1745
+ */
1746
+ function checkScreenInventory(cwd, budget = {}) {
1747
+ const violations = [];
1748
+ const loaded = loadDiscoveryArtifacts(cwd);
1749
+ const artifactsChecked = [];
1750
+ // 1–2. The screen inventory.
1751
+ const screensContent = loaded.readArtifact(SCREENS_FILE);
1752
+ if (screensContent !== null)
1753
+ artifactsChecked.push(SCREENS_FILE);
1754
+ const inventory = parseScreenInventory(screensContent);
1755
+ violations.push(...inventory.violations);
1756
+ // 3. The requirements spine, through rollup's single exported reader.
1757
+ const requirements = resolveRequirements(loaded);
1758
+ if (requirements.status !== 'absent')
1759
+ artifactsChecked.push(REQUIREMENTS_FILE);
1760
+ violations.push(...requirements.violations);
1761
+ // 4. The feedback log.
1762
+ const feedbackContent = loaded.readArtifact(FEEDBACK_FILE);
1763
+ if (feedbackContent !== null)
1764
+ artifactsChecked.push(FEEDBACK_FILE);
1765
+ const feedback = parseFeedbackLog(feedbackContent);
1766
+ violations.push(...feedback.violations);
1767
+ // 5. The cross-artifact resolutions: anchors and Processing citations against
1768
+ // the inventory and the registers, and every screen's requirement
1769
+ // citations against the requirements spine. Each is a named function this
1770
+ // aggregator CALLS, never a rule it declares.
1771
+ violations.push(...resolveCrossArtifact(inventory.screens, feedback.rows, loaded));
1772
+ violations.push(...resolveRequirementRefs(inventory.screens, requirements));
1773
+ // 6. The computed coverage assertion. Computed unconditionally so the payload
1774
+ // can report it, ENFORCED only against a `_screens.md` that exists.
1775
+ const coverage = computeCoverage(inventory.screens, inventory.noScreen, requirements.rows);
1776
+ let expectedBlock;
1777
+ if (screensContent !== null) {
1778
+ const coverageViolations = enforceCoverage(screensContent, coverage, requirements.status);
1779
+ violations.push(...coverageViolations);
1780
+ if (coverageViolations.some(v => v.reason === 'screens_mismatch')) {
1781
+ // The VERBATIM render, not the violation's normalised `expected`: the
1782
+ // operator is going to paste this between the markers, and the normalised
1783
+ // form has had its blank line collapsed out of it.
1784
+ expectedBlock = renderCoverageBlock(coverage);
1785
+ }
1786
+ }
1787
+ // 7. The anti-leak scan, GATED ON ARTIFACTS. A project with no discovery
1788
+ // package never walks its own tree, so a codebase that uses `data-req` for
1789
+ // its own unrelated purposes is untouched (D-52 graceful arm applied to
1790
+ // D-54's false-positive surface, T-04-18).
1791
+ //
1792
+ // The gate reads DISCOVERY_ARTIFACTS rather than `present.size > 0`: the
1793
+ // latter is every directory entry, so a `.DS_Store` that macOS writes on
1794
+ // mere Finder access — or a subdirectory, or an editor scratch file —
1795
+ // promoted an empty discovery folder into a whole-repo walk. The bound has
1796
+ // to be "a discovery package exists", not "the folder has something in it".
1797
+ //
1798
+ // The allowlist is loaded here, in the layer that owns the discovery
1799
+ // package, and handed to the walk. Loading it unconditionally rather than
1800
+ // inside the gate keeps `artifact_unreadable` reachable for an unreadable
1801
+ // list even in a project that never gets scanned; the read is a no-op when
1802
+ // the file is absent, which is the ordinary case.
1803
+ const scanAllow = loadScanAllowlist(loaded);
1804
+ if (DISCOVERY_ARTIFACTS.some(file => loaded.present.has(file))) {
1805
+ violations.push(...scanBuildTree(cwd, { ...budget, allow: scanAllow }));
1806
+ }
1807
+ // LAST, not first: `readArtifact` pushes onto this list as reads happen, and
1808
+ // the citation registers are read lazily inside `resolveCrossArtifact`. Taking
1809
+ // a copy any earlier would silently drop an `artifact_unreadable` fault.
1810
+ violations.push(...loaded.violations);
1811
+ const byVerdict = {};
1812
+ for (const verdict of VERDICTS)
1813
+ byVerdict[verdict] = 0;
1814
+ let openTotal = 0;
1815
+ for (const row of feedback.rows) {
1816
+ if (row.processing !== PROCESSING.OPEN)
1817
+ continue;
1818
+ openTotal += 1;
1819
+ // Guarded membership: a malformed verdict is already a `bad_verdict` fault
1820
+ // and must not be able to mint a key on a derived object.
1821
+ if (VERDICTS.includes(row.verdict))
1822
+ byVerdict[row.verdict] += 1;
1823
+ }
1824
+ const result = {
1825
+ ok: violations.length === 0,
1826
+ artifacts_checked: artifactsChecked,
1827
+ screens: inventory.screens.size,
1828
+ rounds: feedback.rounds.size,
1829
+ open_feedback: { total: openTotal, by_verdict: byVerdict },
1830
+ coverage: {
1831
+ mvp: coverage.mvp,
1832
+ placed: coverage.placed,
1833
+ bucketed: coverage.bucketed,
1834
+ uncovered: coverage.uncovered.length,
1835
+ },
1836
+ violations,
1837
+ };
1838
+ if (expectedBlock !== undefined)
1839
+ result.expected_coverage_block = expectedBlock;
1840
+ return result;
1841
+ }
1842
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
1843
+ /**
1844
+ * CLI adapter for `pp-tools discovery screen-check` (dotted form
1845
+ * `pp-tools query discovery.screen-check`).
1846
+ *
1847
+ * Exit semantics: any structural violation exits non-zero via
1848
+ * `ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED`, naming each violation by
1849
+ * locator and reason. Unprocessed feedback is NOT a violation (D-43) — a round
1850
+ * whose every Processing cell is `Open` exits 0 and is reported under
1851
+ * `open_feedback`.
1852
+ *
1853
+ * The command accepts no positional path argument at all: the convention paths
1854
+ * under `.planning/discovery/` resolve against cwd, which ELIMINATES the
1855
+ * traversal surface rather than guarding it (T-04-17, the T-03-01 precedent).
1856
+ * `--max-files <n>` and `--max-bytes <n>` are the ONLY arguments, and they
1857
+ * adjust the build-tree scan's caps and nothing else. The traversal surface
1858
+ * stays eliminated; only the CAP is operator-adjustable, because
1859
+ * `scan_budget_exceeded` sets `ok` false and the handoff arm is a hard block
1860
+ * with no option to proceed — a cap an operator cannot raise turns an honest
1861
+ * truncation into an unpassable gate whose only remedy is deleting files.
1862
+ *
1863
+ * Each value must be a plain positive integer within `SCAN_ABSOLUTE_MAX`. The
1864
+ * override RAISES or lowers a bound; it can never remove one, so the walk stays
1865
+ * finite on both axes exactly as T-04-14 requires.
1866
+ *
1867
+ * The outstanding-work payload is emitted on the FAILURE path too, and first.
1868
+ * An inventory with one malformed cell must not lose the report that makes an
1869
+ * honest partial sign-off possible — that is precisely the state in which an
1870
+ * operator needs both halves. The payload inherits the content-free contract by
1871
+ * construction: counts, closed-vocabulary enum keys, artifact filenames and the
1872
+ * machine-rendered coverage block, never a screen name or a feedback comment.
1873
+ */
1874
+ function cmdDiscoveryScreenCheck(cwd, args, raw) {
1875
+ // args arrive shaped ['discovery', 'screen-check', ...]; global output flags
1876
+ // are consumed by the dispatcher, so anything left that is not one of the two
1877
+ // budget flags or `--require-processed` — a positional path or an unknown flag
1878
+ // alike — is a usage error. Parsed by hand so the accepted set stays visibly closed.
1879
+ const rest = args.slice(2);
1880
+ const budget = {};
1881
+ let requireProcessed = false;
1882
+ for (let i = 0; i < rest.length; i += 1) {
1883
+ const flag = rest[i];
1884
+ if (flag === '--require-processed') {
1885
+ requireProcessed = true;
1886
+ continue;
1887
+ }
1888
+ if (flag !== '--max-files' && flag !== '--max-bytes') {
1889
+ error(SCREEN_CHECK_USAGE, ERROR_REASON.USAGE);
1890
+ return; // unreachable — error() exits
1891
+ }
1892
+ const ceiling = flag === '--max-files' ? SCAN_ABSOLUTE_MAX.files : SCAN_ABSOLUTE_MAX.bytes;
1893
+ const parsed = parseScanBound(rest[i + 1], ceiling);
1894
+ if (parsed === null) {
1895
+ error(SCREEN_CHECK_USAGE, ERROR_REASON.USAGE);
1896
+ return; // unreachable — error() exits
1897
+ }
1898
+ if (flag === '--max-files')
1899
+ budget.maxFiles = parsed;
1900
+ else
1901
+ budget.maxBytes = parsed;
1902
+ i += 1; // the consumed value
1903
+ }
1904
+ const result = checkScreenInventory(cwd, budget);
1905
+ const payload = {
1906
+ ok: result.ok,
1907
+ artifacts_checked: result.artifacts_checked,
1908
+ screens: result.screens,
1909
+ rounds: result.rounds,
1910
+ open_feedback: result.open_feedback,
1911
+ coverage: result.coverage,
1912
+ };
1913
+ // Present when and only when the block actually drifted, so its presence is
1914
+ // itself the signal that there is something to paste back (D-06). There is no
1915
+ // compute-and-write mode and no second command: this is how the
1916
+ // replace-verbatim workflow is served by a pure validator.
1917
+ if (result.expected_coverage_block !== undefined) {
1918
+ payload.expected_coverage_block = result.expected_coverage_block;
1919
+ }
1920
+ if (!result.ok) {
1921
+ const total = result.violations.length;
1922
+ payload.violations = total;
1923
+ // Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
1924
+ // deterministic: `output` writes fd 1 synchronously and `error` exits.
1925
+ output(payload, raw);
1926
+ error(`discovery screen-check: ${total} violation(s):\n${renderViolations(result.violations).join('\n')}`, ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED);
1927
+ return; // unreachable — error() exits
1928
+ }
1929
+ // Session close and the build handoff: an Open row is feedback nobody has processed yet.
1930
+ if (requireProcessed && result.open_feedback.total > 0) {
1931
+ const open = result.open_feedback;
1932
+ const split = VERDICTS.map((v) => `${v}: ${open.by_verdict[v] ?? 0}`).join(', ');
1933
+ output(payload, raw);
1934
+ error(`discovery screen-check: ${open.total} feedback row(s) still Open (${split}) in ${FEEDBACK_FILE} — process each before continuing`, ERROR_REASON.DISCOVERY_FEEDBACK_OPEN);
1935
+ return; // unreachable — error() exits
1936
+ }
1937
+ output(payload, raw);
1938
+ }
1939
+ module.exports = {
1940
+ SURFACE_STATUS,
1941
+ VIOLATION_REASONS,
1942
+ INSTRUMENTATION_LITERALS,
1943
+ ECHO_MAX_CHARS,
1944
+ MAX_REPORTED_VIOLATIONS,
1945
+ SCREENS_FILE,
1946
+ FEEDBACK_FILE,
1947
+ REQUIREMENTS_FILE,
1948
+ SCREENS_HEADING,
1949
+ NO_SCREEN_HEADING,
1950
+ COVERAGE_HEADING,
1951
+ COVERAGE_OPEN,
1952
+ COVERAGE_CLOSE,
1953
+ SCREENS_OPEN,
1954
+ SCREENS_CLOSE,
1955
+ COVERAGE_LABELS,
1956
+ RISKS_FILE,
1957
+ RESEARCH_FILE,
1958
+ VERDICTS,
1959
+ PROCESSING,
1960
+ ANCHOR_SEP,
1961
+ SCREEN_COL,
1962
+ NO_SCREEN_COL,
1963
+ FEEDBACK_COL,
1964
+ SCREEN_CHECK_FAILED,
1965
+ SCAN_ALLOW_FILE,
1966
+ DISCOVERY_ARTIFACTS,
1967
+ loadScanAllowlist,
1968
+ SCAN_SKIP_DIRS,
1969
+ SCAN_EXTENSIONS,
1970
+ SCAN_MAX_FILES,
1971
+ SCAN_MAX_BYTES,
1972
+ SCAN_FLOOR,
1973
+ SCAN_ABSOLUTE_MAX,
1974
+ SCREEN_CHECK_USAGE,
1975
+ scanBuildTree,
1976
+ loadDiscoveryArtifacts,
1977
+ resolveRequirements,
1978
+ parseScreenInventory,
1979
+ parseFeedbackLog,
1980
+ // Exported so the bounded-span floor can be falsified through THIS engine
1981
+ // rather than only through the shared leaf. Both grammars are two digits, so
1982
+ // the out-of-band arm is unreachable end to end and a test can only reach it
1983
+ // by calling the validator with the numbers directly (04-SECURITY AR-04-01).
1984
+ validateScreenSequence,
1985
+ validateRoundSequence,
1986
+ resolveCrossArtifact,
1987
+ resolveRequirementRefs,
1988
+ computeCoverage,
1989
+ renderCoverageBlock,
1990
+ enforceCoverage,
1991
+ formatViolation,
1992
+ renderViolations,
1993
+ checkScreenInventory,
1994
+ cmdDiscoveryScreenCheck,
1995
+ };