@pareto4/pp-core 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-reviewer.md +1 -1
  3. package/agents/pp-executor.md +2 -13
  4. package/agents/pp-planner.md +1 -1
  5. package/agents/pp-vault-reviewer.md +51 -0
  6. package/commands/pp-ns-review.md +2 -1
  7. package/commands/pp-vault-review.md +33 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/pp-core/bin/lib/capability-activation.cjs +19 -9
  11. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  12. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  13. package/pp-core/bin/lib/clusters.cjs +1 -0
  14. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  16. package/pp-core/bin/lib/config-loader.cjs +151 -8
  17. package/pp-core/bin/lib/decisions.cjs +104 -27
  18. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  19. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  20. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  21. package/pp-core/bin/lib/init-contract.cjs +180 -66
  22. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  23. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  24. package/pp-core/bin/lib/loop-resolver.cjs +111 -35
  25. package/pp-core/bin/lib/org-profile.cjs +218 -44
  26. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  27. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  28. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  29. package/pp-core/bin/lib/phase.cjs +51 -1
  30. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  31. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  32. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  33. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  34. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  35. package/pp-core/bin/lib/vault-update.cjs +266 -0
  36. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  37. package/pp-core/bin/lib/vault.cjs +313 -11
  38. package/pp-core/bin/pp-tools.cjs +30 -13
  39. package/pp-core/bin/shared/model-catalog.json +2 -1
  40. package/pp-core/references/executor-vault-trace.md +47 -0
  41. package/pp-core/references/loop-hook-dispatch.md +20 -2
  42. package/pp-core/references/planner-vault-practices.md +46 -1
  43. package/pp-core/references/planning-config.md +1 -1
  44. package/pp-core/templates/practices/README.md +140 -11
  45. package/pp-core/templates/project-charter.md +226 -0
  46. package/pp-core/workflows/add-tests.md +4 -0
  47. package/pp-core/workflows/audit-fix.md +2 -0
  48. package/pp-core/workflows/audit-milestone.md +1 -1
  49. package/pp-core/workflows/autonomous.md +3 -3
  50. package/pp-core/workflows/check-todos.md +1 -1
  51. package/pp-core/workflows/code-review-fix.md +3 -1
  52. package/pp-core/workflows/code-review.md +1 -1
  53. package/pp-core/workflows/debug.md +4 -0
  54. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  55. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  56. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  57. package/pp-core/workflows/discovery-sprint.md +1 -1
  58. package/pp-core/workflows/discovery-to-build.md +1 -1
  59. package/pp-core/workflows/discuss-phase.md +2 -2
  60. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  61. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  62. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  63. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  64. package/pp-core/workflows/execute-phase.md +29 -12
  65. package/pp-core/workflows/execute-plan.md +4 -0
  66. package/pp-core/workflows/fast.md +4 -0
  67. package/pp-core/workflows/help/modes/full.md +3 -2
  68. package/pp-core/workflows/init.md +2 -2
  69. package/pp-core/workflows/new-project.md +1 -1
  70. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  71. package/pp-core/workflows/plan-phase.md +13 -10
  72. package/pp-core/workflows/quick.md +5 -1
  73. package/pp-core/workflows/secure-phase.md +1 -1
  74. package/pp-core/workflows/ship.md +7 -3
  75. package/pp-core/workflows/undo.md +1 -1
  76. package/pp-core/workflows/validate-phase.md +5 -1
  77. package/pp-core/workflows/vault-review.md +65 -0
  78. package/pp-core/workflows/verify-work.md +6 -3
  79. package/scripts/gen-capability-registry.cjs +11 -0
  80. package/scripts/lint-test-file-count.allowlist.json +10 -1
  81. package/scripts/prompt-injection-scan.sh +2 -0
