@pareto4/pp-core 1.12.1 → 1.17.1

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 (196) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +3 -2
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +7 -21
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +274 -0
  9. package/agents/pp-verifier.md +7 -3
  10. package/bin/install.js +10 -3
  11. package/commands/pp-execute-phase.md +1 -3
  12. package/commands/pp-ns-review.md +2 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +35 -0
  15. package/commands/pp-verify-work.md +1 -2
  16. package/gemini-extension.json +1 -1
  17. package/hooks/dist/pp-check-update.js +0 -0
  18. package/hooks/dist/pp-cursor-session-start.js +1 -1
  19. package/hooks/dist/pp-statusline.js +1 -1
  20. package/hooks/dist/pp-update-banner.js +0 -0
  21. package/hooks/pp-check-update.js +0 -0
  22. package/hooks/pp-cursor-session-start.js +1 -1
  23. package/hooks/pp-statusline.js +1 -1
  24. package/hooks/pp-update-banner.js +0 -0
  25. package/package.json +14 -19
  26. package/pareto-skills/_shared/create-project-board.sh +0 -0
  27. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  28. package/pp-core/bin/check-latest-version.cjs +0 -0
  29. package/pp-core/bin/detect-build-commands.sh +2 -2
  30. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  31. package/pp-core/bin/lib/capability-registry.cjs +526 -22
  32. package/pp-core/bin/lib/capability-state.cjs +160 -4
  33. package/pp-core/bin/lib/capability-writer.cjs +19 -5
  34. package/pp-core/bin/lib/check-command-router.cjs +3867 -274
  35. package/pp-core/bin/lib/clusters.cjs +4 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -28
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +253 -76
  39. package/pp-core/bin/lib/config-loader.cjs +7 -103
  40. package/pp-core/bin/lib/config-schema.cjs +1 -1
  41. package/pp-core/bin/lib/config.cjs +154 -62
  42. package/pp-core/bin/lib/decisions.cjs +104 -27
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -1
  45. package/pp-core/bin/lib/git-base-branch.cjs +138 -10
  46. package/pp-core/bin/lib/git-provider.cjs +4 -4
  47. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  48. package/pp-core/bin/lib/init-contract.cjs +653 -629
  49. package/pp-core/bin/lib/init-interactive.cjs +77 -21
  50. package/pp-core/bin/lib/init.cjs +24 -51
  51. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  52. package/pp-core/bin/lib/io.cjs +8 -0
  53. package/pp-core/bin/lib/loop-resolver.cjs +169 -47
  54. package/pp-core/bin/lib/org-profile.cjs +544 -53
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -23
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -203
  59. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  60. package/pp-core/bin/lib/practices-review-check.cjs +15 -9
  61. package/pp-core/bin/lib/profile-output.cjs +1 -1
  62. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  63. package/pp-core/bin/lib/state.cjs +11 -85
  64. package/pp-core/bin/lib/template.cjs +1 -1
  65. package/pp-core/bin/lib/vault-audit.cjs +2594 -0
  66. package/pp-core/bin/lib/vault-command-router.cjs +2756 -60
  67. package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
  68. package/pp-core/bin/lib/vault-index.cjs +699 -0
  69. package/pp-core/bin/lib/vault-mode.cjs +235 -0
  70. package/pp-core/bin/lib/vault-practices.cjs +1194 -89
  71. package/pp-core/bin/lib/vault-review.cjs +2102 -0
  72. package/pp-core/bin/lib/vault-seal.cjs +1936 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1505 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +3184 -0
  75. package/pp-core/bin/lib/vault-update.cjs +242 -0
  76. package/pp-core/bin/lib/vault-write-back.cjs +78 -31
  77. package/pp-core/bin/lib/vault.cjs +2713 -634
  78. package/pp-core/bin/lib/verify.cjs +0 -11
  79. package/pp-core/bin/portable-timeout.sh +1 -1
  80. package/pp-core/bin/pp-tools.cjs +184 -37
  81. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  82. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  83. package/pp-core/bin/shared/model-catalog.json +1 -0
  84. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  85. package/pp-core/org-profile.json +26 -0
  86. package/pp-core/references/autonomous-best-effort.md +8 -7
  87. package/pp-core/references/loop-hook-dispatch.md +0 -14
  88. package/pp-core/references/planner-gap-closure.md +0 -1
  89. package/pp-core/references/planner-tdd.md +0 -1
  90. package/pp-core/references/planner-vault-practices.md +31 -3
  91. package/pp-core/references/planner-vault-standards.md +320 -0
  92. package/pp-core/references/planning-config.md +0 -8
  93. package/pp-core/references/practice-arithmetic-contract.md +121 -0
  94. package/pp-core/references/tdd.md +0 -1
  95. package/pp-core/references/verifier-practice-verdicts.md +263 -0
  96. package/pp-core/templates/README.md +1 -0
  97. package/pp-core/templates/VAULT-REVIEW.md +202 -0
  98. package/pp-core/templates/phase-prompt.md +2 -6
  99. package/pp-core/templates/practices/README.md +137 -11
  100. package/pp-core/templates/project-charter.md +226 -0
  101. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  102. package/pp-core/workflows/add-tests.md +55 -0
  103. package/pp-core/workflows/audit-fix.md +63 -0
  104. package/pp-core/workflows/audit-milestone.md +1 -1
  105. package/pp-core/workflows/autonomous.md +85 -11
  106. package/pp-core/workflows/check-todos.md +13 -2
  107. package/pp-core/workflows/code-review-fix.md +59 -1
  108. package/pp-core/workflows/code-review.md +10 -1
  109. package/pp-core/workflows/complete-milestone.md +1 -1
  110. package/pp-core/workflows/debug.md +56 -2
  111. package/pp-core/workflows/discovery-sprint/prefill.md +21 -0
  112. package/pp-core/workflows/discovery-sprint/prototype.md +46 -48
  113. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  114. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  118. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  123. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  124. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  127. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  128. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  129. package/pp-core/workflows/discovery-sprint.md +7 -3
  130. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  131. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  132. package/pp-core/workflows/discovery-to-build.md +9 -1
  133. package/pp-core/workflows/discuss-phase.md +3 -10
  134. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  135. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  136. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  137. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  138. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  139. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  140. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  141. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -18
  142. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  143. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  144. package/pp-core/workflows/execute-phase.md +154 -110
  145. package/pp-core/workflows/execute-plan.md +58 -4
  146. package/pp-core/workflows/fast.md +53 -0
  147. package/pp-core/workflows/help/modes/full.md +1 -0
  148. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  149. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  150. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  151. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  152. package/pp-core/workflows/init.md +232 -272
  153. package/pp-core/workflows/new-project.md +31 -23
  154. package/pp-core/workflows/plan-phase.md +99 -43
  155. package/pp-core/workflows/quick.md +120 -2
  156. package/pp-core/workflows/secure-phase.md +1 -1
  157. package/pp-core/workflows/ship.md +57 -24
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +60 -1
  160. package/pp-core/workflows/vault-review.md +325 -0
  161. package/pp-core/workflows/verify-work.md +13 -12
  162. package/scripts/changeset/cli.cjs +6 -5
  163. package/scripts/changeset/lint.cjs +0 -0
  164. package/scripts/changeset/new.cjs +33 -50
  165. package/scripts/dev-env.cjs +25 -85
  166. package/scripts/gen-capability-registry.cjs +199 -0
  167. package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
  168. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  169. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  170. package/scripts/lint-docs-required.cjs +0 -0
  171. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  172. package/scripts/lint-test-file-count.allowlist.json +13 -2
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -15
  175. package/scripts/record-vault-mode-golden.cjs +89 -0
  176. package/scripts/release-channel.cjs +0 -0
  177. package/scripts/sync-manifest-versions.cjs +5 -4
  178. package/scripts/sync-rulesets.sh +1 -1
  179. package/scripts/update-size-baseline.cjs +30 -274
  180. package/scripts/vault-calibrate.cjs +996 -0
  181. package/scripts/verify-opengsd-sweep.cjs +3 -5
  182. package/scripts/workflow-size.cjs +9 -61
  183. package/pp-core/bin/lib/dispatch-plan.cjs +0 -472
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/phase-tier.cjs +0 -467
  186. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  189. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  190. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  191. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -72
  192. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -34
  193. package/scripts/lib/size-budget.cjs +0 -311
  194. package/scripts/lint-neutrality-names.cjs +0 -694
  195. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  196. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -9,9 +9,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
9
9
  *
10
10
  * Responsibility (Phase 24-01 — parse + discover half of the module):
11
11
  * `parsePracticeFile(filePath, raw)` validates a single practice `.md` file's
12
- * frontmatter (id/scope/severity/applies-to/schemaVersion) against the D-03/
13
- * D-06/D-08/D-14 rules from 24-CONTEXT.md, returning skip-with-warning
14
- * results — it NEVER throws. `discoverPracticeFiles(cwd)` lists `.md` files
12
+ * frontmatter (id/scope/severity/applies-to/schemaVersion/check) against the
13
+ * D-03/D-06/D-08/D-14 rules from 24-CONTEXT.md, returning parsed,
14
+ * DEGRADED-with-warnings, or skip-with-warning results (CHECK-02, 05-01) —
15
+ * it NEVER throws. `discoverPracticeFiles(cwd)` lists `.md` files
15
16
  * under the cached vault tier's `practices/` directory, excludes secret-named
16
17
  * files, and parses each survivor, degrading to an empty/benign result when
17
18
  * no vault cache is present.
@@ -58,6 +59,16 @@ const node_fs_1 = __importDefault(require("node:fs"));
58
59
  const fmMod = require("./frontmatter.cjs");
59
60
  // eslint-disable-next-line @typescript-eslint/no-require-imports
60
61
  const vaultMod = require("./vault.cjs");
62
+ // D-17/D-26 (14-04): the fence markers and their defang have ONE owner, the
63
+ // dependency-free vault-index leaf, which src/vault.cts can import without the
64
+ // cycle this module's vault.cjs import would create.
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const vaultIndexLeaf = require("./vault-index.cjs");
67
+ // Quick 261003-jby (FIX-3): the ONE version resolver — the same function and
68
+ // candidate ladder `project.createdWith` is written from — so the block's
69
+ // status stamp and the bindings can never name different engine versions.
70
+ // compose-bindings imports nothing from vault-practices, so no cycle.
71
+ const compose_bindings_cjs_1 = require("./compose-bindings.cjs");
61
72
  const { extractFrontmatter } = fmMod;
62
73
  // ─── Constants ─────────────────────────────────────────────────────────────────
63
74
  // DoS-guard cap (T-24-04) mirroring vault.cts's SUMMARY_FILE_CAP discipline —
@@ -68,9 +79,36 @@ const VALID_SEVERITIES = ['critical', 'warning', 'info'];
68
79
  const VALID_SCOPES = ['scoped', 'global'];
69
80
  const PRACTICE_SCHEMA_VERSION = 1;
70
81
  const PRACTICES_SUBDIR = 'practices/';
71
- // The exactly-five documented frontmatter keys (README "five frontmatter
72
- // keys" table) — any other key present is rejected at parse time (IN-05).
73
- const DOCUMENTED_FRONTMATTER_KEYS = new Set(['id', 'scope', 'severity', 'applies-to', 'schemaVersion']);
82
+ // The exactly-seven documented frontmatter keys — any other key present
83
+ // DEGRADES the practice at parse time (IN-05 as inverted by CHECK-02, 05-01:
84
+ // retained + warned, never dropped).
85
+ //
86
+ // `check` joined the set in 05-01 and `enforced-by` in 10-03 (MECH-03,
87
+ // D-10-04). Both are step 1 of ADR-71924 §5's fixed rollout order (engine
88
+ // release → verified rollout → vault-repo merge): the engine admits the key
89
+ // first, the rollout is verified, and only then does the vault repo merge
90
+ // practices that use it. Neither addition bumped PRACTICE_SCHEMA_VERSION
91
+ // (D-10-17) — an optional key an older engine still retains-and-warns on is
92
+ // not a breaking schema change, and bumping would make every older engine
93
+ // shout about a practice it can read correctly.
94
+ //
95
+ // EXPORTED deliberately: this set has two consumers (`parsePracticeFile` and
96
+ // the promote preflight in src/vault-write-back.cts) and a conformance test
97
+ // that must DERIVE its iteration set from the shipped constant rather than
98
+ // hand-list it (T-4-42 — a hand-listed guard cannot notice the set changing
99
+ // underneath it). BOTH CONSUMERS MOVE IN THE ONE CHANGE THAT WIDENS THIS SET
100
+ // (T-4-49, restated by D-10-14): the promote preflight refuses on any non-empty
101
+ // `degradedWarnings`, so widening the reader alone would quietly make a
102
+ // previously-refused key promotable into the fleet-wide vault.
103
+ const DOCUMENTED_FRONTMATTER_KEYS = new Set([
104
+ 'id',
105
+ 'scope',
106
+ 'severity',
107
+ 'applies-to',
108
+ 'schemaVersion',
109
+ 'check',
110
+ 'enforced-by',
111
+ ]);
74
112
  // Glob-pattern length guard (T-24-04 DoS mitigation) — a client-authored
