@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,979 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery research-track enforcement engine (discovery-v2 Phase 3,
4
+ * RTRK-01/RTRK-02/RTRK-03 + TRACE-05, D-33..D-48).
5
+ *
6
+ * Read-only structural validator over the three per-engagement research
7
+ * registers — `.planning/discovery/_research.md` (the backlog),
8
+ * `_risks.md` (the data-gathering register) and `_sparring.md` (the position
9
+ * tracker) — plus every `⚠️ CONDITIONAL —` marker in the session files and
10
+ * `_context.md` that cites them.
11
+ *
12
+ * Fail-loud (D-42), one bullet per fault class:
13
+ * - bad_id / duplicate_id — a register row whose ID is not `^(RS|DG|SP)-\d{2}$`,
14
+ * or a second row claiming an ID already seen
15
+ * - sequence_gap — a family's observed numbers are not exactly `01..max`;
16
+ * the reported id is the MISSING one, so a deleted row cannot lapse (D-41)
17
+ * - bad_enum — Priority (D-34), Affects, Source (D-37), Feeds (D-40) or
18
+ * Status outside its closed vocabulary, carrying the offending `field`
19
+ * - closed_without_evidence — a bare `Closed`, or `Closed (<evidence>)` whose
20
+ * evidence is neither `D-<ss>-<n>` nor a repo-relative file that exists (D-35)
21
+ * - missing_owner — an `Open` row with an empty Owner cell
22
+ * - unknown_requirement_id / gates_nothing — a `Gates what?` / `Blocks` ID
23
+ * absent from `_requirements.md`, or a `Gates-MVP` item gating nothing (D-36)
24
+ * - unknown_marker_target / stale_marker — a ⚠️ marker naming an ID with no
25
+ * register row, or citing an item already `Closed` (TRACE-05)
26
+ * - conditional_frontmatter_drift — a file's `conditional:` frontmatter and its
27
+ * body markers disagree; derived state is validated, never trusted (D-12)
28
+ * - requirements_unparseable — `_requirements.md` fails its own gate; reported
29
+ * ONCE, naming the owning gate, never by re-enumerating rollup's errors
30
+ * - artifact_unreadable — an artifact the directory listing named but that
31
+ * could not be read. Deliberately distinguished from the D-42 absent arm:
32
+ * "could not read it, so it's fine" is a fail-open on a gate whose whole
33
+ * purpose is structural verification
34
+ *
35
+ * Graceful arms (D-42), all three independent: no `.planning/discovery/`
36
+ * directory at all exits 0 with an empty `artifacts_checked`; a register absent
37
+ * while a sibling is present checks the one it has and stays silent about the
38
+ * one it does not; `_requirements.md` absent skips only the D-36 existence
39
+ * check and every other fault class still runs.
40
+ *
41
+ * Content-free contract (T-03-04): output names artifact paths, `<file>:<row>`
42
+ * locators, item IDs that have already passed their family regex, field names,
43
+ * BOUNDED enum-cell prefixes, BOUNDED marker literals and snake_case reason
44
+ * codes — and nothing else. An unvalidated cell is never echoed at all: a
45
+ * malformed ID is located by row number, and every remaining detail is
46
+ * whitespace-collapsed and capped at `ECHO_MAX_CHARS` by `sanitizeEcho`, the
47
+ * single chokepoint inside `formatViolation`. The bound is a confidentiality
48
+ * control, not a formatting preference — register prose is derived from client
49
+ * documents (D-24). Never-blocks contract (D-43): unfinished research
50
+ * is not a fault — a register whose only notable feature is an `Open`
51
+ * `Gates-MVP` item exits 0 and reports that item under `open_by_priority`.
52
+ */
53
+ var __importDefault = (this && this.__importDefault) || function (mod) {
54
+ return (mod && mod.__esModule) ? mod : { "default": mod };
55
+ };
56
+ const node_fs_1 = __importDefault(require("node:fs"));
57
+ const node_path_1 = __importDefault(require("node:path"));
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
59
+ const io = require("./io.cjs");
60
+ const { output, error, ERROR_REASON } = io;
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const fm = require("./frontmatter.cjs");
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const rollup = require("./discovery-rollup.cjs");
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const table = require("./discovery-table.cjs");
67
+ const { splitTableCells, boundedSequenceMax } = table;
68
+ // ─── Types ────────────────────────────────────────────────────────────────────
69
+ /**
70
+ * The closed set of reason codes this engine can emit, in declaration order.
71
+ *
72
+ * Exported as a runtime VALUE, not merely as a type, specifically so the
73
+ * parser-contract table in `pp-core/references/discovery-research-track.md` can
74
+ * be pinned against it by a test instead of by a manual diff. Verification gap 6
75
+ * was a documented-but-unenforced claim in that table, and what made the audit
76
+ * manual in the first place was that this was a compile-time-only union with
77
+ * nothing to enumerate at runtime. `ViolationReason` is derived from the tuple,
78
+ * so a code added to one is a code added to both.
79
+ *
80
+ * Append-only by convention: `artifact_unreadable` sits last so the twelve
81
+ * earlier entries keep their positions and the reference table extends by a row
82
+ * rather than being reordered.
83
+ */
84
+ const VIOLATION_REASONS = Object.freeze([
85
+ 'bad_id',
86
+ 'duplicate_id',
87
+ 'sequence_gap',
88
+ 'bad_enum',
89
+ 'missing_owner',
90
+ 'gates_nothing',
91
+ 'closed_without_evidence',
92
+ 'unknown_requirement_id',
93
+ 'unknown_marker_target',
94
+ 'stale_marker',
95
+ 'requirements_unparseable',
96
+ 'conditional_frontmatter_drift',
97
+ 'artifact_unreadable',
98
+ ]);
99
+ // ─── Constants ────────────────────────────────────────────────────────────────
100
+ /** D-34: the three research priorities. `Gates-MVP` is the only blocking-shaped one. */
101
+ const PRIORITY = Object.freeze({
102
+ GATES_MVP: 'Gates-MVP',
103
+ BEFORE_GO_LIVE: 'Before-go-live',
104
+ PARALLEL: 'Parallel',
105
+ });
106
+ /** D-35: one close grammar across all three registers — `Open` or `Closed (<evidence>)`. */
107
+ const ITEM_STATUS = Object.freeze({ OPEN: 'Open', CLOSED: 'Closed' });
108
+ /** The `_risks.md` Affects vocabulary (lowercase). */
109
+ const AFFECTS = Object.freeze({ INPUT: 'input', PROCESS: 'process', OUTPUT: 'output' });
110
+ /** The marker literal — single home: references/discovery-id-conventions.md § Conditional Marker. */
111
+ const CONDITIONAL_MARKER = '⚠️ CONDITIONAL —';
112
+ /**
113
+ * The confidentiality bound on every echoed detail (T-03-04, D-24) — a
114
+ * whitespace-collapsed prefix length, not a formatting preference. Register
115
+ * prose is derived from client documents, so the report caps what it can
116
+ * repeat rather than trusting each call site to be careful; applying it at ONE
117
+ * place (`sanitizeEcho`, inside `formatViolation`) is what stops a future
118
+ * fault class from reopening the hole.
119
+ *
120
+ * The value is chosen from BOTH sides. Above it sits nothing legitimate: the
121
+ * longest echo the contract itself produces is the marker literal
122
+ * `⚠️ CONDITIONAL — RS-01`, which is 22 JS characters because `⚠️` is two code
123
+ * units — so 24 leaves exactly 2 characters of headroom and a well-formed
124
+ * value is never truncated. Below it sits the security suite's 33-character
125
+ * canary, which must not fit. Both sides are pinned by test rather than by
126
+ * this comment: 2 characters of headroom is thin enough that a longer ID
127
+ * family or an edit to the marker literal would silently truncate valid
128
+ * output, and an unpinned floor would let a later edit lower the constant into
129
+ * legitimate output with no test explaining the breakage.
130
+ */
131
+ const ECHO_MAX_CHARS = 24;
132
+ /**
133
+ * The cap on how many violations the failure message RENDERS. A register with
134
+ * thousands of malformed rows would otherwise inline one line each into the
135
+ * `--json-errors` JSON `message` (03-REVIEW IN-05). The cap bounds the report,
136
+ * never the accounting: the count in the message prefix and the `violations`
137
+ * field of the failure-path summary both stay the true total, and the tail line
138
+ * names how many were withheld.
139
+ */
140
+ const MAX_REPORTED_VIOLATIONS = 50;
141
+ const DISCOVERY_DIR = '.planning/discovery';
142
+ const RESEARCH_FILE = '_research.md';
143
+ const RISKS_FILE = '_risks.md';
144
+ const SPARRING_FILE = '_sparring.md';
145
+ const REQUIREMENTS_FILE = '_requirements.md';
146
+ const CONTEXT_FILE = '_context.md';
147
+ /** The em dash an author writes to mean "nothing here" in an ID-bearing cell. */
148
+ const NOT_APPLICABLE = '—';
149
+ // Anchored, non-backtracking regexes (threat T-03-02). Every one of them is
150
+ // applied either to a SINGLE already-split line or to a SINGLE already-split
151
+ // table cell — never unanchored across whole-file untrusted content. The
152
+ // register content may be derived from client documents, so a catastrophic
153
+ // backtracking pattern here would be a denial-of-service surface.
154
+ const RS_ID_RE = /^RS-\d{2}$/;
155
+ const DG_ID_RE = /^DG-\d{2}$/;
156
+ const SP_ID_RE = /^SP-\d{2}$/;
157
+ const SEPARATOR_ROW_RE = /^\s*\|[\s:|-]+\|\s*$/;
158
+ /** D-35 evidence form 1: a decision ID. Form 2 is an existing repo-relative path. */
159
+ const EVIDENCE_DECISION_RE = /^D-\d{2}-\d+$/;
160
+ const CLOSED_EVIDENCE_RE = /^Closed \((.+)\)$/;
161
+ /** D-37 Source vocabulary. */
162
+ const SOURCE_RE = /^(DG-\d{2}|SP-\d{2}|S\d+|D-\d{2}-\d+)$/;
163
+ /** D-40 Feeds: comma-separated session refs. */
164
+ const FEEDS_RE = /^S\d+$/;
165
+ /** A requirement ID as `_requirements.md` mints them (rollup's ROW_ID_RE shape). */
166
+ const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
167
+ /**
168
+ * Deliberately PERMISSIVE: it captures any non-whitespace token before the em
169
+ * dash rather than baking `SP-\d{2}` into the pattern. The strict form made
170
+ * `SP_ID_RE` unreachable, so a malformed heading matched nothing, fell through
171
+ * the `### ` flush and vanished with zero violations (03-REVIEW WR-01) — a
172
+ * warn-and-skip in all but name, which D-08 forbids. The family claim is what
173
+ * scopes the parser now, exactly as the two table parsers scope themselves on
174
+ * their first cell; validation is `SP_ID_RE`'s job, done in the loop.
175
+ */
176
+ const SPARRING_HEAD_RE = /^### (\S+) — (.+)$/;
177
+ const SPARRING_FIELD_RE = /^- \*\*(Position|Key point|To nail down|DECISION|Residual risk accepted|Feeds|Status):\*\* (.*)$/;
178
+ /** D-45: the marker owns its whole line; the optional continuation is ` · <description>`. */
179
+ const CONDITIONAL_RE = /^⚠️ CONDITIONAL — (RS-\d{2}|DG-\d{2})( · .+)?$/;
180
+ /** Fixed-shape filename filter — never a regex over untrusted content. */
181
+ const SESSION_FILE_RE = /^session-\d{2}\.md$/;
182
+ const PRIORITY_LOOKUP = new Map(Object.values(PRIORITY).map(v => [v.toLowerCase(), v]));
183
+ const AFFECTS_LOOKUP = new Map(Object.values(AFFECTS).map(v => [v.toLowerCase(), v]));
184
+ // ─── Shared cell helpers ──────────────────────────────────────────────────────
185
+ // The escape-aware cell splitter this module used to own now lives in the
186
+ // `discovery-table.cjs` leaf as `splitTableCells` (discovery-v2 Phase 4, D-60), so the
187
+ // screen-inventory parser reads free-text cells by exactly the same rule rather
188
+ // than by a second copy of it. The behaviour is unchanged: `Item`
189
+ // (`_research.md`) and `Data to obtain` (`_risks.md`) are declared FREE TEXT by
190
+ // the reference, so a naive `.split('|')` would shift every subsequent column
191
+ // (`03-REVIEW.md` § CR-02); the correctly-escaped `\|` is the author's
192
+ // documented workaround, written into the Shared grammar by Plan 03-09. The
193
+ // regex rationale (fixed-width, quantifier-free lookbehind over a single
194
+ // already-split line — T-03-02) travelled with the function.
195
+ /**
196
+ * Collect the pipe-table row lines of a register. Unlike the rollup parser's
197
+ * `sectionRows`, a `## ` heading here is a section START, not a stop: D-32
198
+ * makes H2s theme groupings inside `_research.md`, so the register is the union
199
+ * of every themed table. Header and separator rows are skipped by SHAPE, not by
200
+ * header text — the positional contract (D-26 precedent).
201
+ */
202
+ function registerRows(content) {
203
+ const lines = content.split(/\r?\n/);
204
+ const rows = [];
205
+ for (let i = 0; i < lines.length; i += 1) {
206
+ const line = lines[i];
207
+ const trimmed = line.trim();
208
+ if (!trimmed.includes('|'))
209
+ continue;
210
+ if (SEPARATOR_ROW_RE.test(line))
211
+ continue;
212
+ if (i + 1 < lines.length && SEPARATOR_ROW_RE.test(lines[i + 1]))
213
+ continue;
214
+ rows.push({ line, num: i + 1 });
215
+ }
216
+ return rows;
217
+ }
218
+ /**
219
+ * D-41 sequence integrity. For a family with any rows, the observed numbers
220
+ * must be exactly `01..max`; each hole is reported as a `sequence_gap` whose
221
+ * `id` is the MISSING id — that is what makes non-lapse mechanical: a deleted
222
+ * row leaves a hole the parser names, so history is never the enforcement
223
+ * mechanism. A family with zero rows has no gaps.
224
+ *
225
+ * The maximum is taken through the shared `boundedSequenceMax` fold rather than
226
+ * `Math.max(...seqs)`. This engine's exposure was never the screens engine's
227
+ * uncatchable out-of-memory abort — `RS-`/`DG-`/`SP-` are all two digits, so
228
+ * the value cannot exceed 99 and the loop below cannot amplify — it was a
229
+ * `RangeError` from the spread on a register of a few hundred thousand rows,
230
+ * which the CLI already catches. Symmetry and defence in depth, adopted so the
231
+ * two engines cannot drift again (04-SECURITY AR-04-02).
232
+ */
233
+ function validateSequence(artifact, family, seqs) {
234
+ const violations = [];
235
+ if (seqs.length === 0)
236
+ return violations;
237
+ const seen = new Set(seqs);
238
+ const max = boundedSequenceMax(seqs);
239
+ if (max === null) {
240
+ // Unreachable through the two-digit family grammars, and stated as such:
241
+ // this is the independent floor, not the primary guard. One bounded fault
242
+ // instead of a gap list nobody could read.
243
+ return [{ artifact, reason: 'bad_id' }];
244
+ }
245
+ for (let n = 1; n <= max; n += 1) {
246
+ if (!seen.has(n)) {
247
+ violations.push({ artifact, id: `${family}-${String(n).padStart(2, '0')}`, reason: 'sequence_gap' });
248
+ }
249
+ }
250
+ return violations;
251
+ }
252
+ /**
253
+ * Does a `Closed (<path>)` evidence cell name an existing REGULAR FILE strictly
254
+ * beneath the project root?
255
+ *
256
+ * That is the whole rule, and it is the only mechanism enforcing the Definition
257
+ * of Done: closing a research item means pointing at the decision that settled
258
+ * it or the artifact that answered it, so a directory is neither and is
259
+ * refused. The project root is refused with it — `existsSync` plus the old
260
+ * `target !== root` allowance made `Closed (.)` a one-character bypass of the
261
+ * whole rule (03-REVIEW WR-02). A symlink to a directory is refused for the
262
+ * same reason, since `statSync` resolves it before the type test.
263
+ *
264
+ * The T-03-07 posture is unchanged and merely narrowed from exists to
265
+ * is-a-regular-file: an absolute path, a null byte, or anything escaping `cwd`
266
+ * after resolution is refused outright rather than probed, the file is NEVER
267
+ * read, and nothing but resolves / does-not-resolve is ever reported — so the
268
+ * cell remains unusable as an exfiltration channel.
269
+ */
270
+ function evidenceResolves(cwd, evidence) {
271
+ if (evidence.includes('\0') || node_path_1.default.isAbsolute(evidence))
272
+ return false;
273
+ const root = node_path_1.default.resolve(cwd);
274
+ const target = node_path_1.default.resolve(root, evidence);
275
+ // Strictly beneath: with the root allowance gone, `.` resolves to the root
276
+ // itself and is refused here, before any filesystem call happens at all.
277
+ if (!target.startsWith(root + node_path_1.default.sep))
278
+ return false;
279
+ try {
280
+ return node_fs_1.default.statSync(target).isFile();
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ }
286
+ /**
287
+ * D-35 status grammar, shared by all three registers. `Open` passes.
288
+ * `Closed (<evidence>)` passes when the evidence is a decision ID or an
289
+ * existing repo-relative file. A bare `Closed`, or evidence that does not
290
+ * resolve, is `closed_without_evidence` — "we talked about it" is not a close.
291
+ * Anything that is neither `Open` nor `Closed`-stemmed is `bad_enum`.
292
+ */
293
+ function validateStatus(cwd, artifact, id, cell) {
294
+ const value = cell.trim();
295
+ if (value === ITEM_STATUS.OPEN)
296
+ return [];
297
+ if (!value.startsWith(ITEM_STATUS.CLOSED)) {
298
+ return [{ artifact, id, reason: 'bad_enum', field: 'status', cell: value }];
299
+ }
300
+ const match = CLOSED_EVIDENCE_RE.exec(value);
301
+ const evidence = match ? match[1].trim() : '';
302
+ if (evidence === '')
303
+ return [{ artifact, id, reason: 'closed_without_evidence', evidence: '' }];
304
+ if (EVIDENCE_DECISION_RE.test(evidence))
305
+ return [];
306
+ if (evidenceResolves(cwd, evidence))
307
+ return [];
308
+ return [{ artifact, id, reason: 'closed_without_evidence', evidence }];
309
+ }
310
+ /**
311
+ * D-36 existence check for an ID-bearing join cell (`Gates what?` / `Blocks`).
312
+ * `—` gates nothing and is handled by the caller. Every comma-separated token
313
+ * must both look like a requirement ID and be present in `_requirements.md`;
314
+ * a token that fails either test is reported with the token itself, never the
315
+ * surrounding cell prose.
316
+ */
317
+ function validateRequirementRefs(artifact, id, cell, knownIds) {
318
+ const violations = [];
319
+ for (const token of cell.split(',').map(t => t.trim()).filter(Boolean)) {
320
+ if (!REQUIREMENT_ID_RE.test(token) || !knownIds.has(token)) {
321
+ violations.push({ artifact, id, reason: 'unknown_requirement_id', cell: token });
322
+ }
323
+ }
324
+ return violations;
325
+ }
326
+ /** Comma-separated `S\d+` list, or the em dash (D-40). */
327
+ function feedsAreValid(cell) {
328
+ if (cell === NOT_APPLICABLE || cell === '')
329
+ return true;
330
+ return cell.split(',').map(t => t.trim()).filter(Boolean).every(t => FEEDS_RE.test(t));
331
+ }
332
+ // ─── Register parsers ─────────────────────────────────────────────────────────
333
+ /**
334
+ * Parse `_research.md` into typed rows. Column order is positional
335
+ * (`ID | Item | Priority | Gates what? | Source | Owner | Status`) — header
336
+ * text is never matched, so a header respelling cannot silently change what
337
+ * is parsed. Only the shape/enum faults live here; the cross-artifact checks
338
+ * (status evidence, requirement existence) need `cwd` and the requirement ID
339
+ * set, so they run in `checkResearchTrack`.
340
+ */
341
+ function parseResearchBacklog(content) {
342
+ const rows = [];
343
+ const violations = [];
344
+ if (typeof content !== 'string' || content === '')
345
+ return { rows, violations };
346
+ const seen = new Set();
347
+ for (const { line, num } of registerRows(content)) {
348
+ const cells = splitTableCells(line);
349
+ const id = cells[0] ?? '';
350
+ // A row is a register row when its first cell claims the family prefix.
351
+ // Rows belonging to some other table in the document are not this
352
+ // register's business; a claimed-but-malformed ID is (never warn-and-skip)
353
+ // — and it is reported by its position in the file, never by its content.
354
+ if (!id.startsWith('RS-'))
355
+ continue;
356
+ if (!RS_ID_RE.test(id)) {
357
+ // Reported by POSITION, never by content: the first cell has not passed
358
+ // any validation at this point, so it is arbitrary author prose that the
359
+ // report must not repeat (T-03-04). The row number the loop already
360
+ // holds locates it exactly as well.
361
+ violations.push({ artifact: RESEARCH_FILE, reason: 'bad_id', line: num });
362
+ continue;
363
+ }
364
+ if (seen.has(id)) {
365
+ violations.push({ artifact: RESEARCH_FILE, id, reason: 'duplicate_id' });
366
+ continue;
367
+ }
368
+ seen.add(id);
369
+ const priorityCell = cells[2] ?? '';
370
+ const gates = cells[3] ?? '';
371
+ const sourceCell = cells[4] ?? '';
372
+ const owner = cells[5] ?? '';
373
+ const status = cells[6] ?? '';
374
+ const priority = PRIORITY_LOOKUP.get(priorityCell.toLowerCase()) ?? '';
375
+ if (priority === '') {
376
+ violations.push({ artifact: RESEARCH_FILE, id, reason: 'bad_enum', field: 'priority', cell: priorityCell });
377
+ }
378
+ if (sourceCell !== NOT_APPLICABLE && sourceCell !== '' && !SOURCE_RE.test(sourceCell)) {
379
+ violations.push({ artifact: RESEARCH_FILE, id, reason: 'bad_enum', field: 'source', cell: sourceCell });
380
+ }
381
+ if (owner === '' && status.trim() === ITEM_STATUS.OPEN) {
382
+ violations.push({ artifact: RESEARCH_FILE, id, reason: 'missing_owner' });
383
+ }
384
+ // D-36: an item declared to gate the MVP that gates nothing is a
385
+ // contradiction, and it is a contradiction whether or not
386
+ // `_requirements.md` exists — so this check never depends on the ID set.
387
+ if (priority === PRIORITY.GATES_MVP && (gates === NOT_APPLICABLE || gates === '')) {
388
+ violations.push({ artifact: RESEARCH_FILE, id, reason: 'gates_nothing' });
389
+ }
390
+ rows.push({
391
+ id,
392
+ seq: parseInt(id.slice(3), 10),
393
+ item: cells[1] ?? '',
394
+ priority,
395
+ gates,
396
+ source: sourceCell,
397
+ owner,
398
+ status,
399
+ line: num,
400
+ });
401
+ }
402
+ violations.push(...validateSequence(RESEARCH_FILE, 'RS', rows.map(r => r.seq)));
403
+ return { rows, violations };
404
+ }
405
+ /**
406
+ * Parse `_risks.md` — the data-gathering register, NOT a risk log. Positional
407
+ * order `ID | Data to obtain | Affects | Blocks | Owner | Feeds | Status`.
408
+ */
409
+ function parseRiskRegister(content) {
410
+ const rows = [];
411
+ const violations = [];
412
+ if (typeof content !== 'string' || content === '')
413
+ return { rows, violations };
414
+ const seen = new Set();
415
+ for (const { line, num } of registerRows(content)) {
416
+ const cells = splitTableCells(line);
417
+ const id = cells[0] ?? '';
418
+ if (!id.startsWith('DG-'))
419
+ continue;
420
+ if (!DG_ID_RE.test(id)) {
421
+ // As in `parseResearchBacklog`: a claimed-but-malformed ID is this
422
+ // register's business (never warn-and-skip), but the row is reported by
423
+ // position, never by content.
424
+ violations.push({ artifact: RISKS_FILE, reason: 'bad_id', line: num });
425
+ continue;
426
+ }
427
+ if (seen.has(id)) {
428
+ violations.push({ artifact: RISKS_FILE, id, reason: 'duplicate_id' });
429
+ continue;
430
+ }
431
+ seen.add(id);
432
+ const affectsCell = cells[2] ?? '';
433
+ const owner = cells[4] ?? '';
434
+ const feeds = cells[5] ?? '';
435
+ const status = cells[6] ?? '';
436
+ const affects = AFFECTS_LOOKUP.get(affectsCell.toLowerCase()) ?? '';
437
+ if (affects === '') {
438
+ violations.push({ artifact: RISKS_FILE, id, reason: 'bad_enum', field: 'affects', cell: affectsCell });
439
+ }
440
+ if (!feedsAreValid(feeds)) {
441
+ violations.push({ artifact: RISKS_FILE, id, reason: 'bad_enum', field: 'feeds', cell: feeds });
442
+ }
443
+ if (owner === '' && status.trim() === ITEM_STATUS.OPEN) {
444
+ violations.push({ artifact: RISKS_FILE, id, reason: 'missing_owner' });
445
+ }
446
+ rows.push({
447
+ id,
448
+ seq: parseInt(id.slice(3), 10),
449
+ data: cells[1] ?? '',
450
+ affects,
451
+ blocks: cells[3] ?? '',
452
+ owner,
453
+ feeds,
454
+ status,
455
+ line: num,
456
+ });
457
+ }
458
+ violations.push(...validateSequence(RISKS_FILE, 'DG', rows.map(r => r.seq)));
459
+ return { rows, violations };
460
+ }
461
+ /**
462
+ * Parse `_sparring.md` — a BLOCK parser, not a table parser (D5). A position
463
+ * evolves in place, including reversals, so its fields are multi-sentence prose
464
+ * that a pipe table cannot hold; the H3 block form also removes `|`-in-prose as
465
+ * a parser-fragility class entirely. `SPARRING_HEAD_RE` proposes a position,
466
+ * `SPARRING_FIELD_RE` fills it, and the next `### ` or EOF closes it.
467
+ *
468
+ * Every `### ` line is handled by ONE branch, which decides in this order: the
469
+ * head regex is permissive, so the candidate token is taken from its capture or
470
+ * — when the heading is too malformed to match at all — from the first word
471
+ * after the prefix. A token that does not claim the `SP-` family is not this
472
+ * register's business and is silently ignored (an appendix H3 must never become
473
+ * a false fault). A token that DOES claim the family and then fails either the
474
+ * full heading shape or `SP_ID_RE` is a `bad_id` — never warn-and-skip (D-08).
475
+ * Only a token passing both opens a position.
476
+ *
477
+ * The violation reports the row position ONLY: at that point the token has
478
+ * passed no validation, so there is nothing about it safe to quote (T-03-04),
479
+ * which is the same content-free shape the two table registers emit.
480
+ */
481
+ function parseSparringTracker(content) {
482
+ const rows = [];
483
+ const violations = [];
484
+ if (typeof content !== 'string' || content === '')
485
+ return { rows, violations };
486
+ const seen = new Set();
487
+ const lines = content.split(/\r?\n/);
488
+ let current = null;
489
+ let currentValid = false;
490
+ const flush = () => {
491
+ if (current && currentValid)
492
+ rows.push(current);
493
+ current = null;
494
+ currentValid = false;
495
+ };
496
+ for (let i = 0; i < lines.length; i += 1) {
497
+ const line = lines[i];
498
+ if (line.startsWith('### ')) {
499
+ const head = SPARRING_HEAD_RE.exec(line);
500
+ // When the heading is too malformed for the head regex, the candidate is
501
+ // still recoverable as the first word after the prefix — which is what
502
+ // lets `### SP-01 Retention window` be caught rather than dropped.
503
+ const token = head !== null ? head[1] : (line.slice(4).trim().split(/\s+/)[0] ?? '');
504
+ // Any `### ` closes the open block, whoever it belongs to.
505
+ flush();
506
+ if (!token.startsWith('SP-'))
507
+ continue;
508
+ if (head === null || !SP_ID_RE.test(token)) {
509
+ violations.push({ artifact: SPARRING_FILE, reason: 'bad_id', line: i + 1 });
510
+ continue;
511
+ }
512
+ const id = token;
513
+ if (seen.has(id)) {
514
+ violations.push({ artifact: SPARRING_FILE, id, reason: 'duplicate_id' });
515
+ continue;
516
+ }
517
+ seen.add(id);
518
+ current = {
519
+ id,
520
+ seq: parseInt(id.slice(3), 10),
521
+ question: head[2],
522
+ position: '',
523
+ decision: '',
524
+ feeds: '',
525
+ status: '',
526
+ line: i + 1,
527
+ };
528
+ currentValid = true;
529
+ continue;
530
+ }
531
+ if (!current)
532
+ continue;
533
+ const field = SPARRING_FIELD_RE.exec(line);
534
+ if (!field)
535
+ continue;
536
+ const value = field[2].trim();
537
+ if (field[1] === 'Position')
538
+ current.position = value;
539
+ else if (field[1] === 'DECISION')
540
+ current.decision = value;
541
+ else if (field[1] === 'Feeds')
542
+ current.feeds = value;
543
+ else if (field[1] === 'Status')
544
+ current.status = value;
545
+ }
546
+ flush();
547
+ for (const row of rows) {
548
+ if (!feedsAreValid(row.feeds)) {
549
+ violations.push({ artifact: SPARRING_FILE, id: row.id, reason: 'bad_enum', field: 'feeds', cell: row.feeds });
550
+ }
551
+ }
552
+ violations.push(...validateSequence(SPARRING_FILE, 'SP', rows.map(r => r.seq)));
553
+ return { rows, violations };
554
+ }
555
+ // ─── Conditional markers (TRACE-05) ───────────────────────────────────────────
556
+ /**
557
+ * Find every well-formed `⚠️ CONDITIONAL —` marker. Split by line FIRST, then
558
+ * apply the anchored regex to each single line (T-03-02). D-45 requires the
559
+ * marker to own its whole line, so a marker mentioned mid-sentence in prose is
560
+ * not a marker and is deliberately not returned.
561
+ */
562
+ function findConditionalMarkers(content) {
563
+ const markers = [];
564
+ if (typeof content !== 'string' || content === '')
565
+ return markers;
566
+ const lines = content.split(/\r?\n/);
567
+ for (let i = 0; i < lines.length; i += 1) {
568
+ const trimmed = lines[i].trim();
569
+ if (!trimmed.startsWith(CONDITIONAL_MARKER))
570
+ continue;
571
+ const match = CONDITIONAL_RE.exec(trimmed);
572
+ if (!match)
573
+ continue;
574
+ markers.push({ id: match[1], line: i + 1, description: match[2] ? match[2].slice(3) : '' });
575
+ }
576
+ return markers;
577
+ }
578
+ /**
579
+ * Lines that CLAIM to be markers but fail the anchored grammar — an
580
+ * unrecognised ID family, a typo'd ID, a stray suffix. Returned as sanitized
581
+ * literals (`<marker> <bounded first token>`) so a malformed marker can never
582
+ * hide behind its own malformedness, while its trailing prose still cannot
583
+ * leak into the report (T-03-04).
584
+ *
585
+ * First-token-only is NOT sufficient on its own: the first whitespace-
586
+ * delimited token is itself unbounded author text, and a hyphen-joined client
587
+ * phrase is a single token (reproduced in `03-REVIEW.md` § CR-01). So the
588
+ * token is length-bounded here as well, and the assembled literal passes
589
+ * through `sanitizeEcho` again at the `formatViolation` chokepoint.
590
+ */
591
+ function findMalformedMarkers(content) {
592
+ const malformed = [];
593
+ if (typeof content !== 'string' || content === '')
594
+ return malformed;
595
+ for (const line of content.split(/\r?\n/)) {
596
+ const trimmed = line.trim();
597
+ if (!trimmed.startsWith(CONDITIONAL_MARKER))
598
+ continue;
599
+ if (CONDITIONAL_RE.test(trimmed))
600
+ continue;
601
+ const token = trimmed.slice(CONDITIONAL_MARKER.length).trim().split(/\s+/)[0] ?? '';
602
+ malformed.push(`${CONDITIONAL_MARKER} ${sanitizeEcho(token)}`.trim());
603
+ }
604
+ return malformed;
605
+ }
606
+ /**
607
+ * The `conditional:` frontmatter value, normalized to a string array — read as
608
+ * an OWN property or not at all.
609
+ *
610
+ * D-12 inverted: frontmatter is parsed from an untrusted session file, so
611
+ * `conditional:` is derived state to be validated, never trusted. The concrete
612
+ * attack (`03-REVIEW.md` § CR-03, reproduced): `extractFrontmatter`'s key regex
613
+ * `[a-zA-Z0-9_-]+` admits `__proto__`, and assigning that key onto a plain
614
+ * object literal SETS THE PROTOTYPE rather than creating an own property — so a
615
+ * `__proto__:` block nesting `conditional:` satisfies an unguarded lookup,
616
+ * silently passing the drift gate AND writing its own value into the report's
617
+ * `extra` array. An inherited `conditional` must therefore never satisfy this
618
+ * read. This is the standing project rule in `.claude/CLAUDE.md`
619
+ * ("Prototype pollution guards in config access"), and the same
620
+ * parsed-content-never-touches-an-object-literal discipline the sibling parser
621
+ * documents at `src/discovery-rollup.cts:189-191`.
622
+ */
623
+ function conditionalFrontmatter(front) {
624
+ if (!Object.prototype.hasOwnProperty.call(front, 'conditional'))
625
+ return [];
626
+ const value = front.conditional;
627
+ if (Array.isArray(value))
628
+ return value.map(v => String(v).trim()).filter(Boolean);
629
+ if (typeof value === 'string' && value.trim() !== '')
630
+ return [value.trim()];
631
+ return [];
632
+ }
633
+ // ─── Aggregation ──────────────────────────────────────────────────────────────
634
+ /**
635
+ * Aggregate the full research-track check for a project root. Collect-all-then-
636
+ * fail (D-08 carried forward): every fault class runs, all violations are
637
+ * accumulated, and the result is returned once. This layer never throws — an
638
+ * unreadable file degrades to "not present" rather than crashing the gate.
639
+ *
640
+ * Never-blocks (D-43): an `Open` item — including an `Open` `Gates-MVP` item —
641
+ * is NOT a violation. It is counted into `open_by_priority` so Session 13 can
642
+ * render "N sections conditional on M open research items" and sign off anyway.
643
+ */
644
+ function checkResearchTrack(cwd) {
645
+ const violations = [];
646
+ const dir = node_path_1.default.join(cwd, DISCOVERY_DIR);
647
+ let names = [];
648
+ try {
649
+ names = node_fs_1.default.readdirSync(dir);
650
+ }
651
+ catch {
652
+ names = []; // no discovery package at all — graceful arm (D-42)
653
+ }
654
+ const present = new Set(names);
655
+ // The `present.has(file)` early return is what discriminates the two
656
+ // conditions, and it must stay that way: ABSENT is the D-42 graceful arm and
657
+ // is silent, while PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling
658
+ // symlink) is a fault. Re-collapsing them would let the gate exit 0 having
659
+ // validated nothing at all — a fail-open on a structural verifier (WR-05).
660
+ // The caller does not add the file to `artifactsChecked` either: an
661
+ // unreadable artifact was not checked, and saying it was would be a second,
662
+ // quieter overstatement.
663
+ const readArtifact = (file) => {
664
+ if (!present.has(file))
665
+ return null;
666
+ try {
667
+ return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
668
+ }
669
+ catch {
670
+ violations.push({ artifact: file, reason: 'artifact_unreadable' });
671
+ return null;
672
+ }
673
+ };
674
+ // Each register is read independently, so `_risks.md` present while
675
+ // `_research.md` is absent checks the one it has (graceful arm, per file).
676
+ const artifactsChecked = [];
677
+ const researchContent = readArtifact(RESEARCH_FILE);
678
+ const risksContent = readArtifact(RISKS_FILE);
679
+ const sparringContent = readArtifact(SPARRING_FILE);
680
+ if (researchContent !== null)
681
+ artifactsChecked.push(RESEARCH_FILE);
682
+ if (risksContent !== null)
683
+ artifactsChecked.push(RISKS_FILE);
684
+ if (sparringContent !== null)
685
+ artifactsChecked.push(SPARRING_FILE);
686
+ const research = parseResearchBacklog(researchContent ?? '');
687
+ const risks = parseRiskRegister(risksContent ?? '');
688
+ const sparring = parseSparringTracker(sparringContent ?? '');
689
+ violations.push(...research.violations, ...risks.violations, ...sparring.violations);
690
+ // `_requirements.md` is owned by `discovery rollup`; this gate consumes its
691
+ // single exported reader and NOTHING else. A grammar fault there is reported
692
+ // once, by name, pointing at the owning gate — never re-enumerated here.
693
+ let knownIds = null;
694
+ let requirements = 'absent';
695
+ const requirementsContent = readArtifact(REQUIREMENTS_FILE);
696
+ if (requirementsContent !== null) {
697
+ const parsed = rollup.parseRequirementsArtifact(requirementsContent);
698
+ if (parsed.ok) {
699
+ knownIds = new Set(parsed.rows.map(r => r.id));
700
+ requirements = 'ok';
701
+ }
702
+ else {
703
+ requirements = 'unparseable';
704
+ violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
705
+ }
706
+ }
707
+ // Status is a cross-artifact check (evidence paths resolve against cwd), and
708
+ // D-36 existence needs the requirement ID set — both run here, not in the
709
+ // pure parsers.
710
+ for (const row of research.rows) {
711
+ violations.push(...validateStatus(cwd, RESEARCH_FILE, row.id, row.status));
712
+ if (knownIds !== null && row.gates !== NOT_APPLICABLE && row.gates !== '') {
713
+ violations.push(...validateRequirementRefs(RESEARCH_FILE, row.id, row.gates, knownIds));
714
+ }
715
+ }
716
+ for (const row of risks.rows) {
717
+ violations.push(...validateStatus(cwd, RISKS_FILE, row.id, row.status));
718
+ if (knownIds !== null && row.blocks !== NOT_APPLICABLE && row.blocks !== '') {
719
+ violations.push(...validateRequirementRefs(RISKS_FILE, row.id, row.blocks, knownIds));
720
+ }
721
+ }
722
+ for (const row of sparring.rows) {
723
+ violations.push(...validateStatus(cwd, SPARRING_FILE, row.id, row.status));
724
+ }
725
+ // Marker targets resolve against the RS/DG registers — the only two families
726
+ // the marker grammar admits. A Map, never an object literal keyed on parsed
727
+ // content (T-03-03).
728
+ const statuses = new Map();
729
+ for (const row of research.rows)
730
+ statuses.set(row.id, row.status.trim());
731
+ for (const row of risks.rows)
732
+ statuses.set(row.id, row.status.trim());
733
+ const scanned = [
734
+ ...names.filter(n => SESSION_FILE_RE.test(n)).sort(),
735
+ ...(present.has(CONTEXT_FILE) ? [CONTEXT_FILE] : []),
736
+ ];
737
+ let conditionalSections = 0;
738
+ for (const file of scanned) {
739
+ const content = readArtifact(file);
740
+ if (content === null)
741
+ continue;
742
+ for (const marker of findMalformedMarkers(content)) {
743
+ violations.push({ file, reason: 'unknown_marker_target', marker });
744
+ }
745
+ const markers = findConditionalMarkers(content);
746
+ conditionalSections += markers.length;
747
+ const bodyIds = [];
748
+ for (const marker of markers) {
749
+ if (!bodyIds.includes(marker.id))
750
+ bodyIds.push(marker.id);
751
+ const status = statuses.get(marker.id);
752
+ const literal = `${CONDITIONAL_MARKER} ${marker.id}`;
753
+ if (status === undefined) {
754
+ violations.push({ file, reason: 'unknown_marker_target', marker: literal });
755
+ }
756
+ else if (status.startsWith(ITEM_STATUS.CLOSED)) {
757
+ violations.push({ file, reason: 'stale_marker', marker: literal });
758
+ }
759
+ }
760
+ // D-12 inversion: the `conditional:` frontmatter is derived state, so it is
761
+ // validated rather than trusted. A file with no frontmatter block at all is
762
+ // not drift-checked (graceful arm — it has no derived state to disagree).
763
+ //
764
+ // OWN properties only, for the same reason `conditionalFrontmatter` guards
765
+ // its read: a file whose only frontmatter content is a prototype-shaped key
766
+ // declares nothing, so it has no own derived state and takes the same
767
+ // graceful arm as a file with no frontmatter block at all. That is the
768
+ // honest outcome — the alternative is to treat an injected prototype as a
769
+ // declaration.
770
+ const front = fm.extractFrontmatter(content);
771
+ if (Object.getOwnPropertyNames(front).length === 0)
772
+ continue;
773
+ const declared = conditionalFrontmatter(front);
774
+ const missing = bodyIds.filter(id => !declared.includes(id));
775
+ const extra = declared.filter(id => !bodyIds.includes(id));
776
+ if (missing.length > 0 || extra.length > 0) {
777
+ violations.push({ file, reason: 'conditional_frontmatter_drift', missing, extra });
778
+ }
779
+ }
780
+ const openByPriority = {};
781
+ for (const value of Object.values(PRIORITY))
782
+ openByPriority[value] = 0;
783
+ const open = { RS: 0, DG: 0, SP: 0 };
784
+ const closed = { RS: 0, DG: 0, SP: 0 };
785
+ const tally = (family, status) => {
786
+ const value = status.trim();
787
+ if (value === ITEM_STATUS.OPEN)
788
+ open[family] += 1;
789
+ else if (value.startsWith(ITEM_STATUS.CLOSED))
790
+ closed[family] += 1;
791
+ };
792
+ for (const row of research.rows) {
793
+ tally('RS', row.status);
794
+ if (row.status.trim() === ITEM_STATUS.OPEN && row.priority !== '') {
795
+ openByPriority[row.priority] += 1;
796
+ }
797
+ }
798
+ for (const row of risks.rows)
799
+ tally('DG', row.status);
800
+ for (const row of sparring.rows)
801
+ tally('SP', row.status);
802
+ const summary = {
803
+ artifacts_checked: artifactsChecked,
804
+ open_by_priority: openByPriority,
805
+ open,
806
+ closed,
807
+ conditional_sections: conditionalSections,
808
+ requirements,
809
+ };
810
+ if (violations.length > 0)
811
+ return { ok: false, violations, ...summary };
812
+ return { ok: true, ...summary };
813
+ }
814
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
815
+ /**
816
+ * Every control character the whitespace collapse below does NOT handle: the C0
817
+ * plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
818
+ *
819
+ * Stripped BEFORE the collapse, and this ordering is the whole point.
820
+ * JavaScript `\s` matches only the five C0 whitespace characters plus the
821
+ * Unicode space separators; it does NOT match ESC (`\x1b`), BEL (`\x07`),
822
+ * BS (`\x08`) or NUL. `formatViolation` output reaches `error()`, which in the
823
+ * default non-`--json-errors` mode writes `'Error: ' + message` to fd 2
824
+ * VERBATIM, which is the mode a human operator reads in a terminal. A complete
825
+ * OSC-52 clipboard-write sequence fits inside `ECHO_MAX_CHARS`, and an
826
+ * UNTERMINATED OSC string consumes the report text after it and continues into
827
+ * the next violation's cell. Register prose is derived from client documents:
828
+ * treating it as untrusted for LENGTH while treating it as trusted for BYTES
829
+ * was the gap. Identical to the screens engine's constant, deliberately — the
830
+ * two chokepoints defend the same fd-2 surface and must not drift apart.
831
+ */
832
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
833
+ /**
834
+ * The single chokepoint through which every author-supplied detail reaches
835
+ * output. Strip the control planes, collapse whitespace, trim, and cap at
836
+ * `ECHO_MAX_CHARS`, appending an ellipsis when the value was longer.
837
+ *
838
+ * Both the strip and the collapse are load-bearing and must run BEFORE the
839
+ * slice: together they guarantee a cell can never inject a newline, carriage
840
+ * return, tab or terminal escape sequence into the one-line-per-violation
841
+ * report format, regardless of where the slice happens to land.
842
+ */
843
+ function sanitizeEcho(value) {
844
+ // A non-string is not author data this function can meaningfully bound, and
845
+ // default stringification would emit `[object Object]` — so it renders as
846
+ // nothing rather than as a placeholder that looks like content.
847
+ const raw = typeof value === 'string' ? value : '';
848
+ const collapsed = raw.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
849
+ if (collapsed.length <= ECHO_MAX_CHARS)
850
+ return collapsed;
851
+ return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
852
+ }
853
+ /**
854
+ * One content-free line per violation, two-space indented (T-03-04).
855
+ *
856
+ * This is the SINGLE chokepoint for echoed detail: every author-supplied value
857
+ * below routes through `sanitizeEcho`, so a new reason code added to the union
858
+ * inherits the confidentiality bound by construction rather than by the author
859
+ * of that code remembering it. The reason code and the enum field name are
860
+ * deliberately NOT sanitized — both come from closed unions declared in this
861
+ * module and are not author data.
862
+ *
863
+ * `bad_id` renders as locator-plus-reason only. Its cell has passed no
864
+ * validation at all, so there is nothing about it that is safe to quote; the
865
+ * `<artifact>:<row>` locator finds the row instead.
866
+ */
867
+ function formatViolation(v) {
868
+ const where = v.artifact ?? v.file ?? '';
869
+ const locator = v.line === undefined ? where : `${where}:${v.line}`;
870
+ let detail = '';
871
+ switch (v.reason) {
872
+ case 'unknown_requirement_id':
873
+ detail = `${sanitizeEcho(v.id)} → ${sanitizeEcho(v.cell)}`;
874
+ break;
875
+ case 'bad_enum':
876
+ detail = `${sanitizeEcho(v.id)} ${v.field}: ${sanitizeEcho(v.cell)}`;
877
+ break;
878
+ case 'bad_id':
879
+ detail = '';
880
+ break;
881
+ case 'unknown_marker_target':
882
+ case 'stale_marker':
883
+ detail = sanitizeEcho(v.marker);
884
+ break;
885
+ case 'requirements_unparseable':
886
+ detail = 'run: discovery rollup --check';
887
+ break;
888
+ case 'conditional_frontmatter_drift': {
889
+ // Each ELEMENT is bounded, not just the joined array: an injected or
890
+ // mistyped `conditional:` entry is author data of arbitrary length.
891
+ const missing = (v.missing ?? []).map(sanitizeEcho).join(', ');
892
+ const extra = (v.extra ?? []).map(sanitizeEcho).join(', ');
893
+ detail = `missing: [${missing}], extra: [${extra}]`;
894
+ break;
895
+ }
896
+ default:
897
+ detail = sanitizeEcho(v.id ?? '');
898
+ }
899
+ return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
900
+ }
901
+ /**
902
+ * CLI adapter for `pp-tools discovery research-check` (dotted form
903
+ * `pp-tools query discovery.research-check`).
904
+ *
905
+ * Exit semantics (D-42/D-43): any structural violation exits non-zero via
906
+ * ERROR_REASON.DISCOVERY_RESEARCH_CHECK_FAILED, naming each violation by
907
+ * artifact and reason. Unfinished research is NOT a violation — an `Open`
908
+ * `Gates-MVP` item exits 0 and is reported under `open_by_priority`. The
909
+ * command accepts no positional path argument at all: the convention paths
910
+ * under `.planning/discovery/` resolve against cwd, which eliminates the
911
+ * traversal surface rather than guarding it (T-03-01).
912
+ */
913
+ function cmdDiscoveryResearchCheck(cwd, args, raw) {
914
+ // args arrive shaped ['discovery', 'research-check', ...]; global output
915
+ // flags are consumed by the dispatcher, so anything left — a positional
916
+ // path or an unknown flag alike — is a usage error.
917
+ if (args.slice(2).length > 0) {
918
+ error('Usage: pp-tools discovery research-check', ERROR_REASON.USAGE);
919
+ return; // unreachable — error() exits
920
+ }
921
+ const result = checkResearchTrack(cwd);
922
+ if (!result.ok) {
923
+ const total = result.violations.length;
924
+ const rendered = result.violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
925
+ if (total > MAX_REPORTED_VIOLATIONS) {
926
+ rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
927
+ }
928
+ // D-43 holds on the failure path too: "the report tells you exactly what is
929
+ // still outstanding" (workflows/discovery-sprint/session-13-gates.md). A
930
+ // register with one malformed cell must not lose its outstanding-work
931
+ // report — that is exactly the state where an operator needs both halves.
932
+ // Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
933
+ // deterministic: `output` writes fd 1 synchronously and `error` exits.
934
+ //
935
+ // This payload is a second output surface and inherits the content-free
936
+ // contract (T-03-04) by construction: counts, closed-vocabulary enum keys
937
+ // and artifact filenames only, never a register cell.
938
+ output({
939
+ ok: false,
940
+ violations: total,
941
+ artifacts_checked: result.artifacts_checked,
942
+ open_by_priority: result.open_by_priority,
943
+ open: result.open,
944
+ closed: result.closed,
945
+ conditional_sections: result.conditional_sections,
946
+ requirements: result.requirements,
947
+ }, raw);
948
+ error(`discovery research-check: ${total} violation(s):\n${rendered.join('\n')}`, ERROR_REASON.DISCOVERY_RESEARCH_CHECK_FAILED);
949
+ return; // unreachable — error() exits
950
+ }
951
+ output({
952
+ ok: true,
953
+ artifacts_checked: result.artifacts_checked,
954
+ open_by_priority: result.open_by_priority,
955
+ open: result.open,
956
+ closed: result.closed,
957
+ conditional_sections: result.conditional_sections,
958
+ requirements: result.requirements,
959
+ }, raw);
960
+ }
961
+ module.exports = {
962
+ PRIORITY,
963
+ ITEM_STATUS,
964
+ AFFECTS,
965
+ CONDITIONAL_MARKER,
966
+ ECHO_MAX_CHARS,
967
+ VIOLATION_REASONS,
968
+ parseResearchBacklog,
969
+ parseRiskRegister,
970
+ parseSparringTracker,
971
+ // Exported so the bounded-span floor can be falsified through THIS engine
972
+ // rather than only through the shared leaf. All three family grammars are two
973
+ // digits, so the out-of-band arm is unreachable end to end and a test can only
974
+ // reach it by calling the validator with the numbers directly (AR-04-02).
975
+ validateSequence,
976
+ findConditionalMarkers,
977
+ checkResearchTrack,
978
+ cmdDiscoveryResearchCheck,
979
+ };