@@ -0,0 +1,2359 @@
1
+ 'use strict';
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /**
6
+ * src/vault-review.cts — the DETERMINISTIC half of the agent practice review
7
+ * (`/pp-vault-review`, vault assurance R5).
8
+ *
9
+ * ## What this module is
10
+ *
11
+ * The agent review judges vault practices no `check:` block can settle. A
12
+ * judgement is not reproducible, so everything the agent must NOT invent lives
13
+ * here instead: the input set, both derived-N-A populations, the counts, the
14
+ * rate line, the git range the review was computed over, the artifact the agent
15
+ * fills in, and the lines that artifact must state. Every one of them is an
16
+ * exported, unit-tested function, so the agent's discretionary surface shrinks
17
+ * to the genuinely ambiguous remainder and nothing else.
18
+ *
19
+ * Purely a LEAF. It imports leaves (`./vault-audit.cjs`, `./vault-practices.cjs`,
20
+ * `./vault-trace.cjs`, `./practices-review-check.cjs`, `./git-base-branch.cjs`,
21
+ * `./shell-command-projection.cjs`) and no adapter. The two functions that touch
22
+ * the world — the oracle run and the range resolver — take INJECTED seams
23
+ * (`_audit`, `exec`), so a test drives them without a repo. The I/O composition
24
+ * (reading the trace, the vault cache and the verdict owner) is the CLI arm's,
25
+ * in `src/check-command-router.cts`.
26
+ *
27
+ * The sibling modules are imported at the top rather than required at call
28
+ * time: under `recommendedTypeChecked` a bare `require()` returns `any`, while
29
+ * `import x = require('./y.cjs')` resolves to the sibling's `.cts` source and is
30
+ * fully typed. None of them performs I/O at load.
31
+ *
32
+ * ## The verdict vocabulary is LOCKED
33
+ *
34
+ * `REVIEW_VERDICT` holds exactly PASS / FAIL / N-A / INCONCLUSIVE. It is a module
35
+ * constant and is never re-derived from configuration or from the vault — the
36
+ * rule `src/vault-trace.cts` and `src/vault-audit.cts` state for the same
37
+ * reason: a verdict vocabulary a client repository could move is not one.
38
+ *
39
+ * It shares no token with the mechanical audit's `AUDIT_VERDICT`. `NO_CHECK` and
40
+ * `NOT_MATCHED` are facts about a machine-checkable rule; `N-A` and
41
+ * `INCONCLUSIVE` are facts about a judgement.
42
+ *
43
+ * A DELIBERATE DIVERGENCE from code review's practices findings ("likely
44
+ * violates, never certainty", capped at Warning): this review asserts a FLAT
45
+ * `FAIL`. It can, because it gates nothing — a FAIL here moves no score and
46
+ * blocks no ship. Uncertainty has its own honest home, `INCONCLUSIVE`.
47
+ *
48
+ * ## Arm A widens the judged boundary, and says so in every row
49
+ *
50
+ * `deriveNaAUniverse` (Arm A) reports on practices that were NEVER traced — the
51
+ * vault practice universe recorded at the pinned ref minus the ledger. That is
52
+ * wider than "traced practices with no usable `check:` block", and every Arm A
53
+ * row's reason names the universe as its source, so a reader meets the widening
54
+ * at the row that caused it. It is needed because `buildTraceStatement` fills
55
+ * the ledger from `matchPractices(...).matched ++ .global` only: every ledger
56
+ * entry already matched, so "never matched" can only come from the universe.
57
+ * Arm B re-derives applicability at review time from the phase's real diff.
58
+ *
59
+ * ## `check:`-presence is read LIVE, with an advisory — no trace-schema change
60
+ *
61
+ * The trace records no `check:`-presence field, so "traced ∩ NO_CHECK" needs the
62
+ * vault cache at review time. This module reads it through the shipped
63
+ * evaluator (`evaluatePractices` + `isNoCheckVerdict`); when the trace's pin and
64
+ * the cache's `resolvedSha` disagree it emits one advisory shaped like the trace
65
+ * gate's `PIN_DIVERGED` note — it reports, never blocks. A `checkPresent` field
66
+ * on the trace would be a schema change under `docs/vault-trace-format.md`'s
67
+ * freeze discipline for a consumer that does not need it, and this review is not
68
+ * a gate, so environment-dependence is acceptable here.
69
+ *
70
+ * ## The oracle is HARVESTED, never re-derived
71
+ *
72
+ * `deriveCandidateSet` learns `check:`-presence by running the shipped
73
+ * `evaluatePractices` and filtering with `isNoCheckVerdict`. It never scans for a
74
+ * `check:` key, because the audit tests `hasInvalidCheck` FIRST: an INVALID check
75
+ * is the mechanical audit's `FAIL`, not a `NO_CHECK`. The oracle runs over an
76
+ * EMPTY file set on purpose: `NO_CHECK` is decided before any file is consulted,
77
+ * so the answer is file- and machine-independent.
78
+ *
79
+ * ## Findings this module is built around
80
+ *
81
+ * - `matchPractices` returns `practicesProcessed: 0` for a never-fetched vault
82
+ * and an empty one alike, so Arm B consumes a `discoverPracticeFiles` result
83
+ * (whose `vaultPresent` and `readFailed` stay DISTINCT) plus the verdict
84
+ * owner's view of the cache (`computeVaultVerdict`), never the match count.
85
+ * - A parser-rejected practice is reported as a NAMED exclusion, and a degraded
86
+ * one as a note — never a silent narrowing.
87
+ * - The review range SHARES the audit's phase-scoped resolution first
88
+ * (`resolvePhaseRange`: the manifest's `startCommit`, its recorded commits,
89
+ * then SUMMARY trace digests). Only when that falls back does this module run
90
+ * its own `(NN-` commit-token scan over `base..HEAD`, and only then the whole
91
+ * base-branch span. `REVIEW_RANGE_SOURCE` mirrors the phase-scoped values
92
+ * rather than referencing `AUDIT_RANGE_SOURCE`, which belongs to the blocking
93
+ * gate.
94
+ * - An invalid `check:` is the mechanical audit's FAIL: it is excluded from the
95
+ * candidate set and reported as a named exclusion (the practices README states
96
+ * the audit blocks on it). Every judgement here binds to the audit's verdicts,
97
+ * never to prose.
98
+ *
99
+ * ## Nothing measured must never render as clean
100
+ *
101
+ * Every degenerate path returns a record that SAYS what did not happen —
102
+ * `oracleSource: 'unavailable'` with a note, a `notDerived` record per arm, and
103
+ * `reviewRefusesCleanRender`, the single predicate that forbids a clean review
104
+ * when no file was considered or an input was not measured.
105
+ *
106
+ * ## Trust boundary
107
+ *
108
+ * Practice ids, `applies-to` globs and practice prose are CLIENT-AUTHORED DATA.
109
+ * They are bounded with the shipped `boundedToken` before they reach any string
110
+ * this module returns, and applicability is decided by the shipped `globMatch`
111
+ * alone. No client-authored byte is ever compiled into a regular expression and
112
+ * no second glob grammar is written here (the declarative-check rule,
113
+ * docs/practices-format.md). Reason texts name practice ids and mechanisms,
114
+ * never absolute filesystem paths.
115
+ */
116
+ const node_path_1 = __importDefault(require("node:path"));
117
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
118
+ const git_base_branch_cjs_1 = require("./git-base-branch.cjs");
119
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
120
+ const auditMod = require("./vault-audit.cjs");
121
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
122
+ const practicesMod = require("./vault-practices.cjs");
123
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
124
+ const traceMod = require("./vault-trace.cjs");
125
+ // The artifact validator's fence markers and its parse cap are HARVESTED from
126
+ // the shipped review checker that already exports them (plan 27-28's module),
127
+ // never re-spelled here. A second spelling of a fence marker is a second
128
+ // definition of where untrusted content ends, and this repository has already
129
+ // paid for a decision taken by matching text rather than by reading a value.
130
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
131
+ const reviewCheckMod = require("./practices-review-check.cjs");
132
+ const { AUDIT_VERDICT, AUDIT_FAIL_REASON, GIT_EMPTY_TREE } = auditMod;
133
+ const { boundedToken } = practicesMod;
134
+ const { TRACE_PROBLEM_KIND } = traceMod;
135
+ const { FENCE_START_MARKER, FENCE_END_MARKER, MAX_PARSED_UNITS } = reviewCheckMod;
136
+ // ─── The frozen vocabulary ───────────────────────────────────────────────────
137
+ /**
138
+ * D-06-10's four verdicts, verbatim, and LOCKED.
139
+ *
140
+ * `INCONCLUSIVE` is a distinct fourth verdict and never folds into `N-A`:
141
+ * "not applicable here" and "I could not determine this" are completely
142
+ * different facts, and collapsing them hides the verifier's blind spots inside
143
+ * a count that looks like coverage. That is the same split D-05-03 already made
144
+ * one tier down between `NO_CHECK` and `NOT_MATCHED`.
145
+ */
146
+ const REVIEW_VERDICT = Object.freeze({
147
+ PASS: 'PASS',
148
+ FAIL: 'FAIL',
149
+ NA: 'N-A',
150
+ INCONCLUSIVE: 'INCONCLUSIVE',
151
+ });
152
+ /**
153
+ * The TWO derived-N-A arms, as two distinct machine labels (F-2, resolved by
154
+ * the operator 2026-09-03). They reach the rendered artifact as two distinct
155
+ * derived reasons and MUST NOT be blurred into one "derived" flag: one says the
156
+ * practice was never in this phase's conversation at all, the other says it was
157
+ * and no changed file fell under it. Those are different findings — the first
158
+ * about the vault's reach, the second about this phase's diff — and a reader who
159
+ * cannot tell them apart cannot act on either.
160
+ */
161
+ const NA_DERIVED_REASON = Object.freeze({
162
+ /** Arm A — in the vault practice universe at the pinned ref, absent from the ledger. */
163
+ UNIVERSE_MINUS_LEDGER: 'universe-minus-ledger',
164
+ /** Arm B — a scoped practice whose `applies-to` globs matched no file in the review diff. */
165
+ NO_FILE_IN_DIFF: 'no-file-in-diff',
166
+ });
167
+ /**
168
+ * Machine labels for a `notDerived` ground. Values, not prose: `matchBudgetNotes`
169
+ * is this codebase's standing proof that a decision is never taken by matching
170
+ * text, and a consumer distinguishing "never fetched" from "unreadable" must not
171
+ * have to read a sentence to do it.
172
+ */
173
+ const NOT_DERIVED_GROUND = Object.freeze({
174
+ UNIVERSE_ABSENT: 'universe-absent',
175
+ // 06-12 Task 5 follow-up. A universe the trace DID record, withheld because
176
+ // the trace is degraded, is a different finding from one the trace never
177
+ // recorded — and the caller is the only party that can tell them apart, so it
178
+ // says which. Kept out of NOT_MEASURED_GROUNDS for exactly the reason
179
+ // UNIVERSE_ABSENT is: every degraded-trace review takes the reconstruct arm,
180
+ // and refusing on it would refuse the primary path.
181
+ UNIVERSE_WITHHELD: 'universe-withheld-degraded',
182
+ VAULT_STATE_UNKNOWN: 'vault-state-unknown',
183
+ VAULT_NEVER_FETCHED: 'vault-never-fetched',
184
+ VAULT_UNREADABLE: 'vault-unreadable',
185
+ /**
186
+ * The cache the vault verdict owner reads records a fetch MISS, or holds no
187
+ * file: a vault directory may exist, but no client vault was loaded into it.
188
+ * Without this ground a miss rendered as an empty, clean review.
189
+ */
190
+ VAULT_FETCH_MISS: 'vault-fetch-miss',
191
+ NO_FILES_CONSIDERED: 'no-files-considered',
192
+ MATCHER_UNAVAILABLE: 'matcher-unavailable',
193
+ });
194
+ /** The ground recorded when no NO_CHECK oracle reached the derivation at all. */
195
+ const ORACLE_UNAVAILABLE_GROUND = 'no-check-oracle-unavailable';
196
+ /**
197
+ * The grounds that mean the candidate derivation was NOT MEASURED (D3, plan
198
+ * 06-11) — the half of the distinction `reviewRefusesCleanRender` consumes.
199
+ *
200
+ * EMPTY BECAUSE NOTHING WAS MEASURED and EMPTY BECAUSE THE DERIVATION RAN AND
201
+ * NOTHING QUALIFIED are two different findings, and only one of them is a
202
+ * rubber stamp. Every ground below names an input the derivation NEEDED and did
203
+ * not get, so an empty result computed under one of them says nothing about the
204
+ * phase and must not render as a clean review. A ground absent from this set
205
+ * describes a measurement that happened, whose empty result IS a finding and
206
+ * renders with its stated N-A.
207
+ *
208
+ * `UNIVERSE_ABSENT` is deliberately NOT here, and the omission is the whole
209
+ * difference between fixing D3 and shouting it. That ground says the TRACE
210
+ * records no practice universe — which is true of every reconstruct review by
211
+ * definition, because a phase with no trace has no universe key. Refusing on it
212
+ * would refuse the entire reconstruct arm, which is the primary path (every
213
+ * phase of a project that never traced takes it), not a degraded one. A review that refuses
214
+ * everything judges exactly as much as one that judges nothing; it is only
215
+ * louder about it.
216
+ */
217
+ const UNMEASURED_GROUND = new Set([
218
+ NOT_DERIVED_GROUND.VAULT_STATE_UNKNOWN,
219
+ NOT_DERIVED_GROUND.VAULT_NEVER_FETCHED,
220
+ NOT_DERIVED_GROUND.VAULT_UNREADABLE,
221
+ NOT_DERIVED_GROUND.VAULT_FETCH_MISS,
222
+ NOT_DERIVED_GROUND.NO_FILES_CONSIDERED,
223
+ NOT_DERIVED_GROUND.MATCHER_UNAVAILABLE,
224
+ ORACLE_UNAVAILABLE_GROUND,
225
+ ]);
226
+ /**
227
+ * Where THIS tier's range came from — recorded beside the range itself.
228
+ *
229
+ * Deliberately NOT an extension of `AUDIT_RANGE_SOURCE` in
230
+ * `src/vault-audit.cts`: that vocabulary belongs to the BLOCKING tier, and one
231
+ * shared vocabulary would couple the two sides of the wall D-06-01 exists to
232
+ * keep apart — and would shift the producer-audit citations for no gain.
233
+ *
234
+ * * UAT-6-F1 added three members whose VALUES mirror the
235
+ * phase-scoped sources of the shared resolution. They are this object's own
236
+ * strings, reached from the audit's through `REVIEW_SOURCE_FOR_AUDIT_SOURCE`,
237
+ * never a reference to the audit's object.
238
+ */
239
+ const REVIEW_RANGE_SOURCE = Object.freeze({
240
+ PHASE_START_COMMIT: 'phase-start-commit',
241
+ PHASE_FIRST_COMMIT: 'phase-first-commit',
242
+ TRACE_DIGEST: 'trace-digest',
243
+ PHASE_COMMIT_SCAN: 'phase-commit-scan',
244
+ BASE_BRANCH: 'base-branch',
245
+ });
246
+ /**
247
+ * The audit's phase-scoped range sources, keyed by the audit's OWN member
248
+ * values (read from the module, never re-spelled), mapped to this tier's.
249
+ * `base-branch` is deliberately UNMAPPED: a shared resolution that fell back
250
+ * hands over to this tier's own token scan rather than being adopted. Not
251
+ * exported — the export surface is a pinned contract.
252
+ */
253
+ const REVIEW_SOURCE_FOR_AUDIT_SOURCE = Object.freeze({
254
+ [auditMod.AUDIT_RANGE_SOURCE.PHASE_START_COMMIT]: REVIEW_RANGE_SOURCE.PHASE_START_COMMIT,
255
+ [auditMod.AUDIT_RANGE_SOURCE.PHASE_FIRST_COMMIT]: REVIEW_RANGE_SOURCE.PHASE_FIRST_COMMIT,
256
+ [auditMod.AUDIT_RANGE_SOURCE.TRACE_DIGEST]: REVIEW_RANGE_SOURCE.TRACE_DIGEST,
257
+ });
258
+ /**
259
+ * How to get a phase whose range nobody could resolve back on a resolvable
260
+ * footing (08-05, D-08-17). Exported so the agent tier, a test and the artifact
261
+ * all quote THE SAME sentence: a remedy copied into a test rots the day the
262
+ * shipped one is reworded, and the pin that guards it stops guarding.
263
+ *
264
+ * Both remedies, deliberately. The first prevents the recurrence, the second
265
+ * rescues the phase that already happened — an operator handed only the first
266
+ * still has no way to review the phase in front of them.
267
+ */
268
+ const REVIEW_RANGE_UNRESOLVABLE_REMEDY = 'Anchor the phase by running it through /pp-execute-phase (execute:pre records the start commit ' +
269
+ 'via phase manifest start), or audit it by hand over a range you name: ' +
270
+ 'pp-tools vault audit <phase-dir> --range <from>..<to>.';
271
+ /**
272
+ * The opening of the note `resolveReviewRange` pushes when the SHARED
273
+ * phase-scoped resolution did not answer. One constant, two writers and one
274
+ * reader: the `range_unresolvable` statement finds that note by this prefix to
275
+ * quote the ground it recorded, and a prefix spelled twice is a prefix that
276
+ * stops matching the day one copy is reworded.
277
+ */
278
+ const SHARED_RESOLUTION_NOTE_PREFIX = 'the shared phase-scoped resolution';
279
+ /** The id charset a practice id must satisfy before it is echoed into a reason. */
280
+ const REVIEW_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
281
+ /** A phase token: `6`, `06` or `06.1`. Anything else never reaches `git log`. */
282
+ const PHASE_TOKEN_SHAPE = /^\d{1,3}(?:\.\d{1,3})?$/;
283
+ /** A resolved commit object name, as `git log --format=%H` emits it. */
284
+ const COMMIT_SHA_SHAPE = /^[0-9a-f]{7,64}$/;
285
+ // ─── Small shared helpers ────────────────────────────────────────────────────
286
+ function asStringList(v) {
287
+ if (!Array.isArray(v))
288
+ return [];
289
+ const out = [];
290
+ for (const entry of v) {
291
+ if (typeof entry === 'string' && entry !== '')
292
+ out.push(entry);
293
+ }
294
+ return out;
295
+ }
296
+ /** Order-stable dedup. The ledger's own order is the reported order. */
297
+ function uniqueInOrder(ids) {
298
+ const seen = new Set();
299
+ const out = [];
300
+ for (const id of ids) {
301
+ if (seen.has(id))
302
+ continue;
303
+ seen.add(id);
304
+ out.push(id);
305
+ }
306
+ return out;
307
+ }
308
+ /** Bound a client-authored value before it reaches any returned string. */
309
+ function safe(v) {
310
+ return boundedToken(v);
311
+ }
312
+ /** A practice id fit to quote back. Out-of-charset ids are bounded, never trusted. */
313
+ function quotableId(id) {
314
+ return REVIEW_ID_SHAPE.test(id) ? id : safe(id);
315
+ }
316
+ /** Is this token one of the two verdicts that carry a judgement (D-06-10)? */
317
+ function isJudgedVerdict(verdict) {
318
+ return verdict === REVIEW_VERDICT.PASS || verdict === REVIEW_VERDICT.FAIL;
319
+ }
320
+ /** Is this token one of the two NON-verdicts, which D-06-11 requires a reason of? */
321
+ function isNonVerdict(verdict) {
322
+ return verdict === REVIEW_VERDICT.NA || verdict === REVIEW_VERDICT.INCONCLUSIVE;
323
+ }
324
+ // ─── The input set (traced ∩ NO_CHECK) ───────────────────────────────────────
325
+ /**
326
+ * Derive the candidate set the agent tier may judge: the ids of THIS ARM's
327
+ * practice universe, intersected with the ids the SHIPPED evaluator calls
328
+ * `NO_CHECK`. Order-stable, deduplicated.
329
+ *
330
+ * ## The two universes (D3, plan 06-11)
331
+ *
332
+ * - TRACED (`reconstruct` absent or false) — the universe is the phase's trace
333
+ * ledger. Unchanged, byte for byte, from the shipped behaviour: a traced
334
+ * phase is judged against what it recorded.
335
+ * - RECONSTRUCT (`reconstruct: true`) — there is no ledger, so the universe is
336
+ * the VAULT'S OWN practice list, in the order discovery returned it. This is
337
+ * the half of D3 that made an untraced phase derive nothing at all: iterating
338
+ * an empty ledger produced an empty candidate set, an empty remainder and no
339
+ * fenced prose, no matter how large the diff or the vault, while the arm's
340
+ * own statement said the set had been rebuilt from both.
341
+ *
342
+ * APPLICABILITY IS NOT DECIDED HERE, and deliberately so. This function answers
343
+ * "which practices could the agent tier own at all" (the mechanical tier owns
344
+ * every check-bearing one); crossing that universe with the review's real diff
345
+ * is `deriveNaANoFile`'s job and reaches the SHIPPED `globMatch` through the
346
+ * same `_practices` seam. No second matcher is written for the reconstruct arm,
347
+ * because a second matcher is a second answer to one question (the
348
+ * declarative-check rule). The practices that fall outside the range therefore leave
349
+ * this set as STATED derived N-A rows rather than as silent absences.
350
+ *
351
+ * Three oracle paths, and the third one SAYS so:
352
+ * - `practices` supplied → `oracleSource: 'evaluated'`. The shipped
353
+ * `evaluatePractices` runs over an empty file set (the ladder answers
354
+ * `NO_CHECK` before consulting a file, so no diff is needed and none is
355
+ * used) and `isNoCheckVerdict` selects. Every excluded id carries the
356
+ * shipped verdict and fail-reason that excluded it.
357
+ * - `noCheckIds` supplied → `oracleSource: 'provided'`. The pure path: a
358
+ * caller that already holds the set.
359
+ * - neither → `oracleSource: 'unavailable'`, an EMPTY candidate list and a
360
+ * note stating that nothing was measured. An empty set with no note is
361
+ * indistinguishable from "nothing qualifies", which is the silence this
362
+ * workstream exists to end.
363
+ */
364
+ function deriveCandidateSet(opts) {
365
+ const audit = opts._audit ?? auditMod;
366
+ const ledgerIds = uniqueInOrder(asStringList(opts.ledgerIds));
367
+ const reconstruct = opts.reconstruct === true;
368
+ const notes = [];
369
+ const exclusions = [];
370
+ // ── the oracle ──
371
+ let oracleSource = 'unavailable';
372
+ const noCheck = new Set();
373
+ const verdictById = new Map();
374
+ // Every id the oracle SAW, in the order it saw it. On the reconstruct arm this
375
+ // is the vault's practice list and becomes the universe; on the traced arm it
376
+ // is collected and unused, so the two arms share one traversal and cannot
377
+ // disagree about which ids the oracle covered.
378
+ const oracleIds = [];
379
+ if (Array.isArray(opts.practices)) {
380
+ oracleSource = 'evaluated';
381
+ const inputs = [];
382
+ for (const p of opts.practices) {
383
+ inputs.push({
384
+ id: typeof p?.id === 'string' ? p.id : '',
385
+ check: (p?.check ?? undefined),
386
+ checkInvalid: typeof p?.checkInvalid === 'string' ? p.checkInvalid : undefined,
387
+ });
388
+ }
389
+ const evaluated = audit.evaluatePractices({
390
+ practices: inputs,
391
+ // EMPTY on purpose — see the module header. The ladder returns NO_CHECK
392
+ // before any file is consulted, so the oracle is both file-independent
393
+ // and machine-independent.
394
+ files: [],
395
+ });
396
+ for (const entry of evaluated.practices) {
397
+ verdictById.set(entry.id, { verdict: entry.verdict, reason: entry.reason, detail: entry.detail });
398
+ oracleIds.push(entry.id);
399
+ if (audit.isNoCheckVerdict(entry.verdict))
400
+ noCheck.add(entry.id);
401
+ }
402
+ }
403
+ else if (opts.noCheckIds !== undefined && Array.isArray(opts.noCheckIds)) {
404
+ oracleSource = 'provided';
405
+ for (const id of asStringList(opts.noCheckIds)) {
406
+ noCheck.add(id);
407
+ oracleIds.push(id);
408
+ }
409
+ }
410
+ else {
411
+ notes.push('no NO_CHECK oracle was supplied: neither a live practice list nor a precomputed id set reached ' +
412
+ 'this derivation, so the candidate set is EMPTY because nothing was measured — not because no ' +
413
+ 'practice qualifies.');
414
+ }
415
+ // ── this arm's universe ∩ NO_CHECK, with every exclusion NAMED ──
416
+ //
417
+ // D3: the reconstruct arm's universe is the vault's practice list, because it
418
+ // has no ledger to intersect and iterating one produced an empty set that then
419
+ // rendered as a clean review. The traced arm is untouched.
420
+ const universeIds = reconstruct ? uniqueInOrder(oracleIds) : ledgerIds;
421
+ if (reconstruct) {
422
+ notes.push(
423
+ // NOT "no trace ledger exists": since 06-12 this arm is also taken by a
424
+ // phase whose trace parses but records a DEGRADED vault, and that phase
425
+ // does have a ledger — one this review declines to trust. Claiming it does
426
+ // not exist would be false on exactly the input G1 was about.
427
+ `no trusted trace ledger is available for this phase, so the candidate universe is the ${universeIds.length} practice ` +
428
+ "id(s) the vault's own practice list supplied. Which of them the phase's diff actually falls under is " +
429
+ 'measured separately, by the shipped applicability matcher, and every one that does not is reported as ' +
430
+ 'a derived N-A rather than dropped.');
431
+ }
432
+ const candidates = [];
433
+ for (const id of universeIds) {
434
+ if (noCheck.has(id)) {
435
+ candidates.push(id);
436
+ continue;
437
+ }
438
+ const known = verdictById.get(id);
439
+ if (known && known.reason === AUDIT_FAIL_REASON.CHECK_INVALID) {
440
+ exclusions.push({
441
+ id,
442
+ mechanicalVerdict: known.verdict,
443
+ reason: known.reason,
444
+ detail: `the \`check:\` block on "${quotableId(id)}" is not usable (${safe(known.detail)}). The shipped ` +
445
+ 'ladder tests an invalid check FIRST and returns FAIL, so this practice is the mechanical ' +
446
+ "tier's and never reaches the agent tier.",
447
+ });
448
+ continue;
449
+ }
450
+ if (known) {
451
+ exclusions.push({
452
+ id,
453
+ mechanicalVerdict: known.verdict,
454
+ reason: known.reason,
455
+ detail: `"${quotableId(id)}" carries a usable \`check:\` block, so the shipped mechanical tier owns it ` +
456
+ 'and the agent tier never judges it. The verdict shown was computed over an EMPTY file set — ' +
457
+ 'the ladder decides NO_CHECK before any file is consulted, so no diff was needed and none was used.',
458
+ });
459
+ continue;
460
+ }
461
+ exclusions.push({
462
+ id,
463
+ mechanicalVerdict: null,
464
+ reason: null,
465
+ detail: `"${quotableId(id)}" is recorded in ${reconstruct ? "this vault's practice list" : "this phase's trace ledger"}` +
466
+ ' but is not in the NO_CHECK set the oracle produced, so it is not a candidate for agent judgement.',
467
+ });
468
+ }
469
+ // ── WR-02: a parser-rejected practice never leaves the set in silence ──
470
+ for (const entry of opts.skipped ?? []) {
471
+ const where = typeof entry?.path === 'string' ? entry.path : '';
472
+ exclusions.push({
473
+ id: safe(node_path_1.default.basename(where) || where),
474
+ mechanicalVerdict: AUDIT_VERDICT.FAIL,
475
+ reason: AUDIT_FAIL_REASON.PRACTICE_UNLOADABLE,
476
+ detail: 'a practice file was REJECTED by the parser and therefore never entered the practice set: ' +
477
+ `${safe(entry?.warning)}. Its check-bearing-ness is unknown, so it can be neither a candidate ` +
478
+ 'nor a silent absence.',
479
+ });
480
+ }
481
+ // ── degraded practices are RETAINED (their prose still applies) but named ──
482
+ for (const entry of opts.degraded ?? []) {
483
+ notes.push(`practice "${quotableId(typeof entry?.id === 'string' ? entry.id : '')}" was retained but DEGRADED: ` +
484
+ `${safe(asStringList(entry?.warnings).join('; '))}. Its prose still applies and it stays in the population.`);
485
+ }
486
+ return {
487
+ candidates,
488
+ exclusions,
489
+ advisories: derivePinAdvisories(opts.tracePinSha, opts.cacheSha),
490
+ notes,
491
+ oracleSource,
492
+ };
493
+ }
494
+ /**
495
+ * The pin advisory (recorded decision above; no trace-schema change).
496
+ *
497
+ * Mirrors the wording discipline of the trace gate's `PIN_DIVERGED` note
498
+ * (`cmdVaultTrace`) and harvests its KIND from
499
+ * `TRACE_PROBLEM_KIND` so the two cannot drift. It reports; it never blocks;
500
+ * it states that no reader action is required. An ABSENT cache emits NOTHING —
501
+ * an absent cache is not a divergence, and a check that fired on absence would
502
+ * penalise every machine that never fetched.
503
+ */
504
+ function derivePinAdvisories(tracePinSha, cacheSha) {
505
+ const pinned = typeof tracePinSha === 'string' ? tracePinSha.trim() : '';
506
+ const local = typeof cacheSha === 'string' ? cacheSha.trim() : '';
507
+ if (pinned === '' || local === '' || pinned === local)
508
+ return [];
509
+ return [
510
+ {
511
+ kind: TRACE_PROBLEM_KIND.PIN_DIVERGED,
512
+ detail: `this phase's trace pins the vault at "${safe(pinned)}" while the local cache records ` +
513
+ `"${safe(local)}", and the \`check:\`-presence oracle above was read from the LOCAL cache. This ` +
514
+ 'is a NOTE, not a defect: whether the vault has moved since the trace was written is a different ' +
515
+ 'question from whether this review is valid, and this tier gates nothing. No action is required ' +
516
+ 'of anyone reading this.',
517
+ },
518
+ ];
519
+ }
520
+ // ─── Counts and the rate line ────────────────────────────────────────────────
521
+ /**
522
+ * Count the four verdicts, in the shape the audit's `countVerdicts`
523
+ * established one tier down.
524
+ *
525
+ * `judged` is `total - na`. Recorded because it is a choice: an `INCONCLUSIVE`
526
+ * practice WAS judged — the agent looked at it and could not determine an
527
+ * answer — while an `N-A` practice was not judged at all, either because the
528
+ * derivation removed it or because the agent found it inapplicable. So
529
+ * `INCONCLUSIVE` counts inside `judged` and is ALSO reported on its own, which
530
+ * is what makes a hollow review visible: a rate line reading `12 of 16 judged ·
531
+ * 4 N-A · 11 INCONCLUSIVE` says something a count of PASSes never could.
532
+ *
533
+ * `total` counts only rows with a RECOGNISED verdict, so `judged + na === total`
534
+ * holds unconditionally. Rows outside the vocabulary are counted as
535
+ * `unrecognised` and never absorbed into a real verdict — an unrecognised row
536
+ * silently folded into N-A would be an invented non-verdict.
537
+ *
538
+ * D-06-11 is enforced at the type level by `NonVerdictRow`. It is ALSO checked
539
+ * here, because rows arrive as parsed YAML from an artifact no compiler saw. A
540
+ * bare non-verdict is REPORTED in `malformed` and still counted: dropping it
541
+ * would shrink the denominator, which is the one number this tier must never
542
+ * quietly improve.
543
+ */
544
+ function countReviewVerdicts(rows) {
545
+ const counts = {
546
+ pass: 0,
547
+ fail: 0,
548
+ na: 0,
549
+ inconclusive: 0,
550
+ judged: 0,
551
+ total: 0,
552
+ unrecognised: 0,
553
+ malformed: [],
554
+ };
555
+ const list = Array.isArray(rows) ? rows : [];
556
+ for (const raw of list) {
557
+ if (raw === null || typeof raw !== 'object') {
558
+ counts.unrecognised += 1;
559
+ continue;
560
+ }
561
+ const row = raw;
562
+ const verdict = typeof row.verdict === 'string' ? row.verdict : '';
563
+ const id = typeof row.id === 'string' ? row.id : '';
564
+ if (!isJudgedVerdict(verdict) && !isNonVerdict(verdict)) {
565
+ counts.unrecognised += 1;
566
+ continue;
567
+ }
568
+ if (verdict === REVIEW_VERDICT.PASS)
569
+ counts.pass += 1;
570
+ else if (verdict === REVIEW_VERDICT.FAIL)
571
+ counts.fail += 1;
572
+ else if (verdict === REVIEW_VERDICT.NA)
573
+ counts.na += 1;
574
+ else
575
+ counts.inconclusive += 1;
576
+ counts.total += 1;
577
+ if (isNonVerdict(verdict)) {
578
+ const reason = typeof row.reason === 'string' ? row.reason.trim() : '';
579
+ if (reason === '') {
580
+ counts.malformed.push({
581
+ id: quotableId(id),
582
+ verdict,
583
+ problem: 'A non-verdict must carry a stated reason. A bare N-A or INCONCLUSIVE is ' +
584
+ 'indistinguishable from the agent giving up.',
585
+ });
586
+ }
587
+ }
588
+ }
589
+ counts.judged = counts.total - counts.na;
590
+ return counts;
591
+ }
592
+ /**
593
+ * D-06-12's non-verdict rate line, computed from the four verdict counts and
594
+ * NOTHING else — a supplied `judged` or `total` is deliberately ignored, so a
595
+ * caller cannot narrate a denominator the rows do not support.
596
+ *
597
+ * D-06-13 constrains the shape: counts only. No ratio, no percentage, no letter
598
+ * grade, no single score. A single number invites being quoted as "87 %
599
+ * compliant" — a measured-sounding claim about an LLM judgement, which is
600
+ * precisely the over-claim this phase exists to prevent.
601
+ */
602
+ function renderRateLine(counts) {
603
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
604
+ const pass = n(counts?.pass);
605
+ const fail = n(counts?.fail);
606
+ const na = n(counts?.na);
607
+ const inconclusive = n(counts?.inconclusive);
608
+ const total = pass + fail + na + inconclusive;
609
+ const judged = total - na;
610
+ return `${judged} of ${total} practices judged · ${na} N-A · ${inconclusive} INCONCLUSIVE`;
611
+ }
612
+ /**
613
+ * The MECHANICAL tier's coverage over THIS review's own resolved file set
614
+ * (MECH-02, D-10-03), or `null` when there was nothing honest to compute it
615
+ * from.
616
+ *
617
+ * HARVESTED, NEVER REWRITTEN. Three shipped pieces and no fourth: the shipped
618
+ * `evaluatePractices` through the same audit seam `deriveCandidateSet` reaches,
619
+ * then the shipped `countVerdicts`, then the ONE renderer `src/vault-audit.cts`
620
+ * exports. `src/check-command-router.cts` states the rule for its own
621
+ * pass-through and it holds identically here — a second computation is a second
622
+ * place the report can disagree with the gate that blocks on it.
623
+ *
624
+ * The counter and the renderer are deliberately NOT taken off the seam. The
625
+ * seam exists so a test can substitute the EVALUATOR; letting a caller
626
+ * substitute the arithmetic or the wording would hand back exactly the
627
+ * narrated denominator `renderRateLine` refuses to accept.
628
+ *
629
+ * The reader is the shipped BOUNDED one, rooted at the review's own `cwd`, so a
630
+ * `require:`/`forbid:` assertion is measured rather than collapsed into
631
+ * `file_unreadable` — and so the containment properties that reader's contract
632
+ * proves are the ones in force here too. No reader, no read: a run with no cwd
633
+ * evaluates with the fail-closed default rather than inventing one.
634
+ */
635
+ function deriveMechanicalCoverageLine(opts) {
636
+ const audit = opts?._audit ?? auditMod;
637
+ if (typeof audit?.evaluatePractices !== 'function')
638
+ return null;
639
+ const inputs = [];
640
+ for (const p of opts?.practices ?? []) {
641
+ if (!p || typeof p.id !== 'string' || p.id === '')
642
+ continue;
643
+ inputs.push({
644
+ id: p.id,
645
+ check: (p.check ?? undefined),
646
+ checkInvalid: typeof p.checkInvalid === 'string' ? p.checkInvalid : undefined,
647
+ // MECH-01 (D-10-12): the practice's own applies-to bounds the comparison
648
+ // here for the same reason it does in the gate. An ABSENT list is the
649
+ // same case as an EMPTY one — applies everywhere — which is what the
650
+ // evaluator's own branch says, so nothing is re-decided here.
651
+ appliesTo: [...asStringList(p.appliesTo)],
652
+ });
653
+ }
654
+ const root = typeof opts?.cwd === 'string' && opts.cwd !== '' ? opts.cwd : null;
655
+ const readFile = root === null ? null : auditMod.createBoundedFileReader(root);
656
+ const evaluated = audit.evaluatePractices({
657
+ practices: inputs,
658
+ files: [...asStringList(opts?.files)],
659
+ ...(readFile === null ? {} : { readFile }),
660
+ });
661
+ return auditMod.renderMechanicalCoverageLine(auditMod.countVerdicts(evaluated.practices));
662
+ }
663
+ // ─── Arm A — the vault universe minus the trace ledger ───────────────────────
664
+ /**
665
+ * Arm A: every practice in the vault universe recorded at the pinned ref that
666
+ * the trace ledger never mentions. Those practices were never matched at plan
667
+ * time, so no code in this phase was written against them — a DERIVED N-A, not
668
+ * a judgement (D-06-09).
669
+ *
670
+ * BOUNDARY WIDENING, surfaced in every row (and in the module header): these
671
+ * ids were never traced, so reporting them widens the judged boundary beyond
672
+ * the traced input set (see the module header). The reason
673
+ * text names the vault universe as its source so a reader of the ARTIFACT meets
674
+ * the widening at the row that caused it, never only in a plan nobody reopens.
675
+ *
676
+ * ABSENT and EMPTY are different facts and this function keeps them apart —
677
+ * `docs/vault-trace-format.md` says so explicitly. `practiceUniverse` is
678
+ * OMITTED (or null) whenever the pin is null, because a degraded run has no
679
+ * universe to record: that derives NOTHING and returns a loud not-derived
680
+ * record. A literal empty array means the manifest was read and held no
681
+ * practices: that derives zero rows with NO gap record, because nothing is
682
+ * missing.
683
+ */
684
+ function deriveNaAUniverse(opts) {
685
+ const notes = [];
686
+ const universe = opts?.universe;
687
+ if (universe === undefined || universe === null || !Array.isArray(universe)) {
688
+ return {
689
+ derived: false,
690
+ rows: [],
691
+ notDerived: {
692
+ arm: NA_DERIVED_REASON.UNIVERSE_MINUS_LEDGER,
693
+ ...(opts?.universeWithheld === true
694
+ ? {
695
+ ground: NOT_DERIVED_GROUND.UNIVERSE_WITHHELD,
696
+ statement: 'Arm A derived NOTHING: this trace DOES record a practice universe, and it was ' +
697
+ 'deliberately withheld because the trace is DEGRADED — it records that the vault could ' +
698
+ 'not be loaded when the work was planned, so its universe proves nothing about which ' +
699
+ 'practices governed that work and may not be subtracted from. This is a ' +
700
+ // Same wording constraint as the sibling branch below: the
701
+ // prompt-injection scanner's regex carries no leading word
702
+ // boundary, so "is a different fact from …" is written in place
703
+ // of the natural phrasing.
704
+ 'WITHHELD universe, which is a different fact from a trace that records no universe at ' +
705
+ 'all, and a different fact from a vault that was read and held no practices — no ' +
706
+ 'derived N-A may be reported from it.',
707
+ }
708
+ : {
709
+ ground: NOT_DERIVED_GROUND.UNIVERSE_ABSENT,
710
+ statement: 'Arm A derived NOTHING: this trace records no practice universe at all. The universe key is ' +
711
+ 'omitted whenever the vault pin is null, so a degraded or never-pinned run has no universe to ' +
712
+ // WORDING CONSTRAINT, recorded so a future edit does not undo it:
713
+ // `tests/prompt-injection-scan.security.test.cjs` scans this module's
714
+ // emitted `.cjs` for a role-manipulation pattern whose regex carries
715
+ // no leading word boundary, so the last three letters of "fact"
716
+ // followed by " as a <noun>" match it. Every sentence here therefore
717
+ // says "is a different fact from …" rather than the natural phrasing.
718
+ 'subtract the ledger from. This is an ABSENT universe, which is a different fact from a vault ' +
719
+ 'that was read and held no practices — no derived N-A may be reported from it.',
720
+ }),
721
+ },
722
+ notes,
723
+ };
724
+ }
725
+ const ledger = new Set(asStringList(opts.ledgerIds));
726
+ const rows = [];
727
+ for (const id of uniqueInOrder(asStringList(universe))) {
728
+ if (ledger.has(id))
729
+ continue;
730
+ rows.push({
731
+ id,
732
+ verdict: REVIEW_VERDICT.NA,
733
+ naDerivedReason: NA_DERIVED_REASON.UNIVERSE_MINUS_LEDGER,
734
+ reason: `derived, not judged: "${quotableId(id)}" is in the VAULT PRACTICE UNIVERSE recorded at the ` +
735
+ "pinned ref and absent from this phase's trace ledger, so it was never matched to any file at " +
736
+ 'plan time and no code here was written against it. Reported under a judged boundary WIDENED ' +
737
+ 'beyond the traced set: the trace ledger is not the whole vault, and this practice was never traced.',
738
+ });
739
+ }
740
+ if (rows.length === 0) {
741
+ notes.push(`Arm A derived zero rows: the trace ledger accounts for every one of the ${universe.length} ` +
742
+ 'practice id(s) in the recorded universe. The universe was READ — this is an empty result, not a gap.');
743
+ }
744
+ return { derived: true, rows, notDerived: null, notes };
745
+ }
746
+ // ─── Arm B — review-time applicability against the real diff ────────────────
747
+ /**
748
+ * Arm B: a candidate whose `applies-to` globs match no file in the review
749
+ * range. Re-derived at review time against the phase's REAL diff (D-06-15's
750
+ * wording), never against a plan's `files_modified` — a plan states intent and
751
+ * this tier reports what happened. The artifact states which range produced the
752
+ * file list, and when that range is refused or empty this arm is SKIPPED with a
753
+ * loud not-derived record rather than fabricating N-A from an unmeasured diff:
754
+ * "no file matched" and "no file was looked at" are different facts, and only
755
+ * the first is a finding.
756
+ *
757
+ * The vault-state guard is CR-01's workaround and the reason this function
758
+ * takes a `discoverPracticeFiles` result rather than calling `matchPractices`:
759
+ * `matchPractices` returns `practicesProcessed: 0` for a never-fetched vault
760
+ * and for an empty one alike, so it cannot be the vault-state oracle. Discovery
761
+ * keeps `vaultPresent` and `readFailed` distinct, and the two degraded states
762
+ * get two DIFFERENT loud statements and two different grounds — never fetched
763
+ * and unreadable are different repairs, and collapsing them would tell an
764
+ * operator to do the wrong one.
765
+ *
766
+ * A GLOBAL-scope practice never receives an Arm B N-A, by construction: a
767
+ * global practice applies to the whole change set by definition, so "no file
768
+ * fell under it" is not a statement that can be true of one.
769
+ *
770
+ * Applicability is decided by the SHIPPED `globMatch` alone (the declarative-check
771
+ * rule: no second grammar, no client-authored byte compiled into a RegExp).
772
+ */
773
+ function deriveNaANoFile(opts) {
774
+ const arm = NA_DERIVED_REASON.NO_FILE_IN_DIFF;
775
+ const notes = [];
776
+ const refuse = (ground, statement) => ({
777
+ derived: false,
778
+ rows: [],
779
+ notDerived: { arm, ground, statement },
780
+ notes,
781
+ });
782
+ const discovery = opts?.discovery;
783
+ if (discovery === undefined || discovery === null || typeof discovery !== 'object') {
784
+ return refuse(NOT_DERIVED_GROUND.VAULT_STATE_UNKNOWN, 'Arm B derived NOTHING: no practice-discovery result was supplied, so whether the client vault is ' +
785
+ 'present and readable is UNKNOWN. Applicability is not derived from an unknown vault state, ' +
786
+ 'because a practice set that may be empty only because nothing was read cannot produce an ' +
787
+ 'honest N-A.');
788
+ }
789
+ if (discovery.vaultPresent === false) {
790
+ return refuse(NOT_DERIVED_GROUND.VAULT_NEVER_FETCHED, 'Arm B derived NOTHING: no fetched client vault cache exists on this machine — the vault was ' +
791
+ 'NEVER FETCHED. This is not an empty vault, and no applicability was derived from it. Repair by ' +
792
+ 'fetching the vault, then re-run the review.');
793
+ }
794
+ if (discovery.fetchMiss === true) {
795
+ return refuse(NOT_DERIVED_GROUND.VAULT_FETCH_MISS, 'Arm B derived NOTHING: the vault cache records a FETCH MISS (or holds no file), so no client vault ' +
796
+ 'was loaded and no applicability was derived from it. An empty practice set read from a miss is ' +
797
+ 'not an empty vault. Repair by fetching the vault, then re-run the review.');
798
+ }
799
+ if (discovery.readFailed === true) {
800
+ return refuse(NOT_DERIVED_GROUND.VAULT_UNREADABLE, 'Arm B derived NOTHING: the client vault is PRESENT but a read of it REFUSED — the vault is ' +
801
+ 'unreadable. This is not an empty vault and not a missing one, and no applicability was derived ' +
802
+ 'from it. Repair by making the cached vault readable, then re-run the review.');
803
+ }
804
+ const diffFiles = asStringList(opts.diffFiles).map((f) => f.replace(/\\/g, '/'));
805
+ if (diffFiles.length === 0) {
806
+ return refuse(NOT_DERIVED_GROUND.NO_FILES_CONSIDERED, 'Arm B derived NOTHING: the review range yielded zero files, so no applicability was measured. ' +
807
+ '"No file matched this practice" and "no file was looked at" are different facts and only the ' +
808
+ 'first is a finding — an N-A fabricated from an unmeasured diff would be the second wearing the ' +
809
+ "first's label.");
810
+ }
811
+ const matcher = opts._practices ?? practicesMod;
812
+ if (typeof matcher.globMatch !== 'function') {
813
+ return refuse(NOT_DERIVED_GROUND.MATCHER_UNAVAILABLE, 'Arm B derived NOTHING: the shipped glob matcher could not be reached, and no second matcher is ' +
814
+ 'written here (the declarative-check rule). No applicability was measured.');
815
+ }
816
+ const byId = new Map();
817
+ for (const p of opts.practices ?? []) {
818
+ if (p && typeof p.id === 'string')
819
+ byId.set(p.id, p);
820
+ }
821
+ const rows = [];
822
+ for (const id of uniqueInOrder(asStringList(opts.candidates))) {
823
+ const practice = byId.get(id);
824
+ if (!practice) {
825
+ notes.push(`Arm B skipped "${quotableId(id)}": it is a candidate but no practice record carrying its ` +
826
+ '`applies-to` globs was supplied, so its applicability was NOT measured and no N-A is claimed.');
827
+ continue;
828
+ }
829
+ // A global practice applies to everything; "no file fell under it" cannot
830
+ // be true of one, so it is excluded from this arm by construction.
831
+ if (practice.scope !== 'scoped')
832
+ continue;
833
+ const globs = asStringList(practice.appliesTo);
834
+ if (globs.length === 0) {
835
+ notes.push(`Arm B skipped "${quotableId(id)}": it is scoped but carries no \`applies-to\` glob, so there is ` +
836
+ 'nothing to measure applicability against and no N-A is claimed.');
837
+ continue;
838
+ }
839
+ let matchedAny = false;
840
+ for (const file of diffFiles) {
841
+ for (const glob of globs) {
842
+ if (matcher.globMatch(file, glob)) {
843
+ matchedAny = true;
844
+ break;
845
+ }
846
+ }
847
+ if (matchedAny)
848
+ break;
849
+ }
850
+ if (matchedAny)
851
+ continue;
852
+ rows.push({
853
+ id,
854
+ verdict: REVIEW_VERDICT.NA,
855
+ naDerivedReason: NA_DERIVED_REASON.NO_FILE_IN_DIFF,
856
+ reason: `derived, not judged: "${quotableId(id)}" is a scoped practice and none of its \`applies-to\` ` +
857
+ `glob(s) matched any of the ${diffFiles.length} file(s) in this review's range, so no changed ` +
858
+ 'file in this phase falls under it.',
859
+ });
860
+ }
861
+ return { derived: true, rows, notDerived: null, notes };
862
+ }
863
+ // ─── The partition that feeds the agent ─────────────────────────────────────
864
+ /**
865
+ * Split the reported population into the two derived-N-A sets and the
866
+ * REMAINDER — the only ids that reach the agent (D-06-09).
867
+ *
868
+ * The three sets are DISJOINT and their union is exactly `population`. Arm A's
869
+ * ids come from the universe minus the ledger and Arm B's from the candidates
870
+ * (a subset of the ledger), so they are structurally disjoint already; the
871
+ * ordering below makes it true even when a caller hands in overlapping rows,
872
+ * with Arm A winning, because "never traced at all" is the stronger statement
873
+ * and the weaker one would say less about the same id.
874
+ */
875
+ function partitionForJudgement(opts) {
876
+ const rowsOf = (v) => {
877
+ if (v === undefined || v === null)
878
+ return [];
879
+ if (Array.isArray(v))
880
+ return [...v];
881
+ const arm = v;
882
+ return Array.isArray(arm.rows) ? [...arm.rows] : [];
883
+ };
884
+ const armA = [];
885
+ const armAIds = new Set();
886
+ for (const row of rowsOf(opts?.naUniverse)) {
887
+ if (typeof row?.id !== 'string' || armAIds.has(row.id))
888
+ continue;
889
+ armAIds.add(row.id);
890
+ armA.push(row);
891
+ }
892
+ const armB = [];
893
+ const armBIds = new Set();
894
+ for (const row of rowsOf(opts?.naNoFile)) {
895
+ if (typeof row?.id !== 'string' || armAIds.has(row.id) || armBIds.has(row.id))
896
+ continue;
897
+ armBIds.add(row.id);
898
+ armB.push(row);
899
+ }
900
+ const candidates = uniqueInOrder(asStringList(opts?.candidates));
901
+ const population = uniqueInOrder([...armA.map((r) => r.id), ...candidates]);
902
+ const remainder = population.filter((id) => !armAIds.has(id) && !armBIds.has(id));
903
+ return { naUniverse: armA, naNoFile: armB, remainder, population };
904
+ }
905
+ // ─── The review range, and the refusal that guards it ───────────────────────
906
+ /** Normalise a phase token to the zero-padded form the commit convention uses. */
907
+ function normalisePhaseToken(v) {
908
+ const raw = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
909
+ if (!PHASE_TOKEN_SHAPE.test(raw))
910
+ return null;
911
+ const parts = raw.split('.');
912
+ const padded = parts[0].padStart(2, '0');
913
+ return parts.length === 1 ? padded : `${padded}.${parts[1]}`;
914
+ }
915
+ /**
916
+ * Resolve the range this review is computed over, and RECORD where it came from
917
+ * (F-3 / WR-04).
918
+ *
919
+ * First — the SHARED phase-scoped resolution (UAT-6-F1). When a
920
+ * `phaseDir` is given, the mechanical tier's `resolvePhaseRange` is asked
921
+ * before anything else, and its ladder is the one this tier adopts:
922
+ * `phase-start-commit` (the manifest anchor written at execute:pre),
923
+ * `phase-first-commit` (the commits recorded at verify:post), then
924
+ * `trace-digest` (SUMMARY `gitCommit` digests, for a phase built before that
925
+ * writer). Its notes travel with the adopted range. When it answers
926
+ * `base-branch` — its fourth rung — the fallback is NOT adopted: one note names
927
+ * its ground and this tier continues into its own scan. The vocabulary stays
928
+ * separate for the reason `REVIEW_RANGE_SOURCE` states.
929
+ *
930
+ * Then — `phase-commit-scan`: the EARLIEST commit on `base..HEAD` whose
931
+ * message carries this repository's `type(NN-MM):` convention token for the
932
+ * phase, anchored on that commit's parent. This is WR-04's own suggested fix,
933
+ * and it covers a phase branch that never recorded a manifest. The scan stays
934
+ * over `base..HEAD` and is never widened to whole history: this repository
935
+ * carries two workstreams whose `(05-` tokens would both match. A review that
936
+ * attributes another phase's file to this one is the misleading verdict this
937
+ * tier exists to avoid.
938
+ *
939
+ * When the phase's first commit is the repository root there is no parent to
940
+ * anchor on, so the anchor becomes git's empty-tree object — HARVESTED from the
941
+ * mechanical tier's `GIT_EMPTY_TREE` rather than spelled again here. That is
942
+ * not a fallback: a phase-scoped range was resolved, so the source stays
943
+ * `phase-commit-scan`.
944
+ *
945
+ * Fallback — `base-branch`: `resolveBaseBranchResult(cwd)...HEAD`. The 5-tier
946
+ * ladder is never re-derived (#1146 records what the naive `origin/HEAD` +
947
+ * `:-main` form cost) and every git call goes through the timeout-bounded
948
+ * `execGit` seam, never `execFileSync`. The RESULT form is used rather than the
949
+ * bare string so a REFUSED `git.base_branch` becomes a stated note instead of a
950
+ * silent change of range (T-6-49).
951
+ *
952
+ * TWO deliberate details in the scan:
953
+ * - `-F` (fixed-string). A commit subject is repository data; reading the
954
+ * needle as a pattern would let the convention token's own parenthesis be
955
+ * a group, and a phase token that failed `PHASE_TOKEN_SHAPE` never reaches
956
+ * git at all.
957
+ * - `base..HEAD`, not `base...HEAD`. For `git log` the three-dot form is the
958
+ * SYMMETRIC difference and would admit commits that live only on the base,
959
+ * which are by definition not this phase's.
960
+ *
961
+ * Deletions are excluded from the file list (`--diff-filter=d`), the same
962
+ * scoping the mechanical tier states its own reasons for: a deleted violation
963
+ * is the fix, and a deleted file has no content left to satisfy anything.
964
+ *
965
+ * Every degenerate path pushes a NOTE. A range that resolved nothing and a diff
966
+ * that could not be read must never look like a phase that changed nothing.
967
+ */
968
+ function resolveReviewRange(opts) {
969
+ const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGit;
970
+ const cwd = typeof opts?.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd();
971
+ const notes = [];
972
+ let base = 'main';
973
+ try {
974
+ // The RESULT form, not the bare string: `git.base_branch` is the one value
975
+ // this function puts on a git command line that comes from a file a cloned
976
+ // repository can carry, and the ladder now refuses a shape git would read as
977
+ // an option rather than a revision (T-6-49). A refusal that fell back to
978
+ // "main" in silence would be exactly the failure mode this whole module
979
+ // exists to end, so it is stated here — this function already owns the
980
+ // channel, and every other degenerate path below already uses it.
981
+ const resolved = (0, git_base_branch_cjs_1.resolveBaseBranchResult)(cwd, { execGit: exec });
982
+ base = resolved.branch;
983
+ if (resolved.configRejected !== null) {
984
+ notes.push(`${resolved.configRejected.reason} The range below is computed over "${safe(base)}" instead.`);
985
+ }
986
+ }
987
+ catch {
988
+ base = 'main';
989
+ notes.push('the base-branch ladder threw, so the range fell back to the literal default "main".');
990
+ }
991
+ let range = `${base}...HEAD`;
992
+ let rangeSource = REVIEW_RANGE_SOURCE.BASE_BRANCH;
993
+ // The shared phase-scoped resolution, ahead of the token scan (UAT-6-F1).
994
+ let sharedResolved = false;
995
+ if (typeof opts?.phaseDir === 'string' && opts.phaseDir !== '') {
996
+ try {
997
+ const shared = auditMod.resolvePhaseRange({ root: cwd, phaseDir: node_path_1.default.resolve(cwd, opts.phaseDir), exec });
998
+ const mapped = Object.prototype.hasOwnProperty.call(REVIEW_SOURCE_FOR_AUDIT_SOURCE, shared.rangeSource)
999
+ ? REVIEW_SOURCE_FOR_AUDIT_SOURCE[shared.rangeSource]
1000
+ : undefined;
1001
+ if (mapped !== undefined) {
1002
+ range = shared.range;
1003
+ rangeSource = mapped;
1004
+ for (const note of shared.notes ?? [])
1005
+ notes.push(note);
1006
+ sharedResolved = true;
1007
+ }
1008
+ else {
1009
+ notes.push(`${SHARED_RESOLUTION_NOTE_PREFIX} fell back (${safe(shared.fallbackReason ?? 'no ground recorded')}): ` +
1010
+ 'no usable phase manifest entry and no verifiable SUMMARY trace digest, so the phase-commit scan ran next.');
1011
+ }
1012
+ }
1013
+ catch {
1014
+ notes.push(`${SHARED_RESOLUTION_NOTE_PREFIX} threw, so the phase-commit scan ran next.`);
1015
+ }
1016
+ }
1017
+ const token = normalisePhaseToken(opts?.phaseToken);
1018
+ if (sharedResolved) {
1019
+ // The phase-scoped range above is adopted; the token scan is not needed.
1020
+ }
1021
+ else if (token === null) {
1022
+ notes.push('no usable phase token was supplied, so the phase-commit scan never ran and the range is the whole ' +
1023
+ 'base-branch span — which, on a repository that uses no phase branching, covers every phase since ' +
1024
+ 'the base rather than this one. Files below may belong to another phase.');
1025
+ }
1026
+ else {
1027
+ const needle = `(${token}-`;
1028
+ let anchor = null;
1029
+ try {
1030
+ const log = exec(['log', '--format=%H', '--reverse', '-F', '--grep', needle, `${base}..HEAD`], { cwd });
1031
+ if (log.exitCode === 0 && !log.error) {
1032
+ const first = String(log.stdout || '')
1033
+ .split('\n')
1034
+ .map((line) => line.trim())
1035
+ .find((line) => COMMIT_SHA_SHAPE.test(line));
1036
+ if (first !== undefined) {
1037
+ const parent = exec(['rev-parse', '--verify', `${first}^`], { cwd });
1038
+ anchor = parent.exitCode === 0 && !parent.error ? `${first}^` : GIT_EMPTY_TREE;
1039
+ }
1040
+ }
1041
+ }
1042
+ catch {
1043
+ anchor = null;
1044
+ }
1045
+ if (anchor === null) {
1046
+ notes.push(`no commit on ${base}..HEAD carries the "${needle}" convention token, so the phase-commit scan ` +
1047
+ 'resolved nothing and the range fell back to the whole base-branch span. Files below may belong ' +
1048
+ 'to another phase.');
1049
+ }
1050
+ else {
1051
+ range = `${anchor}..HEAD`;
1052
+ rangeSource = REVIEW_RANGE_SOURCE.PHASE_COMMIT_SCAN;
1053
+ }
1054
+ }
1055
+ let files = [];
1056
+ try {
1057
+ const diff = exec(['diff', '--name-only', '--diff-filter=d', range], { cwd });
1058
+ if (diff.exitCode === 0 && !diff.error) {
1059
+ files = String(diff.stdout || '')
1060
+ .split('\n')
1061
+ .map((line) => line.trim())
1062
+ .filter((line) => line !== '');
1063
+ }
1064
+ else {
1065
+ notes.push(`the diff over ${range} could not be READ, so zero files were considered — this is an unreadable ` +
1066
+ 'diff, not a phase that changed nothing.');
1067
+ }
1068
+ }
1069
+ catch {
1070
+ notes.push(`the diff over ${range} threw, so zero files were considered — this is an unreadable diff, not a ` +
1071
+ 'phase that changed nothing.');
1072
+ }
1073
+ // ── WHAT the range established, read from the audit leaf's ONE predicate
1074
+ // (08-05, D-08-17). `phase-commit-scan` counts as phase-scoped because the
1075
+ // review's OWN scan anchored on this phase's commits; only the base-branch
1076
+ // span establishes nothing about the phase, which is exactly the direction
1077
+ // WR-04's predicate states and 08-04 preserved (D-08-18).
1078
+ //
1079
+ // The values are read from `AUDIT_RANGE_OUTCOME`, never re-spelled here — the
1080
+ // `REVIEW_SOURCE_FOR_AUDIT_SOURCE` rule, for the same reason: a token this
1081
+ // tier spells for itself is a token that can drift from the one the blocking
1082
+ // tier serializes. FAIL TOWARD THE CAUTIOUS CELL if the predicate throws: zero
1083
+ // files is `range_unresolvable`, anything else `fallback_audited`. Neither
1084
+ // fallback can claim `phase_scoped`, because a call we could not complete is
1085
+ // not a witness that the range belongs to this phase.
1086
+ let outcome;
1087
+ try {
1088
+ outcome = auditMod.classifyRangeOutcome({
1089
+ phaseScoped: rangeSource !== REVIEW_RANGE_SOURCE.BASE_BRANCH,
1090
+ filesConsidered: files.length,
1091
+ });
1092
+ }
1093
+ catch {
1094
+ outcome =
1095
+ files.length === 0
1096
+ ? auditMod.AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE
1097
+ : auditMod.AUDIT_RANGE_OUTCOME.FALLBACK_AUDITED;
1098
+ }
1099
+ return { range, rangeSource, base, files, filesConsidered: files.length, outcome, notes };
1100
+ }
1101
+ /**
1102
+ * The grounds, in order, on which THIS input records that the candidate
1103
+ * derivation was not measured. Empty when everything it records was measured —
1104
+ * including when it records nothing at all about measurement.
1105
+ *
1106
+ * Extracted rather than inlined into the predicate because two consumers need
1107
+ * one answer: the predicate decides, and the payload's refusal statement NAMES
1108
+ * what was missing. Recomputing the second from the first's prose, or asking the
1109
+ * question twice, would be the 04-18 shape again.
1110
+ */
1111
+ function unmeasuredGrounds(input) {
1112
+ if (input === null || typeof input !== 'object')
1113
+ return [];
1114
+ const source = input;
1115
+ const grounds = [];
1116
+ if (source.inputSet !== null && typeof source.inputSet === 'object' && source.inputSet.oracleSource === 'unavailable') {
1117
+ grounds.push(ORACLE_UNAVAILABLE_GROUND);
1118
+ }
1119
+ for (const arm of [source.naUniverse, source.naNoFile]) {
1120
+ if (arm === null || arm === undefined || typeof arm !== 'object')
1121
+ continue;
1122
+ const record = arm.notDerived;
1123
+ if (record === null || record === undefined || typeof record !== 'object')
1124
+ continue;
1125
+ const ground = record.ground;
1126
+ if (typeof ground === 'string' && UNMEASURED_GROUND.has(ground) && !grounds.includes(ground)) {
1127
+ grounds.push(ground);
1128
+ }
1129
+ }
1130
+ return grounds;
1131
+ }
1132
+ /**
1133
+ * The ONE predicate that forbids a clean review (F-3, D-06-15; widened by D3).
1134
+ *
1135
+ * True on either of two independent grounds:
1136
+ *
1137
+ * 1. THE REVIEW CONSIDERED NO FILES — whatever the range source, and regardless
1138
+ * of how many rows a caller has assembled. A review that looked at nothing
1139
+ * and rendered clean is the rubber stamp this phase exists to prevent, and
1140
+ * `secure-phase` already states the rule in its own words: empty-by-no-
1141
+ * planning must not rubber-stamp a clean artifact.
1142
+ * 2. THE CANDIDATE DERIVATION WAS NOT MEASURED — the input POSITIVELY records
1143
+ * an unavailable NO_CHECK oracle, or an arm that did not derive on a ground
1144
+ * in `UNMEASURED_GROUND`. This is D3's second half: a phase with a hundred
1145
+ * changed files and an unreachable vault passed condition 1 easily and
1146
+ * rendered clean having judged nothing.
1147
+ *
1148
+ * The two silences are treated DIFFERENTLY, on purpose. A missing, non-numeric,
1149
+ * non-integer or negative file count is not a witness that files were
1150
+ * considered, so it refuses: the range's own contract is to state that number.
1151
+ * An input that says nothing about measurement is a caller holding only a range
1152
+ * — 06-04's CLI arm and the range tests do exactly that — and inventing a
1153
+ * refusal from its silence would refuse every one of them. Condition 2 fires on
1154
+ * a RECORD, never on an absence.
1155
+ *
1156
+ * Exported so the CLI arm (06-04) and the workflow (06-07) consult THIS
1157
+ * predicate rather than a private restatement: two predicates for one claim are
1158
+ * two claims, and this repository has already paid for that once (04-18).
1159
+ */
1160
+ function reviewRefusesCleanRender(input) {
1161
+ const value = typeof input === 'number'
1162
+ ? input
1163
+ : input !== null && typeof input === 'object'
1164
+ ? input.filesConsidered
1165
+ : undefined;
1166
+ if (!(typeof value === 'number' && Number.isInteger(value) && value > 0))
1167
+ return true;
1168
+ return unmeasuredGrounds(input).length > 0;
1169
+ }
1170
+ // ─── The artifact validator (parse, then judge ONE unit) ─────────────────────
1171
+ /**
1172
+ * ## Why this half exists, and why it is shaped like this
1173
+ *
1174
+ * Everything above computes what the agent must NOT invent. This half judges
1175
+ * what the agent DID write. The design is not a new one: it is
1176
+ * `src/practices-review-check.cts`'s, copied deliberately, because that module's
1177
+ * header records a defect class that survived FOUR verification rounds while the
1178
+ * detector lived as inline shell glue built on state shared across findings. Its
1179
+ * answer — parse into nodes FIRST, then judge exactly ONE unit as a pure
1180
+ * function of that unit alone, with the single-parameter signature standing as
1181
+ * the isolation guarantee — is the one this module adopts verbatim.
1182
+ *
1183
+ * So `judgeReviewRow` takes one parameter and reads nothing else. A malformed
1184
+ * row can change its OWN verdict and its own problems, and no sibling's, in
1185
+ * either direction. Everything that is genuinely cross-row — the rate line's
1186
+ * arithmetic, the counts line's arithmetic, the fence balance, the forbidden
1187
+ * score-token scan — is computed by `validateReviewArtifact` from the judged
1188
+ * rows, AFTER every row has been judged in isolation.
1189
+ *
1190
+ * ## Trust boundary
1191
+ *
1192
+ * The text handed here is UNTRUSTED on two counts at once: an LLM wrote the
1193
+ * section, and the fenced excerpts inside it are client-authored vault prose.
1194
+ * Nothing here performs I/O, nothing throws, and every value echoed into a
1195
+ * problem detail passes `boundedToken` first.
1196
+ */
1197
+ /**
1198
+ * The problem vocabulary. Frozen and exported for the same reason the three
1199
+ * vocabularies above are: a consumer must branch on a VALUE, and a test must
1200
+ * harvest the token it asserts on rather than copy it.
1201
+ *
1202
+ * The four `FORBIDDEN_*` members are the D-06-13 score FORMS. They live in this
1203
+ * one vocabulary rather than a second one because a token the scan finds becomes
1204
+ * a problem in this list unchanged — one label, one home, no translation step
1205
+ * where the two could drift.
1206
+ */
1207
+ const REVIEW_PROBLEM_KIND = Object.freeze({
1208
+ /** No `## Practice Verdicts (agent-judged)` heading anywhere in the supplied text. */
1209
+ SECTION_ABSENT: 'section-absent',
1210
+ /** The heading is present and the section holds no verdict row at all. */
1211
+ NO_ROWS: 'no-rows',
1212
+ ROW_MISSING_ID: 'row-missing-id',
1213
+ ROW_UNKNOWN_VERDICT: 'row-unknown-verdict',
1214
+ /** D-06-11: a non-verdict with no stated reason. */
1215
+ ROW_MISSING_REASON: 'row-missing-reason',
1216
+ /** D-06-09: a row marked derived that does not say WHICH derivation produced it. */
1217
+ ROW_DERIVED_LABEL_MISSING: 'row-derived-label-missing',
1218
+ /** D-06-09: a derived row reclassified into a judged verdict. */
1219
+ ROW_DERIVED_VERDICT_CONFLICT: 'row-derived-verdict-conflict',
1220
+ RATE_LINE_ABSENT: 'rate-line-absent',
1221
+ /** D-06-12: the rate line's numbers are not the ones the rows support. */
1222
+ RATE_LINE_ARITHMETIC: 'rate-line-arithmetic',
1223
+ COUNTS_LINE_ABSENT: 'counts-line-absent',
1224
+ /** D-06-13: the counts line's numbers are not the ones the rows support. */
1225
+ COUNTS_LINE_ARITHMETIC: 'counts-line-arithmetic',
1226
+ // ── MECH-02 / D-10-20: the mechanical coverage line ────────────────────────
1227
+ // An instructed-only line is an unenforced one: an artifact that dropped it
1228
+ // validated clean. These four kinds close it.
1229
+ /** The payload carried a coverage line and the artifact does not. */
1230
+ COVERAGE_LINE_ABSENT: 'coverage-line-absent',
1231
+ /** A coverage line is present but is not the engine's sentence, verbatim. */
1232
+ COVERAGE_LINE_MISMATCH: 'coverage-line-mismatch',
1233
+ /**
1234
+ * The line sits inside the verdicts section, which the agent contract
1235
+ * explicitly forbids: "a second bare shaped line makes the section
1236
+ * contradict itself". It belongs under `## Range`.
1237
+ */
1238
+ COVERAGE_LINE_MISPLACED: 'coverage-line-misplaced',
1239
+ /**
1240
+ * The payload carried NO coverage line and the artifact invented one. Its
1241
+ * absence means the inputs were never measured, so any figure here states a
1242
+ * measurement nobody took.
1243
+ */
1244
+ COVERAGE_LINE_UNEXPECTED: 'coverage-line-unexpected',
1245
+ /** The external-content fence markers do not pair up. */
1246
+ FENCE_UNBALANCED: 'fence-unbalanced',
1247
+ /** The parse cap was reached; rows beyond it were NOT judged. */
1248
+ ROWS_TRUNCATED: 'rows-truncated',
1249
+ // ── The population, checked against the payload by `vault review finalize` ──
1250
+ /** A practice of the review's population has no row: dropped rather than judged. */
1251
+ ROW_POPULATION_MISSING: 'row-population-missing',
1252
+ /** A row names a practice outside the population the payload derived. */
1253
+ ROW_POPULATION_UNEXPECTED: 'row-population-unexpected',
1254
+ /** One practice carries more than one row. */
1255
+ ROW_DUPLICATE: 'row-duplicate',
1256
+ // The four D-06-13 score forms.
1257
+ FORBIDDEN_PERCENTAGE: 'forbidden-percentage',
1258
+ FORBIDDEN_RATIO: 'forbidden-ratio',
1259
+ FORBIDDEN_SCORE_ASSIGNMENT: 'forbidden-score-assignment',
1260
+ FORBIDDEN_LETTER_GRADE: 'forbidden-letter-grade',
1261
+ });
1262
+ /** The heading D-06-02 locks, verbatim. Never re-derived from configuration. */
1263
+ const REVIEW_SECTION_HEADING = '## Practice Verdicts (agent-judged)';
1264
+ /** The D-06-12 rate line, as a shape. The renderer above is the only writer. */
1265
+ const RATE_LINE_SHAPE = /^(\d+) of (\d+) practices judged · (\d+) N-A · (\d+) INCONCLUSIVE$/;
1266
+ /**
1267
+ * The D-10-03 mechanical coverage line, as a shape. The audit's
1268
+ * `renderMechanicalCoverageLine` is the only writer; this recognises its output so a
1269
+ * MISPLACED or UNEXPECTED line can be named even when no expectation was supplied.
1270
+ */
1271
+ const COVERAGE_LINE_SHAPE = /^(\d+) of (\d+) practices mechanically checked · (\d+) PASS · (\d+) FAIL · (\d+) NOT_MATCHED · (\d+) NO_CHECK$/;
1272
+ /** The D-06-13 counts line, as a shape. */
1273
+ const COUNTS_LINE_SHAPE = /^(\d+) PASS · (\d+) FAIL · (\d+) INCONCLUSIVE · (\d+) N-A \(of (\d+) traced\)$/;
1274
+ /** A markdown table separator cell: `---`, `:---`, `---:` or `:---:`. */
1275
+ const TABLE_SEPARATOR_CELL = /^:?-{3,}:?$/;
1276
+ /** A `Source` cell that declares the row DERIVED rather than agent-judged. */
1277
+ const DERIVED_SOURCE_SHAPE = /^derived\b/i;
1278
+ /** The distinguishing label inside a derived `Source` cell: `derived (…)`. */
1279
+ const DERIVED_LABEL_SHAPE = /\(([^)]*)\)/;
1280
+ /**
1281
+ * The four forbidden score FORMS, targeted by SHAPE rather than by word.
1282
+ *
1283
+ * That distinction is the whole design. A word-targeted scan would flag the
1284
+ * disclaimer sentence a review is supposed to carry — the one that states no
1285
+ * single score, ratio, percentage or letter grade was computed — and a rule that
1286
+ * forbids saying what the rule forbids is a rule nobody can comply with. Each
1287
+ * pattern below therefore requires the numeric or graded FORM itself, never the
1288
+ * name of it.
1289
+ *
1290
+ * The two sanctioned lines need no exemption entry and deliberately do not get
1291
+ * one: neither carries a percent sign, a slash-separated pair, a score
1292
+ * assignment or a letter grade, so they pass on their own shape. An exemption
1293
+ * list would additionally let a real violation hide inside a line that merely
1294
+ * looked sanctioned.
1295
+ */
1296
+ const FORBIDDEN_SCORE_SHAPES = Object.freeze([
1297
+ { kind: REVIEW_PROBLEM_KIND.FORBIDDEN_PERCENTAGE, re: /\d\s*%|\b\d+(?:\.\d+)?\s*(?:percent|pct)\b/gi },
1298
+ { kind: REVIEW_PROBLEM_KIND.FORBIDDEN_RATIO, re: /\b\d+\s*\/\s*\d+\b/g },
1299
+ {
1300
+ kind: REVIEW_PROBLEM_KIND.FORBIDDEN_SCORE_ASSIGNMENT,
1301
+ re: /\b(?:score|rating|compliance|conformance|health)\b[^\S\n]*[:=][^\S\n]*\S/gi,
1302
+ },
1303
+ {
1304
+ kind: REVIEW_PROBLEM_KIND.FORBIDDEN_LETTER_GRADE,
1305
+ re: /\bgrades?\b[^\S\n]*[:=]?[^\S\n]*[A-F][+-]?(?![A-Za-z0-9])/gi,
1306
+ },
1307
+ ]);
1308
+ /** Compose one problem. Every echoed value is bounded before it lands here. */
1309
+ function reviewProblem(kind, id, line, detail) {
1310
+ return { kind, id, line, detail };
1311
+ }
1312
+ /**
1313
+ * The D-06-13 counts line, computed from the four verdict counts and nothing
1314
+ * else — the same discipline `renderRateLine` states for its own line. Internal:
1315
+ * the ONE writer of this shape is this module, and `validateReviewArtifact`
1316
+ * returns the string it computed, so a consumer harvests it rather than
1317
+ * re-spelling it.
1318
+ */
1319
+ function renderCountsLine(counts) {
1320
+ return (`${counts.pass} PASS · ${counts.fail} FAIL · ${counts.inconclusive} INCONCLUSIVE · ` +
1321
+ `${counts.na} N-A (of ${counts.total} traced)`);
1322
+ }
1323
+ /** Split a markdown table line into trimmed cells, dropping the outer pipes. */
1324
+ function tableCells(line) {
1325
+ const trimmed = line.trim();
1326
+ const inner = trimmed.slice(1, trimmed.endsWith('|') ? -1 : undefined);
1327
+ return inner.split('|').map((c) => c.trim());
1328
+ }
1329
+ function isTableLine(line) {
1330
+ return line.trim().startsWith('|');
1331
+ }
1332
+ function isSeparatorLine(line) {
1333
+ if (!isTableLine(line))
1334
+ return false;
1335
+ const cells = tableCells(line);
1336
+ return cells.length > 0 && cells.every((c) => TABLE_SEPARATOR_CELL.test(c));
1337
+ }
1338
+ /**
1339
+ * Locate the verdict section's own lines inside the supplied text.
1340
+ *
1341
+ * BOTH CONSUMERS, ONE PARSER. The `## Practice Verdicts (agent-judged)` section
1342
+ * inside a VERIFICATION.md and a whole VAULT-REVIEW.md body reach this function
1343
+ * the same way: the heading is found, and the section runs to the next
1344
+ * top-level `##` heading or to the end of the text. A body that carries the
1345
+ * heading nowhere reports `present: false` — which is a finding, never a silent
1346
+ * empty result.
1347
+ */
1348
+ function locateReviewSection(lines) {
1349
+ // A line between a closed pair of fence markers is client prose, never
1350
+ // structure: a heading-shaped client line can neither open the section nor
1351
+ // end it (the same trust boundary `parseReviewRows` draws for rows).
1352
+ const fenced = fencedLineMap(lines);
1353
+ const start = lines.findIndex((l, i) => !fenced[i] && l.trim() === REVIEW_SECTION_HEADING);
1354
+ if (start === -1)
1355
+ return { present: false, start: 0, end: lines.length };
1356
+ let end = lines.length;
1357
+ for (let i = start + 1; i < lines.length; i += 1) {
1358
+ const line = lines[i];
1359
+ if (fenced[i])
1360
+ continue;
1361
+ if (line.startsWith('## ') || line.trim() === '---') {
1362
+ end = i;
1363
+ break;
1364
+ }
1365
+ }
1366
+ return { present: true, start: start + 1, end };
1367
+ }
1368
+ /**
1369
+ * WHICH LINES ARE CLIENT-AUTHORED. One computation, for every pass that needs
1370
+ * the answer.
1371
+ *
1372
+ * The fence MARKERS are harvested from the shipped check module rather than
1373
+ * re-spelled here, for the reason stated at the imports: a second spelling of a
1374
+ * marker is a second definition of where untrusted content ends. A second
1375
+ * COMPUTATION of which lines that marker encloses is the same failure with the
1376
+ * same consequence, and it is what shipped — `scanForbiddenScoreTokens` had this
1377
+ * pass inline and `parseReviewRows` had none, so a client-authored markdown
1378
+ * table inside a closed fence was excluded from the score scan and counted as an
1379
+ * agent verdict by the row parser at the same time (T-6-18). The two passes
1380
+ * disagreeing about the trust boundary let the audited party set the audit's own
1381
+ * number.
1382
+ *
1383
+ * Both semantics are the ones the score scan already had, and both are kept
1384
+ * deliberately:
1385
+ *
1386
+ * - Only a CLOSED pair fences anything. An opener with no closer marks
1387
+ * nothing, so the unterminated remainder is still scanned and still parsed.
1388
+ * Failing towards reading is the direction that cannot hide a violation, and
1389
+ * the unbalanced pair is separately reported as `FENCE_UNBALANCED`.
1390
+ * - The markers' own lines are inside the region. They are structure, never
1391
+ * content, and neither pass has anything to say about them.
1392
+ *
1393
+ * Total over any input; `lines` is never mutated.
1394
+ */
1395
+ function fencedLineMap(lines) {
1396
+ const fenced = new Array(lines.length).fill(false);
1397
+ for (let i = 0; i < lines.length; i += 1) {
1398
+ if (!lines[i].includes(FENCE_START_MARKER))
1399
+ continue;
1400
+ let close = -1;
1401
+ for (let j = i + 1; j < lines.length; j += 1) {
1402
+ if (lines[j].includes(FENCE_END_MARKER)) {
1403
+ close = j;
1404
+ break;
1405
+ }
1406
+ }
1407
+ if (close === -1)
1408
+ break;
1409
+ for (let k = i; k <= close; k += 1)
1410
+ fenced[k] = true;
1411
+ i = close;
1412
+ }
1413
+ return fenced;
1414
+ }
1415
+ /**
1416
+ * Parse the section's verdict rows into units. NOTHING is judged here, and no
1417
+ * unit is ever dropped or truncated out of the returned array below the cap:
1418
+ * a row this parser discarded would be a row whose defects nobody could report.
1419
+ *
1420
+ * FENCED LINES ARE NOT ROWS. The exclusion is `fencedLineMap`'s, shared with the
1421
+ * score scan rather than restated, and it is the trust boundary this module's
1422
+ * header draws: a pipe table between the external-content markers is prose from
1423
+ * the client's vault that the agent was instructed to reproduce verbatim, and it
1424
+ * is not a verdict the agent reached. Counting it is tampering in one direction
1425
+ * — the judged population inflates and the arithmetic cross-check then validates
1426
+ * the inflated figures — and denial of service in the other, since any table in
1427
+ * any practice document turns a cell reading `Type` into a `row-unknown-verdict`
1428
+ * charged to the agent.
1429
+ *
1430
+ * A fenced line is not a discarded row and is never counted against
1431
+ * `MAX_PARSED_UNITS`: it was never a unit to begin with.
1432
+ *
1433
+ * The header row is recognised by a ONE-LINE LOOKAHEAD — a table line
1434
+ * immediately followed by a separator line is the header. That lookahead lives
1435
+ * in the parser and never in the judge, so the isolation invariant below is
1436
+ * untouched by it. Recognising the header by its literal column text would
1437
+ * instead make a row whose verdict cell happened to read `Verdict` silently
1438
+ * vanish.
1439
+ */
1440
+ function parseReviewRows(lines, start, end) {
1441
+ const units = [];
1442
+ let truncated = 0;
1443
+ // The same window the score scan is handed (`lines.slice(section.start,
1444
+ // section.end)`), so the two passes cannot be looking at different text.
1445
+ const fenced = fencedLineMap(lines.slice(start, end));
1446
+ for (let i = start; i < end; i += 1) {
1447
+ const line = lines[i];
1448
+ if (fenced[i - start])
1449
+ continue;
1450
+ if (!isTableLine(line))
1451
+ continue;
1452
+ if (isSeparatorLine(line))
1453
+ continue;
1454
+ if (i + 1 < end && isSeparatorLine(lines[i + 1]))
1455
+ continue;
1456
+ if (units.length >= MAX_PARSED_UNITS) {
1457
+ truncated += 1;
1458
+ continue;
1459
+ }
1460
+ units.push({ index: units.length, line: i + 1, raw: line, cells: tableCells(line) });
1461
+ }
1462
+ return { units, truncated };
1463
+ }
1464
+ /**
1465
+ * Judge exactly ONE row, as a pure function of that row alone.
1466
+ *
1467
+ * ITS ARITY IS THE CONTRACT. One parameter means this function cannot consult a
1468
+ * sibling, so a malformed row can only ever change its own verdict and its own
1469
+ * problems. That is the invariant `src/practices-review-check.cts` pins as
1470
+ * `judgeUnit.length === 1`, for a defect class that four verification rounds
1471
+ * failed to close while any state was shared across units; the arity is pinned
1472
+ * here by a test for the same reason.
1473
+ *
1474
+ * Four row-level rules, and no more — everything cross-row belongs to the caller:
1475
+ * - a practice id is present (an anonymous verdict is not attributable);
1476
+ * - the verdict is one of the four locked tokens (D-06-10);
1477
+ * - a non-verdict carries a stated, non-empty reason (D-06-11);
1478
+ * - a derived row says WHICH derivation produced it and does not carry a
1479
+ * judged verdict (D-06-09 — the two derived populations are different
1480
+ * findings, and a derived N-A reclassified into PASS/FAIL is the agent
1481
+ * overwriting a fact it was handed).
1482
+ */
1483
+ function judgeReviewRow(unit) {
1484
+ const u = (unit ?? {});
1485
+ const cells = Array.isArray(u.cells) ? u.cells : [];
1486
+ const cell = (n) => (typeof cells[n] === 'string' ? cells[n].trim() : '');
1487
+ const line = typeof u.line === 'number' && Number.isFinite(u.line) ? u.line : null;
1488
+ const idCell = cell(0).replace(/`/g, '').trim();
1489
+ const verdict = cell(1);
1490
+ const reason = cell(2);
1491
+ const source = cell(3);
1492
+ const id = idCell === '' ? '' : quotableId(idCell);
1493
+ const problems = [];
1494
+ if (idCell === '') {
1495
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_MISSING_ID, null, line, 'this row carries no practice id, so its verdict is attributable to nothing. A verdict ' +
1496
+ 'nobody can trace back to a practice is not a finding.'));
1497
+ }
1498
+ const recognised = isJudgedVerdict(verdict) || isNonVerdict(verdict);
1499
+ if (!recognised) {
1500
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_UNKNOWN_VERDICT, id === '' ? null : id, line, `"${safe(verdict)}" is not one of the four locked verdicts. A coined fifth verdict ` +
1501
+ 'is counted as unrecognised and is never absorbed into a real one.'));
1502
+ }
1503
+ else if (isNonVerdict(verdict) && reason === '') {
1504
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_MISSING_REASON, id === '' ? null : id, line, 'A non-verdict must carry a stated reason. A bare N-A or INCONCLUSIVE is ' +
1505
+ 'indistinguishable from the agent giving up.'));
1506
+ }
1507
+ const derived = DERIVED_SOURCE_SHAPE.test(source);
1508
+ let derivedLabel = null;
1509
+ if (derived) {
1510
+ const matched = DERIVED_LABEL_SHAPE.exec(source);
1511
+ const label = matched && typeof matched[1] === 'string' ? matched[1].trim() : '';
1512
+ derivedLabel = label === '' ? null : safe(label);
1513
+ if (derivedLabel === null) {
1514
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_DERIVED_LABEL_MISSING, id === '' ? null : id, line, 'this row is marked derived but does not say WHICH derivation produced it. The two ' +
1515
+ `derived populations ("${NA_DERIVED_REASON.UNIVERSE_MINUS_LEDGER}" and ` +
1516
+ `"${NA_DERIVED_REASON.NO_FILE_IN_DIFF}") are different findings about different things, ` +
1517
+ 'and a reader who cannot tell them apart cannot act on either.'));
1518
+ }
1519
+ if (isJudgedVerdict(verdict)) {
1520
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_DERIVED_VERDICT_CONFLICT, id === '' ? null : id, line, `a derived row carries "${safe(verdict)}", a judged verdict. Derived rows are handed to ` +
1521
+ 'the agent to reproduce, not to reclassify: a derived N-A promoted to PASS or FAIL is a ' +
1522
+ 'computed fact overwritten by a judgement.'));
1523
+ }
1524
+ }
1525
+ return { id, verdict, reason, source, derived, derivedLabel, problems };
1526
+ }
1527
+ /**
1528
+ * Find every D-06-13 score form in the supplied text, by SHAPE.
1529
+ *
1530
+ * FENCED REGIONS ARE EXCLUDED, and that is a decision rather than an oversight.
1531
+ * D-06-13 governs what the AGENT writes; text between the external-content fence
1532
+ * markers is client-authored vault prose the agent was instructed to reproduce
1533
+ * verbatim, and a client practice that mentions a ratio of its own is not the
1534
+ * agent scoring a phase. A fence that never closes does NOT swallow the rest of
1535
+ * the document: the unterminated remainder is scanned, because failing towards
1536
+ * scanning is the direction that cannot hide a violation, and the unbalanced
1537
+ * pair is separately reported by `validateReviewArtifact`.
1538
+ *
1539
+ * The exclusion itself is `fencedLineMap`'s and is shared with `parseReviewRows`
1540
+ * rather than computed twice. It used to live inline here, and the row parser
1541
+ * had no equivalent — two passes over one document disagreeing about where
1542
+ * untrusted content ends, which is T-6-18.
1543
+ *
1544
+ * Total over any input. A non-string reports no findings, which is honest: there
1545
+ * was no text to scan, and the caller's own absent-input problem says so.
1546
+ */
1547
+ function scanForbiddenScoreTokens(text) {
1548
+ if (typeof text !== 'string' || text === '')
1549
+ return [];
1550
+ const lines = text.split(/\r?\n/);
1551
+ // Computed before any scanning so an unterminated opener cannot silence
1552
+ // everything after it.
1553
+ const fenced = fencedLineMap(lines);
1554
+ const findings = [];
1555
+ for (let i = 0; i < lines.length; i += 1) {
1556
+ if (fenced[i])
1557
+ continue;
1558
+ for (const shape of FORBIDDEN_SCORE_SHAPES) {
1559
+ for (const m of lines[i].matchAll(shape.re)) {
1560
+ findings.push({ kind: shape.kind, line: i + 1, match: safe(m[0]) });
1561
+ }
1562
+ }
1563
+ }
1564
+ return findings;
1565
+ }
1566
+ function validateReviewArtifact(text, opts) {
1567
+ const problems = [];
1568
+ const src = typeof text === 'string' ? text : '';
1569
+ const lines = src.split(/\r?\n/);
1570
+ const section = locateReviewSection(lines);
1571
+ if (typeof text !== 'string') {
1572
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.SECTION_ABSENT, null, null, 'no artifact text was supplied, so nothing was validated. This is an absent input, which is ' +
1573
+ 'a different finding from an artifact that was read and held no defect.'));
1574
+ }
1575
+ else if (!section.present) {
1576
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.SECTION_ABSENT, null, null, `the artifact carries no "${REVIEW_SECTION_HEADING}" heading, so no verdict row was found ` +
1577
+ 'and nothing was judged. The heading is locked; a section under any other heading is ' +
1578
+ 'not this section.'));
1579
+ }
1580
+ const { units, truncated } = section.present
1581
+ ? parseReviewRows(lines, section.start, section.end)
1582
+ : { units: [], truncated: 0 };
1583
+ if (truncated > 0) {
1584
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROWS_TRUNCATED, null, null, `${truncated} row(s) beyond the parse cap of ${MAX_PARSED_UNITS} were NOT judged. A cap ` +
1585
+ 'reached in silence would report a partial judgement as a whole one.'));
1586
+ }
1587
+ const rows = [];
1588
+ for (const unit of units) {
1589
+ const judged = judgeReviewRow(unit);
1590
+ rows.push(judged);
1591
+ for (const p of judged.problems)
1592
+ problems.push(p);
1593
+ }
1594
+ if (section.present && units.length === 0) {
1595
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.NO_ROWS, null, null, 'the section heading is present and carries no verdict row. An empty section is not a clean ' +
1596
+ 'review: a gap is reported loudly, never as an empty register.'));
1597
+ }
1598
+ const counts = countReviewVerdicts(rows);
1599
+ const expectedRateLine = renderRateLine(counts);
1600
+ const expectedCountsLine = renderCountsLine(counts);
1601
+ const body = section.present ? lines.slice(section.start, section.end) : [];
1602
+ const foundRateLine = body.map((l) => l.trim()).find((l) => RATE_LINE_SHAPE.test(l)) ?? null;
1603
+ const foundCountsLine = body.map((l) => l.trim()).find((l) => COUNTS_LINE_SHAPE.test(l)) ?? null;
1604
+ if (section.present) {
1605
+ if (foundRateLine === null) {
1606
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.RATE_LINE_ABSENT, null, null, `The section carries no non-verdict rate line. Expected the computed line ` +
1607
+ `"${expectedRateLine}" — the denominator is the point, and a list of green rows without ` +
1608
+ 'it cannot tell a reader that a review is hollow.'));
1609
+ }
1610
+ else if (foundRateLine !== expectedRateLine) {
1611
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.RATE_LINE_ARITHMETIC, null, null, `The rate line reads "${safe(foundRateLine)}" while the rows support ` +
1612
+ `"${expectedRateLine}". The arithmetic is computed from the rows and never narrated.`));
1613
+ }
1614
+ if (foundCountsLine === null) {
1615
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COUNTS_LINE_ABSENT, null, null, `The section carries no counts line. Expected "${expectedCountsLine}".`));
1616
+ }
1617
+ else if (foundCountsLine !== expectedCountsLine) {
1618
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COUNTS_LINE_ARITHMETIC, null, null, `The counts line reads "${safe(foundCountsLine)}" while the rows support ` +
1619
+ `"${expectedCountsLine}".`));
1620
+ }
1621
+ }
1622
+ // ── MECH-02 / D-10-20: the mechanical coverage line ────────────────────────
1623
+ // Scanned over the WHOLE document, not `body`: the agent contract puts this
1624
+ // line under `## Range` and forbids it inside the verdicts section, so a
1625
+ // scan scoped to that section could never see it. That scoping is exactly
1626
+ // why phase 10 shipped the line unenforced.
1627
+ if (opts && Object.prototype.hasOwnProperty.call(opts, 'expectedCoverageLine')) {
1628
+ const expected = opts.expectedCoverageLine;
1629
+ const inSection = (i) => section.present && i >= section.start && i < section.end;
1630
+ let foundIdx = -1;
1631
+ const fencedLines = fencedLineMap(lines);
1632
+ for (let i = 0; i < lines.length; i += 1) {
1633
+ if (fencedLines[i])
1634
+ continue;
1635
+ if (COVERAGE_LINE_SHAPE.test(lines[i].trim())) {
1636
+ foundIdx = i;
1637
+ break;
1638
+ }
1639
+ }
1640
+ const found = foundIdx >= 0 ? lines[foundIdx].trim() : null;
1641
+ if (typeof expected === 'string' && expected.length > 0) {
1642
+ if (found === null) {
1643
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COVERAGE_LINE_ABSENT, null, null, `The payload carried the mechanical coverage line "${expected}" and the ` +
1644
+ 'artifact reproduces it nowhere. That line is how a reader learns how much of the ' +
1645
+ 'vault was mechanically checked; without it a page of judged rows cannot say ' +
1646
+ 'whether the mechanical tier ran at all.'));
1647
+ }
1648
+ else if (found !== expected) {
1649
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COVERAGE_LINE_MISMATCH, null, null, `The coverage line reads "${safe(found)}" where the engine computed ` +
1650
+ `"${expected}". It is the engine's sentence, reproduced verbatim and never restated.`));
1651
+ }
1652
+ else if (inSection(foundIdx)) {
1653
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COVERAGE_LINE_MISPLACED, null, null, 'The coverage line sits inside the verdicts section, where a second bare ' +
1654
+ 'shaped line makes the section contradict itself — its figures count a different ' +
1655
+ 'population from the rate line above them. It belongs under "## Range".'));
1656
+ }
1657
+ }
1658
+ else if (expected === null && found !== null) {
1659
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.COVERAGE_LINE_UNEXPECTED, null, null, `The artifact carries "${safe(found)}" while the payload carried no coverage ` +
1660
+ 'line at all. Its absence means the inputs were never measured, so this figure ' +
1661
+ 'states a measurement nobody took.'));
1662
+ }
1663
+ }
1664
+ let fenceStarts = 0;
1665
+ let fenceEnds = 0;
1666
+ let depth = 0;
1667
+ let endBeforeStart = false;
1668
+ for (const line of body) {
1669
+ if (line.includes(FENCE_START_MARKER)) {
1670
+ fenceStarts += 1;
1671
+ depth += 1;
1672
+ }
1673
+ if (line.includes(FENCE_END_MARKER)) {
1674
+ fenceEnds += 1;
1675
+ if (depth === 0)
1676
+ endBeforeStart = true;
1677
+ else
1678
+ depth -= 1;
1679
+ }
1680
+ }
1681
+ if (section.present && (fenceStarts !== fenceEnds || endBeforeStart)) {
1682
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.FENCE_UNBALANCED, null, null, `the external-content fence markers do not pair up: ${fenceStarts} opener(s) and ` +
1683
+ `${fenceEnds} closer(s)` +
1684
+ (endBeforeStart ? ', with a closer appearing before any opener' : '') +
1685
+ '. An unbalanced pair means client-authored prose is sitting outside the boundary that ' +
1686
+ 'marks it as reference data, which is the escape the fencing exists to prevent.'));
1687
+ }
1688
+ for (const finding of scanForbiddenScoreTokens(body.join('\n'))) {
1689
+ problems.push(reviewProblem(finding.kind, null, section.present ? section.start + finding.line : finding.line, `No single-number roll-up may appear anywhere in this section; "${finding.match}" is one. ` +
1690
+ 'Counts by verdict stay sortable and trackable across phases without inviting a ' +
1691
+ 'measured-sounding claim about a judgement.'));
1692
+ }
1693
+ return {
1694
+ sectionPresent: section.present,
1695
+ units,
1696
+ rows,
1697
+ counts,
1698
+ foundRateLine,
1699
+ expectedRateLine,
1700
+ foundCountsLine,
1701
+ expectedCountsLine,
1702
+ fenceStarts,
1703
+ fenceEnds,
1704
+ problems,
1705
+ };
1706
+ }
1707
+ /** The only `predicate.vault.state` value that means the vault was reached. */
1708
+ const TRACE_STATE_LOADED = 'loaded';
1709
+ /**
1710
+ * Normalise the caller's per-block list.
1711
+ *
1712
+ * FAIL-CLOSED at the edge: an entry that is not an object still becomes a
1713
+ * record — one whose `state` is null and therefore is not `loaded`. Dropping it
1714
+ * instead would let a junk entry beside a loaded one read as a loaded phase,
1715
+ * which is the multi-block fail-open T-6-47 names.
1716
+ */
1717
+ function asTraceBlocks(v) {
1718
+ if (!Array.isArray(v))
1719
+ return [];
1720
+ const out = [];
1721
+ for (const entry of v) {
1722
+ const b = (entry !== null && typeof entry === 'object' ? entry : {});
1723
+ const source = typeof b.source === 'string' && b.source !== '' ? b.source : null;
1724
+ out.push({
1725
+ source,
1726
+ state: typeof b.state === 'string' && b.state !== '' ? b.state : null,
1727
+ reason: typeof b.reason === 'string' && b.reason !== '' ? b.reason : null,
1728
+ override: asOverrideRecord(b.override, source),
1729
+ });
1730
+ }
1731
+ return out;
1732
+ }
1733
+ /**
1734
+ * Normalise ONE block's override record, or answer null (08-05, D-08-11).
1735
+ *
1736
+ * Null unless the value is a plain object: an array, a string or a number is
1737
+ * not a record, and treating one as though it were would put a fabricated
1738
+ * bypass in front of a reader. A record whose fields are ALL missing still
1739
+ * survives as an entry — the artifact recorded an override, and "recorded, and
1740
+ * said nothing about it" is a different finding from "no override", which is
1741
+ * exactly the distinction D-06-14 exists to keep visible.
1742
+ *
1743
+ * EVERY field passes `safe()` here rather than at the point of use, including
1744
+ * `source`, because all six reach both `statements[]` and an agent prompt. That
1745
+ * is the discipline `describeDegradedTrace` already applies to the same
1746
+ * artifact's other fields (T-6-44 / T-6-46, and T-08-26 for this one): a trace
1747
+ * artifact is agent- or hand-editable DATA.
1748
+ */
1749
+ function asOverrideRecord(v, source) {
1750
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
1751
+ return null;
1752
+ const o = v;
1753
+ const field = (x) => (typeof x === 'string' && x !== '' ? safe(x) : null);
1754
+ return {
1755
+ source: source === null ? null : safe(source),
1756
+ gate: field(o.gate),
1757
+ reason: field(o.reason),
1758
+ suppressedReason: field(o.suppressedReason),
1759
+ who: field(o.who),
1760
+ when: field(o.when),
1761
+ };
1762
+ }
1763
+ /**
1764
+ * Name the FIRST block that is not `loaded`, and say what it recorded.
1765
+ *
1766
+ * The value is a labelled fact for the reviewer, never a routing signal: the
1767
+ * routing is `traceLoaded`, computed from the same blocks. Every fragment that
1768
+ * originates in a trace artifact — the source path and the recorded reason
1769
+ * alike — passes through `safe()` before it reaches the returned string, exactly
1770
+ * as `sources` and `pinSha` already do (T-6-44, T-6-46). A trace artifact is
1771
+ * agent- or hand-editable data, and this string crosses into an agent prompt.
1772
+ */
1773
+ function describeDegradedTrace(blocks) {
1774
+ const offending = blocks.find((b) => b.state !== TRACE_STATE_LOADED);
1775
+ if (offending === undefined) {
1776
+ return ('no per-block provenance was carried with this trace, so nothing here establishes that the vault ' +
1777
+ 'was reached when this phase was planned');
1778
+ }
1779
+ const source = offending.source === null ? 'an unnamed trace artifact' : safe(offending.source);
1780
+ const state = offending.state;
1781
+ const reason = offending.reason === null ? null : safe(offending.reason);
1782
+ if (state === null) {
1783
+ return `${source}: records no \`predicate.vault.state\` at all, so it cannot record that the vault loaded`;
1784
+ }
1785
+ if (state === 'degraded') {
1786
+ return `${source}: ${reason ?? 'no reason recorded'}`;
1787
+ }
1788
+ return (`${source}: records state "${safe(state)}", which is not a state this tier recognises` +
1789
+ (reason === null ? '' : `; its recorded reason is ${reason}`));
1790
+ }
1791
+ function buildReviewPayload(opts) {
1792
+ const statements = [];
1793
+ const notes = [];
1794
+ const trace = opts?.trace ?? null;
1795
+ const tracePresent = trace?.present === true;
1796
+ // ── The provenance, decided ONCE and here (06-12, G1). `present` answers "did
1797
+ // a block parse"; `traceLoaded` answers the different question the workflow's
1798
+ // §2 actually routes on: "was the vault reached when this phase was planned".
1799
+ // FAIL-CLOSED, deliberately (T-6-43, T-6-47): loaded requires strict equality
1800
+ // with `loaded` on EVERY parsed block, so one degraded block among loaded ones
1801
+ // is not a loaded phase, an unrecognised value is not a loaded one, and a
1802
+ // `present` trace carrying no per-block provenance at all is not one either —
1803
+ // an absence of a record is not a record of success.
1804
+ const traceBlocks = asTraceBlocks(trace?.blocks);
1805
+ const traceLoaded = tracePresent && traceBlocks.length > 0 && traceBlocks.every((b) => b.state === TRACE_STATE_LOADED);
1806
+ const traceState = !tracePresent
1807
+ ? null
1808
+ : traceLoaded
1809
+ ? TRACE_STATE_LOADED
1810
+ : 'degraded';
1811
+ const traceReason = traceState === 'degraded' ? describeDegradedTrace(traceBlocks) : null;
1812
+ const ledgerIds = asStringList(trace?.ledgerIds);
1813
+ const phaseDir = typeof opts?.phaseDir === 'string' && opts.phaseDir !== '' ? safe(opts.phaseDir) : null;
1814
+ // ── The range, first: every derivation below is scoped by it.
1815
+ const range = resolveReviewRange({
1816
+ cwd: opts.cwd,
1817
+ phaseToken: opts?.phaseToken ?? null,
1818
+ // UAT-6-F1: the check router already supplies `phaseDir: phase.rel`, so
1819
+ // forwarding it is all the shared phase-scoped resolution needs.
1820
+ phaseDir: typeof opts?.phaseDir === 'string' && opts.phaseDir !== '' ? opts.phaseDir : null,
1821
+ ...(typeof opts?.exec === 'function' ? { exec: opts.exec } : {}),
1822
+ });
1823
+ statements.push(`This review is computed over the git range "${range.range}" (rangeSource: ${range.rangeSource}, ` +
1824
+ `base: ${safe(range.base)}), which considered ${range.filesConsidered} changed file(s). Every ` +
1825
+ 'applicability judgement below is scoped to exactly that set.');
1826
+ for (const note of range.notes)
1827
+ statements.push(`Range note: ${note}`);
1828
+ // ── The vault verdict owner's answer, stated when it is not `loaded`: the
1829
+ // practices below were read from the cache as it stands, and a reader must
1830
+ // know whether the load gate considers that cache the client's loaded vault.
1831
+ const ownerVerdict = opts?.vaultVerdict ?? null;
1832
+ const vault = ownerVerdict !== null && typeof ownerVerdict === 'object' && typeof ownerVerdict.verdict === 'string'
1833
+ ? { verdict: safe(ownerVerdict.verdict), reason: typeof ownerVerdict.reason === 'string' ? safe(ownerVerdict.reason) : '' }
1834
+ : null;
1835
+ if (vault !== null && vault.verdict !== 'loaded') {
1836
+ statements.push(`The vault verdict owner reports this project's vault as ${vault.verdict}` +
1837
+ (vault.reason === '' ? '' : ` (${vault.reason})`) +
1838
+ ': the practices reviewed below were read from the local cache as it stands, which the vault load ' +
1839
+ "gate does not treat as the client's loaded vault.");
1840
+ }
1841
+ // ── D-06-15: an absent trace does not refuse; it says so and RECONSTRUCTS.
1842
+ if (!tracePresent) {
1843
+ statements.push('No practice trace was found for this phase' +
1844
+ (phaseDir === null ? '' : ` in ${phaseDir}`) +
1845
+ '. This review therefore took the RECONSTRUCT arm: the applicable practice set is rebuilt ' +
1846
+ "from this phase's real diff and the vault's own practice list rather than from a ledger. " +
1847
+ 'Any completed phase is eligible for review and a missing trace is itself a finding, ' +
1848
+ 'so it is recorded here rather than used as a reason to refuse.');
1849
+ }
1850
+ // ── G1: a trace that PARSES but records a failed vault load. This is not the
1851
+ // absent case and must never be reported as one — a found-and-failed trace is
1852
+ // a finding, and calling it an absence loses it entirely.
1853
+ if (tracePresent && !traceLoaded) {
1854
+ statements.push(
1855
+ // The source list is NOT repeated here: `traceReason` already opens with
1856
+ // the artifact path of the block that failed, which is the one a reader
1857
+ // needs. Naming the whole list beside it printed the same path three times
1858
+ // in one sentence.
1859
+ 'A practice trace WAS found for this phase' +
1860
+ (phaseDir === null ? '' : ` in ${phaseDir}`) +
1861
+ `, and it records a FAILURE — ${traceReason ?? 'no reason recorded'}. A degraded block is a valid ` +
1862
+ 'record of a failure — it says the vault could not be loaded when the work was planned — so it ' +
1863
+ 'proves nothing about which practices governed that work and may not stand in for a loaded trace. ' +
1864
+ 'This review therefore took the RECONSTRUCT arm: the applicable practice set is rebuilt ' +
1865
+ "from this phase's real diff and the vault's own practice list, and this trace's own ledger and " +
1866
+ 'practice universe are deliberately not consulted.');
1867
+ }
1868
+ // ── D-08-09 / D-08-11: every RECORDED gate override, stated. `check
1869
+ // vault.trace` reports the same records under `checked_with_override`, so a
1870
+ // review that stayed silent about one would be the second surface disagreeing
1871
+ // with the first about whether a phase was overridden — and the silent one is
1872
+ // the one a human reads. Collected from EVERY parsed block, not keyed on
1873
+ // `traceLoaded`: an override is recordable whenever a gate blocked, which is
1874
+ // independent of whether the vault itself loaded, and a degraded block's
1875
+ // override is exactly the one a reader must not lose.
1876
+ const overrides = traceBlocks
1877
+ .map((b) => b.override)
1878
+ .filter((o) => o !== null);
1879
+ for (const o of overrides) {
1880
+ const said = (v) => v ?? '(not recorded)';
1881
+ statements.push(`A vault-gate OVERRIDE is recorded in ${said(o.source)}: gate ${said(o.gate)} was overridden by ` +
1882
+ `${said(o.who)} at ${said(o.when)} with the stated reason "${said(o.reason)}", suppressing the ` +
1883
+ `verdict ${said(o.suppressedReason)}. The work this phase records was therefore planned or ` +
1884
+ 'executed under a deliberate, attributed bypass of that gate; this review reports the fact ' +
1885
+ 'and does not judge it.');
1886
+ }
1887
+ // ── The input set: this arm's universe ∩ NO_CHECK, harvested through the
1888
+ // shipped ladder. On the reconstruct arm the universe is the vault's own
1889
+ // practice list (D3): the ledger is empty there by definition, and deriving
1890
+ // from it is what let an untraced phase render a clean review having judged
1891
+ // nothing. Crossing that universe with this range's real diff is Arm B's job
1892
+ // below, through the shipped matcher — not a second one written here.
1893
+ //
1894
+ // KEYED ON `traceLoaded`, NOT ON `tracePresent` (06-12, G1). A degraded block
1895
+ // carries a ledger and a practice universe of its own, and both are worthless:
1896
+ // docs/vault-trace-format.md §"What a degraded record proves" says an empty
1897
+ // ledger and a complete one are indistinguishable through it. Feeding them to
1898
+ // the derivation while the statement above announces a reconstruction from the
1899
+ // vault's practice list would make that statement false — which is the whole
1900
+ // reason this is more than a label change.
1901
+ const inputSet = deriveCandidateSet({
1902
+ ledgerIds: traceLoaded ? ledgerIds : [],
1903
+ reconstruct: !traceLoaded,
1904
+ practices: [...(opts?.practices ?? [])],
1905
+ skipped: opts?.skipped ?? [],
1906
+ degraded: opts?.degraded ?? [],
1907
+ tracePinSha: trace?.pinSha ?? null,
1908
+ cacheSha: opts?.cacheSha ?? null,
1909
+ ...(opts?._audit ? { _audit: opts._audit } : {}),
1910
+ });
1911
+ for (const note of inputSet.notes)
1912
+ statements.push(`Input set: ${note}`);
1913
+ // ── Both derived-N-A arms, kept as two labels that never blur.
1914
+ // Arm A consumes the BLOCK's own recorded universe, so it is keyed on
1915
+ // `traceLoaded` for the same reason the derivation above is: a degraded
1916
+ // block's universe is not a universe anyone may report against.
1917
+ const naUniverse = deriveNaAUniverse({
1918
+ universe: traceLoaded ? (trace?.universe ?? null) : null,
1919
+ ledgerIds: traceLoaded ? ledgerIds : [],
1920
+ // Only true when a universe EXISTED and this call suppressed it. An absent
1921
+ // trace, and a degraded one that recorded no universe, both stay on the
1922
+ // absent ground — whose sentence is true of them (06-12 Task 5 follow-up).
1923
+ universeWithheld: !traceLoaded && (trace?.universe ?? null) !== null,
1924
+ });
1925
+ const naNoFile = deriveNaANoFile({
1926
+ candidates: inputSet.candidates,
1927
+ practices: opts?.practices ?? [],
1928
+ diffFiles: range.files,
1929
+ discovery: opts?.discovery ?? null,
1930
+ ...(opts?._practices ? { _practices: opts._practices } : {}),
1931
+ });
1932
+ for (const arm of [naUniverse, naNoFile]) {
1933
+ if (arm.notDerived)
1934
+ statements.push(arm.notDerived.statement);
1935
+ for (const note of arm.notes)
1936
+ notes.push(note);
1937
+ }
1938
+ // ── The clean-render refusal, drawn LAST because it is the conclusion of
1939
+ // everything above: the range's file count AND whether the candidate
1940
+ // derivation was measured at all (D3). The predicate is the shipped one and
1941
+ // the grounds it names are the ones the derivation recorded — this function
1942
+ // restates neither.
1943
+ const refusalInput = { filesConsidered: range.filesConsidered, inputSet, naUniverse, naNoFile };
1944
+ const refusesCleanRender = reviewRefusesCleanRender(refusalInput);
1945
+ if (reviewRefusesCleanRender(range)) {
1946
+ statements.push('This review considered ZERO files, so no clean review may be rendered from it. A review that ' +
1947
+ 'looked at nothing and reported cleanly is the rubber stamp this tier exists to prevent; the ' +
1948
+ 'refusal is the shipped predicate\'s, not a restatement of it.');
1949
+ }
1950
+ // ── D-08-17, the review half: the refusal above says a review looked at
1951
+ // nothing. THIS one says WHY, by the code the blocking tier already reports,
1952
+ // and hands back both remedies. The two sentences stay side by side because
1953
+ // they are different facts — "no clean render may follow" and "the range
1954
+ // resolved nothing about this phase" — and an operator who is told only the
1955
+ // first has been told their review is worthless and nothing else.
1956
+ if (range.outcome === auditMod.AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE) {
1957
+ const sharedGround = range.notes.find((n) => n.startsWith(SHARED_RESOLUTION_NOTE_PREFIX));
1958
+ statements.push(`Range outcome ${range.outcome}: the range fell to the base-branch span (${range.range}) and ` +
1959
+ 'considered zero files, so nothing was established about this phase — ' +
1960
+ `${sharedGround ?? 'no phase manifest entry and no SUMMARY trace digest resolved.'} ` +
1961
+ REVIEW_RANGE_UNRESOLVABLE_REMEDY);
1962
+ }
1963
+ const unmeasured = unmeasuredGrounds(refusalInput);
1964
+ if (unmeasured.length > 0) {
1965
+ statements.push(`This review could not MEASURE the inputs its candidate set is derived from (${unmeasured.join(', ')}), ` +
1966
+ 'so no clean review may be rendered from it. An empty candidate set nobody measured is a different ' +
1967
+ 'finding from one that was measured and held nothing, and rendering the first as though it were the ' +
1968
+ 'second is exactly the silence this tier exists to end.');
1969
+ }
1970
+ const partition = partitionForJudgement({
1971
+ candidates: inputSet.candidates,
1972
+ naUniverse,
1973
+ naNoFile,
1974
+ });
1975
+ const derivedCounts = countReviewVerdicts([...naUniverse.rows, ...naNoFile.rows]);
1976
+ const derivedRateLine = renderRateLine(derivedCounts);
1977
+ // ── MECH-02 / D-10-03: the MECHANICAL tier's coverage over THIS review's own
1978
+ // file set, joining the two derived facts above. Gated on the SHIPPED
1979
+ // `unmeasuredGrounds` answer already computed above rather than on a second
1980
+ // reading of the same inputs: where that predicate names a ground, nothing
1981
+ // was measured, and a `0 of 0` line would state a measurement nobody took.
1982
+ const mechanicalCoverageLine = unmeasured.length > 0
1983
+ ? null
1984
+ : deriveMechanicalCoverageLine({
1985
+ practices: opts?.practices ?? [],
1986
+ files: range.files,
1987
+ cwd: opts?.cwd,
1988
+ ...(opts?._audit ? { _audit: opts._audit } : {}),
1989
+ });
1990
+ // ── The remainder's prose, rendered through the SHIPPED per-practice renderer.
1991
+ // Not a renderer of this module's own: a second one is a second place a
1992
+ // client-authored byte can reach an agent context unfenced (CR-02).
1993
+ const practiceEntries = [];
1994
+ const renderEntry = typeof opts?.renderEntry === 'function' ? opts.renderEntry : null;
1995
+ const byId = new Map();
1996
+ const entries = typeof opts?.matchEntries === 'function' ? opts.matchEntries(range.files) : (opts?.entries ?? []);
1997
+ for (const entry of entries) {
1998
+ if (entry && typeof entry.id === 'string')
1999
+ byId.set(entry.id, entry);
2000
+ }
2001
+ for (const id of partition.remainder) {
2002
+ const entry = byId.get(id);
2003
+ if (entry === undefined) {
2004
+ notes.push(`"${quotableId(id)}" reaches the agent as a remainder id with no matcher entry, so no fenced ` +
2005
+ 'prose is supplied for it. The id is reported rather than dropped.');
2006
+ continue;
2007
+ }
2008
+ if (renderEntry === null) {
2009
+ notes.push('no practice renderer was supplied, so no fenced practice prose is emitted at all. Vault prose ' +
2010
+ 'is never emitted unfenced, so the honest answer is to emit none.');
2011
+ break;
2012
+ }
2013
+ practiceEntries.push(renderEntry(entry));
2014
+ }
2015
+ return {
2016
+ status: 'reviewed',
2017
+ // ONE routing decision, read from ONE field. `arm` is derived from
2018
+ // `traceLoaded` and so is `trace.loaded`, which is why the workflow reads
2019
+ // the latter and never re-derives it from the former (06-12, T-6-48).
2020
+ arm: traceLoaded ? 'traced' : 'reconstruct',
2021
+ phaseDir,
2022
+ vault,
2023
+ statements,
2024
+ trace: {
2025
+ present: tracePresent,
2026
+ sources: asStringList(trace?.sources).map((s) => safe(s)),
2027
+ pinSha: typeof trace?.pinSha === 'string' ? safe(trace.pinSha) : null,
2028
+ state: traceState,
2029
+ reason: traceReason,
2030
+ loaded: traceLoaded,
2031
+ overrides,
2032
+ },
2033
+ range,
2034
+ refusesCleanRender,
2035
+ inputSet,
2036
+ naUniverse,
2037
+ naNoFile,
2038
+ partition,
2039
+ derivedCounts,
2040
+ derivedRateLine,
2041
+ // OMITTED, not zeroed, on the unmeasured path — see the field's comment.
2042
+ ...(mechanicalCoverageLine === null ? {} : { mechanicalCoverageLine }),
2043
+ practiceEntries,
2044
+ notes,
2045
+ };
2046
+ }
2047
+ // ─── The artifact the agent fills in, and the lines only code writes ─────────
2048
+ /**
2049
+ * The verdict cell of a remainder row until the agent judges it. Deliberately
2050
+ * outside the locked vocabulary: a row left unjudged is reported by the
2051
+ * validator as `row-unknown-verdict`, never counted as any verdict.
2052
+ */
2053
+ const REVIEW_UNJUDGED_VERDICT = 'TODO';
2054
+ /** The `## Range` heading the coverage line is stamped under. */
2055
+ const RANGE_SECTION_HEADING = '## Range';
2056
+ /** A table cell: one line, no column separator, never empty. */
2057
+ function tableCell(v, empty = 'none.') {
2058
+ const s = (typeof v === 'string' ? v : '').replace(/[\r\n]+/g, ' ').replace(/\|/g, '¦').trim();
2059
+ return s === '' ? empty : s;
2060
+ }
2061
+ /**
2062
+ * The gaps the payload recorded, one row each, in a fixed order. Every row is
2063
+ * written on every run; a row with nothing to report says `none.`, so "nothing
2064
+ * to report" and "not looked at" never look alike.
2065
+ */
2066
+ function reviewGapRows(p) {
2067
+ const notDerived = (arm) => (arm.notDerived ? arm.notDerived.statement : '');
2068
+ const vaultGround = p.naNoFile.notDerived?.ground ?? '';
2069
+ const vaultGap = [
2070
+ NOT_DERIVED_GROUND.VAULT_STATE_UNKNOWN,
2071
+ NOT_DERIVED_GROUND.VAULT_NEVER_FETCHED,
2072
+ NOT_DERIVED_GROUND.VAULT_FETCH_MISS,
2073
+ NOT_DERIVED_GROUND.VAULT_UNREADABLE,
2074
+ ].some((g) => g === vaultGround)
2075
+ ? notDerived(p.naNoFile)
2076
+ : '';
2077
+ const traceGap = !p.trace.present
2078
+ ? 'absent — no practice trace was found, so this review took the reconstruct arm'
2079
+ : p.trace.loaded
2080
+ ? ''
2081
+ : `degraded — ${p.trace.reason ?? 'no reason recorded'}`;
2082
+ const said = (v) => v ?? '(not recorded)';
2083
+ const overrides = p.trace.overrides
2084
+ .map((o) => `${said(o.gate)} overridden by ${said(o.who)} at ${said(o.when)} in ${said(o.source)}: "${said(o.reason)}" (suppressed ${said(o.suppressedReason)})`)
2085
+ .join('; ');
2086
+ const rangeGap = p.range.outcome === auditMod.AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE
2087
+ ? `${p.range.outcome}: ${REVIEW_RANGE_UNRESOLVABLE_REMEDY}`
2088
+ : '';
2089
+ const skipped = p.inputSet.exclusions
2090
+ .filter((e) => e.reason === AUDIT_FAIL_REASON.PRACTICE_UNLOADABLE)
2091
+ .map((e) => quotableId(e.id))
2092
+ .join(', ');
2093
+ const unmeasured = unmeasuredGrounds({
2094
+ filesConsidered: p.range.filesConsidered,
2095
+ inputSet: p.inputSet,
2096
+ naUniverse: p.naUniverse,
2097
+ naNoFile: p.naNoFile,
2098
+ });
2099
+ const refused = p.refusesCleanRender
2100
+ ? `no clean review may be rendered: ${p.range.filesConsidered === 0 ? 'zero files were considered' : `inputs not measured (${unmeasured.join(', ')})`}`
2101
+ : '';
2102
+ return [
2103
+ ['Vault absent, never fetched, missed or unreadable', vaultGap],
2104
+ ['Practice universe not recorded at the pin', notDerived(p.naUniverse)],
2105
+ ['Trace absent or degraded', traceGap],
2106
+ ['Gate override recorded (who, when, reason)', overrides],
2107
+ ['Range refused or unresolvable', rangeGap],
2108
+ ['Empty diff — no file considered', p.range.filesConsidered === 0 ? 'zero files were considered' : ''],
2109
+ ['Practices skipped or rejected before matching', skipped],
2110
+ ['Clean review refused', refused],
2111
+ ];
2112
+ }
2113
+ /**
2114
+ * Render the `NN-VAULT-REVIEW.md` the agent fills in, from the payload alone.
2115
+ *
2116
+ * Everything computed is written here, by code: provenance, the range, every
2117
+ * derived row with its reason and arm label, every gap row, the payload's
2118
+ * statements, the fixed limits, and — through `stampReviewLines` — the rate,
2119
+ * counts and coverage lines. Each remainder practice gets one row whose verdict
2120
+ * reads `TODO` and whose reason is empty: those two cells are the agent's whole
2121
+ * writable surface. The remainder's practice text follows, last, inside the
2122
+ * fences the shipped renderer already put around it.
2123
+ *
2124
+ * Pure: payload and date in, text out.
2125
+ */
2126
+ function renderReviewScaffold(payload, meta = {}) {
2127
+ const p = payload;
2128
+ const token = typeof meta.phaseToken === 'string' && meta.phaseToken !== '' ? safe(meta.phaseToken) : '?';
2129
+ const date = typeof meta.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(meta.date) ? meta.date : '';
2130
+ const traceCell = !p.trace.present
2131
+ ? 'absent'
2132
+ : p.trace.loaded
2133
+ ? 'present and loaded'
2134
+ : `degraded — ${p.trace.reason ?? 'no reason recorded'}`;
2135
+ const advisories = p.inputSet.advisories.map((a) => a.detail).join(' ');
2136
+ const rows = [];
2137
+ for (const id of p.partition.remainder) {
2138
+ rows.push(`| \`${tableCell(quotableId(id))}\` | ${REVIEW_UNJUDGED_VERDICT} | | agent-judged |`);
2139
+ }
2140
+ for (const r of [...p.partition.naUniverse, ...p.partition.naNoFile]) {
2141
+ rows.push(`| \`${tableCell(quotableId(r.id))}\` | ${r.verdict} | ${tableCell(r.reason)} | derived (${tableCell(r.naDerivedReason ?? '')}) |`);
2142
+ }
2143
+ const lines = [
2144
+ '---',
2145
+ `phase: "${token}"`,
2146
+ 'artifact: vault-review',
2147
+ `reviewed: ${date === '' ? 'unknown' : date}`,
2148
+ `arm: ${p.arm}`,
2149
+ '---',
2150
+ '',
2151
+ `# Phase ${token} — Vault Practice Review`,
2152
+ '',
2153
+ '> This review reports and never gates: no verdict here changes a phase status, a score, a gap or a',
2154
+ '> ship. Verdicts are agent-judged, not measured, and two runs may differ. Counts by verdict only.',
2155
+ '',
2156
+ '## Provenance',
2157
+ '',
2158
+ '| Field | Value |',
2159
+ '| --- | --- |',
2160
+ `| Phase directory | ${tableCell(p.phaseDir ?? '')} |`,
2161
+ `| Arm | ${p.arm} |`,
2162
+ `| Trace | ${tableCell(traceCell)} |`,
2163
+ `| Trace sources | ${tableCell(p.trace.sources.join(', '))} |`,
2164
+ `| Vault verdict | ${tableCell(p.vault ? `${p.vault.verdict}${p.vault.reason ? ` (${p.vault.reason})` : ''}` : '', 'not supplied')} |`,
2165
+ `| Pin advisory | ${tableCell(advisories)} |`,
2166
+ '',
2167
+ RANGE_SECTION_HEADING,
2168
+ '',
2169
+ '| Field | Value |',
2170
+ '| --- | --- |',
2171
+ `| \`range\` | \`${tableCell(p.range.range)}\` |`,
2172
+ `| \`rangeSource\` | \`${tableCell(p.range.rangeSource)}\` |`,
2173
+ `| \`filesConsidered\` | ${p.range.filesConsidered} |`,
2174
+ `| \`outcome\` | \`${tableCell(p.range.outcome)}\` |`,
2175
+ '',
2176
+ REVIEW_SECTION_HEADING,
2177
+ '',
2178
+ '| Practice | Verdict | Reason / evidence | Source |',
2179
+ '| --- | --- | --- | --- |',
2180
+ ...rows,
2181
+ '',
2182
+ '## Not Derived — the gaps',
2183
+ '',
2184
+ '| Gap | Statement |',
2185
+ '| --- | --- |',
2186
+ ...reviewGapRows(p).map(([gap, statement]) => `| ${gap} | ${tableCell(statement)} |`),
2187
+ '',
2188
+ '## Derivation Statements',
2189
+ '',
2190
+ ...(p.statements.length + p.notes.length === 0
2191
+ ? ['none.']
2192
+ : [...p.statements.map((st) => `- ${st}`), ...p.notes.map((n) => `- Note: ${n}`)]),
2193
+ '',
2194
+ '## Limits of This Review',
2195
+ '',
2196
+ '- Nothing in this artifact gates anything. A `FAIL` is a report to a human, not a halt.',
2197
+ '- A verdict is one agent run\'s judgement; reproducibility is not claimed.',
2198
+ '- The non-verdict rate is reported, not judged: there is no threshold at which a review is declared',
2199
+ ' low-confidence.',
2200
+ '- A practice absent from the register was not in the input set; the gaps table says why. Absence',
2201
+ ' is not a statement that it was followed.',
2202
+ '',
2203
+ '## Practice Text (client reference data)',
2204
+ '',
2205
+ ...(p.practiceEntries.length === 0 ? ['none.'] : p.practiceEntries.flatMap((e) => [e, ''])),
2206
+ '',
2207
+ ];
2208
+ return stampReviewLines(lines.join('\n'), { coverageLine: p.mechanicalCoverageLine ?? null }).text;
2209
+ }
2210
+ /** Index of the first unfenced line equal to `heading`, or -1. */
2211
+ function unfencedHeadingIndex(lines, heading) {
2212
+ const fenced = fencedLineMap(lines);
2213
+ return lines.findIndex((l, i) => !fenced[i] && l.trim() === heading);
2214
+ }
2215
+ /**
2216
+ * Write the three computed lines into an artifact — the ONLY writer of them.
2217
+ *
2218
+ * Every unfenced rate- or counts-shaped line inside the verdicts section, and
2219
+ * every unfenced coverage-shaped line anywhere, is removed; then the rate and
2220
+ * counts lines the validator computes from the section's own rows are written
2221
+ * directly under the section heading, and the payload's coverage line (when it
2222
+ * carries one) at the end of `## Range` — or above the verdicts section when
2223
+ * there is no `## Range`. A narrated figure therefore cannot survive, and the
2224
+ * two count populations (the payload's derived rows and the register's rows)
2225
+ * cannot be confused by anyone: the register's lines are always the register's.
2226
+ *
2227
+ * Fenced client prose is never touched. Without the verdicts heading nothing is
2228
+ * stamped and the validator reports the absent section.
2229
+ */
2230
+ function stampReviewLines(text, opts = {}) {
2231
+ if (typeof text !== 'string')
2232
+ return { text: '', stamped: false };
2233
+ const src = text.split(/\r?\n/);
2234
+ const section = locateReviewSection(src);
2235
+ if (!section.present)
2236
+ return { text, stamped: false };
2237
+ const fenced = fencedLineMap(src);
2238
+ const kept = [];
2239
+ for (let i = 0; i < src.length; i += 1) {
2240
+ const t = src[i].trim();
2241
+ if (!fenced[i]) {
2242
+ if (COVERAGE_LINE_SHAPE.test(t))
2243
+ continue;
2244
+ if (i >= section.start && i < section.end && (RATE_LINE_SHAPE.test(t) || COUNTS_LINE_SHAPE.test(t)))
2245
+ continue;
2246
+ }
2247
+ kept.push(src[i]);
2248
+ }
2249
+ const probe = validateReviewArtifact(kept.join('\n'));
2250
+ const head = unfencedHeadingIndex(kept, REVIEW_SECTION_HEADING);
2251
+ let after = head + 1;
2252
+ while (after < kept.length && kept[after].trim() === '')
2253
+ after += 1;
2254
+ kept.splice(head + 1, after - (head + 1), '', probe.expectedRateLine, probe.expectedCountsLine, '');
2255
+ const coverage = typeof opts.coverageLine === 'string' ? opts.coverageLine.trim() : '';
2256
+ if (coverage !== '' && COVERAGE_LINE_SHAPE.test(coverage)) {
2257
+ const fencedNow = fencedLineMap(kept);
2258
+ const range = unfencedHeadingIndex(kept, RANGE_SECTION_HEADING);
2259
+ let at = unfencedHeadingIndex(kept, REVIEW_SECTION_HEADING);
2260
+ if (range !== -1) {
2261
+ at = kept.length;
2262
+ for (let i = range + 1; i < kept.length; i += 1) {
2263
+ if (!fencedNow[i] && (kept[i].startsWith('## ') || kept[i].trim() === '---')) {
2264
+ at = i;
2265
+ break;
2266
+ }
2267
+ }
2268
+ while (at > range + 1 && kept[at - 1].trim() === '')
2269
+ at -= 1;
2270
+ kept.splice(at, 0, '', coverage);
2271
+ }
2272
+ else {
2273
+ kept.splice(at, 0, coverage, '');
2274
+ }
2275
+ }
2276
+ // Removing a line leaves its blank neighbours behind; collapse every unfenced
2277
+ // run of blank lines to one, so stamping is idempotent.
2278
+ const fencedOut = fencedLineMap(kept);
2279
+ const out = kept.filter((l, i) => fencedOut[i] || l.trim() !== '' || i === 0 || kept[i - 1].trim() !== '');
2280
+ return { text: out.join('\n'), stamped: true };
2281
+ }
2282
+ /**
2283
+ * The register's rows against the population the payload derived: every
2284
+ * practice of `partition.population` (remainder plus both derived arms) has
2285
+ * exactly one row, and no row names anything else. Deleting a row is how a
2286
+ * hard practice disappears from a review; this makes that a reported problem.
2287
+ */
2288
+ function compareReviewPopulation(rows, partition) {
2289
+ const expected = uniqueInOrder([
2290
+ ...partition.remainder,
2291
+ ...partition.naUniverse.map((r) => r.id),
2292
+ ...partition.naNoFile.map((r) => r.id),
2293
+ ]);
2294
+ const want = new Set(expected.map((id) => quotableId(id)));
2295
+ const seen = new Map();
2296
+ for (const r of rows) {
2297
+ if (typeof r?.id !== 'string' || r.id === '')
2298
+ continue;
2299
+ seen.set(r.id, (seen.get(r.id) ?? 0) + 1);
2300
+ }
2301
+ const problems = [];
2302
+ for (const id of want) {
2303
+ if (!seen.has(id)) {
2304
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_POPULATION_MISSING, id, null, `"${id}" is in the population this review derived and has no row: a practice handed to the review ` +
2305
+ 'is judged or reproduced, never dropped.'));
2306
+ }
2307
+ }
2308
+ for (const [id, n] of seen) {
2309
+ if (!want.has(id)) {
2310
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_POPULATION_UNEXPECTED, id, null, `"${safe(id)}" has a row but is not in the population this review derived, so its verdict judges ` +
2311
+ 'something the review was not handed.'));
2312
+ }
2313
+ if (n > 1) {
2314
+ problems.push(reviewProblem(REVIEW_PROBLEM_KIND.ROW_DUPLICATE, id, null, `"${safe(id)}" carries ${n} rows; each practice carries exactly one verdict.`));
2315
+ }
2316
+ }
2317
+ return problems;
2318
+ }
2319
+ module.exports = {
2320
+ // The three frozen vocabularies. These ARE the contract 06-04/06-06/06-08 and
2321
+ // 06-10 serialize; nothing downstream re-derives a token.
2322
+ REVIEW_VERDICT,
2323
+ NA_DERIVED_REASON,
2324
+ REVIEW_RANGE_SOURCE,
2325
+ // The input set, harvested from the shipped mechanical tier.
2326
+ deriveCandidateSet,
2327
+ // The two derived-N-A arms, deliberately two functions and two labels.
2328
+ deriveNaAUniverse,
2329
+ deriveNaANoFile,
2330
+ // The split that decides what may reach the agent at all.
2331
+ partitionForJudgement,
2332
+ // The arithmetic. Computed, never narrated.
2333
+ countReviewVerdicts,
2334
+ renderRateLine,
2335
+ // The range, and the single refusal predicate that guards it.
2336
+ resolveReviewRange,
2337
+ reviewRefusesCleanRender,
2338
+ // 08-05 (D-08-17): the two remedies for a range nobody could resolve, as ONE
2339
+ // sentence. Exported on the same harvest rule the audit's repair sentences
2340
+ // use — a test and an artifact quote this, never a copy of it.
2341
+ REVIEW_RANGE_UNRESOLVABLE_REMEDY,
2342
+ // 06-04 — the deterministic half of what the AGENT writes back. The problem
2343
+ // vocabulary joins the three above for the same stated reason: a consumer
2344
+ // branches on a value, and a test harvests the token it asserts on.
2345
+ REVIEW_PROBLEM_KIND,
2346
+ judgeReviewRow,
2347
+ validateReviewArtifact,
2348
+ scanForbiddenScoreTokens,
2349
+ // 06-04 — the ONE composition of every derivation above into the envelope the
2350
+ // CLI arm emits. Composed here rather than in the handler for the reason
2351
+ // `cmdVaultAudit` states about its own leaf: a second composition is a second
2352
+ // place the report can disagree with the derivation it claims to report.
2353
+ buildReviewPayload,
2354
+ // The artifact the agent fills in, and the three lines only code writes.
2355
+ REVIEW_UNJUDGED_VERDICT,
2356
+ renderReviewScaffold,
2357
+ stampReviewLines,
2358
+ compareReviewPopulation,
2359
+ };