75
113
  // applies-to glob longer than this is rejected by fallbackGlobToRegex (never
76
114
  // matches) rather than compiled, bounding worst-case compile+match cost.
@@ -78,9 +116,27 @@ const GLOB_PATTERN_MAX = 200;
78
116
  // Bounded markdown-body excerpt size for scope:global practices (D-15) —
79
117
  // mirrors vault.cts's SUMMARY_READ_BYTES bounded-read discipline.
80
118
  const PRACTICE_EXCERPT_BYTES = 1024;
119
+ // Over-read factor for the excerpt's bounded read (quick 261003-jby, FIX-1).
120
+ // The excerpt is normalised to LF before it is capped, and every carriage
121
+ // return that normalisation removes consumed a raw byte as half of a CRLF pair.
122
+ // Reading `PRACTICE_EXCERPT_BYTES * 2` body bytes therefore guarantees the cap
123
+ // can be filled from normalised text even when every line is CRLF-terminated.
124
+ // The read stays bounded — the whole file is still never read.
125
+ const EXCERPT_CRLF_OVERREAD_FACTOR = 2;
81
126
  // Matched-file list cap per practice (T-24-04) — matchedCount still carries
82
127
  // the true total so callers know the cap was hit.
83
128
  const MATCHED_FILES_CAP = 20;
129
+ // The unmatched-source-file note's candidate set (quick 261003-jby, FIX-2,
130
+ // QD-2): a file passed to the matcher whose basename ends with one of these
131
+ // extensions (case-insensitive), or starts with the Dockerfile prefix (exact),
132
+ // is one a scoped practice is EXPECTED to govern in the client's stacks. When
133
+ // such a file matched no scoped practice the block says so in one plain line,
134
+ // because a vault laid out for a different folder structure otherwise drops its
135
+ // scoped practices with no word at all (probe test 6: 20 scoped practices, 5 of
136
+ // them critical, silently matched nothing). Frozen constants so widening the
137
+ // set is a one-line edit, not a decision buried in a predicate.
138
+ const UNMATCHED_NOTE_SOURCE_EXTENSIONS = Object.freeze(['.cs', '.sql', '.ts', '.vue', '.kt']);
139
+ const UNMATCHED_NOTE_BASENAME_PREFIX = 'Dockerfile';
84
140
  // Bounded prefix read for practice files during DISCOVERY (CR-03) — frontmatter
85
141
  // + bodyOffset comfortably fit well inside this; the whole file is NEVER read.
86
142
  // Mirrors vault.cts's SUMMARY_READ_BYTES bounded-read discipline.
@@ -90,6 +146,199 @@ const PRACTICE_FILE_READ_BYTES = 8192;
90
146
  // bounds worst-case warning size and prevents multi-line injection into a
91
147
  // human/agent-visible report.
92
148
  const WARNING_TOKEN_MAX = 80;
149
+ // ─── Matching cost bound (CHECK-04 / T-5-10, T-5-11, 05-04) ──────────────────
150
+ //
151
+ // `matchPractices` walks (scoped practices × their globs × candidate files).
152
+ // The complexity class is polynomial and the pattern language compiles no
153
+ // `RegExp` — both true, and both irrelevant to wall clock.
154
+ //
155
+ // MEASURED, and REPRODUCIBLE by running tests/vault-practices.slow.test.cjs,
156
+ // on the six-axis worst-case corpus at the shipped caps (100 practices × 39
157
+ // globs derived from PRACTICE_FILE_READ_BYTES × a POSIX-maximal subject path
158
+ // = 3,900 pairs) — Apple M5 Pro, macOS 26.5.2, **Node v25.9.0**, 2026-09-01:
159
+ //
160
+ // native `path.posix.matchesGlob` 1.76 ms / (glob, file) pair
161
+ // pre-22.5 fallback matcher 0.098 ms / pair (same corpus, feature
162
+ // detect deleted before require)
163
+ //
164
+ // 05-RESEARCH § OQ1 measured 1.47 ms/pair native over three points that were
165
+ // linear in pair count, so `cost ≈ pairs × rate` is a measured model rather
166
+ // than a hoped-for one. Its 5.0 ms/pair fallback figure did NOT reproduce on
167
+ // this corpus; nothing here is derived from it (the slow suite records the
168
+ // discrepancy rather than dropping it).
169
+ //
170
+ // That makes the SHIPPED bound a multi-hour hang: 100 practices ×
171
+ // 39 globs × `SHIP_DIFF_FILE_CAP` (2000) = 7,800,000 pairs ⇒ ≈3.8 h at the
172
+ // native rate above (a projection from a measured rate, labelled as such).
173
+ // Both already-shipped call sites — `vault practices match` at plan:pre and
174
+ // `check ship.practices` at ship:pre — reach this function with no budget of
175
+ // any kind, which is why the bound lives HERE and not only in a later
176
+ // evaluator: a bound placed downstream would ship the gate and leave the hole.
177
+ //
178
+ // A number recorded without the machine it was measured on is the warning
179
+ // sign this project has learned to distrust, so every figure above names its
180
+ // runtime. Node-25 numbers are never recorded as Node-22 numbers; the Node-22
181
+ // confirmation is carried separately (05-09) and appends its own numbers to
182
+ // the slow suite's record.
183
+ // PRIMARY, DETERMINISTIC guard. Evaluated BEFORE the loop, so exhaustion is
184
+ // the same verdict on a laptop and on a CI runner (T-5-11 — a time-only budget
185
+ // makes the verdict machine-dependent, which is exactly the non-determinism
186
+ // VERIFY-03 bars from a deterministic gate).
187
+ //
188
+ // Sized above the legitimate ceiling and well below the hostile one: the real
189
+ // client vault's 92 globs against the 2000-file ship diff cap is 184,000
190
+ // pairs, so this leaves ~5× headroom over any honest corpus while sitting
191
+ // ~7.8× below the 7,800,000-pair hostile saturation bound.
192
+ //
193
+ // BE CLEAR ABOUT WHAT THIS DOES AND DOES NOT BOUND. At the worst-case rate
194
+ // above, 1,000,000 pairs still extrapolates to ~1,760 s. This cap is NOT a
195
+ // wall-clock bound; it is the deterministic, machine-independent refusal of
196
+ // the pathological fan-out, and it is the arm a reproducible gate can rest on.
197
+ // Wall clock is bounded by MATCH_BUDGET_MS below.
198
+ const MATCH_PAIR_CAP = 1000000;
199
+ // SECONDARY guard, and the one that actually bounds wall clock. Machine-
200
+ // dependent by nature, which is precisely why the pair cap is primary and this
201
+ // one can only ever NARROW what the pair cap already permitted.
202
+ //
203
+ // Derivation: the real client vault's plain `**/…` globs cost microseconds per
204
+ // pair (05-RESEARCH § OQ2), so its 184,000-pair worst case runs in well under
205
+ // a second — this sits more than an order of magnitude above that legitimate
206
+ // ceiling, while turning the pathological corpus from hours into ten seconds.
207
+ const MATCH_BUDGET_MS = 10000;
208
+ // Every note this bound emits carries this marker, so a consumer channel can
209
+ // forward exactly those notes without re-composing the wording and without
210
+ // matching on prose (one emission, N channels — T-4-49).
211
+ const MATCH_BUDGET_NOTE_PREFIX = 'practice matching bounded: ';
212
+ /**
213
+ * The single sentence every executor-facing channel states when the client
214
+ * vault itself could not be READ (05-13, G-4).
215
+ *
216
+ * ONE constant, two channels, EXPORTED so their pins harvest it rather than
217
+ * copy it — the same discipline `AUDIT_VAULT_REPAIR_SENTENCE` follows in
218
+ * src/vault-audit.cts. A refusal whose wording drifts between the block a
219
+ * code-writing agent reads and the message an operator reads is how one fact
220
+ * ends up meaning two things (T-4-49), and this pair of channels is exactly
221
+ * where that already happened once.
222
+ *
223
+ * It names no path and no errno: the counted note beside it carries the
224
+ * diagnostic, already bounded to an errno code by discovery (T-5-36).
225
+ */
226
+ const PRACTICES_VAULT_UNREADABLE_SENTENCE = 'Practices were NOT checked — the client vault could not be read.';
227
+ /**
228
+ * The THIRD member of the family (CR-01), and the one a fresh checkout hits.
229
+ *
230
+ * `vaultPresent: false` and `readFailed: true` are different facts and have been
231
+ * since 05-10, but only `readFailed` was carried past the matcher. An absent
232
+ * vault therefore fell through to `practicesProcessed === 0` and was answered
233
+ * with "none found in the vault" — a statement about a vault that WAS read,
234
+ * made about one that was never fetched.
235
+ *
236
+ * `.planning/` is gitignored on this fork, so "never fetched" is the ordinary
237
+ * state of a fresh clone rather than an exotic one. Told the client documented
238
+ * no standards, a planner writes a plan governed by nothing, and `check
239
+ * vault.audit` then halts the build for the vault that was never there.
240
+ *
241
+ * Names the remedy, because unlike an unreadable vault this one has a single
242
+ * obvious repair. Names no path: the same T-5-36 discipline as its sibling.
243
+ */
244
+ const PRACTICES_VAULT_ABSENT_SENTENCE = 'Practices were NOT checked — no client vault has been fetched. Run the vault fetch first.';
245
+ /**
246
+ * The FOURTH member of the family (quick 261003-jby, FIX-4, QD-5).
247
+ *
248
+ * A fetch that failed every tier with no prior copy leaves a manifest recording
249
+ * a miss: no tier, no sha, no files. The vault directory is present, nothing
250
+ * failed to READ, and discovery yields zero practices — so the ladder fell
251
+ * through to "none found in the vault", telling a code-writing agent its client
252
+ * documented no standards about a fetch that reached nothing (probe test 2).
253
+ *
254
+ * The documented "could not be checked: <reason>." form, with the reason
255
+ * phrase copied from the practice trace's own miss reason (`resolvePin` in
256
+ * src/vault-trace.cts) so the two surfaces say the same thing.
257
+ */
258
+ const PRACTICES_VAULT_MISS_SENTENCE = 'Practices could not be checked: the vault manifest records a miss — no vault content was reached.';
259
+ /**
260
+ * The FIFTH member of the family (quick 261015-at3, Vince 2026-10-15).
261
+ *
262
+ * The vault was fetched and read, and it is a standards vault: `INDEX.md` at
263
+ * its root and no practice files. The project reads it in practices mode, so
264
+ * discovery yields zero practices and the ladder fell through to "none found in
265
+ * the vault" — telling a code-writing agent the client documented no practices,
266
+ * about a vault whose standards the project is not reading at all. Names the
267
+ * one repair: the mode, and the engine version that has it.
268
+ */
269
+ const PRACTICES_VAULT_STANDARDS_ONLY_SENTENCE = 'Practices were NOT checked — this vault holds standards (INDEX.md) and no practice files, but the project reads it in practices mode. Set "vault": {"mode": "standards"} in .planning/config.json (pp-core 1.16.0 or later) and re-plan.';
270
+ /**
271
+ * The status stamp's fixed opening (quick 261003-jby, FIX-3, QD-4).
272
+ *
273
+ * Every rendered `<vault_practices>` block carries ONE HTML comment as its
274
+ * first inner line, written by this module, naming the vault ref, short sha,
275
+ * either the number of fenced practice entries or a degraded-state token, and
276
+ * the engine version. A distinct prefix — not `vault:external-content` — so no
277
+ * fence parser can mistake it for a marker, and it never begins with `id:`.
278
+ */
279
+ const PRACTICES_STAMP_PREFIX = '<!-- vault-practices-stamp:';
280
+ /**
281
+ * The stamp's degraded-state vocabulary: exactly one token per sentence the
282
+ * block can state instead of rendering entries (FIX-3). A new rung cannot ship
283
+ * without a token — the stamp test harvests this map and pins its size.
284
+ */
285
+ const PRACTICES_STAMP_STATES = Object.freeze({
286
+ COULD_NOT_BE_CHECKED: 'could-not-be-checked',
287
+ NOT_CHECKED_PAIR_CAP: 'not-checked-pair-cap',
288
+ VAULT_UNREADABLE: 'vault-unreadable',
289
+ VAULT_ABSENT: 'vault-absent',
290
+ VAULT_MISS: 'vault-miss',
291
+ // Quick 261015-at3: a standards vault (INDEX.md, no practice files) read in
292
+ // practices mode.
293
+ STANDARDS_ONLY: 'standards-only',
294
+ ALL_REFUSED: 'all-refused',
295
+ NONE_FOUND: 'none-found',
296
+ NONE_APPLY: 'none-apply',
297
+ });
298
+ // ─── check: block caps (T-5-05, CHECK-02 / D-05-05) ──────────────────────────
299
+ //
300
+ // Each cap below copies the PRACTICE_COUNT_CAP discipline exactly: it bounds a
301
+ // client-authored fan-out BEFORE any work is done with it, and exhaustion is a
302
+ // COUNTED REFUSAL — the block is dropped whole and `checkInvalid` says so —
303
+ // never a silent truncation that would leave a check quietly asserting less
304
+ // than its author wrote.
305
+ // Max path globs one check block may declare. The largest applies-to fan-out
306
+ // observed in the real client vault is 4; this doubles it, so a legitimate
307
+ // block never meets the cap and a padded one is refused before matching.
308
+ const CHECK_FILES_CAP = 8;
309
+ // Max literal content tokens across `require` and `forbid` COMBINED. Bounds the
310
+ // per-file substring work the evaluator does, which is the product of this and
311
+ // the matched-file count.
312
+ const CHECK_TOKEN_COUNT_CAP = 16;
313
+ // Max length (chars) of a single literal content token. Mirrors
314
+ // GLOB_PATTERN_MAX deliberately: the two assertion kinds bound their
315
+ // client-authored payload at the same size, so neither becomes the cheap way
316
+ // to smuggle a large one past the other.
317
+ const CHECK_TOKEN_MAX = 200;
318
+ // ─── enforced-by: cap (T-10-11, MECH-03 / D-10-04) ───────────────────────────
319
+ //
320
+ // Max analyzer rules one practice may name. Set to 8 to MATCH CHECK_FILES_CAP —
321
+ // the number is chosen to match rather than invented, because there is no
322
+ // observed fan-out to double here (the key is new and the client vault carries
323
+ // zero occurrences of it) and a second, differently-sized bound on the same
324
+ // file would be a number a reader has to justify twice.
325
+ //
326
+ // The treatment DIFFERS from the check caps above and the difference is
327
+ // deliberate: a check cap is a counted REFUSAL because truncating an assertion
328
+ // would leave it quietly asserting less than its author wrote. `enforced-by:`
329
+ // asserts nothing and governs nothing — it is provenance data — so exhaustion
330
+ // TRUNCATES to the first ENFORCED_BY_COUNT_CAP entries and STATES the cap in
331
+ // one degraded warning. Nothing is lost silently: the warning is loud on the
332
+ // read side and, because the promote preflight refuses on any degraded parse,
333
+ // an over-cap practice cannot be promoted at all.
334
+ //
335
+ // EXPORTED for the same reason the check caps are: tests HARVEST the shipped
336
+ // value and size their boundary fixtures from it, never copying the number.
337
+ const ENFORCED_BY_COUNT_CAP = 8;
338
+ // The exactly-four keys a `check:` mapping may carry. Anything else invalidates
339
+ // the block — the same closed-vocabulary posture the frontmatter allowlist
340
+ // takes, for the same reason (a typo must be loud, never silently ignored).
341
+ const CHECK_BLOCK_KEYS = new Set(['files', 'require', 'forbid', 'forbidMatch']);
93
342
  /**
94
343
  * Safe stringify for warning messages — `extractFrontmatter` can yield
95
344
  * string | string[] | Record<string, unknown> for any key, and a bare
@@ -270,6 +519,15 @@ function globSegmentsMatch(patternSegs, subjectSegs) {
270
519
  * two-pointer algorithm (`globSegmentsMatch` + `segmentMatches`) — a
271
520
  * `.test()`-shaped duck type keeps `globMatch`'s call site unchanged.
272
521
  * Patterns over `GLOB_PATTERN_MAX` are rejected outright (never matches).
522
+ *
523
+ * ON THE NAME (05-04). Despite it, this function constructs NO `RegExp` — it
524
+ * returns an object literal whose `test` runs the two-pointer matcher above.
525
+ * ADR-71924 §3 says the same thing ("despite its name") and the confusion has
526
+ * now cost more than one verification pass, so it is stated here at the
527
+ * definition rather than only in the ADR. A rename to `compileGlobMatcher`
528
+ * was considered and REJECTED: the rename guard's 2331-file baseline is not
529
+ * re-cut for a readability win, and this note delivers the same correction at
530
+ * zero churn. Recorded so the option is not silently re-opened.
273
531
  */
274
532
  function fallbackGlobToRegex(pattern) {
275
533
  if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > GLOB_PATTERN_MAX) {
@@ -380,6 +638,174 @@ function isSupportedGlob(pattern) {
380
638
  return false;
381
639
  return bracesSupported(pattern);
382
640
  }
641
+ /**
642
+ * Normalize a frontmatter value that must be a list of non-empty strings.
643
+ * `extractFrontmatter` yields `string | string[] | Record<string, unknown>` for
644
+ * any key; a lone scalar is normalized to a one-element list exactly as
645
+ * `applies-to` already is, so both assertion kinds read the same way for an
646
+ * author. Returns `null` when the value is present but is not a usable string
647
+ * list — the caller turns that into a stated defect, never a silent drop.
648
+ */
649
+ function normalizeStringList(v) {
650
+ if (Array.isArray(v)) {
651
+ return v.every((e) => typeof e === 'string' && e.length > 0) ? v : null;
652
+ }
653
+ if (typeof v === 'string' && v.trim())
654
+ return [v.trim()];
655
+ return null;
656
+ }
657
+ /**
658
+ * Normalize a frontmatter boolean. The shared frontmatter parser is line-based
659
+ * and yields the STRING `'true'`, not a boolean, so both forms are accepted and
660
+ * anything else is a stated defect (a `forbidMatch: yes` must not silently read
661
+ * as false).
662
+ */
663
+ function normalizeCheckBoolean(v) {
664
+ if (typeof v === 'boolean')
665
+ return v;
666
+ if (v === 'true')
667
+ return true;
668
+ if (v === 'false')
669
+ return false;
670
+ return null;
671
+ }
672
+ /**
673
+ * Parse and validate a practice's `check:` block. THE ONLY parser for this
674
+ * shape (ADR-71924 §2): 05-05's evaluator receives a validated record and
675
+ * validates nothing itself, so a rule that is not enforced here is not
676
+ * enforced anywhere.
677
+ *
678
+ * A violation never skips the practice. The block is dropped whole — never
679
+ * half-built — and one bounded, single-line `checkInvalid` records the defect,
680
+ * because the practice's prose still applies (ADR §5). 05-05 maps
681
+ * `checkInvalid` to a fail-closed FAIL so a broken block can never read as a
682
+ * pass, nor be mistaken for "this practice was never checkable".
683
+ *
684
+ * The glob half is gated by `isSupportedGlob` BY REFERENCE — the grammar is
685
+ * never restated here and there is never a second parser, which is what stops
686
+ * the evaluator and the reader drifting apart. The literal half is bounded by
687
+ * CHECK_TOKEN_COUNT_CAP and CHECK_TOKEN_MAX, and is compared only by plain
688
+ * substring containment: no client-authored byte reaches a RegExp constructor
689
+ * or any other pattern compiler (ADR §3, T-5-05).
690
+ */
691
+ function parseCheckBlock(id, rawCheck) {
692
+ const invalid = (defect) => ({
693
+ checkInvalid: `check block on practice "${boundedToken(id)}" was dropped: ${defect}. ` +
694
+ `The practice itself still applies; fix the block to make it mechanically checkable again.`,
695
+ });
696
+ if (typeof rawCheck !== 'object' || rawCheck === null || Array.isArray(rawCheck)) {
697
+ return invalid('check must be a mapping of files/require/forbid/forbidMatch');
698
+ }
699
+ const block = rawCheck;
700
+ const unknownKey = Object.keys(block).find((k) => !CHECK_BLOCK_KEYS.has(k));
701
+ if (unknownKey !== undefined) {
702
+ return invalid(`unrecognized key "${boundedToken(unknownKey)}" inside check ` +
703
+ `(expected only: ${Array.from(CHECK_BLOCK_KEYS).join(', ')})`);
704
+ }
705
+ const files = normalizeStringList(block.files);
706
+ if (files === null || files.length === 0) {
707
+ return invalid('files is required and must be a non-empty list of path globs');
708
+ }
709
+ // Count-before-work: refuse an over-wide fan-out BEFORE any glob is examined.
710
+ if (files.length > CHECK_FILES_CAP) {
711
+ return invalid(`files declares ${files.length} globs, over the cap of ${CHECK_FILES_CAP}`);
712
+ }
713
+ const unsupportedGlob = files.find((g) => !isSupportedGlob(g));
714
+ if (unsupportedGlob !== undefined) {
715
+ return invalid(`files glob "${boundedToken(unsupportedGlob)}" uses unsupported syntax ` +
716
+ `(see the practices README "Supported glob syntax" table)`);
717
+ }
718
+ // `globMatch` already refuses an over-long pattern by never matching. Refusing
719
+ // it here too turns a silent never-matches into a stated defect, so an author
720
+ // is told rather than left with a check that looks live and is not.
721
+ if (files.some((g) => g.length > GLOB_PATTERN_MAX)) {
722
+ return invalid(`a files glob is longer than the ${GLOB_PATTERN_MAX}-character pattern cap`);
723
+ }
724
+ const requireTokens = block.require === undefined ? undefined : normalizeStringList(block.require);
725
+ if (requireTokens !== undefined && (requireTokens === null || requireTokens.length === 0)) {
726
+ return invalid('require must be a non-empty list of literal content tokens');
727
+ }
728
+ const forbidTokens = block.forbid === undefined ? undefined : normalizeStringList(block.forbid);
729
+ if (forbidTokens !== undefined && (forbidTokens === null || forbidTokens.length === 0)) {
730
+ return invalid('forbid must be a non-empty list of literal content tokens');
731
+ }
732
+ const forbidMatch = block.forbidMatch === undefined ? undefined : normalizeCheckBoolean(block.forbidMatch);
733
+ if (block.forbidMatch !== undefined && forbidMatch === null) {
734
+ return invalid('forbidMatch must be true or false');
735
+ }
736
+ if (forbidMatch !== undefined && forbidMatch !== null && (requireTokens || forbidTokens)) {
737
+ return invalid('forbidMatch cannot combine with require or forbid — a forbidden match has no content to inspect');
738
+ }
739
+ if (requireTokens === undefined && forbidTokens === undefined && forbidMatch === undefined) {
740
+ return invalid('a check block must declare at least one of require, forbid or forbidMatch');
741
+ }
742
+ const tokens = [...(requireTokens ?? []), ...(forbidTokens ?? [])];
743
+ if (tokens.length > CHECK_TOKEN_COUNT_CAP) {
744
+ return invalid(`require and forbid declare ${tokens.length} tokens combined, over the cap of ${CHECK_TOKEN_COUNT_CAP}`);
745
+ }
746
+ // The offending token is deliberately NOT echoed: it is client-authored and
747
+ // over-long by definition, and the cap is what the author needs to be told.
748
+ if (tokens.some((t) => t.length > CHECK_TOKEN_MAX)) {
749
+ return invalid(`a content token is longer than the ${CHECK_TOKEN_MAX}-character cap`);
750
+ }
751
+ const check = { files };
752
+ if (requireTokens !== undefined && requireTokens !== null)
753
+ check.require = requireTokens;
754
+ if (forbidTokens !== undefined && forbidTokens !== null)
755
+ check.forbid = forbidTokens;
756
+ if (forbidMatch !== undefined && forbidMatch !== null)
757
+ check.forbidMatch = forbidMatch;
758
+ return { check };
759
+ }
760
+ /**
761
+ * Read a practice's `enforced-by:` value. Never throws; never drops a practice.
762
+ *
763
+ * `enforced-by:` names stock analyzer rules whose authors already enforce this
764
+ * practice. pp-core RECORDS the claim and runs nothing (D-10-05) — so this
765
+ * parser's whole job is to turn a client-authored value into a bounded, capped
766
+ * list of tokens, or into a stated defect.
767
+ *
768
+ * Both authored shapes are accepted through the shipped `normalizeStringList`,
769
+ * which turns a lone scalar into a one-element list exactly as `applies-to` is
770
+ * handled, and returns `null` when the value is present but not a usable string
771
+ * list. That `null` becomes a STATED defect here — never a silent drop, which
772
+ * would let a typo'd value read as "this practice names no analyzer".
773
+ *
774
+ * @param safePath The already-bounded path prefix every warning interpolates.
775
+ * @param rawValue The frontmatter value; callers pass only a defined value.
776
+ */
777
+ function parseEnforcedBy(safePath, rawValue) {
778
+ const entries = normalizeStringList(rawValue);
779
+ if (entries === null || entries.length === 0) {
780
+ // The offending value is deliberately NOT echoed: it is client-authored and
781
+ // by definition not a string list, so what the author needs to be told is
782
+ // the shape, not their own bytes back.
783
+ return {
784
+ warning: `${safePath} degraded: enforced-by is present but unusable — ` +
785
+ `expected an analyzer rule name or a list of them (e.g. "enforced-by: VSTHRD002") — ` +
786
+ `the practice still applies; the enforced-by claim was dropped (D-10-04)`,
787
+ };
788
+ }
789
+ // Count-before-work, and ONE warning whose length is independent of the
790
+ // input (T-10-11): the entries themselves are never enumerated into it, so a
791
+ // giant list cannot grow a human/agent-visible line with attacker-chosen
792
+ // bytes. The PRACTICE_FILE_READ_BYTES bounded prefix read stays the primary
793
+ // bound on how much of that input is ever seen at all.
794
+ const overCap = entries.length > ENFORCED_BY_COUNT_CAP;
795
+ const kept = overCap ? entries.slice(0, ENFORCED_BY_COUNT_CAP) : entries;
796
+ // boundedToken on EVERY retained entry (T-10-10 / T-24-07): the value reaches
797
+ // an agent-visible surface in 10-06 and 10-08, and a CR/LF in it would forge a
798
+ // line there. Bounding at parse time means no renderer has to remember to.
799
+ const enforcedBy = kept.map((e) => boundedToken(e));
800
+ return overCap
801
+ ? {
802
+ enforcedBy,
803
+ warning: `${safePath} degraded: enforced-by names ${entries.length} rules, over the cap of ` +
804
+ `${ENFORCED_BY_COUNT_CAP} — the practice still applies and the first ` +
805
+ `${ENFORCED_BY_COUNT_CAP} are recorded; the rest were dropped (D-10-04)`,
806
+ }
807
+ : { enforcedBy };
808
+ }
383
809
  // ─── Parsing + validation ───────────────────────────────────────────────────────
384
810
  /**
385
811
  * Parse and validate a single practice file's frontmatter. Never throws —
@@ -387,14 +813,24 @@ function isSupportedGlob(pattern) {
387
813
  * never-throws discipline modeled by `vaultSummary`/`readHeadBounded` in
388
814
  * src/vault.cts).
389
815
  *
390
- * Validation order (each rule its own warning string, D-03/D-06/D-08/D-14):
391
- * 1. null byte in filePath → invalid path (path-traversal guard)
392
- * 2. missing string `id` in frontmatter
393
- * 3. `id` must equal the file's basename (sans `.md`)
394
- * 4. `severity` must be one of VALID_SEVERITIES (case-insensitive, normalized)
395
- * 5. `scope` must be exactly 'scoped' or 'global'
396
- * 6. scope↔applies-to consistency (scoped needs non-empty globs; global needs none)
397
- * 7. `schemaVersion`: absent → 1; a value > 1 is "authored for a newer pp-core"
816
+ * Validation order (each rule its own warning string, D-03/D-06/D-08/D-14).
817
+ * The order is LOAD-BEARING: the two degrade rules must not short-circuit any
818
+ * hard rule below them, or a practice with an unrecognized key would bypass the
819
+ * id/basename check that neutralizes the CRLF-poisoned-id shape (T-5-02).
820
+ * 1. null byte in filePath → SKIP (path-traversal guard)
821
+ * 2. a frontmatter key outside DOCUMENTED_FRONTMATTER_KEYS → DEGRADE (IN-05)
822
+ * 3. missing string `id` in frontmatter → SKIP
823
+ * 4. `id` must equal the file's basename (sans `.md`) → SKIP
824
+ * 5. `severity` must be one of VALID_SEVERITIES (case-insensitive, normalized) → SKIP
825
+ * 6. `scope` must be exactly 'scoped' or 'global' → SKIP
826
+ * 7. scope↔applies-to consistency (scoped needs non-empty globs; global needs none) → SKIP
827
+ * 8. every applies-to glob must pass `isSupportedGlob` → SKIP
828
+ * 9. `schemaVersion`: absent → 1; malformed (`< 1` / non-integer) → SKIP;
829
+ * a well-formed value > 1 → DEGRADE ("authored for a newer pp-core")
830
+ * 10. `check:` and `enforced-by:` are read LAST, below every hard rule above.
831
+ * Neither can skip a practice: an unusable value is stated (on
832
+ * `checkInvalid` and on `degradedWarnings` respectively) and the prose
833
+ * still applies.
398
834
  *
399
835
  * @param filePath Repo-relative (or synthetic, in unit tests) path — used only
400
836
  * for the id/basename check and to stamp `PracticeRecord.filePath`. Never
@@ -413,18 +849,40 @@ function parsePracticeFile(filePath, raw) {
413
849
  return { ok: false, warning: `${safePath} skipped: invalid path (contains null byte)` };
414
850
  }
415
851
  const fm = extractFrontmatter(raw);
416
- // Unknown-key rejection (IN-05): a practice frontmatter carrying a key
417
- // outside the documented five is skipped-with-warning rather than
418
- // silently ignored — this both catches author typos (a misspelled
419
- // `applies_to`) and closes off a giant-frontmatter DoS surface (T-24-04)
420
- // where extra keys could otherwise be padded arbitrarily.
852
+ // Collects the warnings that DEGRADE this practice without discarding it.
853
+ // Populated below; attached to the success result at the very end, so a
854
+ // practice that later fails a hard rule discards these along with itself.
855
+ const degradedWarnings = [];
856
+ // Unknown-key DEGRADE (IN-05, inverted by CHECK-02 in 05-01).
857
+ //
858
+ // This branch used to `return { ok: false }`, dropping the whole practice.
859
+ // ADR-71924 §5 requires the opposite: an engine that meets a key it does
860
+ // not recognize must degrade LOUDLY — retain the practice, apply its prose
861
+ // through the normal path, and warn naming the key. Dropping it meant the
862
+ // practice silently governed nothing, which is the "governance check that
863
+ // quietly does nothing" the ADR calls worse than no check at all.
864
+ //
865
+ // The typo case (a misspelled `applies_to`) takes this SAME path
866
+ // deliberately. A typo'd key silently dropping the whole practice is
867
+ // exactly the governance loss CHECK-02 removes; the warning is what makes
868
+ // the typo loud, and the practice's prose is still worth applying.
869
+ //
870
+ // Only the FIRST unknown key is named (T-5-03). Naming every key would
871
+ // re-open the giant-frontmatter surface the original IN-05 comment cites
872
+ // (T-24-04) by making the warning grow with attacker-chosen input; the
873
+ // PRACTICE_FILE_READ_BYTES bounded prefix read stays the primary bound.
874
+ //
875
+ // VALIDATION ORDER IS LOAD-BEARING: this degrade must not short-circuit
876
+ // anything below it. id-equals-basename, the severity and scope
877
+ // vocabularies, the scope↔applies-to rules, glob support and a malformed
878
+ // schemaVersion all still hard-skip exactly as before — which is what keeps
879
+ // the CRLF-poisoned-id shape neutralized (T-5-02) now that IN-05 no longer
880
+ // discards it.
421
881
  const unexpectedKey = Object.keys(fm).find((k) => !DOCUMENTED_FRONTMATTER_KEYS.has(k));
422
882
  if (unexpectedKey !== undefined) {
423
- return {
424
- ok: false,
425
- warning: `${safePath} skipped: unexpected frontmatter key "${boundedToken(unexpectedKey)}" ` +
426
- `(expected only: ${Array.from(DOCUMENTED_FRONTMATTER_KEYS).join(', ')}) (IN-05)`,
427
- };
883
+ degradedWarnings.push(`${safePath} degraded: unexpected frontmatter key "${boundedToken(unexpectedKey)}" ` +
884
+ `(expected only: ${Array.from(DOCUMENTED_FRONTMATTER_KEYS).join(', ')}) — ` +
885
+ `the practice still applies; this key was not understood (IN-05)`);
428
886
  }
429
887
  const id = typeof fm.id === 'string' && fm.id.trim() ? fm.id.trim() : null;
430
888
  if (!id) {
@@ -506,31 +964,57 @@ function parsePracticeFile(filePath, raw) {
506
964
  warning: `${safePath} skipped: malformed schemaVersion "${boundedToken(schemaVersionRaw)}"`,
507
965
  };
508
966
  }
967
+ // Forward-compatibility DEGRADE (D-08, inverted by CHECK-02 in 05-01).
968
+ // Note the asymmetry with the `< 1` / non-integer arm just above, which
969
+ // stays a HARD skip: a malformed value is not a forward-compat signal,
970
+ // it is a broken file. Only a well-formed version this engine is too old
971
+ // to understand degrades — that is the case ADR-71924 §5's rollout order
972
+ // exists to make legible rather than silent.
509
973
  if (parsed > PRACTICE_SCHEMA_VERSION) {
510
- return {
511
- ok: false,
512
- warning: `${safePath} skipped: schemaVersion ${parsed} is newer than this pp-core supports ` +
513
- `(max ${PRACTICE_SCHEMA_VERSION}) — authored for a newer pp-core (D-08)`,
514
- };
974
+ degradedWarnings.push(`${safePath} degraded: schemaVersion ${parsed} is newer than this pp-core supports ` +
975
+ `(max ${PRACTICE_SCHEMA_VERSION}) — authored for a newer pp-core (D-08)`);
515
976
  }
516
977
  schemaVersion = parsed;
517
978
  }
518
979
  // bodyOffset: byte index right after the closing `---` frontmatter fence.
519
980
  const fmMatch = raw.match(/^---\r?\n[\s\S]+?\r?\n---/);
520
981
  const bodyOffset = fmMatch ? fmMatch[0].length : 0;
521
- return {
522
- ok: true,
523
- practice: {
524
- id,
525
- scope,
526
- severity,
527
- appliesTo,
528
- schemaVersion,
529
- tier: null, // stamped by discoverPracticeFiles from the manifest
530
- filePath,
531
- bodyOffset,
532
- },
982
+ // The check: block is validated HERE, alongside every other parse-time
983
+ // rule, so there is exactly one parser for it (ADR-71924 §2). An invalid
984
+ // block never skips the practice — see `parseCheckBlock`.
985
+ const checkOutcome = fm.check === undefined ? null : parseCheckBlock(id, fm.check);
986
+ // `enforced-by:` is read HERE, in the position parseCheckBlock occupies —
987
+ // BELOW every hard-skip rule above (10-03, MECH-03). The placement is the
988
+ // same load-bearing constraint the validation-order comment states: this
989
+ // key's own validation must not short-circuit id-equals-basename, the
990
+ // severity and scope vocabularies, the scope↔applies-to rules, glob support
991
+ // or a malformed schemaVersion, all of which still hard-skip exactly as
992
+ // before.
993
+ const enforcedByRaw = fm['enforced-by'];
994
+ const enforcedByOutcome = enforcedByRaw === undefined ? null : parseEnforcedBy(safePath, enforcedByRaw);
995
+ if (enforcedByOutcome?.warning !== undefined)
996
+ degradedWarnings.push(enforcedByOutcome.warning);
997
+ const practice = {
998
+ id,
999
+ scope,
1000
+ severity,
1001
+ appliesTo,
1002
+ schemaVersion,
1003
+ tier: null, // stamped by discoverPracticeFiles from the manifest
1004
+ filePath,
1005
+ bodyOffset,
533
1006
  };
1007
+ if (checkOutcome !== null && 'check' in checkOutcome)
1008
+ practice.check = checkOutcome.check;
1009
+ if (checkOutcome !== null && 'checkInvalid' in checkOutcome)
1010
+ practice.checkInvalid = checkOutcome.checkInvalid;
1011
+ if (enforcedByOutcome?.enforcedBy !== undefined)
1012
+ practice.enforcedBy = enforcedByOutcome.enforcedBy;
1013
+ // The degraded warnings ride on the SUCCESS result — the practice is
1014
+ // returned, its prose applies, and the caller decides how to surface the
1015
+ // warning. The key is omitted entirely when there is nothing to say, so a
1016
+ // healthy practice's result shape is unchanged from before this plan.
1017
+ return degradedWarnings.length > 0 ? { ok: true, practice, degradedWarnings } : { ok: true, practice };
534
1018
  }
535
1019
  catch (e) {
536
1020
  return {
@@ -540,6 +1024,49 @@ function parsePracticeFile(filePath, raw) {
540
1024
  }
541
1025
  }
542
1026
  // ─── Discovery ───────────────────────────────────────────────────────────────────
1027
+ /**
1028
+ * The ERRNO code of a caught filesystem error, never its message.
1029
+ *
1030
+ * A Node fs error message interpolates the absolute path it failed on
1031
+ * (`EACCES: permission denied, scandir '/home/u/proj/.planning/vault/practices'`).
1032
+ * 05-10 routes discovery's `notes[]` into the audit's `message`, which
1033
+ * `check-query-gate.md` prints verbatim into an orchestrating agent's context
1034
+ * on EVERY run including passes — so these notes reach an agent-read channel
1035
+ * for the first time. CLAUDE.md § Logging forbids surfacing user file paths
1036
+ * unredacted and records that there is no shared redactor: a call site that
1037
+ * would surface one redacts locally. The errno is the whole diagnostic value
1038
+ * of the message anyway; the path is the part the reader already knows.
1039
+ */
1040
+ function errnoOf(e) {
1041
+ const code = e?.code;
1042
+ return typeof code === 'string' && code.length > 0 ? boundedToken(code) : 'unknown error';
1043
+ }
1044
+ /**
1045
+ * Probe the `practices/` directory's own readability, reporting its refusal as
1046
+ * a BOOLEAN alongside the note (T-24-08a; typed for 05-10).
1047
+ *
1048
+ * The probe runs BEFORE `usableFiles()` because that function's internal
1049
+ * `walk()` (src/vault.cts) catches a `readdirSync` failure itself and returns
1050
+ * an empty list with no throw — so wrapping the `usableFiles` call in a
1051
+ * try/catch could never detect an unreadable directory (it would look
1052
+ * identical to a genuinely empty vault).
1053
+ *
1054
+ * An ABSENT `practices/` directory is a genuinely empty vault and stays silent
1055
+ * (`failed: false`, no note). A failure does NOT short-circuit discovery: any
1056
+ * practices that ARE independently readable still surface, because the signal
1057
+ * is a signal and not a data replacement.
1058
+ */
1059
+ function probePracticesDir(practicesDirPath) {
1060
+ if (!node_fs_1.default.existsSync(practicesDirPath))
1061
+ return { failed: false, note: null };
1062
+ try {
1063
+ node_fs_1.default.readdirSync(practicesDirPath);
1064
+ return { failed: false, note: null };
1065
+ }
1066
+ catch (e) {
1067
+ return { failed: true, note: `practices directory unreadable: ${errnoOf(e)}` };
1068
+ }
1069
+ }
543
1070
  /**
544
1071
  * Discover and parse practice files from the single cached vault tier.
545
1072
  *
@@ -563,32 +1090,29 @@ function parsePracticeFile(filePath, raw) {
563
1090
  * degrades to an empty result, a `skipped` entry, or a bounded `notes` entry.
564
1091
  *
565
1092
  * @param cwd Project root (parent of `.planning/`).
1093
+ * @param vaultDir Optional cache directory to read instead of
1094
+ * `<cwd>/.planning/vault` (260915-ms2, UAT-6-F3 — the trace writer passes the
1095
+ * owning main checkout's cache from a linked worktree). Defaults to the cwd
1096
+ * join, so the absent-versus-unreadable distinction below is unchanged for
1097
+ * every caller that does not pass it.
566
1098
  */
567
- function discoverPracticeFiles(cwd) {
1099
+ function discoverPracticeFiles(cwd, vaultDir) {
568
1100
  try {
569
- const vaultDirPath = node_path_1.default.join(cwd, '.planning', 'vault');
1101
+ const vaultDirPath = typeof vaultDir === 'string' && vaultDir !== ''
1102
+ ? vaultDir
1103
+ : node_path_1.default.join(cwd, '.planning', 'vault');
570
1104
  if (!node_fs_1.default.existsSync(vaultDirPath)) {
571
- return { practices: [], skipped: [], tier: null, notes: [] };
572
- }
573
- // T-24-08a: probe the practices/ directory's own readability BEFORE
574
- // calling usableFiles() — usableFiles's internal walk() (src/vault.cts)
575
- // catches a readdirSync failure itself and returns an empty list with no
576
- // throw, so wrapping the usableFiles call below in try/catch can never
577
- // detect an unreadable directory (it would look identical to a genuinely
578
- // empty vault). An ABSENT practices/ directory is a genuinely empty vault
579
- // and stays silent (pushes nothing). The probe does NOT short-circuit
580
- // discovery on failure — any practices that ARE independently readable
581
- // still surface below; the note is the signal, not a data replacement.
582
- const notes = [];
583
- const practicesDirPath = node_path_1.default.join(vaultDirPath, PRACTICES_SUBDIR.slice(0, -1));
584
- if (node_fs_1.default.existsSync(practicesDirPath)) {
585
- try {
586
- node_fs_1.default.readdirSync(practicesDirPath);
587
- }
588
- catch (e) {
589
- notes.push(`practices directory unreadable: ${boundedToken(e.message)}`);
590
- }
1105
+ // ABSENT is its own fact: nothing refused to be read, there was simply
1106
+ // nothing there. `runVaultAudit` blocks on it all the same (05-10) — a
1107
+ // gate that was never given a vault verified nothing — but the two
1108
+ // grounds stay separable for whoever has to repair it.
1109
+ return {
1110
+ practices: [], skipped: [], degraded: [], tier: null, notes: [], vaultPresent: false, readFailed: false,
1111
+ vaultRef: null, vaultSha: null, vaultMiss: false, vaultStandardsOnly: false,
1112
+ };
591
1113
  }
1114
+ const probe = probePracticesDir(node_path_1.default.join(vaultDirPath, PRACTICES_SUBDIR.slice(0, -1)));
1115
+ const notes = probe.note ? [probe.note] : [];
592
1116
  const allFiles = vaultMod.usableFiles(vaultDirPath);
593
1117
  const practiceFiles = allFiles
594
1118
  .filter((rel) => rel.startsWith(PRACTICES_SUBDIR) && rel.toLowerCase().endsWith('.md'))
@@ -601,8 +1125,27 @@ function discoverPracticeFiles(cwd) {
601
1125
  }
602
1126
  const manifest = vaultMod.readManifest(vaultDirPath);
603
1127
  const tier = manifest && manifest.tier ? manifest.tier : null;
1128
+ // Quick 261003-jby (FIX-3/FIX-4): the coordinates the status stamp carries,
1129
+ // and the recorded-miss fact the sentence ladder states. Typed facts on the
1130
+ // result, never sniffed out of notes prose.
1131
+ const vaultRef = manifest && typeof manifest.ref === 'string' ? manifest.ref : null;
1132
+ const vaultSha = manifest && typeof manifest.resolvedSha === 'string' ? manifest.resolvedSha : null;
1133
+ const vaultMiss = vaultMod.manifestRecordsMiss(manifest);
1134
+ // Quick 261015-at3 (Vince 2026-10-15): a standards vault read in practices
1135
+ // mode. Two facts from the ONE `allFiles` listing the practice filter read
1136
+ // above (exact-case INDEX match, the standards reader's own contract) plus
1137
+ // one mode read. The mode is part of the owner on purpose: the audit's and
1138
+ // the trace's readers have no standards-mode short-circuit of their own, so
1139
+ // a caller that forgot one still cannot trip this flag in standards mode.
1140
+ const modeVerdict = vaultMod.vaultModeVerdict(cwd);
1141
+ const vaultStandardsOnly = modeVerdict.active === true
1142
+ && modeVerdict.ok === true
1143
+ && modeVerdict.mode === 'practices'
1144
+ && allFiles.includes(vaultIndexLeaf.VAULT_INDEX_FILENAME)
1145
+ && practiceFiles.length === 0;
604
1146
  const practices = [];
605
1147
  const skipped = [];
1148
+ const degraded = [];
606
1149
  for (const rel of shown) {
607
1150
  // Bounded prefix read (CR-03) — the whole file is NEVER read here;
608
1151
  // frontmatter + bodyOffset comfortably fit inside PRACTICE_FILE_READ_BYTES.
@@ -620,52 +1163,95 @@ function discoverPracticeFiles(cwd) {
620
1163
  }
621
1164
  const result = parsePracticeFile(rel, raw);
622
1165
  if (result.ok) {
1166
+ // A degraded practice stays in `practices[]` — that is the whole point
1167
+ // of the degrade (its prose still applies). Its warnings ALSO land in
1168
+ // `degraded[]` so the rendered channels can surface them; a warning
1169
+ // recorded on a result nobody renders is the T-4-64 defect.
623
1170
  practices.push({ ...result.practice, tier });
1171
+ const warnings = [...(result.degradedWarnings ?? [])];
1172
+ // An invalid `check:` block is a degradation of the same class: the
1173
+ // practice applies, one thing about it was not usable. It travels the
1174
+ // same channel so an author learns the block was dropped without having
1175
+ // to read a different surface.
1176
+ if (result.practice.checkInvalid)
1177
+ warnings.push(result.practice.checkInvalid);
1178
+ if (warnings.length > 0)
1179
+ degraded.push({ id: result.practice.id, path: rel, warnings });
624
1180
  }
625
1181
  else {
626
1182
  skipped.push({ path: rel, warning: result.warning });
627
1183
  }
628
1184
  }
629
- return { practices, skipped, tier, notes };
1185
+ return {
1186
+ practices, skipped, degraded, tier, notes, vaultPresent: true, readFailed: probe.failed,
1187
+ vaultRef, vaultSha, vaultMiss, vaultStandardsOnly,
1188
+ };
630
1189
  }
631
1190
  catch (e) {
632
1191
  // T-24-08a: bind and bound the caught error rather than discarding it —
633
1192
  // the bare `catch {}` this replaced is exactly the shape
634
1193
  // pp-core/templates/practices/error-handling.md forbids ("never an empty
635
1194
  // catch block that hides the failure").
1195
+ //
1196
+ // On every fs path below, the vault directory was proved to exist before
1197
+ // anything could throw (the `existsSync` guard returns early), so reaching
1198
+ // here means it was there and something about READING it failed:
1199
+ // vaultPresent stays true and readFailed goes true. The two fields drive
1200
+ // the same blocking outcome in `runVaultAudit` either way — they differ
1201
+ // only in which repair they point an operator at.
636
1202
  return {
637
1203
  practices: [],
638
1204
  skipped: [],
1205
+ degraded: [],
639
1206
  tier: null,
640
- notes: [`practice discovery failed: ${boundedToken(e.message)}`],
1207
+ notes: [`practice discovery failed: ${errnoOf(e)}`],
1208
+ vaultPresent: true,
1209
+ readFailed: true,
1210
+ vaultRef: null,
1211
+ vaultSha: null,
1212
+ vaultMiss: false,
1213
+ vaultStandardsOnly: false,
641
1214
  };
642
1215
  }
643
1216
  }
644
1217
  // ─── Matching + report (PRAC-02 / PRAC-03 / COMM-01) ───────────────────────────
645
- // External-content fence (D-15 / T-24-05). Pack 6 D-08: the fence has ONE
646
- // owner, src/vault.cts — the marker strings and `defangFenceMarkers` are
647
- // imported from it rather than copied, so every path that hands vault text to
648
- // an agent defangs every marker spelling (case, whitespace, HTML entities,
649
- // compatibility forms, zero-width characters), not only the literal marker.
650
- const { EXTERNAL_CONTENT_FENCE_START, EXTERNAL_CONTENT_FENCE_END, defangFenceMarkers } = vaultMod;
1218
+ // External-content fence markers and `defangFenceMarkers` (D-15 / CR-01 /
1219
+ // T-24-05): the owner moved to the vault-index leaf (D-17/D-26, 14-04), and
1220
+ // these bindings re-export it by reference so every consumer of this module
1221
+ // measures the leaf's one function rather than a copy.
1222
+ const { EXTERNAL_CONTENT_FENCE_START, EXTERNAL_CONTENT_FENCE_END, defangFenceMarkers } = vaultIndexLeaf;
651
1223
  /**
652
1224
  * Read a BOUNDED prefix of a practice file's markdown BODY (frontmatter
653
1225
  * stripped) and wrap it in the `vault:external-content` fence + untrusted-
654
1226
  * reference preamble (T-24-05: the excerpt is the exact untrusted payload a
655
1227
  * later phase injects into a code-writing agent's context). Uses
656
1228
  * `vaultMod.readHeadBounded` — a single bounded read of `bodyOffset +
657
- * PRACTICE_EXCERPT_BYTES` bytes — the whole file is NEVER read. The bounded
658
- * body has any planted fence-marker strings neutralized via
659
- * `defangFenceMarkers` BEFORE being placed between the real markers (CR-01),
660
- * so an untrusted body can never spoof an early fence closer.
1229
+ * PRACTICE_EXCERPT_BYTES * EXCERPT_CRLF_OVERREAD_FACTOR` bytes — the whole file
1230
+ * is NEVER read. The bounded body has any planted fence-marker strings
1231
+ * neutralized via `defangFenceMarkers` BEFORE being placed between the real
1232
+ * markers (CR-01), so an untrusted body can never spoof an early fence closer.
1233
+ *
1234
+ * LINE ENDINGS (quick 261003-jby, FIX-1). The body is sliced at `bodyOffset`
1235
+ * FIRST — it is an index into the raw text, computed by `parsePracticeFile` on
1236
+ * the same bytes — and THEN normalised by deleting every carriage return, which
1237
+ * turns each CRLF pair into LF and drops any lone carriage return (a control
1238
+ * character that must not reach an agent context). Only then is it trimmed and capped, so the
1239
+ * `PRACTICE_EXCERPT_BYTES` cap is measured on normalised text and a CRLF
1240
+ * checkout renders the same excerpt as its LF twin. Before this, the cap was
1241
+ * applied to raw text: probe test 4 measured 8 of 55 practices losing 3–18
1242
+ * chars on a `core.autocrlf=true` checkout, and 610 carriage returns reaching
1243
+ * the executor-facing block.
661
1244
  *
662
1245
  * @param filePath Absolute path to the practice `.md` file on disk.
663
1246
  * @param bodyOffset Byte index where the markdown body begins (from the
664
1247
  * practice's `PracticeRecord.bodyOffset`, set by `parsePracticeFile`).
665
1248
  */
666
1249
  function boundedFencedExcerpt(filePath, bodyOffset) {
667
- const raw = vaultMod.readHeadBounded(filePath, bodyOffset + PRACTICE_EXCERPT_BYTES);
668
- const body = raw.slice(bodyOffset).trim();
1250
+ const raw = vaultMod.readHeadBounded(filePath, bodyOffset + PRACTICE_EXCERPT_BYTES * EXCERPT_CRLF_OVERREAD_FACTOR);
1251
+ // ONE replace, not two: deleting every carriage return turns each CRLF pair
1252
+ // into LF AND drops any lone carriage return, so a separate CRLF→LF pass
1253
+ // would be a step no test could tell apart from its absence.
1254
+ const body = raw.slice(bodyOffset).replace(/\r/g, '').trim();
669
1255
  const bounded = body.length > PRACTICE_EXCERPT_BYTES ? body.slice(0, PRACTICE_EXCERPT_BYTES) : body;
670
1256
  const defanged = defangFenceMarkers(bounded);
671
1257
  return [
@@ -679,6 +1265,22 @@ function boundedFencedExcerpt(filePath, bodyOffset) {
679
1265
  EXTERNAL_CONTENT_FENCE_END,
680
1266
  ].join('\n');
681
1267
  }
1268
+ /**
1269
+ * Is this file one the unmatched-source-file note reports when no scoped
1270
+ * practice matched it? (quick 261003-jby, FIX-2, QD-2.)
1271
+ *
1272
+ * POSIX basename (Windows separators are normalised first); the extension
1273
+ * compare is case-insensitive, the `Dockerfile` prefix compare is exact.
1274
+ *
1275
+ * @param filePath A project-relative file path, either separator.
1276
+ */
1277
+ function isUnmatchedNoteCandidate(filePath) {
1278
+ const base = node_path_1.default.posix.basename(String(filePath).replace(/\\/g, '/'));
1279
+ if (base.startsWith(UNMATCHED_NOTE_BASENAME_PREFIX))
1280
+ return true;
1281
+ const lower = base.toLowerCase();
1282
+ return UNMATCHED_NOTE_SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext));
1283
+ }
682
1284
  /**
683
1285
  * Match discovered practices against a candidate file list, partitioning
684
1286
  * results into two buckets (D-13):
@@ -702,12 +1304,86 @@ function boundedFencedExcerpt(filePath, bodyOffset) {
702
1304
  */
703
1305
  function matchPractices(opts) {
704
1306
  const { cwd, files } = opts;
705
- const discovery = discoverPracticeFiles(cwd);
706
- const vaultDirPath = node_path_1.default.join(cwd, '.planning', 'vault');
1307
+ const clock = opts.now ?? (() => Number(process.hrtime.bigint()) / 1e6);
1308
+ const vaultDirPath = typeof opts.vaultDir === 'string' && opts.vaultDir !== ''
1309
+ ? opts.vaultDir
1310
+ : node_path_1.default.join(cwd, '.planning', 'vault');
1311
+ const discovery = discoverPracticeFiles(cwd, vaultDirPath);
707
1312
  const normalizedFiles = files.map((f) => f.replace(/\\/g, '/'));
1313
+ // COST BOUND, PRIMARY ARM (CHECK-04 / T-5-10). Counted BEFORE any matching,
1314
+ // so the refusal is arithmetic rather than a race: identical inputs refuse
1315
+ // identically on every machine (T-5-11). Global practices carry no globs and
1316
+ // so contribute no pairs.
1317
+ const scopedGlobCount = discovery.practices.reduce((n, p) => (p.scope === 'scoped' ? n + p.appliesTo.length : n), 0);
1318
+ const pairs = scopedGlobCount * normalizedFiles.length;
1319
+ if (pairs > MATCH_PAIR_CAP) {
1320
+ // A COUNTED refusal, never a silent empty result — the discipline
1321
+ // `PRACTICE_COUNT_CAP` and `MATCHED_FILES_CAP` already keep. An empty
1322
+ // `matched` with no note would read as "nothing applies", which is
1323
+ // compliance claimed for work that was never done (T-5-12). The note
1324
+ // states what was measured and asks for nothing destructive.
1325
+ return {
1326
+ matched: [],
1327
+ global: [],
1328
+ skipped: discovery.skipped,
1329
+ degraded: discovery.degraded,
1330
+ notes: [
1331
+ ...discovery.notes,
1332
+ `${MATCH_BUDGET_NOTE_PREFIX}this vault and change set produce ${pairs} (glob, file) pair(s), over the ${MATCH_PAIR_CAP}-pair matching cap — no practice was evaluated against these files`,
1333
+ ],
1334
+ // `practicesProcessed` keeps its established meaning (how many practices
1335
+ // discovery yielded), so a renderer can still tell "the vault holds no
1336
+ // practices" from "the vault holds practices that were not evaluated".
1337
+ practicesProcessed: discovery.practices.length,
1338
+ filesConsidered: files.length,
1339
+ // Threaded on BOTH return sites (05-13): a refusal here does not stop
1340
+ // the vault-read fact from being the one an unreadable vault needs
1341
+ // stated, and a field set on one path only is a field a reader cannot
1342
+ // trust on either.
1343
+ vaultReadFailed: discovery.readFailed === true,
1344
+ // Threaded on BOTH return sites for the same reason (CR-01): a field set
1345
+ // on one path only is a field a reader cannot trust on either.
1346
+ vaultAbsent: discovery.vaultPresent === false,
1347
+ // Nothing was evaluated, so no file can be said to have matched nothing
1348
+ // (QD-3) — the refusal note above states what happened instead.
1349
+ unmatchedSourceFiles: [],
1350
+ // Threaded on BOTH return sites (05-13 rule).
1351
+ vaultRef: discovery.vaultRef,
1352
+ vaultSha: discovery.vaultSha,
1353
+ vaultMiss: discovery.vaultMiss,
1354
+ // Quick 261015-at3: threaded on BOTH return sites (05-13 rule).
1355
+ vaultStandardsOnly: discovery.vaultStandardsOnly === true,
1356
+ pairBudget: {
1357
+ pairs,
1358
+ cap: MATCH_PAIR_CAP,
1359
+ exceeded: true,
1360
+ budgetMs: MATCH_BUDGET_MS,
1361
+ // The loop never started, so the wall-clock arm never had a chance to
1362
+ // fire. Reporting it as exhausted too would name a guard that did not
1363
+ // act.
1364
+ budgetExceeded: false,
1365
+ },
1366
+ };
1367
+ }
708
1368
  const matched = [];
709
1369
  const global = [];
710
- for (const practice of discovery.practices) {
1370
+ // Every normalised file ANY scoped practice matched — uncapped, unlike each
1371
+ // entry's `matchedFiles` slice — for the unmatched-source-file note (FIX-2).
1372
+ const anyScopedMatch = new Set();
1373
+ const startedAt = clock();
1374
+ let practicesUnprocessed = 0;
1375
+ let budgetExceeded = false;
1376
+ for (const [index, practice] of discovery.practices.entries()) {
1377
+ // COST BOUND, SECONDARY ARM. Checked between whole practices: the unit
1378
+ // skipped over is one practice's full glob × file scan, which the pair cap
1379
+ // above has already bounded, so the granularity is bounded too. Verdicts
1380
+ // already computed are KEPT — but the omission is counted, so a partial
1381
+ // result can never be read as a complete one.
1382
+ if (clock() - startedAt > MATCH_BUDGET_MS) {
1383
+ budgetExceeded = true;
1384
+ practicesUnprocessed = discovery.practices.length - index;
1385
+ break;
1386
+ }
711
1387
  if (practice.scope === 'scoped') {
712
1388
  const winningGlobs = new Set();
713
1389
  const matchedFilesSet = new Set();
@@ -716,6 +1392,7 @@ function matchPractices(opts) {
716
1392
  if (globMatch(file, glob)) {
717
1393
  winningGlobs.add(glob);
718
1394
  matchedFilesSet.add(file);
1395
+ anyScopedMatch.add(file);
719
1396
  }
720
1397
  }
721
1398
  }
@@ -749,15 +1426,137 @@ function matchPractices(opts) {
749
1426
  }
750
1427
  matched.sort((a, b) => a.id.localeCompare(b.id));
751
1428
  global.sort((a, b) => a.id.localeCompare(b.id));
1429
+ const notes = budgetExceeded
1430
+ ? [
1431
+ ...discovery.notes,
1432
+ `${MATCH_BUDGET_NOTE_PREFIX}${practicesUnprocessed} of ${discovery.practices.length} practice(s) were not matched because the ${MATCH_BUDGET_MS}ms matching budget was exhausted — the practices reported were evaluated, the rest were not`,
1433
+ ]
1434
+ : discovery.notes;
1435
+ // QD-3: only a claim about practices that WERE evaluated. With no practice
1436
+ // discovered (absent, unreadable, missed or empty vault) or the wall-clock
1437
+ // budget exhausted part-way, "matched no scoped practice" would state
1438
+ // something nobody measured; the sentence ladder and the budget note already
1439
+ // say what happened there.
1440
+ const unmatchedSourceFiles = discovery.practices.length > 0 && !budgetExceeded
1441
+ ? Array.from(new Set(normalizedFiles))
1442
+ .filter((f) => isUnmatchedNoteCandidate(f) && !anyScopedMatch.has(f))
1443
+ .sort()
1444
+ : [];
752
1445
  return {
753
1446
  matched,
754
1447
  global,
755
1448
  skipped: discovery.skipped,
756
- notes: discovery.notes,
1449
+ // Passed straight through — the matcher makes no judgement about a
1450
+ // degradation, it only carries it to the renderers (CHECK-02d).
1451
+ degraded: discovery.degraded,
1452
+ notes,
757
1453
  practicesProcessed: discovery.practices.length,
758
1454
  filesConsidered: files.length,
1455
+ // The typed vault-read fact, carried verbatim from discovery — never
1456
+ // re-derived here and never sniffed out of `notes[]` prose (05-13).
1457
+ vaultReadFailed: discovery.readFailed === true,
1458
+ // The typed vault-absence fact, carried verbatim from discovery (CR-01).
1459
+ vaultAbsent: discovery.vaultPresent === false,
1460
+ unmatchedSourceFiles,
1461
+ // The manifest coordinates and recorded-miss fact, carried verbatim from
1462
+ // discovery (quick 261003-jby, FIX-3/FIX-4).
1463
+ vaultRef: discovery.vaultRef,
1464
+ vaultSha: discovery.vaultSha,
1465
+ vaultMiss: discovery.vaultMiss,
1466
+ // Quick 261015-at3: the standards-vault-in-practices-mode fact, verbatim.
1467
+ vaultStandardsOnly: discovery.vaultStandardsOnly === true,
1468
+ pairBudget: {
1469
+ pairs,
1470
+ cap: MATCH_PAIR_CAP,
1471
+ exceeded: false,
1472
+ budgetMs: MATCH_BUDGET_MS,
1473
+ budgetExceeded,
1474
+ },
759
1475
  };
760
1476
  }
1477
+ /**
1478
+ * The cost bound's notes, selected out of a result's `notes[]` by the marker
1479
+ * the matcher stamped on them.
1480
+ *
1481
+ * NO LONGER THE SLOT FILTER FOR THE TWO RENDERED CHANNELS (05-13, G-4). The
1482
+ * `<vault_practices>` block and the ship gate's message now forward EVERY note
1483
+ * the matcher counted, because this filter's prefix test also dropped the
1484
+ * discovery-failure and cap-omission notes, and a note computed and rendered
1485
+ * nowhere governs nothing. What survives from the original rationale is the
1486
+ * part that was always the point: whatever a channel forwards, it forwards the
1487
+ * sentence the matcher COMPOSED, never one re-composed from `pairBudget`'s
1488
+ * numbers (one emission, N channels — T-4-49).
1489
+ *
1490
+ * Retained and exported as the SELECTOR for a caller that wants exactly the
1491
+ * cost bound's subset — the slow corpus counts refusals with it, and a test
1492
+ * uses it to prove which notes the old filter would have dropped.
1493
+ */
1494
+ function matchBudgetNotes(notes) {
1495
+ return notes.filter((n) => n.startsWith(MATCH_BUDGET_NOTE_PREFIX));
1496
+ }
1497
+ /**
1498
+ * Bound the text of a degradation warning before it is rendered (T-5-02).
1499
+ *
1500
+ * The warning is ENGINE-composed around tokens `parsePracticeFile` already put
1501
+ * through `boundedToken`, so it is not truncated again here — truncating a
1502
+ * composed sentence would cut the defect off mid-word. What it still does is
1503
+ * collapse any CR/LF (so a warning can never become two rendered lines) and
1504
+ * defang planted fence markers (so a client-authored filename carrying the
1505
+ * marker string cannot open or close a fence in a rendered channel).
1506
+ */
1507
+ function boundedDegradationText(warning) {
1508
+ return defangFenceMarkers(String(warning).replace(/[\r\n]+/g, ' '));
1509
+ }
1510
+ /**
1511
+ * The per-practice degradation lines, shared VERBATIM by both rendered
1512
+ * channels. One warning renders exactly one line, and every client-derived
1513
+ * token on it is bounded: the practice `id` is vault-authored, and the warning
1514
+ * is engine-composed around bounded tokens.
1515
+ */
1516
+ function renderDegradationLines(degraded) {
1517
+ return degraded.flatMap((d) => d.warnings.map((w) => `- **${defangFenceMarkers(boundedToken(d.id))}** — ${boundedDegradationText(w)}`));
1518
+ }
1519
+ /**
1520
+ * The per-REFUSED-file lines, shared VERBATIM by both executor-facing channels
1521
+ * (05-REVIEW WR-02).
1522
+ *
1523
+ * `skipped[]` is a structurally different fact from `degraded[]`: a degraded
1524
+ * practice still applies with one part not understood, a skipped one was
1525
+ * REFUSED WHOLE and nothing in it applies. Discovery has always carried both,
1526
+ * `renderPracticesReport` has always rendered both, and the two channels an
1527
+ * executor actually reads rendered only the degraded half — so a vault whose
1528
+ * practice file the parser rejected looked, to a planner and to the ship gate,
1529
+ * exactly like a vault that never held it. `vault.audit` meanwhile emits
1530
+ * `FAIL(practice_unloadable)` and blocks on that identical state, so the three
1531
+ * channels disagreed about one vault.
1532
+ *
1533
+ * Each warning already embeds its own path and is composed by the engine, but
1534
+ * it interpolates vault-authored tokens, so it goes through the same
1535
+ * bounding/defanging as a degradation line and renders inside the fence.
1536
+ */
1537
+ function renderSkippedLines(skipped) {
1538
+ return skipped.map((s) => `- ${boundedDegradationText(s.warning)}`);
1539
+ }
1540
+ /**
1541
+ * The one-line note naming candidate files that matched no scoped practice
1542
+ * (quick 261003-jby, FIX-2). `null` when there are none.
1543
+ *
1544
+ * The paths are project-authored — the plan's `files_modified` or the
1545
+ * operator's argv, the same trust class `matched_files` already renders — so
1546
+ * the line may sit outside a fence, but each path is still bounded and
1547
+ * defanged so none can open or close one. The list is capped at
1548
+ * `MATCHED_FILES_CAP`; the raw array carries every file.
1549
+ *
1550
+ * @param files `PracticeMatchResult.unmatchedSourceFiles`.
1551
+ */
1552
+ function renderUnmatchedSourceNote(files) {
1553
+ const all = Array.isArray(files) ? files : [];
1554
+ if (all.length === 0)
1555
+ return null;
1556
+ const shown = all.slice(0, MATCHED_FILES_CAP).map((p) => defangFenceMarkers(boundedToken(p)));
1557
+ const capped = all.length > MATCHED_FILES_CAP ? ` (showing ${MATCHED_FILES_CAP} of ${all.length})` : '';
1558
+ return `Note: ${all.length} file(s) matched no scoped practice: ${shown.join(', ')}${capped} — check the practices' applies-to globs against this layout.`;
1559
+ }
761
1560
  /**
762
1561
  * Format a `PracticeMatchResult` as plain English (COMM-01 / D-12) — the
763
1562
  * default human-readable output (a later CLI wiring emits `--raw` JSON from
@@ -807,6 +1606,31 @@ function renderPracticesReport(result) {
807
1606
  lines.push(`- ${s.warning}`);
808
1607
  lines.push('');
809
1608
  }
1609
+ // CHECK-02d — the report's half of the degradation channel, carrying the SAME
1610
+ // lines the block carries (T-4-49: both rendered channels move together, or a
1611
+ // reader of one is told something a reader of the other never learns).
1612
+ // Deliberately a section of its own, beside "Skipped" rather than inside it:
1613
+ // a skipped practice did not apply, a degraded one DID.
1614
+ //
1615
+ // Unfenced here, fenced in the block, and that asymmetry is deliberate: this
1616
+ // report is human-facing and its neighbouring `Skipped` section already
1617
+ // prints bounded warnings unfenced, whereas the block is read by a
1618
+ // code-WRITING agent and holds the stricter invariant that nothing
1619
+ // vault-authored appears outside a fence.
1620
+ const degraded = result.degraded ?? [];
1621
+ if (degraded.length > 0) {
1622
+ lines.push(`## Degraded practices (${degraded.length})`);
1623
+ lines.push('_These practices still applied — each line names what this pp-core did not understand._');
1624
+ for (const line of renderDegradationLines(degraded))
1625
+ lines.push(line);
1626
+ lines.push('');
1627
+ }
1628
+ // FIX-2: the same line the block carries, as its own paragraph.
1629
+ const unmatchedNote = renderUnmatchedSourceNote(result.unmatchedSourceFiles);
1630
+ if (unmatchedNote !== null) {
1631
+ lines.push(unmatchedNote);
1632
+ lines.push('');
1633
+ }
810
1634
  if (result.notes.length > 0) {
811
1635
  lines.push('## Notes');
812
1636
  for (const n of result.notes)
@@ -854,8 +1678,8 @@ function unwrapFencedExcerpt(fenced) {
854
1678
  * `id` and `source` just as much as the excerpt prose — and this block is read
855
1679
  * by a code-WRITING executor in Phase 26. So every field passes through
856
1680
  * `boundedToken` (length bound, control-character strip) AND
857
- * `defangFenceMarkers` (so a planted `<!-- vault:external-content-end -->`
858
- * cannot terminate the fence early and smuggle text out of the data region)
1681
+ * `defangFenceMarkers` (so a planted end-of-fence marker, spelled only in the
1682
+ * vault-index leaf, cannot terminate the fence early and smuggle text out of the data region)
859
1683
  * before it is placed between the markers. Nothing vault-authored is emitted
860
1684
  * outside a fence anywhere in this block.
861
1685
  */
@@ -885,6 +1709,98 @@ function renderPracticeEntry(entry) {
885
1709
  lines.push(EXTERNAL_CONTENT_FENCE_END);
886
1710
  return lines.join('\n');
887
1711
  }
1712
+ /** Stamp `ref`: charset-and-length bounded like the load gate's ref (T-08-39). */
1713
+ function stampRef(ref) {
1714
+ const kept = typeof ref === 'string' ? ref.replace(/[^A-Za-z0-9._/@+~-]/g, '').slice(0, 100) : '';
1715
+ return kept === '' ? '(none)' : kept;
1716
+ }
1717
+ /** Stamp `sha`: lowercase hex only, first 8; `(none)` when fewer than 8 remain. */
1718
+ function stampSha(sha) {
1719
+ const hex = typeof sha === 'string' ? sha.toLowerCase().replace(/[^0-9a-f]/g, '') : '';
1720
+ return hex.length >= 8 ? hex.slice(0, 8) : '(none)';
1721
+ }
1722
+ /**
1723
+ * Render the block's status stamp — ONE line, an HTML comment (quick
1724
+ * 261003-jby, FIX-3, QD-4).
1725
+ *
1726
+ * `ref=` and `sha=` name the vault read, `entries=<n>` counts the fenced
1727
+ * practice entries rendered OR `state=<token>` names the degraded state —
1728
+ * never both — and `engine=` is the `createdWith` string. Every value is
1729
+ * charset-bounded so no `<` or `>` survives: the comment cannot be closed early
1730
+ * and nothing in it can read as a fence marker or an `id:` line (T-jby-04/05).
1731
+ *
1732
+ * @param parts.ref Manifest ref (project-configured; bounded here).
1733
+ * @param parts.sha Manifest resolvedSha (bounded to 8 lowercase hex).
1734
+ * @param parts.entries Number of fenced entries, when the block renders entries.
1735
+ * @param parts.state A `PRACTICES_STAMP_STATES` token, when it renders a sentence.
1736
+ * @param parts.engine Override for the engine string (tests); defaults to
1737
+ * `resolveCreatedWithFrom(__dirname)`.
1738
+ */
1739
+ function renderPracticesStamp(parts) {
1740
+ const engineRaw = typeof parts.engine === 'string' ? parts.engine : (0, compose_bindings_cjs_1.resolveCreatedWithFrom)(__dirname);
1741
+ const engine = engineRaw.replace(/[^A-Za-z0-9.@_-]/g, '') || '(none)';
1742
+ const tail = typeof parts.state === 'string'
1743
+ ? `state=${parts.state.replace(/[^a-z-]/g, '')}`
1744
+ : `entries=${Math.max(0, Math.trunc(Number(parts.entries) || 0))}`;
1745
+ return `${PRACTICES_STAMP_PREFIX} ref=${stampRef(parts.ref)} sha=${stampSha(parts.sha)} ${tail} engine=${engine} -->`;
1746
+ }
1747
+ /**
1748
+ * Choose the sentence an entry-less block states AND its stamp token, in ONE
1749
+ * place so the two cannot drift (quick 261003-jby, FIX-3/FIX-4).
1750
+ *
1751
+ * ORDER IS LOAD-BEARING and pinned rung by rung. The pair-cap refusal stays
1752
+ * FIRST: it is a statement about work this engine declined to do. Read
1753
+ * failure, absence and a recorded miss follow — each is a fact about a vault
1754
+ * nothing could be read from, and each sits above the all-refused and
1755
+ * none-found rungs, which are facts about a vault that WAS read. The miss rung
1756
+ * (FIX-4) sits after absence (an absent directory has no manifest to record a
1757
+ * miss in, so absence is the more basic fact) and before all-refused.
1758
+ *
1759
+ * The standards-only rung (quick 261015-at3) sits after the miss — a miss
1760
+ * reached no vault that could hold an index, so it is the more basic fact —
1761
+ * and ABOVE all-refused and none-found. It is a statement about a vault that
1762
+ * WAS read, which is why it is below the unread family; and "none found" is
1763
+ * false of a vault whose practice layer the project is not even reading.
1764
+ */
1765
+ function selectEmptyBlockSentence(result, skippedCount) {
1766
+ if (result?.pairBudget?.exceeded === true) {
1767
+ return { sentence: "Practices were NOT checked against this plan's files.", state: PRACTICES_STAMP_STATES.NOT_CHECKED_PAIR_CAP };
1768
+ }
1769
+ // THE THIRD RUNG (05-13, G-4). `practicesProcessed === 0` is true of a vault
1770
+ // that holds no practices AND of one nothing could read; the none-found
1771
+ // sentence is a fact about a vault that WAS read. Reads the typed boolean,
1772
+ // never `notes[]` prose.
1773
+ if (result?.vaultReadFailed === true) {
1774
+ return { sentence: PRACTICES_VAULT_UNREADABLE_SENTENCE, state: PRACTICES_STAMP_STATES.VAULT_UNREADABLE };
1775
+ }
1776
+ if (result?.vaultAbsent === true) {
1777
+ return { sentence: PRACTICES_VAULT_ABSENT_SENTENCE, state: PRACTICES_STAMP_STATES.VAULT_ABSENT };
1778
+ }
1779
+ // Quick 261003-jby (FIX-4): a manifest that records a miss — the fetch ran
1780
+ // and reached nothing.
1781
+ if (result?.vaultMiss === true) {
1782
+ return { sentence: PRACTICES_VAULT_MISS_SENTENCE, state: PRACTICES_STAMP_STATES.VAULT_MISS };
1783
+ }
1784
+ // Quick 261015-at3: a standards vault (INDEX.md, no practice files) read in
1785
+ // practices mode — NOT checked, never none-found.
1786
+ if (result?.vaultStandardsOnly === true) {
1787
+ return { sentence: PRACTICES_VAULT_STANDARDS_ONLY_SENTENCE, state: PRACTICES_STAMP_STATES.STANDARDS_ONLY };
1788
+ }
1789
+ if (result && result.practicesProcessed === 0 && skippedCount > 0) {
1790
+ // 05-REVIEW WR-02, the fifth rung. `practicesProcessed === 0` is true of a
1791
+ // THIRD kind of vault as well: one that holds practice files and had every
1792
+ // one of them refused. "None found" is false here — the files were found,
1793
+ // read, and rejected.
1794
+ return {
1795
+ sentence: 'Practices were NOT applied — every practice file found in the vault was refused. See “Refused practices”.',
1796
+ state: PRACTICES_STAMP_STATES.ALL_REFUSED,
1797
+ };
1798
+ }
1799
+ if (result && result.practicesProcessed === 0) {
1800
+ return { sentence: 'Practices checked — none found in the vault.', state: PRACTICES_STAMP_STATES.NONE_FOUND };
1801
+ }
1802
+ return { sentence: "Practices checked — none apply to this plan's files.", state: PRACTICES_STAMP_STATES.NONE_APPLY };
1803
+ }
888
1804
  /**
889
1805
  * Render the complete `<vault_practices>` block for a plan, ready to embed
890
1806
  * VERBATIM — no renaming, reshaping or re-fencing by the caller.
@@ -904,18 +1820,130 @@ function renderPracticeEntry(entry) {
904
1820
  function renderPracticesBlock(result, reason) {
905
1821
  const open = '<vault_practices>';
906
1822
  const close = '</vault_practices>';
907
- const head = [open, '', BLOCK_PREAMBLE, ''];
1823
+ // FIX-3 (quick 261003-jby): the status stamp is the first line inside the
1824
+ // block on all three return paths, then the blank line, then the preamble.
1825
+ const engine = (0, compose_bindings_cjs_1.resolveCreatedWithFrom)(__dirname);
1826
+ const headWith = (stamp) => [
1827
+ open,
1828
+ renderPracticesStamp({ ref: result?.vaultRef, sha: result?.vaultSha, engine, ...stamp }),
1829
+ '',
1830
+ BLOCK_PREAMBLE,
1831
+ '',
1832
+ ];
1833
+ // CHECK-02d — the executor-facing degradation slot.
1834
+ //
1835
+ // This is the channel the code-writing agent actually reads, so it is the
1836
+ // channel the warning has to reach. A degradation recorded on a parse result
1837
+ // and rendered nowhere repeats T-4-64 exactly: the record exists, nobody sees
1838
+ // it, and the practice governs nothing while appearing to govern.
1839
+ //
1840
+ // FENCED, because this block's standing invariant is that nothing
1841
+ // vault-authored is emitted outside an external-content fence — the practice
1842
+ // `id` on each line is vault-authored. The lines are defanged first, so a
1843
+ // planted marker cannot close the fence early and smuggle text out.
1844
+ //
1845
+ // The slot ADDS to the rendering, never replaces: degraded means "applies
1846
+ // with a warning", so the practice's own entry below is untouched. When
1847
+ // nothing is degraded the slot contributes zero bytes, leaving every healthy
1848
+ // vault's block byte-identical to its pre-plan output.
1849
+ const degraded = result?.degraded ?? [];
1850
+ const degradedSection = degraded.length > 0
1851
+ ? [
1852
+ '## Degraded practices',
1853
+ '',
1854
+ 'These practices still apply. Each line below names something in the practice file that this pp-core did not understand.',
1855
+ '',
1856
+ EXTERNAL_CONTENT_FENCE_START,
1857
+ ...FENCE_NOTICE,
1858
+ '',
1859
+ ...renderDegradationLines(degraded),
1860
+ EXTERNAL_CONTENT_FENCE_END,
1861
+ '',
1862
+ ]
1863
+ : [];
1864
+ // CHECK-04 / T-5-12 — the cost bound's slot (05-04).
1865
+ //
1866
+ // Before this the block rendered no `notes[]` at all, so a refusal computed
1867
+ // by the matcher reached the human report and the --raw JSON and STOPPED —
1868
+ // the one channel a code-writing agent actually reads stayed silent and, on
1869
+ // a total refusal, went on to print "none apply to this plan's files" about
1870
+ // files nothing had been evaluated against. That is T-4-64 with a worse
1871
+ // consequence: not an invisible note, an actively false one.
1872
+ //
1873
+ // UNFENCED, unlike the degradation slot beside it: every byte here is
1874
+ // engine-composed. That was argued for the cost bound's two sentences and
1875
+ // it holds for the WHOLE set (05-13) — the cap-omission note is an integer
1876
+ // and a fixed phrase, and the discovery-failure notes were bounded to an
1877
+ // errno code by 05-10 precisely so no path text could ride one into an
1878
+ // agent-read channel. No vault-authored token reaches this section on any
1879
+ // path, so the block's nothing-vault-authored-outside-a-fence invariant is
1880
+ // untouched.
1881
+ //
1882
+ // WIDENED from `matchBudgetNotes` (05-13, G-4). That prefix filter admitted
1883
+ // the cost bound's notes and silently dropped every other note the matcher
1884
+ // counted — the discovery failure and the cap omission among them. A note
1885
+ // computed and rendered nowhere is T-4-64, and here it was worse than
1886
+ // invisible: the sentence chosen below claimed a clean, empty vault while
1887
+ // the note explaining that nothing could be read was being discarded one
1888
+ // line above it. Every note is still forwarded VERBATIM as the matcher
1889
+ // composed it, never re-composed from `pairBudget`'s numbers, so this
1890
+ // channel and the ship gate's message cannot drift apart (T-4-49).
1891
+ const resultNotes = result?.notes ?? [];
1892
+ const notesSection = resultNotes.length > 0
1893
+ ? ['## Practices not checked', '', ...resultNotes.map((n) => `- ${n}`), '']
1894
+ : [];
1895
+ // 05-REVIEW WR-02 — the refused half of discovery, which this channel dropped.
1896
+ // FENCED like the degradation slot beside it and for the same reason: the
1897
+ // warnings interpolate vault-authored tokens. Rendered on BOTH returns below,
1898
+ // because the all-refused vault lands on the early one.
1899
+ const skipped = result?.skipped ?? [];
1900
+ const refusedSection = skipped.length > 0
1901
+ ? [
1902
+ '## Refused practices',
1903
+ '',
1904
+ 'These practice files were found in the vault and refused whole — nothing in them was applied. Each line names why.',
1905
+ '',
1906
+ EXTERNAL_CONTENT_FENCE_START,
1907
+ ...FENCE_NOTICE,
1908
+ '',
1909
+ ...renderSkippedLines(skipped),
1910
+ EXTERNAL_CONTENT_FENCE_END,
1911
+ '',
1912
+ ]
1913
+ : [];
1914
+ // FIX-2 (quick 261003-jby): candidate files no scoped practice matched. The
1915
+ // matcher owns the gating (QD-3); the renderer renders whenever the array is
1916
+ // non-empty. Engine-composed text around project-authored, bounded and
1917
+ // defanged paths — nothing vault-authored — so it sits outside a fence.
1918
+ const unmatchedNote = renderUnmatchedSourceNote(result?.unmatchedSourceFiles);
1919
+ const noteSection = unmatchedNote !== null ? [unmatchedNote, ''] : [];
908
1920
  if (reason) {
1921
+ const head = headWith({ state: PRACTICES_STAMP_STATES.COULD_NOT_BE_CHECKED });
909
1922
  return [...head, `Practices could not be checked: ${boundedToken(reason)}.`, '', close].join('\n');
910
1923
  }
911
1924
  if (!result || (result.matched.length === 0 && result.global.length === 0)) {
912
1925
  // D-14 (vault holds no practices at all) vs D-13 (checked, none applied).
913
- const sentence = result && result.practicesProcessed === 0
914
- ? 'Practices checked — none found in the vault.'
915
- : "Practices checked — none apply to this plan's files.";
916
- return [...head, sentence, '', close].join('\n');
1926
+ //
1927
+ // A degraded SCOPED practice whose globs matched nothing lands in neither
1928
+ // bucket and reaches this early return — which is precisely where a warning
1929
+ // would otherwise vanish, so the slot is rendered here too.
1930
+ //
1931
+ // A TOTAL refusal (the pair cap fired before the loop) lands here too, and
1932
+ // neither of the two pre-existing sentences is true of it: nothing was
1933
+ // checked, so "checked — none apply" claims a result that was never
1934
+ // computed. A partial refusal (the wall-clock budget) keeps its sentence,
1935
+ // because the practices that WERE evaluated genuinely did not apply — the
1936
+ // note above states how many were not reached.
1937
+ //
1938
+ // The rungs themselves — and their order — live in
1939
+ // `selectEmptyBlockSentence`, which picks the sentence and the stamp token
1940
+ // together (quick 261003-jby) so the two cannot drift.
1941
+ const { sentence, state } = selectEmptyBlockSentence(result, skipped.length);
1942
+ const head = headWith({ state });
1943
+ return [...head, ...notesSection, ...degradedSection, ...refusedSection, ...noteSection, sentence, '', close].join('\n');
917
1944
  }
918
- const body = [];
1945
+ const head = headWith({ entries: result.matched.length + result.global.length });
1946
+ const body = [...notesSection, ...degradedSection, ...refusedSection, ...noteSection];
919
1947
  if (result.matched.length > 0) {
920
1948
  body.push('## Scoped practices', '');
921
1949
  for (const m of result.matched)
@@ -937,6 +1965,19 @@ module.exports = {
937
1965
  matchPractices,
938
1966
  renderPracticesReport,
939
1967
  renderPracticesBlock,
1968
+ // 06-04 (VERIFY-02, CR-02): exported so the retroactive review arm hands the
1969
+ // agent EXACTLY the entries `renderPracticesBlock` hands a planner — one
1970
+ // fencing idiom, one defang, one reference-data preamble. The alternative was
1971
+ // for that arm to render a per-practice entry of its own, which is how CR-02's
1972
+ // class comes back: a second renderer is a second place a client-authored byte
1973
+ // can reach an agent context unfenced. Purely additive; no call site changed.
1974
+ renderPracticeEntry,
1975
+ // 261003-j89 (D-05-07 closed): exported so the trace scaffold digests the SAME
1976
+ // text `renderPracticeEntry` places under `ask:`/`excerpt:` — one unwrap, two
1977
+ // readers. A second unwrap in src/vault-trace.cts would be a second place the
1978
+ // fence-stripping rule could drift, and the digest would then disagree with
1979
+ // the block it was written to verify.
1980
+ unwrapFencedExcerpt,
940
1981
  boundedFencedExcerpt,
941
1982
  PRACTICE_COUNT_CAP,
942
1983
  VALID_SEVERITIES,
@@ -949,4 +1990,68 @@ module.exports = {
949
1990
  boundedToken,
950
1991
  PRACTICE_FILE_READ_BYTES,
951
1992
  WARNING_TOKEN_MAX,
1993
+ // 05-01 (CHECK-02, T-4-42): exported so the allowlist's conformance test can
1994
+ // DERIVE its iteration set from the shipped constant instead of hand-listing
1995
+ // it, and so the promote path's mirror can be checked against the same set.
1996
+ DOCUMENTED_FRONTMATTER_KEYS,
1997
+ // 05-01 (D-05-05): the check block's three caps. Exported so tests HARVEST
1998
+ // them at run time and size their boundary fixtures from the shipped values —
1999
+ // never copying the numbers, which is how a fixture rots into asserting a cap
2000
+ // the code no longer has.
2001
+ CHECK_FILES_CAP,
2002
+ CHECK_TOKEN_COUNT_CAP,
2003
+ CHECK_TOKEN_MAX,
2004
+ // 10-03 (MECH-03 / D-10-04): the enforced-by list cap, exported for the same
2005
+ // harvest-never-copy reason as the three check caps above.
2006
+ ENFORCED_BY_COUNT_CAP,
2007
+ // 05-04 (CHECK-04, T-5-10): the matching cost bound. Exported so the slow
2008
+ // corpus can extrapolate its measured per-pair rate to the cap the running
2009
+ // code actually holds, and so a consumer channel can name the cap it hit.
2010
+ MATCH_PAIR_CAP,
2011
+ MATCH_BUDGET_MS,
2012
+ matchBudgetNotes,
2013
+ // 05-13 (CHECK-02, G-4): the read-failure sentence both executor-facing
2014
+ // channels state. Exported so `cmdShipPractices` and every pin HARVEST the
2015
+ // wording instead of copying it — a copy is a wording free to drift.
2016
+ PRACTICES_VAULT_UNREADABLE_SENTENCE,
2017
+ PRACTICES_VAULT_ABSENT_SENTENCE,
2018
+ // 05-REVIEW CR-02: exported so the audit's gate message fences its
2019
+ // vault-authored fail items with the SAME idiom this module already uses —
2020
+ // one marker pair, one defang, one reference-data notice. Authoring a second
2021
+ // fence or preamble in the audit module is how CR-02's class returns (same
2022
+ // argument as the renderPracticeEntry export above).
2023
+ defangFenceMarkers,
2024
+ EXTERNAL_CONTENT_FENCE_START,
2025
+ EXTERNAL_CONTENT_FENCE_END,
2026
+ FENCE_NOTICE,
2027
+ // 05-REVIEW WR-02: the refused-file lines, exported for the SAME reason
2028
+ // `renderPracticeEntry` is — the ship gate renders the identical lines from
2029
+ // this one composer, so the two executor-facing channels cannot describe one
2030
+ // refused practice differently (T-4-49).
2031
+ renderSkippedLines,
2032
+ // 05-REVIEW CR-03: exported so the audit's bounded reader reports a caught
2033
+ // filesystem error by ERRNO only, through the one redactor written for this
2034
+ // exact hazard (see its doc comment) — never a raw Node error message, which
2035
+ // interpolates the absolute path CLAUDE.md § Logging forbids surfacing.
2036
+ errnoOf,
2037
+ // Quick 261003-jby (FIX-1): the excerpt's CRLF over-read factor, exported so
2038
+ // the bounded-read test and the contract page HARVEST it.
2039
+ EXCERPT_CRLF_OVERREAD_FACTOR,
2040
+ // Quick 261003-jby (FIX-2): the unmatched-source-file note's candidate set,
2041
+ // predicate and one-line composer — exported so tests and the contract page
2042
+ // harvest them and both rendered channels compose the line in one place.
2043
+ UNMATCHED_NOTE_SOURCE_EXTENSIONS,
2044
+ UNMATCHED_NOTE_BASENAME_PREFIX,
2045
+ isUnmatchedNoteCandidate,
2046
+ renderUnmatchedSourceNote,
2047
+ // Quick 261003-jby (FIX-3/FIX-4): the status stamp's prefix, state vocabulary
2048
+ // and renderer, and the recorded-miss sentence — exported so tests and the
2049
+ // contract page harvest them rather than copy them.
2050
+ PRACTICES_VAULT_MISS_SENTENCE,
2051
+ // Quick 261015-at3: the standards-vault-in-practices-mode sentence, exported
2052
+ // on the same harvest rule.
2053
+ PRACTICES_VAULT_STANDARDS_ONLY_SENTENCE,
2054
+ PRACTICES_STAMP_PREFIX,
2055
+ PRACTICES_STAMP_STATES,
2056
+ renderPracticesStamp,
952
2057
  };