@pareto4/pp-core 1.12.1 → 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 (74) 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-registry.cjs +200 -10
  11. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  12. package/pp-core/bin/lib/clusters.cjs +1 -0
  13. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  14. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  15. package/pp-core/bin/lib/decisions.cjs +104 -27
  16. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  17. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  18. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  19. package/pp-core/bin/lib/init-contract.cjs +180 -66
  20. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  21. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  22. package/pp-core/bin/lib/loop-resolver.cjs +107 -45
  23. package/pp-core/bin/lib/org-profile.cjs +218 -44
  24. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  25. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  26. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  27. package/pp-core/bin/lib/phase.cjs +51 -1
  28. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  29. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  30. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  31. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  32. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  33. package/pp-core/bin/lib/vault-update.cjs +266 -0
  34. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  35. package/pp-core/bin/lib/vault.cjs +277 -12
  36. package/pp-core/bin/pp-tools.cjs +30 -13
  37. package/pp-core/bin/shared/model-catalog.json +2 -1
  38. package/pp-core/references/executor-vault-trace.md +47 -0
  39. package/pp-core/references/loop-hook-dispatch.md +13 -2
  40. package/pp-core/references/planner-vault-practices.md +46 -1
  41. package/pp-core/references/planning-config.md +1 -1
  42. package/pp-core/templates/practices/README.md +140 -11
  43. package/pp-core/templates/project-charter.md +226 -0
  44. package/pp-core/workflows/add-tests.md +4 -0
  45. package/pp-core/workflows/audit-fix.md +2 -0
  46. package/pp-core/workflows/check-todos.md +1 -1
  47. package/pp-core/workflows/code-review-fix.md +2 -0
  48. package/pp-core/workflows/debug.md +4 -0
  49. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  50. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  51. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  52. package/pp-core/workflows/discovery-sprint.md +1 -1
  53. package/pp-core/workflows/discovery-to-build.md +1 -1
  54. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  55. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  56. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  57. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  58. package/pp-core/workflows/execute-phase.md +22 -5
  59. package/pp-core/workflows/execute-plan.md +4 -0
  60. package/pp-core/workflows/fast.md +4 -0
  61. package/pp-core/workflows/help/modes/full.md +3 -2
  62. package/pp-core/workflows/init.md +2 -2
  63. package/pp-core/workflows/new-project.md +1 -1
  64. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  65. package/pp-core/workflows/plan-phase.md +7 -4
  66. package/pp-core/workflows/quick.md +4 -0
  67. package/pp-core/workflows/ship.md +5 -1
  68. package/pp-core/workflows/undo.md +1 -1
  69. package/pp-core/workflows/validate-phase.md +4 -0
  70. package/pp-core/workflows/vault-review.md +65 -0
  71. package/pp-core/workflows/verify-work.md +3 -0
  72. package/scripts/gen-capability-registry.cjs +11 -0
  73. package/scripts/lint-test-file-count.allowlist.json +10 -1
  74. package/scripts/prompt-injection-scan.sh +2 -0
@@ -0,0 +1,2831 @@
1
+ 'use strict';
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /**
6
+ * src/vault-trace.cts — the vault practice-trace envelope: the frozen
7
+ * contract's constants, the statement builder, the frontmatter serializer,
8
+ * the `## Practices Applied` renderer, the bespoke block parser, and the
9
+ * pure validators a trace gate composes.
10
+ *
11
+ * Responsibility (Phase 03-01 — task 1, the frozen half):
12
+ * The five constants below ARE the contract. `docs/vault-trace-format.md`
13
+ * states the same values in prose, and `tests/vault-trace.test.cjs` asserts
14
+ * the document and this module agree from both ends, so the two cannot
15
+ * drift. They are module constants and are NEVER re-derived at runtime from
16
+ * configuration or from the vault: a version marker a client repository
17
+ * could move is not a version marker.
18
+ *
19
+ * Reuses (no new runtime dependency):
20
+ * - `./vault.cjs` (`computeVaultVerdict`, `readManifest`) — the pin's state
21
+ * is the vault verdict's, and its values are READ through the existing
22
+ * manifest reader, never re-parsed here. A second reader would let the
23
+ * trace disagree with the load gate about one cache.
24
+ * - `./vault-practices.cjs` (`matchPractices`, `boundedToken`) — the practice
25
+ * denominator is the matcher's return set, and the id bounding is the
26
+ * shared helper.
27
+ *
28
+ * DELIBERATELY NOT REUSED — and this omission is load-bearing, not an oversight:
29
+ * This module does NOT use the shared frontmatter extractor/serializer pair
30
+ * in `./frontmatter.cjs`, even though that is the only sanctioned
31
+ * frontmatter parser in this repo and `src/vault-practices.cts` reuses it by
32
+ * name. (The mechanical proof is in `tests/vault-trace.test.cjs`: this module
33
+ * must not IMPORT `./frontmatter.cjs`, and must not CALL any of its four
34
+ * entry points — `extractFrontmatter`, its `parseFrontmatter` alias,
35
+ * `reconstructFrontmatter`, or `spliceFrontmatter`. Naming them here is
36
+ * deliberate and safe: the guard is keyed on import and call shapes, not on
37
+ * the bare strings. It used to be keyed on the strings, which forbade the
38
+ * very citation the explanation needs — so this paragraph claimed the names
39
+ * appeared nowhere in the file while `spliceFrontmatter` was quoted twenty
40
+ * lines below, and the guard covered only two of the four names. 03-SECURITY
41
+ * OF-1 / T-03-07.)
42
+ *
43
+ * The reason for the omission: the trace's practice list is a LIST OF MAPS,
44
+ * and neither half of that shared pair can represent that shape.
45
+ *
46
+ * - The shared EXTRACTOR's key regex (`^(\s*)([a-zA-Z0-9_-]+):\s*(.*)`)
47
+ * cannot match a line beginning with `- ` — the space after the dash
48
+ * fails the character class — so its list branch always wins and pushes
49
+ * `line.trim().slice(2)` as a RAW STRING. A practices list reads back as
50
+ * the string array `["id: x", "status: applied"]`: the structure is
51
+ * gone, silently.
52
+ * - The shared SERIALIZER is worse, and says so itself. Quoting the
53
+ * comment inside `spliceFrontmatter` verbatim: it is "a deliberately
54
+ * lossy serializer — it cannot faithfully re-emit nested object-list
55
+ * items (e.g. must_haves.artifacts / must_haves.prohibitions, whose
56
+ * items are `{ path, provides }` / `{ statement, status, … }` maps)".
57
+ *
58
+ * So the trace gets its own emitter (`serializeTraceBlock`) and its own
59
+ * parser (`parseVaultTraceBlock`), the latter modelled on the in-repo
60
+ * precedent `parseMustHavesBlock` (`src/frontmatter.cts`) — the technique is
61
+ * copied, not the function, because that function is anchored on its own
62
+ * literal `must_haves:` key and widening it would dissolve its whole shape.
63
+ *
64
+ * If you are here to "fix" this module by wiring it to the shared parser:
65
+ * don't. It will flatten every trace it touches, and the flattening is
66
+ * silent — an emptied trace and a genuine coverage failure produce the same
67
+ * verdict from different causes.
68
+ */
69
+ const node_path_1 = __importDefault(require("node:path"));
70
+ const node_fs_1 = __importDefault(require("node:fs"));
71
+ const node_crypto_1 = __importDefault(require("node:crypto"));
72
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
73
+ const vaultMod = require("./vault.cjs");
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const practicesMod = require("./vault-practices.cjs");
76
+ // The count cap is IMPORTED, never re-declared. `derivePracticeUniverse` below
77
+ // must bound the recorded universe by the same number `discoverPracticeFiles`
78
+ // bounds its return set by; a second literal here would be a second cap that
79
+ // drifts, which is the shape of the defect this whole file's universe
80
+ // derivation was found to carry (04-REVIEW WR-05). This widens an EXISTING
81
+ // destructure over an EXISTING import edge — no new edge is created, and the
82
+ // direction (trace leaf -> practices leaf) is the one D-12 settled on.
83
+ // `defangFenceMarkers` joins the destructure in 10-08 on the reason its own
84
+ // export comment gives: "Authoring a second fence or preamble in the audit
85
+ // module is how CR-02's class returns." The same holds here — the analyzer
86
+ // provenance clause is a client-authored byte written into a committed record
87
+ // an agent reads, and it is defanged with the SHIPPED marker pair, never a
88
+ // second one spelled locally.
89
+ const { boundedToken, PRACTICE_COUNT_CAP, defangFenceMarkers } = practicesMod;
90
+ /**
91
+ * Collapse every run of C0 control characters and DEL to a single space.
92
+ *
93
+ * `boundedToken` collapses CR/LF only — it is the LENGTH bound plus a
94
+ * line-forging guard — so a TAB, a NUL or a DEL in a client-authored value
95
+ * still reaches a message an enforcer prints verbatim and a reviewer agent
96
+ * loads unfenced (03-REVIEW CR-03, T-03-31/T-03-36). This is the character
97
+ * half, applied wherever an untrusted value crosses into generated output.
98
+ */
99
+ function collapseControlChars(value) {
100
+ return value.replace(/[\u0000-\u001F\u007F]+/g, ' ');
101
+ }
102
+ // ─── The frozen contract (Phase 03-01, TRACE-05) ──────────────────────────────
103
+ // Changing any value below is a breaking change to a schema that other phases
104
+ // develop against in parallel. See docs/vault-trace-format.md § Versioning
105
+ // discipline: the URI major and TRACE_SCHEMA_VERSION move together, or not at
106
+ // all.
107
+ /**
108
+ * The in-toto Statement type. The envelope is in-toto SHAPED — it is never fed
109
+ * to an in-toto verifier and carries no library dependency.
110
+ */
111
+ const TRACE_STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
112
+ /**
113
+ * The custom predicate type, and the MAJOR version carrier. pp-core's own
114
+ * namespace: every trace this engine WRITES carries exactly this URI. The URI
115
+ * is an identifier, not an endpoint — nothing fetches it.
116
+ *
117
+ * READERS accept this URI plus any URI the project's org profile lists under
118
+ * `vault.trace.accepted_predicates` (`acceptedPredicateTypes`), so a project
119
+ * that switches engine mid-engagement keeps reading the traces its earlier
120
+ * engine wrote. An accepted URI must carry the same major as this one
121
+ * (`/v<TRACE_SCHEMA_VERSION>`): the URI major and the schema version move
122
+ * together, or not at all.
123
+ */
124
+ const TRACE_PREDICATE_TYPE = 'https://pareto.ooo/attestation/vault-practice-trace/v1';
125
+ /**
126
+ * The schema version, mirroring the `PRACTICE_SCHEMA_VERSION` discipline in
127
+ * `src/vault-practices.cts`. An integer, not a string. A consumer reading a
128
+ * value above the highest it knows warns loudly and does not silently pass.
129
+ */
130
+ const TRACE_SCHEMA_VERSION = 1;
131
+ /**
132
+ * The three-state disposition vocabulary. These exact strings ALREADY ship in
133
+ * `agents/pp-executor.md` §<summary_creation> and are read by the code
134
+ * reviewer's cross-plan reconciliation contract — they are copied here, never
135
+ * re-coined. Frozen so a caller cannot mutate the shared array in place.
136
+ */
137
+ const TRACE_STATUS_VALUES = Object.freeze(['applied', 'not-applicable', 'deviated']);
138
+ /**
139
+ * The permitted charset for a practice `id` — the ONLY client-authored token
140
+ * that reaches the ledger. An alphanumeric first character, then letters,
141
+ * digits, dots, underscores and hyphens, to a maximum of 64 characters.
142
+ *
143
+ * A practice id derives from a client-authored FILENAME, and POSIX permits
144
+ * nearly anything in a filename including an embedded newline (a fact
145
+ * `boundedToken`'s own docstring cites). `boundedToken` bounds length and
146
+ * collapses newlines; it does NOT restrict the charset. This pattern is the
147
+ * charset half, and it exists so `docs/vault-trace-format.md` can state
148
+ * "the only client-authored token is bounded, quoted and charset-validated"
149
+ * as a property rather than a hope.
150
+ *
151
+ * No `g` flag: a global RegExp carries `lastIndex` state across `.test()`
152
+ * calls and would return alternating results for the same input.
153
+ */
154
+ const TRACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
155
+ const NULL_PIN = Object.freeze({
156
+ ref: null,
157
+ resolvedSha: null,
158
+ tier: null,
159
+ source: null,
160
+ repo: null,
161
+ });
162
+ /**
163
+ * The degraded reason sentences, one per cause. Contract surface: the format
164
+ * page lists them, and a reader six months later needs to know WHICH failure
165
+ * a degraded trace records. Every interpolated value is bounded.
166
+ */
167
+ const PIN_REASON = Object.freeze({
168
+ noCache: 'no vault cache present at .planning/vault',
169
+ manifestMissing: 'vault manifest missing at .planning/vault/.manifest.json',
170
+ manifestMalformed: 'vault manifest unreadable or malformed at .planning/vault/.manifest.json',
171
+ miss: 'vault manifest records a miss; no vault content was reached',
172
+ empty: 'vault cache holds no files; no vault content was reached',
173
+ noBindings: 'no client vault is bound (.platform-bindings.json needs client.vaultRepo and client.vaultRef); '
174
+ + 'only a client-tier load at the configured ref records loaded',
175
+ configUnreadable: 'the project config cannot be read, so the vault state is unknown',
176
+ disabled: 'vault.enabled is not true',
177
+ });
178
+ /**
179
+ * Read the pin. The STATE is the vault verdict's — `computeVaultVerdict` in
180
+ * src/vault.cts is the one owner of "is the client vault loaded" (pack 6), so
181
+ * the trace and the load gate cannot disagree about one cache: `loaded`
182
+ * exactly when the verdict is `loaded` (the client tier, at the configured
183
+ * repo and ref, fresh). The pin VALUES (ref, sha, tier, source, repo) are
184
+ * read from the same cache's manifest through `readManifest`, which already
185
+ * returns the display-safe repo; this module parses `.manifest.json` nowhere.
186
+ *
187
+ * A degraded run is a FIRST-CLASS path, not an error branch — the caller
188
+ * always gets a pin object, and a statement is always produced. A stale,
189
+ * fallback-tier or drifted cache keeps its pin: the cached copy really did
190
+ * come from that SHA, and only the reason says why it does not prove the
191
+ * configured standards were applied.
192
+ *
193
+ * WORKTREES. `vaultDirPath` is the directory `buildTraceStatement` resolved
194
+ * once through `vaultMod.resolveVaultCacheDir`: the local `.planning/vault`,
195
+ * or, for the root of a linked git worktree with no local manifest, the
196
+ * owning main checkout's cache. The verdict is judged against that same
197
+ * directory.
198
+ */
199
+ function resolvePin(cwd, vaultDirPath = node_path_1.default.join(cwd, '.planning', 'vault')) {
200
+ const v = vaultMod.computeVaultVerdict(cwd, { vaultDir: vaultDirPath });
201
+ const manifest = vaultMod.readManifest(vaultDirPath);
202
+ const str = (x) => (typeof x === 'string' && x !== '' ? x : null);
203
+ const pin = manifest
204
+ ? {
205
+ ref: str(manifest.ref),
206
+ resolvedSha: str(manifest.resolvedSha),
207
+ tier: str(manifest.tier),
208
+ source: str(manifest.source),
209
+ repo: vaultMod.redactCloneTarget(str(manifest.repo)),
210
+ }
211
+ : { ...NULL_PIN };
212
+ if (v.verdict === 'loaded') {
213
+ return {
214
+ state: 'loaded',
215
+ reason: null,
216
+ ...pin,
217
+ manifestFiles: manifest && Array.isArray(manifest.files)
218
+ ? manifest.files.filter((f) => typeof f === 'string')
219
+ : [],
220
+ };
221
+ }
222
+ let reason;
223
+ switch (v.reason) {
224
+ case 'never_fetched':
225
+ if (!node_fs_1.default.existsSync(vaultDirPath))
226
+ reason = PIN_REASON.noCache;
227
+ else if (!node_fs_1.default.existsSync(node_path_1.default.join(vaultDirPath, '.manifest.json')))
228
+ reason = PIN_REASON.manifestMissing;
229
+ else
230
+ reason = PIN_REASON.manifestMalformed;
231
+ break;
232
+ case 'fetch_miss':
233
+ reason = vaultMod.manifestRecordsMiss(manifest) ? PIN_REASON.miss : PIN_REASON.empty;
234
+ break;
235
+ case 'fallback_tier':
236
+ reason =
237
+ `vault loaded on the ${boundedToken(pin.tier ?? 'unknown')} tier `
238
+ + `(${boundedToken(pin.repo ?? 'no repo recorded')}); no client standards were applied `
239
+ + '— only a client-tier load records loaded';
240
+ break;
241
+ case 'ref_drift':
242
+ // Both refs come FIRST so they survive an 80-character head, and both go
243
+ // through `boundedToken`: a ref is project-authored text reaching an
244
+ // agent's context unfenced.
245
+ reason = pin.ref !== v.configuredRef
246
+ ? `vault cache was fetched from ref ${boundedToken(pin.ref ?? 'unknown')} `
247
+ + `but client.vaultRef is now ${boundedToken(v.configuredRef ?? 'unknown')}; `
248
+ + 'the cached standards are not the configured ones — only a client-tier load '
249
+ + 'at the configured ref records loaded'
250
+ : `vault cache was fetched from ${boundedToken(pin.repo ?? 'unknown')} `
251
+ + `but client.vaultRepo is now ${boundedToken(v.configuredRepo ?? 'unknown')}; `
252
+ + 'the cached standards are not the configured ones — only a client-tier load '
253
+ + 'at the configured repo records loaded';
254
+ break;
255
+ case 'stale_cache':
256
+ reason =
257
+ `vault cache is stale (fetched ${boundedToken(str(manifest?.fetchedAt) ?? 'unknown')}); `
258
+ + 'it exceeds vault.cache_ttl_hours';
259
+ break;
260
+ case 'no_bindings':
261
+ reason = PIN_REASON.noBindings;
262
+ break;
263
+ case 'config_unreadable':
264
+ reason = PIN_REASON.configUnreadable;
265
+ break;
266
+ default:
267
+ reason = PIN_REASON.disabled;
268
+ }
269
+ return { state: 'degraded', reason, ...pin, manifestFiles: null };
270
+ }
271
+ // ─── The universe ──────────────────────────────────────────────────────────────
272
+ /** The manifest-relative directory prefix under which practice files live. */
273
+ const PRACTICE_PATH_PREFIX = 'practices/';
274
+ /** The extension a practice file carries; stripped to yield the id. */
275
+ const PRACTICE_FILE_EXT = '.md';
276
+ /**
277
+ * Derive the practice-ID universe from the manifest file list the pin already
278
+ * carries.
279
+ *
280
+ * THE PROPERTY THIS FUNCTION EXISTS TO HOLD: every identifier the practice
281
+ * MATCHER can put into `predicate.practices` must be present here. The gate
282
+ * resolves the ledger against this list, so any file the matcher accepts and
283
+ * this derivation rejects becomes an `practice_id_unresolved` block whose only
284
+ * remedy — regenerate the trace — reproduces itself, and whose real fix lives
285
+ * in the client's own vault repository (04-REVIEW CR-02). The two sides are
286
+ * therefore filtered by the SAME rules, in the SAME order, and a test drives
287
+ * both over one shared file list so the pair cannot drift again.
288
+ *
289
+ * The three filters, mirroring `discoverPracticeFiles` in
290
+ * `src/vault-practices.cts` exactly:
291
+ *
292
+ * 1. the `practices/` prefix — a vault holds more than its practices
293
+ * directory, and a README must never become a practice identifier;
294
+ * 2. the markdown extension tested CASE-INSENSITIVELY — the matcher
295
+ * lowercases before testing, so `practices/beta.MD` is a practice file to
296
+ * it. Testing case-sensitively here was the CR-02 divergence;
297
+ * 3. the secret-file exclusion — `vaultMod.isSecretFile` is a pure function
298
+ * of the relative path (it opens nothing, so FENCE 1's read discipline is
299
+ * untouched) and the matcher applies it BEFORE any read because such
300
+ * names must never be surfaced into agent or model context (VSEC-03).
301
+ * The universe is written into a durable artifact that agents read, so
302
+ * omitting it here re-opened the exact disclosure the matcher closes.
303
+ *
304
+ * ORDER IS LOAD-BEARING and is the matcher's order: filter, sort the PATHS,
305
+ * then cap.
306
+ *
307
+ * - The exclusions run BEFORE the cap so an excluded entry can never displace
308
+ * an included one out of the bound.
309
+ * - The sort is over the PATHS, not over the derived ids, because that is the
310
+ * order the matcher caps in. Sorting ids instead would select a different
311
+ * hundred at the boundary (`practices/a-b.md` sorts before
312
+ * `practices/a.md`, while the id `a` sorts before `a-b`), and the two sides
313
+ * would disagree about exactly the entries nobody looks at.
314
+ * - `PRACTICE_COUNT_CAP` is the matcher's own exported constant, not a copy.
315
+ * The bound exists so client-vault data cannot dictate artifact size: an
316
+ * unbounded universe writes one line per vault file into every PLAN.md and
317
+ * SUMMARY.md, and past the gate's artifact byte cap the client's file count
318
+ * decides whether the project can ship (04-REVIEW WR-05).
319
+ * - An omission is recorded as a COUNT and never as a name. The matcher emits
320
+ * counts for precisely this reason, and the universe must not be the place
321
+ * an excluded filename leaks back out.
322
+ *
323
+ * The id is derived with `path.basename(rel, PRACTICE_FILE_EXT)` — the SAME
324
+ * call `parsePracticeFile` uses to decide whether a practice's declared id
325
+ * matches its filename (D-03). That is deliberate and it is not the same thing
326
+ * as stripping the last three characters: Node strips the suffix only on an
327
+ * exact match, so `practices/beta.MD` yields the id `beta.MD` on BOTH sides.
328
+ * A length-based strip would yield `beta` here and `beta.MD` there, which fixes
329
+ * the case test and relocates CR-02 rather than closing it. Lowercasing the
330
+ * basename would break it a third way. The derivation must mirror the matcher's
331
+ * operation, not approximate it.
332
+ *
333
+ * No practice file is opened: the basename is the whole input, and
334
+ * `src/vault-practices.cts` enforces (D-03) that a practice's frontmatter
335
+ * identifier equals it, SKIPPING any file that disagrees. A file the matcher
336
+ * skips for that reason leaves the universe a strict superset, which only ever
337
+ * makes MORE identifiers resolve — never fewer.
338
+ *
339
+ * Every derived id is re-validated against `TRACE_ID_PATTERN` here at build
340
+ * time, copying the defence-in-depth shape the practices list uses: a failing
341
+ * id is DROPPED and a note names the rejected value. A vault-authored filename
342
+ * reaches an agent's context through this list; it must not be the first
343
+ * unvalidated string in the statement.
344
+ *
345
+ * Null in, null out: a degraded pin carries no file list and records no
346
+ * universe (D-04).
347
+ */
348
+ function derivePracticeUniverse(manifestFiles, notes) {
349
+ if (manifestFiles === null)
350
+ return null;
351
+ const kept = [];
352
+ for (const rel of manifestFiles) {
353
+ if (!rel.startsWith(PRACTICE_PATH_PREFIX))
354
+ continue;
355
+ if (!rel.toLowerCase().endsWith(PRACTICE_FILE_EXT))
356
+ continue;
357
+ if (vaultMod.isSecretFile(rel))
358
+ continue;
359
+ kept.push(rel);
360
+ }
361
+ kept.sort();
362
+ const shown = kept.slice(0, PRACTICE_COUNT_CAP);
363
+ const omitted = kept.length - shown.length;
364
+ if (omitted > 0) {
365
+ notes.push(`${omitted} practice universe entries omitted (cap: ${PRACTICE_COUNT_CAP})`);
366
+ }
367
+ const ids = new Set();
368
+ for (const rel of shown) {
369
+ const id = node_path_1.default.basename(rel, PRACTICE_FILE_EXT);
370
+ if (!TRACE_ID_PATTERN.test(id)) {
371
+ notes.push(`practice universe id skipped: "${boundedToken(id)}" does not match the permitted id charset`);
372
+ continue;
373
+ }
374
+ ids.add(id);
375
+ }
376
+ return [...ids].sort();
377
+ }
378
+ /**
379
+ * The lead of the analyzer-provenance clause, and the ONLY wording this module
380
+ * puts in front of a rule name (MECH-03, D-10-05).
381
+ *
382
+ * "also enforced by", never "verified by". pp-core runs no analyzer and
383
+ * confirms none; the clause is a claim about what the PRACTICE SAYS,
384
+ * attributable to the vault.
385
+ *
386
+ * ONE SENTENCE, TWO MODULES, AND WHY THAT IS NOT A DRIFT WAITING TO HAPPEN.
387
+ * `src/vault-audit.cts` is the OWNER — 10-06 shipped this wording there as
388
+ * `AUDIT_ENFORCED_BY_LEAD`, and harvesting rather than restating is this
389
+ * project's standing rule. It is not harvested HERE because it cannot be:
390
+ * `src/vault-audit.cts:70-72` requires this module's compiled form and
391
+ * DESTRUCTURES it at top level, and the comment at `:66-68` records the
392
+ * invariant that keeps that safe — "neither imports this module, so this adds
393
+ * no cycle". An edge from here back to the audit leaf, eager or lazy, makes
394
+ * that sentence false and the destructure load-order-dependent. So the copy
395
+ * stays and is PINNED against its owner by a test that fails when the two
396
+ * spellings disagree, which is the remedy this project prescribes for a copy
397
+ * that has to stay.
398
+ */
399
+ const TRACE_ENFORCED_BY_LEAD = 'also enforced by';
400
+ /**
401
+ * Compose one practice's provenance clause, or `''` when it names nothing.
402
+ *
403
+ * Every rule name is a CLIENT-AUTHORED BYTE on its way into a committed record
404
+ * an agent reads, so each is bounded and defanged here even though
405
+ * `parseEnforcedBy` already bounded it at parse time: this is the boundary that
406
+ * matters, and a statement can be hand-built or mutated between build and
407
+ * serialize.
408
+ */
409
+ function composeEnforcedByClause(rules) {
410
+ if (!Array.isArray(rules))
411
+ return '';
412
+ const named = [];
413
+ for (const rule of rules) {
414
+ if (typeof rule !== 'string')
415
+ continue;
416
+ const safe = defangFenceMarkers(collapseControlChars(boundedToken(rule))).trim();
417
+ if (safe !== '')
418
+ named.push(safe);
419
+ }
420
+ if (named.length === 0)
421
+ return '';
422
+ return `${TRACE_ENFORCED_BY_LEAD} ${named.join(', ')}`;
423
+ }
424
+ const NO_PINNED_FACTS = Object.freeze({
425
+ checkBearingIds: new Set(),
426
+ clauseById: new Map(),
427
+ read: false,
428
+ });
429
+ /**
430
+ * Read the pinned practice cache ONCE and derive both facts 10-08 adds
431
+ * (MECH-02, MECH-03).
432
+ *
433
+ * Reads through `discoverPracticeFiles` — the ONE practice reader — against the
434
+ * SAME resolved cache directory the pin used (the comment above `resolvePin`'s
435
+ * call site states why one cache directory serves the pin and the matcher; the
436
+ * census must not reach for a second). A second frontmatter scan here would be
437
+ * a second answer to one question, and the two would disagree the day either is
438
+ * edited. This is the widened read profile stated on
439
+ * `ResolvedPin.manifestFiles`, named rather than left implicit.
440
+ *
441
+ * ONE call for both facts, not two: the census and the provenance map are two
442
+ * projections of the same read, and taking the cache twice would let them
443
+ * describe two different moments.
444
+ *
445
+ * A read that FAILS records `read: false` and says so in `notes`. The census
446
+ * then lands on `null`, never `0`: zero is a claim about a vault that WAS read,
447
+ * and collapsing the two is how "we checked nothing" comes to read as "nothing
448
+ * needed checking".
449
+ */
450
+ function readPinnedPractices(cwd, vaultDir, notes) {
451
+ try {
452
+ const discovery = practicesMod.discoverPracticeFiles(cwd, vaultDir);
453
+ const checkBearingIds = new Set();
454
+ const clauseById = new Map();
455
+ for (const practice of discovery.practices ?? []) {
456
+ if (typeof practice.id !== 'string')
457
+ continue;
458
+ // `check` is present ONLY when a `check:` key was authored AND every rule
459
+ // in `parseCheckBlock` passed. A practice carrying `checkInvalid` instead
460
+ // is deliberately NOT counted: that defect is a fail-closed FAIL at the
461
+ // audit, and counting it as adoption would overstate the vault's
462
+ // mechanical coverage — the over-claim this milestone exists to prevent
463
+ // (T-10-33).
464
+ if (practice.check !== undefined && practice.check !== null) {
465
+ checkBearingIds.add(practice.id);
466
+ }
467
+ const clause = composeEnforcedByClause(practice.enforcedBy);
468
+ if (clause !== '')
469
+ clauseById.set(practice.id, clause);
470
+ }
471
+ return { checkBearingIds, clauseById, read: true };
472
+ }
473
+ catch (e) {
474
+ notes.push(`the pinned practice cache could not be read for the check-bearing census (${boundedToken(e?.message ?? e)})`);
475
+ return NO_PINNED_FACTS;
476
+ }
477
+ }
478
+ /**
479
+ * Take the check-block CENSUS of the recorded universe (MECH-02, D-10-13).
480
+ *
481
+ * `universe === null` in, `null` out: a degraded run has no universe to take a
482
+ * census of (D-04), and the census carries the rule of the sibling it sits
483
+ * beside rather than of the per-practice fields further down the statement.
484
+ *
485
+ * Counting is confined to ids the universe records, which is what makes
486
+ * "N of `universe.length`" exactly true of one statement read on its own: a
487
+ * practice file present on disk but absent from the manifest at the pinned SHA
488
+ * is not at that ref.
489
+ */
490
+ function deriveCheckBearingCensus(universe, facts) {
491
+ if (universe === null || !facts.read)
492
+ return null;
493
+ let checkBearing = 0;
494
+ for (const id of universe) {
495
+ if (facts.checkBearingIds.has(id))
496
+ checkBearing += 1;
497
+ }
498
+ return checkBearing;
499
+ }
500
+ /** At most this many PLAN/SUMMARY artifacts are read for the census. */
501
+ const CENSUS_ARTIFACT_CAP = 200;
502
+ /** Each artifact is read whole or not at all, up to this many bytes. */
503
+ const CENSUS_ARTIFACT_BYTE_CAP = 1024 * 1024;
504
+ /**
505
+ * Read the `check:` census this phase's ledgers RECORDED — never re-derive it.
506
+ *
507
+ * The census is taken once, by `buildTraceStatement`, against the pinned vault
508
+ * the plan or summary was written under. A consumer (the mechanical audit)
509
+ * reads that record back here instead of counting the vault again: a second
510
+ * count could describe a different vault than the ledger does.
511
+ *
512
+ * Execute-stage ledgers (SUMMARY.md) are preferred over plan-stage ones, and
513
+ * within a stage the last artifact in name order — the latest plan — is the
514
+ * one reported. `distinct` counts how many different censuses the phase's
515
+ * ledgers record, so a vault that moved during the phase is visible. A ledger
516
+ * with no census (a degraded run) is skipped. `null` when no ledger records
517
+ * one. Pure read; never throws.
518
+ */
519
+ function readRecordedCensus(phaseDir) {
520
+ try {
521
+ const names = node_fs_1.default
522
+ .readdirSync(phaseDir)
523
+ .filter((name) => /(^|-)(PLAN|SUMMARY)\.md$/.test(name))
524
+ .sort()
525
+ .slice(0, CENSUS_ARTIFACT_CAP);
526
+ const found = [];
527
+ for (const name of names) {
528
+ const abs = node_path_1.default.join(phaseDir, name);
529
+ let content;
530
+ try {
531
+ const stat = node_fs_1.default.lstatSync(abs);
532
+ if (!stat.isFile() || stat.size > CENSUS_ARTIFACT_BYTE_CAP)
533
+ continue;
534
+ content = node_fs_1.default.readFileSync(abs, 'utf8');
535
+ }
536
+ catch {
537
+ continue;
538
+ }
539
+ const parsed = parseVaultTraceBlock(content, name);
540
+ const vault = parsed.present && parsed.statement ? parsed.statement.predicate?.vault : null;
541
+ if (!vault)
542
+ continue;
543
+ const census = vault.checkBearingPractices;
544
+ if (typeof census !== 'number' || !Number.isInteger(census) || census < 0)
545
+ continue;
546
+ if (!Array.isArray(vault.practiceUniverse))
547
+ continue;
548
+ found.push({
549
+ census,
550
+ universe: vault.practiceUniverse.length,
551
+ sha: typeof vault.resolvedSha === 'string' ? vault.resolvedSha : null,
552
+ artifact: name,
553
+ execute: /(^|-)SUMMARY\.md$/.test(name),
554
+ });
555
+ }
556
+ if (found.length === 0)
557
+ return null;
558
+ const pool = found.some((f) => f.execute) ? found.filter((f) => f.execute) : found;
559
+ const chosen = pool[pool.length - 1];
560
+ const distinct = new Set(found.map((f) => `${f.census}/${f.universe}/${f.sha ?? ''}`)).size;
561
+ return {
562
+ checkBearingPractices: chosen.census,
563
+ practiceUniverse: chosen.universe,
564
+ resolvedSha: chosen.sha,
565
+ artifact: chosen.artifact,
566
+ distinct,
567
+ };
568
+ }
569
+ catch {
570
+ return null;
571
+ }
572
+ }
573
+ // ─── The builder ───────────────────────────────────────────────────────────────
574
+ /**
575
+ * The statement note recorded when the cache was read from the owning main git
576
+ * worktree (worktree executors). A fact, never a path.
577
+ */
578
+ const TRACE_WORKTREE_CACHE_NOTE = 'vault cache read from the owning main git worktree; this checkout carries no local .planning/vault';
579
+ /**
580
+ * Build a complete trace statement.
581
+ *
582
+ * ALWAYS returns a statement. A vault that cannot be read produces a
583
+ * `state: degraded` record with a non-null reason, never an omitted artifact —
584
+ * an absent trace would be indistinguishable from a phase that was never
585
+ * vault-enabled, and that silence is exactly what this schema exists to end.
586
+ */
587
+ function buildTraceStatement(opts) {
588
+ const { cwd, stage, plan } = opts;
589
+ const files = Array.isArray(opts.files) ? opts.files : [];
590
+ const notes = [];
591
+ const generatedAt = new Date().toISOString();
592
+ // ONE cache directory for the pin AND the matcher (worktree executors), so
593
+ // the practice set and the ref it is recorded against come from one vault.
594
+ const cache = vaultMod.resolveVaultCacheDir(cwd);
595
+ if (cache.source === 'main-worktree') {
596
+ // The fact, never the path: an absolute path of the owning checkout would
597
+ // leak a machine layout into a committed record.
598
+ notes.push(TRACE_WORKTREE_CACHE_NOTE);
599
+ }
600
+ const pin = resolvePin(cwd, cache.dir);
601
+ // The universe comes from the SAME manifest read that produced the pin
602
+ // (D-01); charset rejects are dropped and noted onto THIS statement's notes.
603
+ const practiceUniverse = derivePracticeUniverse(pin.manifestFiles, notes);
604
+ // ONE read of the pinned cache, two facts (10-08): the check-block census of
605
+ // the universe above, and the per-practice analyzer-provenance clauses the
606
+ // ledger entries below carry. This is the widened read profile this path now
607
+ // owns; it is stated on `ResolvedPin.manifestFiles` rather than left implicit.
608
+ const pinnedFacts = readPinnedPractices(cwd, cache.dir, notes);
609
+ const checkBearingPractices = deriveCheckBearingCensus(practiceUniverse, pinnedFacts);
610
+ // ── subject ──
611
+ const subject = [];
612
+ const names = [];
613
+ for (const entry of files) {
614
+ const rawName = typeof entry === 'string' ? entry : entry?.name;
615
+ if (typeof rawName !== 'string' || rawName === '')
616
+ continue;
617
+ const name = rawName.replace(/\\/g, '/');
618
+ names.push(name);
619
+ if (stage === 'execute') {
620
+ const digest = typeof entry === 'string' ? undefined : entry.gitCommit;
621
+ if (typeof digest === 'string' && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(digest)) {
622
+ subject.push({ name, digest: { gitCommit: digest } });
623
+ }
624
+ else {
625
+ // Loud, not silent: an execute-stage entry with no usable digest is a
626
+ // real coverage gap and the reader must be able to see it.
627
+ notes.push(`subject "${boundedToken(name)}" has no usable gitCommit digest at execute stage`);
628
+ subject.push({ name, uri: name });
629
+ }
630
+ }
631
+ else {
632
+ // Plan stage: no digest key at all. `uri` is set so the entry still
633
+ // satisfies in-toto's ResourceDescriptor minimum.
634
+ subject.push({ name, uri: name });
635
+ }
636
+ }
637
+ // ── practices: the matcher's return set, and nothing else ──
638
+ const dispositionById = new Map();
639
+ for (const d of opts.dispositions ?? []) {
640
+ if (d && typeof d.id === 'string')
641
+ dispositionById.set(d.id, d);
642
+ }
643
+ const practices = [];
644
+ try {
645
+ const match = practicesMod.matchPractices({ cwd, files: names, vaultDir: cache.dir });
646
+ // Concatenate matched THEN global, preserving the matcher's own sort. Do
647
+ // not re-glob, do not read the practices directory, and do not filter by
648
+ // severity — a `not-applicable` entry is exactly the record TRACE-02
649
+ // exists to produce.
650
+ for (const p of [...match.matched, ...match.global]) {
651
+ const id = boundedToken(p.id);
652
+ if (!TRACE_ID_PATTERN.test(id)) {
653
+ notes.push(`practice id skipped: "${id}" does not match the permitted id charset`);
654
+ continue;
655
+ }
656
+ const d = dispositionById.get(id);
657
+ const entry = {
658
+ id,
659
+ scope: p.scope ?? null,
660
+ severity: p.severity ?? null,
661
+ status: typeof d?.status === 'string' ? d.status : '',
662
+ reason: typeof d?.reason === 'string' ? d.reason : '',
663
+ tasks: Array.isArray(d?.tasks) ? d.tasks.map((t) => String(t)) : [],
664
+ };
665
+ // Set ONLY when the practice carries a claim. An `undefined` property
666
+ // would change every deepEqual in every consumer for no behavioural gain
667
+ // — the same reasoning the validator's optional `tail` field already
668
+ // carries — and an entry for a practice with no claim must stay
669
+ // byte-identical to the one it produced before this phase (MECH-03).
670
+ const clause = pinnedFacts.clauseById.get(id);
671
+ if (typeof clause === 'string' && clause !== '')
672
+ entry.enforcedBy = clause;
673
+ // THE TEXT DIGEST (D-05-07). Digested from the SAME unwrapped excerpt
674
+ // `renderPracticeEntry` places under `ask:`/`excerpt:` in the body block
675
+ // — `unwrapFencedExcerpt` is the practices module's own, exported for
676
+ // exactly this, so the two readers cannot strip the fence differently.
677
+ // The scaffold and `vault practices match --block` run the same matcher
678
+ // on the same cache in the same session, which is what makes a digest
679
+ // written here comparable to a block embedded there.
680
+ const fenced = p.scope === 'scoped'
681
+ ? p.ask
682
+ : p.excerpt;
683
+ if (typeof fenced === 'string') {
684
+ entry.textSha256 = practiceTextDigest(practicesMod.unwrapFencedExcerpt(fenced));
685
+ }
686
+ practices.push(entry);
687
+ }
688
+ }
689
+ catch (e) {
690
+ // The matcher degrades internally rather than throwing, but a trace is
691
+ // never worth losing to an unexpected read failure.
692
+ notes.push(`practice matching failed: ${boundedToken(e.message)}`);
693
+ }
694
+ return {
695
+ _type: TRACE_STATEMENT_TYPE,
696
+ predicateType: TRACE_PREDICATE_TYPE,
697
+ schemaVersion: TRACE_SCHEMA_VERSION,
698
+ subject,
699
+ predicate: {
700
+ stage,
701
+ plan,
702
+ generatedAt,
703
+ vault: {
704
+ state: pin.state,
705
+ reason: pin.reason,
706
+ ref: pin.ref,
707
+ resolvedSha: pin.resolvedSha,
708
+ practiceUniverse,
709
+ checkBearingPractices,
710
+ tier: pin.tier,
711
+ source: pin.source,
712
+ repo: pin.repo,
713
+ },
714
+ practices,
715
+ notes,
716
+ override: null,
717
+ },
718
+ };
719
+ }
720
+ // ─── The reader allowlist ──────────────────────────────────────────────────────
721
+ /**
722
+ * The predicate types a trace READER accepts for the project at `cwd`:
723
+ * `TRACE_PREDICATE_TYPE` first, then each URI the org profile lists under
724
+ * `vault.trace.accepted_predicates` that carries the same major
725
+ * (`/v<TRACE_SCHEMA_VERSION>`). A listed URI of another major is ignored —
726
+ * the URI major and the schema version move together. Never throws; writers
727
+ * never call it.
728
+ */
729
+ function acceptedPredicateTypes(cwd) {
730
+ const out = [TRACE_PREDICATE_TYPE];
731
+ try {
732
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
733
+ const orgProfile = require('./org-profile.cjs');
734
+ const majorSuffix = `/v${TRACE_SCHEMA_VERSION}`;
735
+ for (const uri of orgProfile.resolveAcceptedTracePredicates(cwd)) {
736
+ if (typeof uri === 'string' && uri.endsWith(majorSuffix) && !out.includes(uri))
737
+ out.push(uri);
738
+ }
739
+ }
740
+ catch {
741
+ /* no profile reader: pp-core's own type only */
742
+ }
743
+ return out;
744
+ }
745
+ // ─── The serializer ────────────────────────────────────────────────────────────
746
+ const INDENT = ' ';
747
+ /**
748
+ * Quote a scalar for the emitted block. Every string value is emitted
749
+ * double-quoted — uniformly, rather than only when it "looks dangerous",
750
+ * because a conditional quoting rule is one review away from being widened to
751
+ * the wrong case. Backslashes and quotes are escaped, and any CR/LF is
752
+ * collapsed to a space so no value can ever add a line to the document.
753
+ */
754
+ function yamlQuote(value) {
755
+ const flat = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ');
756
+ return `"${flat}"`;
757
+ }
758
+ /** Emit `key: value` where value is a string, a number, or null. */
759
+ function scalarLine(depth, key, value) {
760
+ const rendered = value === null ? 'null' : typeof value === 'number' ? String(value) : yamlQuote(value);
761
+ return `${INDENT.repeat(depth)}${key}: ${rendered}`;
762
+ }
763
+ /** Emit a list of quoted strings, or an explicit `[]`. */
764
+ function stringListLines(depth, key, values) {
765
+ if (values.length === 0)
766
+ return [`${INDENT.repeat(depth)}${key}: []`];
767
+ return [
768
+ `${INDENT.repeat(depth)}${key}:`,
769
+ ...values.map((v) => `${INDENT.repeat(depth + 1)}- ${yamlQuote(v)}`),
770
+ ];
771
+ }
772
+ /** Emit an inline list of quoted strings (`tasks: ["T1", "T3"]`). */
773
+ function inlineList(values) {
774
+ if (values.length === 0)
775
+ return '[]';
776
+ return `[${values.map(yamlQuote).join(', ')}]`;
777
+ }
778
+ /**
779
+ * Serialize a statement as the `vault:` frontmatter block.
780
+ *
781
+ * Hand-written, with no YAML library: `pp-core/bin/lib/*.cjs` takes no external
782
+ * runtime dependency, and TRACE-01 adopts the in-toto shape without one.
783
+ *
784
+ * The practice list is emitted as a LIST OF MAPS so every id sits in a VALUE
785
+ * position (`- id: "db-naming"`). A map keyed by id would put a
786
+ * client-authored token in a KEY position, where a `:`, a `#`, a leading `-`
787
+ * or a newline changes the document's structure instead of a value's content.
788
+ *
789
+ * An id that fails the charset is SKIPPED with a note recorded inside the
790
+ * emitted statement, never dropped silently — a silent drop is
791
+ * indistinguishable from an agent omission and would break the 1:1 coverage
792
+ * check a gate performs against this list.
793
+ */
794
+ function serializeTraceBlock(statement) {
795
+ const p = statement.predicate;
796
+ const notes = [...(p.notes ?? [])];
797
+ const lines = ['vault:'];
798
+ lines.push(scalarLine(1, '_type', statement._type));
799
+ lines.push(scalarLine(1, 'predicateType', statement.predicateType));
800
+ lines.push(`${INDENT}schemaVersion: ${Number(statement.schemaVersion)}`);
801
+ // ── subject ──
802
+ const subjects = statement.subject ?? [];
803
+ if (subjects.length === 0) {
804
+ lines.push(`${INDENT}subject: []`);
805
+ }
806
+ else {
807
+ lines.push(`${INDENT}subject:`);
808
+ for (const s of subjects) {
809
+ lines.push(`${INDENT.repeat(2)}- name: ${yamlQuote(s.name)}`);
810
+ if (typeof s.uri === 'string')
811
+ lines.push(scalarLine(3, 'uri', s.uri));
812
+ if (s.digest && typeof s.digest.gitCommit === 'string') {
813
+ lines.push(`${INDENT.repeat(3)}digest:`);
814
+ lines.push(scalarLine(4, 'gitCommit', s.digest.gitCommit));
815
+ }
816
+ }
817
+ }
818
+ // ── predicate ──
819
+ lines.push(`${INDENT}predicate:`);
820
+ lines.push(scalarLine(2, 'stage', p.stage));
821
+ lines.push(scalarLine(2, 'plan', p.plan));
822
+ lines.push(scalarLine(2, 'generatedAt', p.generatedAt));
823
+ lines.push(`${INDENT.repeat(2)}vault:`);
824
+ lines.push(scalarLine(3, 'state', p.vault.state));
825
+ lines.push(scalarLine(3, 'reason', p.vault.reason));
826
+ lines.push(scalarLine(3, 'ref', p.vault.ref));
827
+ lines.push(scalarLine(3, 'resolvedSha', p.vault.resolvedSha));
828
+ // practiceUniverse sits immediately after `resolvedSha`: the two facts come
829
+ // from the same manifest read (D-01). When null or absent, NO line is
830
+ // emitted — an empty universe (`[]`) and an unrecorded one are different
831
+ // facts and plan 04-05 must be able to tell them apart. Re-validated at emit
832
+ // time exactly like the practices list below (defence in depth): the
833
+ // statement may have been hand-built or mutated between build and serialize.
834
+ if (Array.isArray(p.vault.practiceUniverse)) {
835
+ const emittableIds = p.vault.practiceUniverse.filter((id) => {
836
+ const ok = typeof id === 'string' && TRACE_ID_PATTERN.test(id);
837
+ if (!ok) {
838
+ notes.push(`practice universe id skipped at serialize time: "${boundedToken(id)}" does not match the permitted id charset`);
839
+ }
840
+ return ok;
841
+ });
842
+ lines.push(...stringListLines(3, 'practiceUniverse', emittableIds));
843
+ }
844
+ // The census sits with the universe it is a census OF, on the same rule the
845
+ // universe follows: when the value is not a usable count, NO line is emitted,
846
+ // because an unrecorded census and a census of ZERO are different facts and a
847
+ // consumer must be able to tell them apart. Zero IS emitted — "0 of 55" is
848
+ // the fact MECH-02 exists to make legible, and a fact nobody writes down is
849
+ // not a record.
850
+ //
851
+ // Re-validated at emit time exactly like the practices list below (defence in
852
+ // depth): the statement may have been hand-built or mutated between build and
853
+ // serialize, and the YAML boundary is the one that matters. A value that is
854
+ // not an integer >= 0 is DROPPED WITH A NOTE rather than emitted — a silent
855
+ // drop would be indistinguishable from a census that was never taken.
856
+ const census = p.vault.checkBearingPractices;
857
+ if (typeof census === 'number' && Number.isInteger(census) && census >= 0) {
858
+ lines.push(scalarLine(3, 'checkBearingPractices', census));
859
+ }
860
+ else if (census !== null && census !== undefined) {
861
+ notes.push(`check-bearing census dropped at serialize time: "${boundedToken(census)}" is not an integer count of zero or more`);
862
+ }
863
+ lines.push(scalarLine(3, 'tier', p.vault.tier));
864
+ lines.push(scalarLine(3, 'source', p.vault.source));
865
+ lines.push(scalarLine(3, 'repo', p.vault.repo));
866
+ // ── practices ──
867
+ // Re-validate at emit time (defence in depth): the statement may have been
868
+ // hand-built or mutated between build and serialize, and the YAML boundary
869
+ // is the one that matters.
870
+ const emittable = (p.practices ?? []).filter((entry) => {
871
+ const ok = typeof entry.id === 'string' && TRACE_ID_PATTERN.test(entry.id);
872
+ if (!ok) {
873
+ notes.push(`practice id skipped at serialize time: "${boundedToken(entry.id)}" does not match the permitted id charset`);
874
+ }
875
+ return ok;
876
+ });
877
+ if (emittable.length === 0) {
878
+ lines.push(`${INDENT.repeat(2)}practices: []`);
879
+ }
880
+ else {
881
+ lines.push(`${INDENT.repeat(2)}practices:`);
882
+ for (const entry of emittable) {
883
+ lines.push(`${INDENT.repeat(3)}- id: ${yamlQuote(entry.id)}`);
884
+ lines.push(scalarLine(4, 'scope', entry.scope ?? null));
885
+ lines.push(scalarLine(4, 'severity', entry.severity ?? null));
886
+ lines.push(scalarLine(4, 'status', entry.status ?? ''));
887
+ lines.push(scalarLine(4, 'reason', entry.reason ?? ''));
888
+ // Analyzer provenance, between `reason` and `tasks` (10-08, MECH-03).
889
+ // NOTHING is emitted when the practice carries no claim, so an entry with
890
+ // no new fact keeps the shape it had before this phase.
891
+ //
892
+ // Re-bounded and re-defanged HERE even though the builder already did it,
893
+ // on the emit-time re-validation rule the id filter below states: the
894
+ // statement may have been hand-built or mutated between build and
895
+ // serialize, and the YAML boundary is the one that matters. Three
896
+ // distinct hazards, three shipped guards: `renderSafeField` collapses the
897
+ // control characters `boundedToken` does not (it handles CR/LF only) and
898
+ // caps the length so one ledger entry cannot fill an agent's context;
899
+ // `defangFenceMarkers` neutralises the SHIPPED external-content markers,
900
+ // which `yamlQuote` does not touch at all; and `yamlQuote` below owns the
901
+ // CR/LF half, so a newline cannot add a line to the document.
902
+ const clause = defangFenceMarkers(renderSafeField(entry.enforcedBy));
903
+ if (clause !== '')
904
+ lines.push(scalarLine(4, 'enforcedBy', clause));
905
+ // The text digest (D-05-07), between `enforcedBy` and `tasks`. NOTHING
906
+ // is emitted when the entry carries none, so a ledger written from a
907
+ // statement without digests keeps the shape it had. Re-validated at emit
908
+ // time on the same rule as the id filter: only a 64-character lowercase
909
+ // hex string is a digest, and anything else is dropped WITH a note rather
910
+ // than written as a value the gate would then compare against.
911
+ const digest = entry.textSha256;
912
+ if (typeof digest === 'string' && TEXT_DIGEST_PATTERN.test(digest)) {
913
+ lines.push(scalarLine(4, 'textSha256', digest));
914
+ }
915
+ else if (digest !== undefined && digest !== null) {
916
+ notes.push(`text digest dropped at serialize time for "${boundedToken(entry.id)}": not a sha256 hex digest`);
917
+ }
918
+ lines.push(`${INDENT.repeat(4)}tasks: ${inlineList(entry.tasks ?? [])}`);
919
+ }
920
+ }
921
+ lines.push(...stringListLines(2, 'notes', notes));
922
+ // ── override ──
923
+ if (!p.override) {
924
+ lines.push(`${INDENT.repeat(2)}override: null`);
925
+ }
926
+ else {
927
+ lines.push(`${INDENT.repeat(2)}override:`);
928
+ lines.push(scalarLine(3, 'gate', p.override.gate));
929
+ lines.push(scalarLine(3, 'reason', p.override.reason));
930
+ lines.push(scalarLine(3, 'suppressedReason', p.override.suppressedReason));
931
+ lines.push(scalarLine(3, 'who', p.override.who));
932
+ lines.push(scalarLine(3, 'when', p.override.when));
933
+ }
934
+ // TERMINATED, and the trailing newline is load-bearing (04-REVIEW IN-01).
935
+ // The block's destination is a document's YAML frontmatter, and the agent
936
+ // that puts it there splices by concatenation. Without a terminator the final
937
+ // line runs into whatever follows — `override: null---` — and
938
+ // `collectBlockLines`' frontmatter match then fails against a block that is
939
+ // physically present. The verdict is `trace_absent`, and its remedy is to
940
+ // regenerate the block that is already there: an unrecoverable false block of
941
+ // exactly the shape D-11 exists to prevent. One character removes the failure
942
+ // mode; a caller that joins with its own newline sees a blank line, which
943
+ // `collectBlockLines` already skips.
944
+ return `${lines.join('\n')}\n`;
945
+ }
946
+ // ─── The rendered surface ──────────────────────────────────────────────────────
947
+ const PRACTICES_APPLIED_HEADING = '## Practices Applied';
948
+ /**
949
+ * The cap applied to every client-influenced field the renderer interpolates
950
+ * into a generated markdown line (03-REVIEW WR-12, T-03-34). Wider than
951
+ * `WARNING_TOKEN_MAX` (80) because a one-line reason is not a warning token,
952
+ * and `boundedToken` is deliberately NOT reused here — widening its bound
953
+ * would silently change unrelated warning messages across two modules.
954
+ */
955
+ const TRACE_RENDERED_FIELD_MAX = 300;
956
+ /**
957
+ * The single line emitted in place of an entry whose id fails the charset. It
958
+ * has to teach a human reader three things at once, because it is the only
959
+ * thing they will see: that an id was refused AT THIS POSITION (the section
960
+ * never silently shrinks), that the reason is the permitted id charset, and
961
+ * that the record is regenerated rather than hand-repaired.
962
+ */
963
+ const TRACE_REFUSED_ID_LINE = '- (a practice id was refused here: it does not match the permitted id charset — regenerate the trace block rather than hand-repairing it)';
964
+ /**
965
+ * Bound and flatten one client-influenced value for a generated markdown line.
966
+ *
967
+ * The `## Practices Applied` section is loaded UNFENCED by name — the code
968
+ * reviewer's cross-plan reconciliation contract reads it — so this is the
969
+ * shortest injection path in the capability (T-03-34). A non-string coerces to
970
+ * the empty string rather than to `[object Object]`, control characters cannot
971
+ * forge a second line, and the length cap keeps one ledger entry from filling a
972
+ * downstream agent's context.
973
+ */
974
+ function renderSafeField(value) {
975
+ if (typeof value !== 'string')
976
+ return '';
977
+ const flat = collapseControlChars(value).trim();
978
+ return flat.length > TRACE_RENDERED_FIELD_MAX
979
+ ? `${flat.slice(0, TRACE_RENDERED_FIELD_MAX)}…`
980
+ : flat;
981
+ }
982
+ /**
983
+ * Render the `## Practices Applied` section from the ledger.
984
+ *
985
+ * D-02 retires the hand-authoring of this section; D-03 PRESERVES the surface
986
+ * by generating it, so the two can never disagree and nothing downstream has
987
+ * to move. The heading has far more reach than its size suggests — the code
988
+ * reviewer's cross-plan reconciliation contract reads it by name, a capability
989
+ * contribution reads it, and four test files pin the literal string. The
990
+ * output must therefore be substitutable for what the executor writes today,
991
+ * which is why the line shape and the three-word vocabulary are reproduced
992
+ * rather than improved.
993
+ */
994
+ function renderPracticesApplied(statement) {
995
+ const entries = statement?.predicate?.practices ?? [];
996
+ const lines = [PRACTICES_APPLIED_HEADING, ''];
997
+ if (entries.length === 0) {
998
+ // Never an empty section: D-04 requires a degraded or empty run to be
999
+ // READABLE as a fact. An omitted section and an unrecorded one look
1000
+ // identical to every downstream reader.
1001
+ lines.push('No client-vault practices were in scope for this plan (the recorded practice ledger is empty).');
1002
+ return lines.join('\n');
1003
+ }
1004
+ for (const entry of entries) {
1005
+ // 03-REVIEW CR-03, render half. The id is the only client-authored token
1006
+ // that reaches this line, and this line is read UNFENCED by another agent.
1007
+ // A refusal keeps the entry's POSITION — pushing nothing would let a
1008
+ // hostile ledger quietly shorten the section, which reads as "fewer
1009
+ // practices were in scope" rather than "something was refused here".
1010
+ if (typeof entry?.id !== 'string' || !TRACE_ID_PATTERN.test(entry.id)) {
1011
+ lines.push(TRACE_REFUSED_ID_LINE);
1012
+ continue;
1013
+ }
1014
+ const statusText = renderSafeField(entry.status);
1015
+ const status = statusText !== '' ? statusText : 'not-applicable';
1016
+ const reason = renderSafeField(entry.reason);
1017
+ lines.push(reason === '' ? `- ${entry.id}: ${status}` : `- ${entry.id}: ${status} — ${reason}`);
1018
+ }
1019
+ return lines.join('\n');
1020
+ }
1021
+ // ─── The parser ────────────────────────────────────────────────────────────────
1022
+ /**
1023
+ * The frontmatter key the trace is anchored on. The parser locates THIS key and
1024
+ * its indent, exactly as `parseMustHavesBlock` anchors on `must_haves:`.
1025
+ */
1026
+ const TRACE_BLOCK_KEY = 'vault';
1027
+ /** A complete double-quoted scalar, allowing escaped quotes and backslashes. */
1028
+ const QUOTED_RE = /^"(?:[^"\\]|\\.)*"$/;
1029
+ /**
1030
+ * Key-value recognizer, carrying the whitespace-after-colon guard.
1031
+ *
1032
+ * The `\s+` after the colon is NOT cosmetic. YAML key-value always has at
1033
+ * least one space after the colon, and requiring it is what stops
1034
+ * `Class::Method` and `db:seed` being mis-read as a key-value pair when they
1035
+ * appear as a plain scalar (#2757). Without it, a hand-edited note reading
1036
+ * `db:seed was used` silently becomes the object `{ db: 'seed was used' }` and
1037
+ * the round-trip stops being an identity.
1038
+ */
1039
+ const KV_RE = /^([A-Za-z0-9_-]+):(?:\s+(.*))?$/;
1040
+ function unquoteScalar(s) {
1041
+ return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
1042
+ }
1043
+ function parseScalar(raw) {
1044
+ const s = raw.trim();
1045
+ if (s === '')
1046
+ return '';
1047
+ if (s === 'null')
1048
+ return null;
1049
+ if (s === '[]')
1050
+ return [];
1051
+ if (QUOTED_RE.test(s))
1052
+ return unquoteScalar(s);
1053
+ if (/^\[.*\]$/.test(s)) {
1054
+ const inner = s.slice(1, -1);
1055
+ const quoted = inner.match(/"(?:[^"\\]|\\.)*"/g);
1056
+ if (quoted)
1057
+ return quoted.map(unquoteScalar);
1058
+ return inner
1059
+ .split(',')
1060
+ .map((x) => x.trim())
1061
+ .filter((x) => x !== '');
1062
+ }
1063
+ if (/^-?\d+$/.test(s))
1064
+ return Number(s);
1065
+ return s;
1066
+ }
1067
+ /**
1068
+ * The three keys that are never assigned from document text (03-REVIEW CR-05).
1069
+ *
1070
+ * `KV_RE`'s character class matches `__proto__`, and a key carrying a nested
1071
+ * block is assigned an OBJECT — which reassigns the prototype of the plain
1072
+ * accumulator the parser is building. A block whose whole body sat under
1073
+ * `__proto__` therefore validated clean while neither frozen literal appeared
1074
+ * anywhere a `grep` would find it (T-03-32/T-03-33). For a capability whose
1075
+ * product is a provable, READABLE audit record, the gate and the document text
1076
+ * disagreeing is the defect itself.
1077
+ *
1078
+ * The guard sits at the key-recognition boundary — this project's stated
1079
+ * convention for prototype guards, and the same three-key shape the phase's own
1080
+ * integration-test helper already implements — so BOTH `parseListItem` and
1081
+ * `parseValueLines` inherit one guard instead of each growing their own.
1082
+ */
1083
+ const FORBIDDEN_KEYS = Object.freeze(new Set(['__proto__', 'constructor', 'prototype']));
1084
+ function matchKeyValue(text) {
1085
+ const m = text.match(KV_RE);
1086
+ if (!m || FORBIDDEN_KEYS.has(m[1]))
1087
+ return null;
1088
+ return { key: m[1], value: (m[2] ?? '').trim() };
1089
+ }
1090
+ function parseListItem(head, children) {
1091
+ const trimmed = head.trim();
1092
+ // A fully-quoted item is a STRING, checked before the key-value branch so a
1093
+ // quoted value containing a colon can never be re-read as a mapping.
1094
+ if (QUOTED_RE.test(trimmed) && children.length === 0)
1095
+ return unquoteScalar(trimmed);
1096
+ const kv = matchKeyValue(trimmed);
1097
+ if (!kv) {
1098
+ if (children.length === 0)
1099
+ return parseScalar(trimmed);
1100
+ return parseValueLines(children);
1101
+ }
1102
+ // Null prototype as defence in depth (CR-05): a key that ever slipped past
1103
+ // the recognizer above still cannot reach a prototype from here.
1104
+ const obj = Object.create(null);
1105
+ obj[kv.key] = kv.value === '' ? {} : parseScalar(kv.value);
1106
+ if (children.length > 0) {
1107
+ const rest = parseValueLines(children);
1108
+ if (rest && typeof rest === 'object' && !Array.isArray(rest)) {
1109
+ Object.assign(obj, rest);
1110
+ }
1111
+ }
1112
+ return obj;
1113
+ }
1114
+ /**
1115
+ * Parse a run of block lines that are all more indented than their parent.
1116
+ *
1117
+ * The technique is copied from `parseMustHavesBlock` and not the function
1118
+ * itself: that one is anchored on its own literal key, and widening it would
1119
+ * dissolve the very shape that makes it work. The five moves reproduced here
1120
+ * are — capture the parent indent; require the nested block be more indented;
1121
+ * detect the list-item indent from the first `- `; treat a deeper indent under
1122
+ * an object item as a continuation key-value; and stop at the first line whose
1123
+ * indent is not greater than the parent's.
1124
+ */
1125
+ function parseValueLines(lines) {
1126
+ if (lines.length === 0)
1127
+ return {};
1128
+ const base = lines[0].indent;
1129
+ if (lines[0].text.startsWith('- ')) {
1130
+ const items = [];
1131
+ let i = 0;
1132
+ while (i < lines.length) {
1133
+ if (lines[i].indent !== base || !lines[i].text.startsWith('- ')) {
1134
+ i += 1;
1135
+ continue;
1136
+ }
1137
+ const head = lines[i].text.slice(2);
1138
+ const children = [];
1139
+ let j = i + 1;
1140
+ while (j < lines.length && lines[j].indent > base) {
1141
+ children.push(lines[j]);
1142
+ j += 1;
1143
+ }
1144
+ items.push(parseListItem(head, children));
1145
+ i = j;
1146
+ }
1147
+ return items;
1148
+ }
1149
+ // Null prototype as defence in depth (CR-05) — see `FORBIDDEN_KEYS`.
1150
+ const obj = Object.create(null);
1151
+ let i = 0;
1152
+ while (i < lines.length) {
1153
+ if (lines[i].indent !== base) {
1154
+ i += 1;
1155
+ continue;
1156
+ }
1157
+ const kv = matchKeyValue(lines[i].text);
1158
+ if (!kv) {
1159
+ i += 1;
1160
+ continue;
1161
+ }
1162
+ const children = [];
1163
+ let j = i + 1;
1164
+ while (j < lines.length && lines[j].indent > base) {
1165
+ children.push(lines[j]);
1166
+ j += 1;
1167
+ }
1168
+ obj[kv.key] = kv.value === '' ? parseValueLines(children) : parseScalar(kv.value);
1169
+ i = j;
1170
+ }
1171
+ return obj;
1172
+ }
1173
+ /** Collect the block's lines, anchored on `vault:` and bounded by its indent. */
1174
+ function collectBlockLines(content) {
1175
+ const fm = String(content ?? '').match(/^---\r?\n([\s\S]+?)\r?\n---/);
1176
+ if (!fm)
1177
+ return null;
1178
+ const lines = fm[1].split(/\r?\n/);
1179
+ // 03-REVIEW WR-09: anchor on the TOP-LEVEL key only — no leading-whitespace
1180
+ // capture. The trace is only ever WRITTEN at the top level of the
1181
+ // frontmatter, so a nested `vault:` sub-key is not a trace. Taking the first
1182
+ // match at any indent let an unrelated nested key (a config echo, a tool
1183
+ // block, a hand-added note) shadow the real ledger: the collected lines were
1184
+ // that node's children, the statement normalised to empty, and the gate
1185
+ // reported `trace_unparsable`. Fail-closed, so never a bypass — but an
1186
+ // UNRECOVERABLE false block, because the block's own remedy ("regenerate the
1187
+ // block") re-emits the same second `vault:` key (T-03-35).
1188
+ const anchorRe = new RegExp(`^${TRACE_BLOCK_KEY}:\\s*$`);
1189
+ const anchorIndent = 0;
1190
+ let anchor = -1;
1191
+ for (let i = 0; i < lines.length; i += 1) {
1192
+ if (anchorRe.test(lines[i])) {
1193
+ anchor = i;
1194
+ break;
1195
+ }
1196
+ }
1197
+ if (anchor === -1)
1198
+ return null;
1199
+ const out = [];
1200
+ for (let i = anchor + 1; i < lines.length; i += 1) {
1201
+ const line = lines[i];
1202
+ if (line.trim() === '')
1203
+ continue;
1204
+ const indent = line.match(/^(\s*)/)[1].length;
1205
+ if (indent <= anchorIndent)
1206
+ break;
1207
+ out.push({ indent, text: line.trim() });
1208
+ }
1209
+ return out;
1210
+ }
1211
+ /** How many content lines sit under a nested `practices:` key (0 if inline). */
1212
+ function practicesContentLineCount(lines) {
1213
+ const idx = lines.findIndex((l) => l.text === 'practices:');
1214
+ if (idx === -1)
1215
+ return 0;
1216
+ const base = lines[idx].indent;
1217
+ let n = 0;
1218
+ for (let i = idx + 1; i < lines.length; i += 1) {
1219
+ if (lines[i].indent <= base)
1220
+ break;
1221
+ n += 1;
1222
+ }
1223
+ return n;
1224
+ }
1225
+ function asObject(v) {
1226
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
1227
+ }
1228
+ function asArray(v) {
1229
+ return Array.isArray(v) ? v : [];
1230
+ }
1231
+ /**
1232
+ * Render a parsed scalar as a string WITHOUT ever falling through to
1233
+ * `[object Object]`. A string passes through byte-identically, which is what
1234
+ * keeps the round-trip an identity; anything else is a shape the emitter never
1235
+ * produces, so it is rendered defensively rather than trusted.
1236
+ */
1237
+ function stringifyScalar(v) {
1238
+ if (typeof v === 'string')
1239
+ return v;
1240
+ if (typeof v === 'number' || typeof v === 'boolean')
1241
+ return String(v);
1242
+ if (v === null || v === undefined)
1243
+ return '';
1244
+ try {
1245
+ return JSON.stringify(v) ?? '';
1246
+ }
1247
+ catch {
1248
+ return '';
1249
+ }
1250
+ }
1251
+ function asStringOrNull(v) {
1252
+ if (v === null || v === undefined)
1253
+ return null;
1254
+ return stringifyScalar(v);
1255
+ }
1256
+ function asString(v) {
1257
+ return stringifyScalar(v);
1258
+ }
1259
+ const ABSENT = Object.freeze({
1260
+ present: false,
1261
+ reason: 'no_vault_block',
1262
+ statement: null,
1263
+ });
1264
+ /**
1265
+ * Read a `vault:` trace block back out of a document.
1266
+ *
1267
+ * Returns a documented ABSENT result rather than throwing when the document
1268
+ * carries no block: handlers in this repository never throw and adapters
1269
+ * return a structured reason, and the parser sits on the adapter side of that
1270
+ * line.
1271
+ *
1272
+ * @param content The full document text (frontmatter included).
1273
+ * @param artifactPath Named in the empty-parse diagnostic so an operator knows
1274
+ * WHICH file to look at.
1275
+ */
1276
+ function parseVaultTraceBlock(content, artifactPath = 'the artifact') {
1277
+ const lines = collectBlockLines(content);
1278
+ if (lines === null || lines.length === 0)
1279
+ return ABSENT;
1280
+ const root = asObject(parseValueLines(lines));
1281
+ const predicate = asObject(root.predicate);
1282
+ const vault = asObject(predicate.vault);
1283
+ const practices = asArray(predicate.practices).map((raw) => {
1284
+ const p = asObject(raw);
1285
+ const entry = {
1286
+ id: asString(p.id),
1287
+ scope: asStringOrNull(p.scope),
1288
+ severity: asStringOrNull(p.severity),
1289
+ status: typeof p.status === 'string' ? p.status : '',
1290
+ reason: typeof p.reason === 'string' ? p.reason : '',
1291
+ tasks: asArray(p.tasks).map((t) => asString(t)),
1292
+ };
1293
+ // Optional (10-08): reconstructed ONLY when the document carried it, so a
1294
+ // trace written before this phase parses back into the exact entry it
1295
+ // parsed into then. An `undefined` property would not be that.
1296
+ if (typeof p.enforcedBy === 'string' && p.enforcedBy !== '')
1297
+ entry.enforcedBy = p.enforcedBy;
1298
+ // Optional (D-05-07): reconstructed ONLY when the document carried it,
1299
+ // on the same reasoning as `enforcedBy` above.
1300
+ if (typeof p.textSha256 === 'string' && p.textSha256 !== '')
1301
+ entry.textSha256 = p.textSha256;
1302
+ return entry;
1303
+ });
1304
+ // A silently empty parse is not merely unhelpful here: a downstream
1305
+ // validator checks that dispositions cover the emitted id set one-to-one, so
1306
+ // an empty parse and a genuine coverage failure would produce the same
1307
+ // verdict from different causes. Say which it was.
1308
+ const contentLines = practicesContentLineCount(lines);
1309
+ if (practices.length === 0 && contentLines > 0) {
1310
+ // 03-REVIEW WR-03: `artifactPath` is built from `fs.readdirSync` output or
1311
+ // straight from argv, and POSIX permits a newline in a filename — the same
1312
+ // fact `boundedToken`'s docstring cites. Sanitized HERE, inside the leaf,
1313
+ // so the guard does not depend on every caller remembering; the check
1314
+ // router sanitizes independently and both defences are wanted (T-03-36).
1315
+ const safeArtifactPath = boundedToken(collapseControlChars(String(artifactPath)));
1316
+ process.stderr.write(`[pp-tools] WARNING: the \`${TRACE_BLOCK_KEY}:\` trace block in ${safeArtifactPath} has ` +
1317
+ `${contentLines} practice content line(s) but parsed 0 entries. The trace is unreadable, ` +
1318
+ `which is NOT the same as an empty practice set — do not treat this as a clean run. ` +
1319
+ `Regenerate the block rather than hand-repairing it.\n`);
1320
+ }
1321
+ const subject = asArray(root.subject).map((raw) => {
1322
+ const s = asObject(raw);
1323
+ const entry = { name: asString(s.name) };
1324
+ if (typeof s.uri === 'string')
1325
+ entry.uri = s.uri;
1326
+ const digest = asObject(s.digest);
1327
+ if (typeof digest.gitCommit === 'string')
1328
+ entry.digest = { gitCommit: digest.gitCommit };
1329
+ return entry;
1330
+ });
1331
+ const rawOverride = predicate.override;
1332
+ let override = null;
1333
+ if (rawOverride && typeof rawOverride === 'object' && !Array.isArray(rawOverride)) {
1334
+ const o = asObject(rawOverride);
1335
+ override = {
1336
+ gate: asString(o.gate),
1337
+ reason: asString(o.reason),
1338
+ suppressedReason: asString(o.suppressedReason),
1339
+ who: asString(o.who),
1340
+ when: asString(o.when),
1341
+ };
1342
+ }
1343
+ const statement = {
1344
+ _type: asString(root._type),
1345
+ predicateType: asString(root.predicateType),
1346
+ schemaVersion: typeof root.schemaVersion === 'number' ? root.schemaVersion : Number(root.schemaVersion),
1347
+ subject,
1348
+ predicate: {
1349
+ stage: asString(predicate.stage),
1350
+ plan: asString(predicate.plan),
1351
+ generatedAt: asString(predicate.generatedAt),
1352
+ vault: {
1353
+ state: asString(vault.state),
1354
+ reason: asStringOrNull(vault.reason),
1355
+ ref: asStringOrNull(vault.ref),
1356
+ resolvedSha: asStringOrNull(vault.resolvedSha),
1357
+ // The `notes` parse partner's shape, with the optional-key absent case
1358
+ // made explicit: a present list (even `[]`) reconstructs element by
1359
+ // element; an absent or non-list value reconstructs as null, which is
1360
+ // how the writer spells "no universe was recorded" (degraded run).
1361
+ practiceUniverse: Array.isArray(vault.practiceUniverse)
1362
+ ? asArray(vault.practiceUniverse).map((v) => asString(v))
1363
+ : null,
1364
+ // The universe's absent-case rule, applied to a number: a present
1365
+ // integer count (INCLUDING zero) reconstructs as itself; anything else
1366
+ // — absent, a string, a float, a negative — reconstructs as null, which
1367
+ // is how the writer spells "no census was recorded". It is deliberately
1368
+ // NOT coerced with `Number(...)`: a coercion would turn a hand-edited
1369
+ // `checkBearingPractices: none` into `NaN` and a blank into `0`, and
1370
+ // that `0` would be a claim the document never made.
1371
+ checkBearingPractices: typeof vault.checkBearingPractices === 'number' &&
1372
+ Number.isInteger(vault.checkBearingPractices) &&
1373
+ vault.checkBearingPractices >= 0
1374
+ ? vault.checkBearingPractices
1375
+ : null,
1376
+ tier: asStringOrNull(vault.tier),
1377
+ source: asStringOrNull(vault.source),
1378
+ // VAULT-EGRESS-1: a trace written before the pin was display-safe carries the raw
1379
+ // coordinate; normalised here, at the one parse site every persisted-trace reader shares.
1380
+ repo: vaultMod.redactCloneTarget(asStringOrNull(vault.repo)),
1381
+ },
1382
+ practices,
1383
+ notes: asArray(predicate.notes).map((n) => asString(n)),
1384
+ override,
1385
+ },
1386
+ };
1387
+ return { present: true, reason: null, statement };
1388
+ }
1389
+ // ─── The validator (Phase 03-04, TRACE-04 / D-09) ─────────────────────────────
1390
+ //
1391
+ // SCOPE, AND WHY THE SEAM SITS EXACTLY HERE. This validator implements the
1392
+ // checks that need NO vault read: the block is there, it parsed, the required
1393
+ // keys are present, the pin is non-null when the state is `loaded`, the
1394
+ // dispositions cover the emitted id set one-to-one, every status is in the
1395
+ // three-value vocabulary, and every `deviated` entry carries a non-empty
1396
+ // reason. Plus the version discipline the frozen document states.
1397
+ //
1398
+ // Phase 3 named TWO checks as belonging to Phase 4 and deliberately did not
1399
+ // build them. BOTH are now closed, and the record of what was deferred is kept
1400
+ // rather than deleted — a deleted note loses the fact that the gap existed:
1401
+ //
1402
+ // 1. resolving each practice id against the vault manifest at the pinned SHA
1403
+ // — CLOSED by plan 04-05 (TGATE-01), and closed WITHOUT crossing the seam.
1404
+ // It never needed a vault read: 04-02 recorded the manifest's practice-ID
1405
+ // universe INTO the statement (D-01), so resolution is set membership over
1406
+ // data already in hand. The seam Phase 3 drew — "does the check have to
1407
+ // read the vault" — is exactly what let it close: the answer was no.
1408
+ // 2. proving the executor's disposition list covers the planner's — CLOSED by
1409
+ // plan 04-07 (TGATE-01, D-09), and NOT inside this function. It compares
1410
+ // TWO statements, which a single-statement validator cannot do, so the
1411
+ // closure took the form of a SECOND entry point in this leaf,
1412
+ // `comparePracticeCoverage`, which the gate handler composes into its own
1413
+ // verdict. Nothing below compares two statements, and nothing below should.
1414
+ //
1415
+ // The seam is a PROPERTY OF THE CHECK — whether it has to read the vault — not
1416
+ // a judgement about difficulty. Nothing in this region calls the manifest
1417
+ // reader or the practice matcher; an integration fence asserts that
1418
+ // mechanically over this function's source, and it is what keeps the verdict a
1419
+ // function of the record rather than of the machine it ran on.
1420
+ //
1421
+ // A NOTE ON THE PARSER AND THE REQUIRED-KEY CHECK. `parseVaultTraceBlock`
1422
+ // NORMALISES: it always constructs a full statement, filling an absent scalar
1423
+ // with `''` or `null`. So on the handler's path a missing key arrives as a
1424
+ // present-but-empty one, and the required-key check is not what catches it —
1425
+ // the VALUE checks below are (an empty `_type` fails the literal comparison, a
1426
+ // null `ref` at `loaded` fails the pin, an empty `status` at execute fails
1427
+ // coverage). The required-key list is nonetheless the load-bearing contract
1428
+ // surface: it is what `docs/vault-trace-format.md` states, what plan 03-08
1429
+ // compares the document against from both ends, and what a caller building a
1430
+ // statement by hand — as Phase 4 and Phase 5 will — is checked against.
1431
+ /**
1432
+ * The required-key list, per stage, written in the SAME path vocabulary as the
1433
+ * freeze table in `docs/vault-trace-format.md`: the `vault.` prefix is the
1434
+ * frontmatter key, and `[]` means "for every element of that list".
1435
+ *
1436
+ * The two stages differ in exactly one entry each, which is the document's own
1437
+ * plan/execute split: a plan-stage subject entry carries `uri` and no digest
1438
+ * (the governed files do not exist yet), an execute-stage one carries the
1439
+ * `gitCommit` digest that makes the statement falsifiable.
1440
+ *
1441
+ * Keys the document marks **required key** (present, value may be null) are in
1442
+ * this list; keys it marks optional — `reason`, `scope`, `severity`, `tasks`,
1443
+ * `notes` — are not.
1444
+ */
1445
+ const TRACE_REQUIRED_KEYS_COMMON = [
1446
+ 'vault._type',
1447
+ 'vault.predicateType',
1448
+ 'vault.schemaVersion',
1449
+ 'vault.subject',
1450
+ 'vault.subject[].name',
1451
+ 'vault.predicate.stage',
1452
+ 'vault.predicate.plan',
1453
+ 'vault.predicate.generatedAt',
1454
+ 'vault.predicate.vault.state',
1455
+ 'vault.predicate.vault.reason',
1456
+ 'vault.predicate.vault.ref',
1457
+ 'vault.predicate.vault.resolvedSha',
1458
+ 'vault.predicate.vault.tier',
1459
+ 'vault.predicate.vault.source',
1460
+ 'vault.predicate.vault.repo',
1461
+ 'vault.predicate.practices',
1462
+ 'vault.predicate.practices[].id',
1463
+ 'vault.predicate.practices[].status',
1464
+ 'vault.predicate.override',
1465
+ ];
1466
+ const TRACE_REQUIRED_KEYS = Object.freeze({
1467
+ plan: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].uri']),
1468
+ execute: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].digest.gitCommit']),
1469
+ });
1470
+ /**
1471
+ * The one required key whose failure the generic message could not resolve
1472
+ * (03-REVIEW WR-10). A governed file with no commit yields no digest, so the
1473
+ * execute-stage trace blocks — and the generic message sent the operator back
1474
+ * to the scaffold, which re-emits the same digest-less entry and the same
1475
+ * block, indefinitely.
1476
+ *
1477
+ * The requirement itself is NOT relaxed: the per-stage table is the frozen,
1478
+ * documented contract, and a digest is what makes an execute-stage statement
1479
+ * falsifiable. Only the message changes, and only for this one path.
1480
+ */
1481
+ const EXECUTE_DIGEST_KEY = 'vault.subject[].digest.gitCommit';
1482
+ const EXECUTE_DIGEST_REMEDY = ' A governed file in this trace has no git commit, so no digest could be recorded.' +
1483
+ ' Commit the governed file first, then re-run the execute-stage trace scaffold —' +
1484
+ ' re-running the scaffold against an uncommitted file produces the same digest-less' +
1485
+ ' entry and the same block.';
1486
+ /**
1487
+ * The command that regenerates a trace block, named in any failure message that
1488
+ * has a regeneration remedy.
1489
+ *
1490
+ * D-12: the execute-side gate runs in the ORCHESTRATOR, after the SUMMARY has
1491
+ * been rescued out of the executor's worktree and before wave cleanup. By then
1492
+ * the worktree that wrote the artifact is gone, so the remedy can never be
1493
+ * phrased as re-running or asking whoever produced it — it has to be something
1494
+ * the orchestrator can do on its own, against the file named in the message.
1495
+ *
1496
+ * ONE constant, so the plan that ships the writer changes it in one place. It
1497
+ * lives in this leaf rather than in the gate handler (04-05) because the
1498
+ * validator now needs it too, and the handler already imports this module —
1499
+ * the reverse edge would be a cycle (the D-12 split's own lesson).
1500
+ *
1501
+ * The remedy names `pp_run`, the launcher every workflow binds in its
1502
+ * preamble and ships as `pp-core/bin/pp_run`, because a bare `pp-tools` can
1503
+ * resolve to a different install than the project's own. INVARIANT 1 derives
1504
+ * its floor from the command's first token, so the floor moved with the rename.
1505
+ * The literal pins live in the two vault-trace suites and vault-status-check.
1506
+ */
1507
+ const TRACE_REGENERATE_COMMAND = 'pp_run vault trace scaffold --stage <plan|execute> --plan <plan-id> <files_modified...>';
1508
+ /**
1509
+ * Stable, machine-readable problem kinds. A consumer branches on these; the
1510
+ * `detail` string is for a human and is not a contract.
1511
+ *
1512
+ * The two coverage directions carry DIFFERENT kinds on purpose: "an emitted id
1513
+ * has no disposition" and "a disposition names an id nobody emitted" are two
1514
+ * defects with two different fixes, and collapsing them into one message makes
1515
+ * the second unfixable.
1516
+ */
1517
+ const TRACE_PROBLEM_KIND = Object.freeze({
1518
+ ABSENT: 'trace_absent',
1519
+ UNPARSABLE: 'trace_unparsable',
1520
+ MALFORMED_INPUT: 'trace_malformed_input',
1521
+ MISSING_REQUIRED_KEY: 'missing_required_key',
1522
+ STAGE_MISMATCH: 'stage_mismatch',
1523
+ STATEMENT_TYPE_MISMATCH: 'statement_type_mismatch',
1524
+ PREDICATE_TYPE_MISMATCH: 'predicate_type_mismatch',
1525
+ SCHEMA_VERSION_INVALID: 'schema_version_invalid',
1526
+ SCHEMA_VERSION_UNSUPPORTED: 'schema_version_unsupported',
1527
+ VAULT_STATE_INVALID: 'vault_state_invalid',
1528
+ PIN_MISSING: 'pin_missing',
1529
+ // 04-05 (TGATE-01, D-01): the statement-shape sibling of PIN_MISSING, and it
1530
+ // sits here beside it rather than with the practice-entry family because the
1531
+ // defect is of the RECORD AS A WHOLE — this trace is incomplete — not of any
1532
+ // one entry. It arises from the same read as the pin: a loaded run that wrote
1533
+ // a pin but no universe recorded half of what it saw. Filing it against an
1534
+ // entry would mean naming an offending id, and there is none to name — every
1535
+ // id is equally unresolvable when the universe is absent.
1536
+ UNIVERSE_MISSING: 'practice_universe_missing',
1537
+ // 04-07 (TGATE-01, D-03): the statement-shape sibling of PIN_MISSING and
1538
+ // UNIVERSE_MISSING, and — uniquely in this table — ADVISORY. The handler
1539
+ // classifies it so, and it must NEVER contribute to a block.
1540
+ //
1541
+ // WHY ADVISORY, ARGUED. Whether the vault has MOVED since this trace was
1542
+ // written is a different question from whether this trace is VALID. The
1543
+ // statement is self-verifying: it carries its own pin and its own universe,
1544
+ // so its verdict is a function of the record rather than of the machine that
1545
+ // happens to hold a cache. A blocking divergence would invert that — every
1546
+ // machine whose cache advanced past the pin would fail its build for a fact
1547
+ // about the cache, not about the artifact. D-03's binding content is "a note,
1548
+ // never a block", and this member is that note's carrier.
1549
+ //
1550
+ // It is a ROUTER-side emission, never a validator one: detecting it requires
1551
+ // reading the local manifest, and FENCE 1 asserts the validator reads no
1552
+ // manifest. The kind lives here because this table owns STATEMENT-LEVEL kinds
1553
+ // — defects of a trace statement, which is what `pin_diverged` names — not
1554
+ // because the validator emits it.
1555
+ //
1556
+ // 04-11 CORRECTS THE CLAIM THAT USED TO SIT HERE. It read: "the table is the
1557
+ // one home for a machine-readable kind". That was never true — the gate
1558
+ // handler emitted nine ENVELOPE kinds as inline string literals with no table
1559
+ // at all, and this comment's confidence was part of why nobody noticed. There
1560
+ // are now two tables and the split is BY CATEGORY:
1561
+ //
1562
+ // - this one owns kinds that describe a STATEMENT: its shape, its version
1563
+ // discipline, its pin, its ledger entries, and the comparison of two
1564
+ // statements against each other;
1565
+ // - `TRACE_HANDLER_KIND` (src/check-command-router.cts) owns kinds that
1566
+ // describe the ARGUMENT the gate was invoked with, or the artifact SET it
1567
+ // found — neither of which a statement validator can see, because it is
1568
+ // handed one statement at a time and no argv at all.
1569
+ //
1570
+ // Stated as a category, and NO MEMBER of the other table is named here. A
1571
+ // list of the router's kinds inside a leaf comment is a stale claim waiting
1572
+ // to happen, and a stale list of somewhere else's kinds is the exact defect
1573
+ // 04-11 exists to stop repeating.
1574
+ PIN_DIVERGED: 'pin_diverged',
1575
+ COVERAGE_MISSING_DISPOSITION: 'coverage_missing_disposition',
1576
+ COVERAGE_UNKNOWN_DISPOSITION: 'coverage_unknown_disposition',
1577
+ COVERAGE_DUPLICATE_ID: 'coverage_duplicate_id',
1578
+ // 04-07 (TGATE-01, TGATE-03, D-09): the coverage family's THIRD direction,
1579
+ // and the first one that spans TWO artifacts. The two members above compare
1580
+ // an emitted set against a disposition set INSIDE one statement; this one
1581
+ // asks whether the executor's record accounts for the planner's, plan by
1582
+ // plan. One problem PER uncovered identifier, and filed against the SUMMARY —
1583
+ // the plan is not the file an operator has to fix.
1584
+ //
1585
+ // A PHASE AGGREGATE WAS REJECTED, and the reason belongs here beside the
1586
+ // member: an aggregate union passes VACUOUSLY. If one plan dropped every
1587
+ // practice while a sibling happened to carry the same identifiers, the union
1588
+ // still covers, and a gate that cannot fail is not a gate. It also cannot
1589
+ // satisfy TGATE-03, which requires naming the file to fix — an aggregate has
1590
+ // no file to name.
1591
+ COVERAGE_PLAN_UNCOVERED: 'coverage_plan_uncovered',
1592
+ // 04-07 (TGATE-01, D-10): a plan statement with no summary counterpart at
1593
+ // all. Coverage family, because "nothing covered any of it" is the limiting
1594
+ // case of the member above rather than a different defect.
1595
+ //
1596
+ // Emitted only where the CALLER declares the point strict. Mid-phase an
1597
+ // unpaired plan is normal — waves are still running and the execute-side
1598
+ // point fires once per wave — so a gate that always emitted this would have
1599
+ // blocked Phase 3 continuously across all seven of its waves. At `ship:pre`
1600
+ // and `verify:post` every plan must have an executed counterpart, and a phase
1601
+ // shipping with a planned-but-never-executed plan is a real defect this gate
1602
+ // can see cheaply.
1603
+ COVERAGE_PAIR_MISSING: 'coverage_pair_missing',
1604
+ // 04-07 (TGATE-01, D-11): the pair exists but the summary was generated
1605
+ // STRICTLY BEFORE its plan — a re-planned plan whose summary answers an
1606
+ // earlier version of it. Its own defect, not a coverage gap: the identifiers
1607
+ // may all match and the record still prove nothing.
1608
+ //
1609
+ // CR-02's lesson binds this member's DETAIL, not just its existence. Phase 3
1610
+ // shipped a block of exactly this shape whose message named no remedy the
1611
+ // reader could perform, and it had to be fixed. So the detail extends the
1612
+ // single `TRACE_REGENERATE_COMMAND` constant and is phrased for the
1613
+ // ORCHESTRATOR: at the execute-side point the executor's worktree is already
1614
+ // gone, and a remedy addressed to whoever wrote the artifact is a trap.
1615
+ COVERAGE_PAIR_STALE: 'coverage_pair_stale',
1616
+ // 04-13 (TGATE-01, CR-01): the pair family's FOURTH member, and the one that
1617
+ // closes this phase's own fail-open. A pair whose halves are both present and
1618
+ // both well-formed can still prove nothing. If either half records a vault
1619
+ // state other than `loaded`, the ledger answering the plan was written
1620
+ // against a vault that was never read — so an empty ledger and a complete one
1621
+ // are indistinguishable, and the two judgements above are both vacuous.
1622
+ // Until this member the comparison returned early on that state with no
1623
+ // problem of any kind, so the strict `ship:pre` and `verify:post` gates
1624
+ // answered `block: false` with ZERO problems over a record accounting for
1625
+ // nothing, and the phase shipped recorded as governed.
1626
+ //
1627
+ // A DEFECT OF THE PAIR — argued, not asserted, which is why it sits here
1628
+ // beside COVERAGE_PAIR_MISSING and COVERAGE_PAIR_STALE.
1629
+ //
1630
+ // - NOT the practice-entry family. An unprovable pair names NO offending
1631
+ // identifier, because when the answering half proved nothing every
1632
+ // identifier is equally unaccounted for. Filing one problem per promised
1633
+ // id would hand an operator N things to chase for ONE cause, and point
1634
+ // each of them at a ledger section where the fix does not live.
1635
+ // - NOT the statement-shape family. Neither half is malformed: each is a
1636
+ // CORRECT record of what really happened (D-04), and Phase 3 requires a
1637
+ // degraded run to write exactly this record. Filing it against a
1638
+ // statement would say the record is wrong when the record is right.
1639
+ // - It IS the relation that fails: this summary cannot answer this plan.
1640
+ // That is the same category as "there is no summary" and "the summary
1641
+ // answers an earlier plan", and it is one problem per PAIR for the same
1642
+ // reason those are.
1643
+ //
1644
+ // EMITTED ONLY UNDER THE STRICT MODE, on COVERAGE_PAIR_MISSING's reasoning
1645
+ // exactly (D-10). At the two write points the question is whether the record
1646
+ // is CORRECT, and a degraded record is correct. At `ship:pre` and
1647
+ // `verify:post` the question is whether it PROVES the phase was governed, and
1648
+ // it does not. One artifact, two questions, one flag already parsed for the
1649
+ // sibling member above — see the state gate in `comparePracticeCoverage` for
1650
+ // the whole argument, including the remedy asymmetry that makes the strict
1651
+ // arm a gate rather than a trap.
1652
+ COVERAGE_PAIR_UNPROVABLE: 'coverage_pair_unprovable',
1653
+ // 03-REVIEW CR-03: the READ path's half of the charset the write path has
1654
+ // always applied. Sits with the practice-entry family because it is a defect
1655
+ // of one entry, not of the statement's shape.
1656
+ ID_CHARSET_INVALID: 'practice_id_invalid',
1657
+ // 04-05 (TGATE-01, D-01): the read side's verdict on the universe 04-02 put
1658
+ // into the statement. Sits with the practice-entry family beside the charset
1659
+ // kind — and for the same reason CR-03 put that one here — because it is a
1660
+ // defect of ONE entry: this id, in this ledger, resolves nowhere. That
1661
+ // entry-level placement is what lets the detail name the id at fault, which
1662
+ // is TGATE-03's core clause and is only possible at all because D-02 chose an
1663
+ // id LIST over a digest: a digest could prove disagreement but never say
1664
+ // which id caused it.
1665
+ ID_UNRESOLVED: 'practice_id_unresolved',
1666
+ STATUS_OUT_OF_VOCABULARY: 'status_out_of_vocabulary',
1667
+ DEVIATED_REASON_MISSING: 'deviated_reason_missing',
1668
+ // ── THE BODY-BLOCK FAMILY (D-05-07) ────────────────────
1669
+ //
1670
+ // Seven kinds that compare the `vault:` LEDGER against the `<vault_practices>`
1671
+ // BODY BLOCK of the same plan artifact. Measured on a fresh project: seven
1672
+ // perturbations that removed or
1673
+ // gutted the body block — the only practice text the executor reads — all
1674
+ // answered `block: false`, because nothing read the block back. A plan could
1675
+ // record full governance and deliver none. These kinds end that silence.
1676
+ //
1677
+ // WHY THE LEAF TABLE. They are defects of ONE artifact's record — its two
1678
+ // halves disagree — not of the argument the gate was invoked with nor of the
1679
+ // artifact SET, which is the category split the two tables draw. The body is
1680
+ // read by `validatePracticesBody`, which is handed the artifact text and the
1681
+ // parsed statement and nothing else: no manifest, no matcher, no disk (D-01).
1682
+ //
1683
+ // ONE-PER-ARTIFACT vs ONE-PER-ID, argued. `absent`, `duplicate`,
1684
+ // `state_mismatch` and `empty` are facts about the block as a whole and name
1685
+ // no identifier, so each is
1686
+ // emitted once and the per-id checks are skipped behind it — a block that
1687
+ // renders nothing would otherwise yield one problem per promised id for ONE
1688
+ // cause (the COVERAGE_PAIR_UNPROVABLE argument). The id and text kinds name
1689
+ // the identifier at fault, one problem each (TGATE-03).
1690
+ //
1691
+ // ALL SEVEN BLOCK. None is advisory: each means the executor is handed less
1692
+ // than, or other than, what the ledger records, which is exactly a record
1693
+ // claiming more governance than was delivered.
1694
+ PRACTICES_BLOCK_ABSENT: 'practices_block_absent',
1695
+ PRACTICES_BLOCK_DUPLICATE: 'practices_block_duplicate',
1696
+ // The block states the vault was NOT read (the absent / unreadable /
1697
+ // could-not-be-checked sentence) while the ledger records `state: loaded`.
1698
+ // Its own kind rather than a clause on `empty`, because it fires whatever the
1699
+ // ledger promises — including nothing — and names a different repair target:
1700
+ // the two halves disagree about the RUN, not merely about a count.
1701
+ PRACTICES_BLOCK_STATE_MISMATCH: 'practices_block_state_mismatch',
1702
+ PRACTICES_BLOCK_EMPTY: 'practices_block_empty',
1703
+ PRACTICES_BLOCK_ID_MISSING: 'practices_block_id_missing',
1704
+ PRACTICES_BLOCK_ID_UNEXPECTED: 'practices_block_id_unexpected',
1705
+ PRACTICES_BLOCK_TEXT_MISMATCH: 'practices_block_text_mismatch',
1706
+ VALIDATOR_ERROR: 'validator_error',
1707
+ });
1708
+ function isPlainObject(v) {
1709
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1710
+ }
1711
+ /** Does `obj` own every segment of a dotted path with no `[]` in it? */
1712
+ function hasPlainPath(obj, dotted) {
1713
+ if (dotted === '')
1714
+ return isPlainObject(obj);
1715
+ let cursor = obj;
1716
+ for (const seg of dotted.split('.')) {
1717
+ if (!isPlainObject(cursor))
1718
+ return false;
1719
+ if (!Object.prototype.hasOwnProperty.call(cursor, seg))
1720
+ return false;
1721
+ cursor = cursor[seg];
1722
+ }
1723
+ return true;
1724
+ }
1725
+ function readPlainPath(obj, dotted) {
1726
+ if (dotted === '')
1727
+ return obj;
1728
+ let cursor = obj;
1729
+ for (const seg of dotted.split('.')) {
1730
+ if (!isPlainObject(cursor))
1731
+ return undefined;
1732
+ cursor = cursor[seg];
1733
+ }
1734
+ return cursor;
1735
+ }
1736
+ /**
1737
+ * Presence of one required-key path against a statement. The `vault.` prefix is
1738
+ * stripped because it names the frontmatter key, not a key of the statement.
1739
+ *
1740
+ * An EMPTY list satisfies a `[]` path vacuously and deliberately: the document
1741
+ * says `subject: []` is legal for a doc-only plan, and requiring `name` of a
1742
+ * list with no elements would reject that legal shape.
1743
+ */
1744
+ function hasRequiredKey(statement, docPath) {
1745
+ const rel = docPath.replace(/^vault\.?/, '');
1746
+ const arrIdx = rel.indexOf('[]');
1747
+ if (arrIdx === -1)
1748
+ return hasPlainPath(statement, rel);
1749
+ const head = rel.slice(0, arrIdx);
1750
+ const tail = rel.slice(arrIdx + 2).replace(/^\./, '');
1751
+ const arr = readPlainPath(statement, head);
1752
+ if (!Array.isArray(arr))
1753
+ return false;
1754
+ if (tail === '')
1755
+ return true;
1756
+ return arr.every((el) => hasPlainPath(el, tail));
1757
+ }
1758
+ /** A short, bounded rendering of an arbitrary value for a problem detail. */
1759
+ function describeValue(v) {
1760
+ if (v === null)
1761
+ return 'null';
1762
+ if (v === undefined)
1763
+ return 'absent';
1764
+ if (typeof v === 'string')
1765
+ return v.length > 120 ? `${v.slice(0, 120)}…` : v;
1766
+ if (typeof v === 'number' || typeof v === 'boolean')
1767
+ return String(v);
1768
+ if (Array.isArray(v))
1769
+ return `a list of ${v.length}`;
1770
+ return 'an object';
1771
+ }
1772
+ /**
1773
+ * A statement that reached the parser but from which nothing was read. The
1774
+ * parser NORMALISES an unreadable block into a full-shaped statement with empty
1775
+ * scalars, so "did not parse" cannot be detected from key presence — it is
1776
+ * detected from every identifying value being empty at once. A genuine
1777
+ * statement always carries `_type`.
1778
+ */
1779
+ function statementReadNothing(st) {
1780
+ const predicate = isPlainObject(st['predicate']) ? st['predicate'] : {};
1781
+ const subject = st['subject'];
1782
+ const practices = predicate['practices'];
1783
+ return ((st['_type'] ?? '') === '' &&
1784
+ (st['predicateType'] ?? '') === '' &&
1785
+ (predicate['stage'] ?? '') === '' &&
1786
+ (predicate['plan'] ?? '') === '' &&
1787
+ (!Array.isArray(subject) || subject.length === 0) &&
1788
+ (!Array.isArray(practices) || practices.length === 0));
1789
+ }
1790
+ /**
1791
+ * Validate a trace statement against the structure-and-pin half of the contract
1792
+ * (D-09). NEVER throws, for any input: this sits on the read path for an
1793
+ * attacker-influenceable document, handlers in this repository never throw, and
1794
+ * a handler is what calls this.
1795
+ *
1796
+ * Returns EVERY defect rather than the first — an operator fixing a trace wants
1797
+ * the whole list at once, and Phase 4's gate output builds on the same list.
1798
+ *
1799
+ * @param input A statement, a `parseVaultTraceBlock` result, or nothing.
1800
+ * @param options `stage` is the stage being validated; `file` names the
1801
+ * artifact in every problem; `dispositions` supplies the
1802
+ * judgment half when the caller holds it separately.
1803
+ */
1804
+ function validateTraceStatement(input, options) {
1805
+ const opts = isPlainObject(options) ? options : {};
1806
+ const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
1807
+ const stage = typeof opts.stage === 'string' ? opts.stage : '';
1808
+ const problems = [];
1809
+ // `tail` is optional and is omitted from the pushed object when absent, so a
1810
+ // problem that carries no remedy is byte-identical to what it was before the
1811
+ // field existed — an `undefined` property would change every deepEqual in
1812
+ // every consumer for no behavioural gain.
1813
+ const add = (kind, detail, tail) => {
1814
+ problems.push(tail === undefined ? { file, kind, detail } : { file, kind, detail, tail });
1815
+ };
1816
+ try {
1817
+ // ── the block is there at all (D-11: a hard cut, no grandfather clause) ──
1818
+ let statement = input;
1819
+ if (isPlainObject(input) && typeof input['present'] === 'boolean' && 'statement' in input) {
1820
+ if (input['present'] !== true) {
1821
+ add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
1822
+ return { block: true, problems };
1823
+ }
1824
+ statement = input['statement'];
1825
+ }
1826
+ if (statement === null || statement === undefined) {
1827
+ add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
1828
+ return { block: true, problems };
1829
+ }
1830
+ if (!isPlainObject(statement)) {
1831
+ add(TRACE_PROBLEM_KIND.MALFORMED_INPUT, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is not a mapping (found ${describeValue(statement)}).`);
1832
+ return { block: true, problems };
1833
+ }
1834
+ // ── it parsed ──
1835
+ if (statementReadNothing(statement)) {
1836
+ add(TRACE_PROBLEM_KIND.UNPARSABLE, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is present but nothing could be read from it — no statement type, no stage and no practice entries parsed. The block is unreadable, which is NOT the same as an empty practice set.`);
1837
+ return { block: true, problems };
1838
+ }
1839
+ // ── required keys present ──
1840
+ const requiredKeys = stage === 'plan' || stage === 'execute'
1841
+ ? TRACE_REQUIRED_KEYS[stage]
1842
+ : TRACE_REQUIRED_KEYS.plan;
1843
+ for (const docPath of requiredKeys) {
1844
+ if (!hasRequiredKey(statement, docPath)) {
1845
+ add(TRACE_PROBLEM_KIND.MISSING_REQUIRED_KEY, `required key ${docPath} is missing from the trace in ${file}.` +
1846
+ (docPath === EXECUTE_DIGEST_KEY ? EXECUTE_DIGEST_REMEDY : ''));
1847
+ }
1848
+ }
1849
+ // ── version discipline (TRACE-05) ──
1850
+ // An unknown OPTIONAL key is never inspected, and that silence is the point:
1851
+ // it is what makes an additive minor change non-breaking for the Phase 4 and
1852
+ // Phase 5 consumers. An unexpected MAJOR marker, by contrast, fails loudly
1853
+ // and names both values — a consumer silently accepting a future major is
1854
+ // the failure mode the whole freeze exists to prevent.
1855
+ if (statement['_type'] !== TRACE_STATEMENT_TYPE) {
1856
+ add(TRACE_PROBLEM_KIND.STATEMENT_TYPE_MISMATCH, `_type must be exactly "${TRACE_STATEMENT_TYPE}"; found "${describeValue(statement['_type'])}".`);
1857
+ }
1858
+ const acceptedTypes = [
1859
+ TRACE_PREDICATE_TYPE,
1860
+ ...(Array.isArray(opts.acceptedPredicateTypes)
1861
+ ? opts.acceptedPredicateTypes.filter((t) => typeof t === 'string' && t !== '')
1862
+ : []),
1863
+ ];
1864
+ if (!acceptedTypes.includes(statement['predicateType'])) {
1865
+ add(TRACE_PROBLEM_KIND.PREDICATE_TYPE_MISMATCH, `predicateType must be exactly "${TRACE_PREDICATE_TYPE}"` +
1866
+ (acceptedTypes.length > 1 ? ` or one of the ${acceptedTypes.length - 1} the org profile accepts` : '') +
1867
+ `; found "${describeValue(statement['predicateType'])}". The URI carries the MAJOR version — this reader is pinned to it and will not silently accept another.`);
1868
+ }
1869
+ const schemaVersion = statement['schemaVersion'];
1870
+ if (typeof schemaVersion !== 'number' || !Number.isInteger(schemaVersion)) {
1871
+ add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_INVALID, `schemaVersion must be an integer; found "${describeValue(schemaVersion)}".`);
1872
+ }
1873
+ else if (schemaVersion > TRACE_SCHEMA_VERSION) {
1874
+ add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_UNSUPPORTED, `schemaVersion ${schemaVersion} is above the highest version this reader knows (${TRACE_SCHEMA_VERSION}). A newer major is refused loudly rather than read as if it were understood.`);
1875
+ }
1876
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
1877
+ // The stage is the one field that tells the two traces apart, so a required
1878
+ // key whose value could be anything would not be a required key in any
1879
+ // useful sense. Validated against the stage the caller asked for.
1880
+ if (stage === 'plan' || stage === 'execute') {
1881
+ if (predicate['stage'] !== stage) {
1882
+ add(TRACE_PROBLEM_KIND.STAGE_MISMATCH, `predicate.stage must be "${stage}" for this artifact; found "${describeValue(predicate['stage'])}".`);
1883
+ }
1884
+ }
1885
+ // ── the pin, scoped to a LOADED state ──
1886
+ // A degraded statement legitimately carries a null pin (D-04), and a stale
1887
+ // cache is degraded WITH a live pin. A validator that checked the pin
1888
+ // unconditionally would reject exactly the record D-04 exists to produce.
1889
+ const vaultBlock = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
1890
+ const vaultState = vaultBlock['state'];
1891
+ if (vaultState !== 'loaded' && vaultState !== 'degraded') {
1892
+ add(TRACE_PROBLEM_KIND.VAULT_STATE_INVALID, `predicate.vault.state must be "loaded" or "degraded"; found "${describeValue(vaultState)}".`);
1893
+ }
1894
+ else if (vaultState === 'loaded') {
1895
+ for (const half of ['ref', 'resolvedSha']) {
1896
+ const value = vaultBlock[half];
1897
+ if (typeof value !== 'string' || value.trim() === '') {
1898
+ add(TRACE_PROBLEM_KIND.PIN_MISSING, `predicate.vault.${half} must be non-null when predicate.vault.state is "loaded"; found "${describeValue(value)}". Without both halves of the pin nothing about this run is provable.`);
1899
+ }
1900
+ }
1901
+ }
1902
+ // ── the practice entries: vocabulary, deviated reasons, coverage ──
1903
+ const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
1904
+ const emittedIds = [];
1905
+ const seenIds = new Set();
1906
+ const duplicateIds = new Set();
1907
+ const decidedIds = new Set();
1908
+ for (const raw of practices) {
1909
+ const entry = isPlainObject(raw) ? raw : {};
1910
+ const id = typeof entry['id'] === 'string' ? entry['id'] : '';
1911
+ // 03-REVIEW CR-03: the charset the write path has always applied, applied
1912
+ // here too. `docs/vault-trace-format.md` states as a FACT that the id is
1913
+ // "bounded, charset-validated, and always emitted quoted"; without this
1914
+ // the claim held only where the engine writes an id, and a ledger whose
1915
+ // id was a whole English sentence validated clean (T-03-31).
1916
+ //
1917
+ // Reads ONLY the module-local pattern — nothing on disk, and nothing from
1918
+ // the vault. Resolving an id against the manifest is Phase 4's.
1919
+ //
1920
+ // Deliberately does NOT `continue`: the entry still joins the duplicate
1921
+ // and coverage sets below, because suppressing it would turn one defect
1922
+ // into a second, quieter one.
1923
+ if (typeof entry['id'] !== 'string' || !TRACE_ID_PATTERN.test(entry['id'])) {
1924
+ add(TRACE_PROBLEM_KIND.ID_CHARSET_INVALID, `practice id "${collapseControlChars(boundedToken(entry['id']))}" is not in the permitted charset (alphanumerics plus . _ -, first character alphanumeric, max 64).`);
1925
+ }
1926
+ emittedIds.push(id);
1927
+ if (seenIds.has(id))
1928
+ duplicateIds.add(id);
1929
+ seenIds.add(id);
1930
+ const status = entry['status'];
1931
+ const statusText = typeof status === 'string' ? status : '';
1932
+ const inVocabulary = TRACE_STATUS_VALUES.includes(statusText);
1933
+ // The empty string is the documented PLAN-stage value — the slot the
1934
+ // executor fills in later — so it is not an out-of-vocabulary word there.
1935
+ const emptyIsLegal = stage !== 'execute';
1936
+ if (!inVocabulary && !(statusText === '' && emptyIsLegal)) {
1937
+ add(TRACE_PROBLEM_KIND.STATUS_OUT_OF_VOCABULARY, `practice "${id}" carries status "${describeValue(status)}", which is not one of ${TRACE_STATUS_VALUES.join(', ')}.`);
1938
+ }
1939
+ if (inVocabulary)
1940
+ decidedIds.add(id);
1941
+ if (statusText === 'deviated') {
1942
+ const reason = entry['reason'];
1943
+ if (typeof reason !== 'string' || reason.trim() === '') {
1944
+ add(TRACE_PROBLEM_KIND.DEVIATED_REASON_MISSING, `practice "${id}" is recorded as deviated with no reason. A deviation without a stated reason is indistinguishable from a practice that was never considered.`);
1945
+ }
1946
+ }
1947
+ }
1948
+ for (const dup of duplicateIds) {
1949
+ add(TRACE_PROBLEM_KIND.COVERAGE_DUPLICATE_ID, `practice id "${dup}" appears more than once in the emitted set, so its disposition is ambiguous.`);
1950
+ }
1951
+ // ── identifier resolution, scoped to a LOADED state (04-05, TGATE-01) ────
1952
+ //
1953
+ // A PURE SET OPERATION over data already in the statement. The universe is
1954
+ // an INPUT — 04-02 recorded it from the SAME vault read that produced the
1955
+ // pin — and is never re-derived here. A validator that went to disk for it
1956
+ // would make the verdict a function of the machine it ran on rather than of
1957
+ // the record, which is precisely the environment-dependence D-01 was
1958
+ // written to remove: the same trace must yield the same verdict on any
1959
+ // machine, with or without a cache. Nothing below opens a file, spawns a
1960
+ // process, reads an environment variable or consults the practice matcher.
1961
+ //
1962
+ // SUPPRESSED — not failed, and not silently skipped — when the state is not
1963
+ // `loaded`, on the pin check's reasoning verbatim. A degraded run
1964
+ // legitimately carries a null pin and no universe (D-04), and Phase 3
1965
+ // REQUIRES such a run to write a trace recording the degraded state and its
1966
+ // reason. The miss is therefore already a recorded fact in the artifact; a
1967
+ // validator that additionally rejected the record would be rejecting a
1968
+ // correct record of a real failure. What keeps this from being an escape
1969
+ // hatch is the other half 04-02 pinned: the state is derived by the CLI
1970
+ // from the vault fixture and cannot be selected through any
1971
+ // statement-builder option.
1972
+ if (vaultState === 'loaded') {
1973
+ const universe = vaultBlock['practiceUniverse'];
1974
+ if (!Array.isArray(universe)) {
1975
+ // Fail closed, on the posture the handler already holds at its
1976
+ // unresolved-phase branch: a check that proved nothing must not read as
1977
+ // a check that passed. Unreachable from the shipped writer — every
1978
+ // loaded statement it emits carries a universe (04-02) — so this names
1979
+ // a hand-authored statement, and the remedy is one the reader can
1980
+ // perform without the worktree that wrote the artifact.
1981
+ //
1982
+ // 04-11 (CR-05): the remedy is the TAIL, never the head. The head names
1983
+ // the artifact — an untrusted, unbounded-in-practice path — and the
1984
+ // whole of that sentence used to be followed by the command inside a
1985
+ // single 400-character budget. A 138-character artifact path, which is
1986
+ // an ordinary workstream phase path rather than a hostile one, printed
1987
+ // a PARTIAL command an orchestrator agent could run.
1988
+ add(TRACE_PROBLEM_KIND.UNIVERSE_MISSING, `the trace in ${file} records no practice-identifier universe (predicate.vault.practiceUniverse), so no identifier it lists can be resolved and the ledger proves nothing.`, ` Regenerate the trace in place: ${TRACE_REGENERATE_COMMAND}`);
1989
+ }
1990
+ else {
1991
+ // Built ONCE per validation rather than scanned per id: a linear scan
1992
+ // per identifier turns a large universe into a quadratic walk (T-4-22).
1993
+ const universeIds = new Set(universe.filter((v) => typeof v === 'string'));
1994
+ // `seenIds` is the emitted set already de-duplicated, so a duplicated
1995
+ // unresolvable id yields one problem, not one per occurrence — the
1996
+ // duplication is its own defect above and has its own kind.
1997
+ for (const id of seenIds) {
1998
+ if (universeIds.has(id))
1999
+ continue;
2000
+ // ONE problem PER unresolved id, never one aggregate: the detail has
2001
+ // to name the id at fault (TGATE-03), and a single problem standing
2002
+ // for three broken ids makes the problem count meaningless.
2003
+ add(TRACE_PROBLEM_KIND.ID_UNRESOLVED, `practice id "${collapseControlChars(boundedToken(id))}" is not in the practice-identifier universe this trace records for the vault at its pinned SHA, so it resolves to no practice.`);
2004
+ }
2005
+ }
2006
+ }
2007
+ // The 1:1 coverage check runs at EXECUTE stage: at plan stage nothing has
2008
+ // been decided yet by design, so every id would read as uncovered.
2009
+ if (stage === 'execute') {
2010
+ const supplied = Array.isArray(opts.dispositions) ? opts.dispositions : null;
2011
+ const dispositionIds = supplied
2012
+ ? new Set(supplied.map((d) => (isPlainObject(d) && typeof d['id'] === 'string' ? d['id'] : '')))
2013
+ : decidedIds;
2014
+ const emittedSet = new Set(emittedIds);
2015
+ const uncovered = [...emittedSet].filter((id) => !dispositionIds.has(id));
2016
+ if (uncovered.length > 0) {
2017
+ add(TRACE_PROBLEM_KIND.COVERAGE_MISSING_DISPOSITION, `these practice ids are in the emitted set with no recorded disposition: ${uncovered.map((id) => `"${id}"`).join(', ')}.`);
2018
+ }
2019
+ const unknown = [...dispositionIds].filter((id) => !emittedSet.has(id));
2020
+ if (unknown.length > 0) {
2021
+ add(TRACE_PROBLEM_KIND.COVERAGE_UNKNOWN_DISPOSITION, `these dispositions name a practice id the emitted set does not contain: ${unknown.map((id) => `"${id}"`).join(', ')}.`);
2022
+ }
2023
+ }
2024
+ return { block: problems.length > 0, problems };
2025
+ }
2026
+ catch (e) {
2027
+ // Totality is the contract, not a courtesy: a throw here would turn a
2028
+ // halt-on-error gate into a permanently stopped build (T-03-16).
2029
+ return {
2030
+ block: true,
2031
+ problems: [
2032
+ {
2033
+ file,
2034
+ kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
2035
+ detail: `the trace validator could not complete: ${boundedToken(String(e?.message ?? e))}`,
2036
+ },
2037
+ ],
2038
+ };
2039
+ }
2040
+ }
2041
+ /** The vault state a statement records, or `''` when it records none. */
2042
+ function traceVaultStateOf(statement) {
2043
+ if (!isPlainObject(statement))
2044
+ return '';
2045
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2046
+ const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
2047
+ return typeof vault['state'] === 'string' ? vault['state'] : '';
2048
+ }
2049
+ /**
2050
+ * The degraded reason a statement records, or `''` (04-13).
2051
+ *
2052
+ * The fifth sibling of the four accessors around it, in their register and
2053
+ * with their defensive shape: every hop is type-checked, and a statement that
2054
+ * carries none of them yields the empty string rather than throwing. The
2055
+ * comparison never throws, so nothing it calls may either.
2056
+ */
2057
+ function traceVaultReasonOf(statement) {
2058
+ if (!isPlainObject(statement))
2059
+ return '';
2060
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2061
+ const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
2062
+ return typeof vault['reason'] === 'string' ? vault['reason'] : '';
2063
+ }
2064
+ /** The non-empty practice identifiers a statement emits, in recorded order. */
2065
+ function traceEmittedIdsOf(statement) {
2066
+ if (!isPlainObject(statement))
2067
+ return [];
2068
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2069
+ const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
2070
+ const out = [];
2071
+ for (const raw of practices) {
2072
+ const entry = isPlainObject(raw) ? raw : {};
2073
+ const id = entry['id'];
2074
+ if (typeof id === 'string' && id !== '')
2075
+ out.push(id);
2076
+ }
2077
+ return out;
2078
+ }
2079
+ /** The plan identifier a statement records, or `''`. */
2080
+ function tracePlanIdOf(statement) {
2081
+ if (!isPlainObject(statement))
2082
+ return '';
2083
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2084
+ return typeof predicate['plan'] === 'string' ? predicate['plan'] : '';
2085
+ }
2086
+ /** The generation timestamp a statement records, or `''`. */
2087
+ function traceGeneratedAtOf(statement) {
2088
+ if (!isPlainObject(statement))
2089
+ return '';
2090
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2091
+ return typeof predicate['generatedAt'] === 'string' ? predicate['generatedAt'] : '';
2092
+ }
2093
+ /**
2094
+ * The performable remedy for an unprovable pair, as a TRUSTED TAIL (04-13).
2095
+ *
2096
+ * TWO ROUTES, ALWAYS BOTH, AND THAT IS THE WHOLE POINT. Phase 3's CR-02
2097
+ * established that a block naming no remedy the reader can perform is a trap,
2098
+ * and this block is emitted on SEVEN distinct degraded causes that do not behave
2099
+ * the same way. Read from the pin resolver above rather than from memory:
2100
+ *
2101
+ * - no vault cache directory, an absent manifest, an unreadable or malformed
2102
+ * manifest — and a stale cache whenever the vault is REACHABLE. Three (and
2103
+ * a half) causes a re-fetch clears: the strict points run on the main
2104
+ * checkout, so regenerating really does produce a loaded statement.
2105
+ * - a recorded MISS, and a stale cache whose refresh FAILS. Two causes
2106
+ * regeneration cannot clear at all: the fetch itself failed on credentials,
2107
+ * network or an unreachable repository, so re-fetching reproduces the miss
2108
+ * and regenerating reproduces the identical record. A tail offering only
2109
+ * "regenerate" would be CR-02's trap reintroduced on exactly these two.
2110
+ *
2111
+ * - a load at a tier that is not `client` (D-08-25). The sixth cause, and
2112
+ * the one NEITHER route above clears on its own: the vault was reached,
2113
+ * freshly and successfully, and it was the wrong vault. Re-fetching
2114
+ * reproduces the same tier and regenerating reproduces the same record,
2115
+ * because the project's own `client.vaultRepo` / `client.vaultRef` point
2116
+ * where they point. Its lead therefore names the configuration edit.
2117
+ *
2118
+ * - a client-tier cache fetched from a ref other than the configured
2119
+ * `client.vaultRef` (D-08-34, UAT F5). The seventh cause, and it needs NO
2120
+ * fourth lead: `vault fetch` reads the CONFIGURED ref, so a successful
2121
+ * fetch genuinely replaces the drifted cache and the default re-fetch lead
2122
+ * clears it. It reproduces only when that ref cannot be fetched — the same
2123
+ * "and a half" the stale cause already carries — and the tail names the
2124
+ * disable route in every case regardless. So its reason is phrased OUTSIDE
2125
+ * all three classifier substrings below, deliberately, and lands on the
2126
+ * re-fetch-first lead (D-08-36).
2127
+ *
2128
+ * So ALL THREE tails name BOTH routes, and the second route — turning the practices
2129
+ * capability off for this project, a local `.planning/config.json` edit — is
2130
+ * performable with the vault down, from the same checkout the gate ran in.
2131
+ * There is no third route: nothing overrides this kind.
2132
+ *
2133
+ * WHAT THE CLASSIFIER DOES, AND WHAT IT DELIBERATELY CANNOT DO. It reads the
2134
+ * recorded reason only to choose which route to state FIRST, so a reader on the
2135
+ * unreachable arm is not sent to re-run a command that will come back
2136
+ * identical. It selects between three FIXED strings; nothing from the artifact is
2137
+ * interpolated, so a hand-edited or hostile reason can change the emphasis and
2138
+ * can never change what is offered. Misclassification therefore costs an
2139
+ * operator one sentence of reading order, never a performable remedy — which is
2140
+ * why a substring probe over our own generated prose is an acceptable
2141
+ * mechanism here and would not be if it gated the routes themselves.
2142
+ */
2143
+ function unprovablePairTail(reason) {
2144
+ // THREE leads for SEVEN causes now (D-08-25, then D-08-34). The tier cause is tested FIRST
2145
+ // because it is the only one neither existing lead describes: the vault was
2146
+ // reached, freshly, successfully — at the WRONG TIER. Regenerating alone
2147
+ // reproduces it (the scaffold reads the cache, it does not fetch), and so
2148
+ // does restoring reachability (the shared vault was already reachable; that
2149
+ // is how it loaded). The project is pointed at the wrong vault, and only a
2150
+ // configuration edit moves it — which is why this lead names the key.
2151
+ //
2152
+ // A stale FALLBACK cache lands here rather than on the unreachable lead,
2153
+ // because `resolvePin` returns the tier reason ahead of the stale one: the
2154
+ // tier is the stronger fact and its remedy is the one that clears both.
2155
+ //
2156
+ // THE LEAD IS LENGTH-BUDGETED, NOT PHRASED TO TASTE, AND THE BUDGET WAS
2157
+ // MEASURED RATHER THAN ESTIMATED. `TRACE_DETAIL_CAP` is 400 and the router
2158
+ // gives the head `cap - tail.length - 1`. The tier cause's worst realistic
2159
+ // head is the BOTH-HALVES case — a fallback-tier plan and its summary are
2160
+ // written from the same cache, so both degrade together — which opens "both
2161
+ // halves of this pair record" and carries the reason bounded to
2162
+ // `boundedToken`'s 80 characters: 187 characters, measured. With the command
2163
+ // (87) and the disable sentence (63) fixed, the lead has 62 characters. A
2164
+ // longer one truncates the head that names the plan, which is the
2165
+ // truncated-remedy bug arriving from the other end.
2166
+ //
2167
+ // So the lead states the DIAGNOSIS the other two leads do not give — the
2168
+ // project is pointed at the wrong vault — plus the key to change and the
2169
+ // fact that a fetch must follow it. The fuller remedy (re-point
2170
+ // `client.vaultRepo` / `client.vaultRef`, re-fetch, regenerate) is spelled
2171
+ // out in `docs/vault-trace-format.md`, which has no cap.
2172
+ const lead = /no client standards were applied/.test(reason)
2173
+ ? ' Wrong vault: fix client.vaultRepo, re-fetch, then '
2174
+ // The two reasons the resolver emits when the vault was REACHED FOR and not
2175
+ // reached. Anything else — including an empty reason — falls to the
2176
+ // re-fetch-first tail, which also names the disable route.
2177
+ : /records a miss|is stale/.test(reason)
2178
+ ? ' Regenerating alone reproduces this: restore vault access first, then '
2179
+ : " Re-fetch the vault and regenerate that stage's trace: ";
2180
+ return (lead
2181
+ + TRACE_REGENERATE_COMMAND
2182
+ // The SECOND route, and it is the same sentence in all three tails on
2183
+ // purpose: it is the one action performable while the vault is down, so it
2184
+ // must not be the clause that a rewording of a first route quietly drops.
2185
+ + ' Or set vault.practices_enabled=false in .planning/config.json.');
2186
+ }
2187
+ /**
2188
+ * Compare ONE plan statement against ONE summary statement — the first relation
2189
+ * between two artifacts this module has ever expressed (TGATE-01's third
2190
+ * clause, under D-09).
2191
+ *
2192
+ * THE RELATION. A summary's identifier set must COVER its own plan's,
2193
+ * plan by plan. Not the intersection, not a phase-wide union: a SUPERSET test
2194
+ * against exactly one counterpart. Both rejected alternatives fail the same
2195
+ * way, VACUOUSLY. An intersection test passes for a summary that kept one
2196
+ * identifier and dropped the rest. A phase aggregate passes when one plan
2197
+ * dropped every practice and a sibling happened to carry the same identifiers.
2198
+ * A gate that cannot fail is not a gate, and this workstream has already
2199
+ * shipped one coverage gate that reported `passed` while parsing nothing.
2200
+ *
2201
+ * The aggregate also cannot satisfy TGATE-03, which requires the failure to
2202
+ * name the file and section to fix: an aggregate has no file to name. That is
2203
+ * why every problem returned here is filed against the SUMMARY — it is the
2204
+ * artifact whose record is incomplete and the one an operator must repair.
2205
+ *
2206
+ * RETURNS PROBLEMS, NEVER A BOOLEAN. The caller composes them into its own
2207
+ * verdict alongside every other problem it holds, exactly as it does with the
2208
+ * single-statement validator's. A boolean here would force a second, parallel
2209
+ * verdict composition in the handler.
2210
+ *
2211
+ * PURE. Two parsed statements in, problems out. It opens no file, stats
2212
+ * nothing, and invokes no git — deliberately, and not merely as tidiness. The
2213
+ * two alternatives for pairing were considered and are unusable in this
2214
+ * workflow: artifacts are copied out of executor worktrees as a routine step,
2215
+ * which destroys modification times, and `.planning/` is gitignored, so there
2216
+ * is no history to read. Only the recorded plan identifier and the recorded
2217
+ * generation timestamp — both frozen REQUIRED keys in both stage variants —
2218
+ * survive both. The divergence check, which does read a manifest, is the
2219
+ * router's and lives there.
2220
+ *
2221
+ * NEVER THROWS, on the validator's reasoning verbatim: this sits on the read
2222
+ * path for an attacker-influenceable document, and a throw would turn a
2223
+ * halt-on-error gate into a permanently stopped build.
2224
+ */
2225
+ function comparePracticeCoverage(planStatement, summaryStatement, options) {
2226
+ const opts = isPlainObject(options) ? options : {};
2227
+ const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the summary artifact';
2228
+ const planFile = typeof opts.planFile === 'string' && opts.planFile !== '' ? opts.planFile : 'its plan artifact';
2229
+ // Anything that is not the strict word is tolerant, so an absent option, an
2230
+ // undefined one and a misspelt one all land on the silent arm rather than on
2231
+ // the blocking one. Fail-open is the correct default for THIS parameter and
2232
+ // only for this one: it is the caller's declaration of which question is
2233
+ // being asked, and a typo must not invent a halt nobody declared.
2234
+ const strictness = opts.strictness === 'strict' ? 'strict' : 'tolerant';
2235
+ const problems = [];
2236
+ try {
2237
+ // ── the state gate: THREE outcomes, not two (04-13, CR-01) ─────────────
2238
+ //
2239
+ // THE TOLERANT ARM, and its reasoning is 04-07's PRESERVED VERBATIM below
2240
+ // because 04-13 does not challenge a word of it. What 04-13 changes is that
2241
+ // this paragraph used to be the WHOLE answer, at every call site:
2242
+ //
2243
+ // > SUPPRESSED — not failed — unless BOTH halves record a `loaded` vault. A
2244
+ // > degraded run legitimately recorded no universe and may legitimately have
2245
+ // > recorded no practices at all (D-04), and Phase 3 REQUIRES it to write a
2246
+ // > trace stating the degraded state and its reason. The miss is therefore
2247
+ // > already a recorded fact in the artifact; a comparison that additionally
2248
+ // > blocked would be rejecting a correct record of a real failure. What keeps
2249
+ // > this from being an escape hatch is 04-02's pin: the state is derived by
2250
+ // > the CLI from the vault fixture and cannot be selected through any
2251
+ // > statement-builder option.
2252
+ //
2253
+ // THE STRICT ARM, and why the paragraph above stops being the whole answer
2254
+ // there. At the two WRITE points the gate asks *is this a correct record?*
2255
+ // and a degraded record is correct. At `ship:pre` and `verify:post` it asks
2256
+ // a different question: *does this record prove the phase was governed?* A
2257
+ // degraded half answering a loaded plan proves nothing — it records that
2258
+ // the vault was unreachable, which is a fact about the machine and not
2259
+ // about the work. Scoring "proved nothing" as "passed" was the fail-open
2260
+ // (CR-01): a summary written while `.planning/vault/` was absent recorded
2261
+ // an empty ledger and this gate returned zero problems, so a phase that
2262
+ // promised practices and accounted for none SHIPPED RECORDED AS GOVERNED.
2263
+ // The record is neither a valid proof nor a rejectable record; it is an
2264
+ // UNPROVEN one, and that third outcome had no name.
2265
+ //
2266
+ // STRICTNESS IS A PROPERTY OF THE CALL SITE, exactly as D-10 already made
2267
+ // it one flag ago for an unpaired plan — normal mid-phase, a defect at
2268
+ // ship. The same flag carries this, so the tolerant points stay
2269
+ // byte-identically silent on the very fixture the strict points block on.
2270
+ //
2271
+ // WHY THE STRICT ARM IS A GATE AND NOT A TRAP, stated honestly rather than
2272
+ // rounded off: the remedy's availability is PARTIAL. Three of the
2273
+ // resolver's five degraded causes are cleared by re-fetching and
2274
+ // regenerating from the main checkout the strict points run on; a recorded
2275
+ // miss and a stale cache whose refresh fails are NOT — regeneration
2276
+ // reproduces them exactly. There the block is still deliberate, because the
2277
+ // remedy is OPERATIONAL rather than a re-run: restore vault reachability,
2278
+ // or turn the practices capability off for this project. Both are stated in
2279
+ // the tail, both are performable with the vault down, and the residual is
2280
+ // accepted: while a client vault is genuinely unreachable, a correct record
2281
+ // and a blocked ship coexist. That is the intended behaviour of a
2282
+ // workstream whose premise is that a vault miss must never silently produce
2283
+ // ungoverned code.
2284
+ //
2285
+ // REJECTED, one sentence each, so the next reader does not re-open them.
2286
+ // Rejecting a degraded statement at VALIDATION time blocks everywhere and
2287
+ // fails a correct record of a real failure — D-04 is right, and at the
2288
+ // write points the remedy reproduces the block. An ADVISORY-only problem is
2289
+ // measurably invisible: a non-blocking verdict sets no message and the
2290
+ // enforcer prints only the message, so it would restate the silence in a
2291
+ // quieter channel. Letting the existing uncovered-identifier kind fire on
2292
+ // an empty ledger yields one problem per promised id for ONE cause, and
2293
+ // points each at a section where the fix does not live. A phase-level
2294
+ // aggregate cannot name a file, which TGATE-03 requires.
2295
+ //
2296
+ // SYMMETRIC ON PURPOSE — either half, not only the summary. A degraded PLAN
2297
+ // at ship time is equally unproven: it says no practices governed the work
2298
+ // because the vault was unreachable when it was planned. Closing only the
2299
+ // summary arm would leave the mirror-image hole, which is this phase's own
2300
+ // signature failure.
2301
+ //
2302
+ // THE BOUNDARY, AND IT IS A PROHIBITION. `validateTraceStatement`'s
2303
+ // degraded suppression is UNCHANGED, at every strictness, and must stay so.
2304
+ // It takes no strictness parameter and must not be given one. The reason is
2305
+ // informational, not stylistic: a lone degraded statement records no
2306
+ // universe and no practices, so there is nothing to compare and identifier
2307
+ // resolution there is VACUOUS rather than suppressed-with-content. The
2308
+ // judgement below is possible only because the PLAN half says what was
2309
+ // promised. Do not "complete the set" by adding a strict arm there — that
2310
+ // is precisely the everywhere-block D-04 argues against, arrived at from
2311
+ // the other side.
2312
+ const planState = traceVaultStateOf(planStatement);
2313
+ const summaryState = traceVaultStateOf(summaryStatement);
2314
+ if (planState !== 'loaded' || summaryState !== 'loaded') {
2315
+ if (strictness !== 'strict')
2316
+ return problems;
2317
+ const summaryUnproven = summaryState !== 'loaded';
2318
+ const planUnproven = planState !== 'loaded';
2319
+ // ONE problem per pair, and it RETURNS: a ledger that proved nothing
2320
+ // cannot be compared, so continuing into the coverage loop and the
2321
+ // staleness check would report three problems for one cause and give an
2322
+ // operator three things to chase.
2323
+ //
2324
+ // The reason quoted is the SUMMARY's when the summary is unproven, and
2325
+ // the plan's otherwise — one reason, not two, because both halves
2326
+ // degraded is overwhelmingly one environment failing once, and a second
2327
+ // bounded artifact value would double the untrusted share of a head that
2328
+ // already carries two.
2329
+ const reason = summaryUnproven
2330
+ ? traceVaultReasonOf(summaryStatement)
2331
+ : traceVaultReasonOf(planStatement);
2332
+ problems.push({
2333
+ // Filed against the UNPROVEN half rather than always the summary, which
2334
+ // is this function's only departure from that rule and is TGATE-03's
2335
+ // own clause: the artifact to regenerate is the one whose run could not
2336
+ // read the vault. With both halves unproven the summary is named, as
2337
+ // the artifact whose record was supposed to answer.
2338
+ file: summaryUnproven ? file : planFile,
2339
+ kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_UNPROVABLE,
2340
+ detail: `${planUnproven && summaryUnproven
2341
+ ? 'both halves of this pair record'
2342
+ : summaryUnproven
2343
+ ? 'this summary records'
2344
+ : 'the plan this summary answers records'} no loaded vault, so nothing proves plan ` +
2345
+ `"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}" was governed ` +
2346
+ `— reason: "${collapseControlChars(boundedToken(reason))}".`,
2347
+ // 04-11's mechanism, and the head above contains NO part of the
2348
+ // command: this is the fourth remedy-bearing kind, added by the plan
2349
+ // that follows the fix for the truncated-remedy bug's third occurrence.
2350
+ tail: unprovablePairTail(reason),
2351
+ });
2352
+ return problems;
2353
+ }
2354
+ // Built ONCE, then probed per identifier — a scan per id turns a large
2355
+ // ledger into a quadratic walk (T-4-22), the same bound the resolution
2356
+ // check is written to.
2357
+ const covered = new Set(traceEmittedIdsOf(summaryStatement));
2358
+ const seen = new Set();
2359
+ for (const id of traceEmittedIdsOf(planStatement)) {
2360
+ // De-duplicated: a plan that recorded one id twice has a duplication
2361
+ // defect with its own kind, and must not also produce two coverage
2362
+ // problems for one gap.
2363
+ if (seen.has(id))
2364
+ continue;
2365
+ seen.add(id);
2366
+ if (covered.has(id))
2367
+ continue;
2368
+ // ONE problem PER uncovered identifier, never one aggregate: TGATE-03
2369
+ // requires the failure to name the identifier at fault, and a single
2370
+ // problem standing for three dropped practices makes the count
2371
+ // meaningless.
2372
+ problems.push({
2373
+ file,
2374
+ kind: TRACE_PROBLEM_KIND.COVERAGE_PLAN_UNCOVERED,
2375
+ detail: `practice id "${collapseControlChars(boundedToken(id))}" is recorded in the plan trace of ` +
2376
+ `${planFile} and appears nowhere in this summary's ledger, so the executor's record does ` +
2377
+ `not account for it. A partial overlap is not coverage.`,
2378
+ });
2379
+ }
2380
+ // ── staleness: the recorded timestamps, compared STRICTLY (D-11) ────────
2381
+ //
2382
+ // Strict on purpose. Equal timestamps are not stale — a plan and a summary
2383
+ // generated inside the same clock tick is an ordinary fast run, and
2384
+ // blocking it would fail correct records for a clock's resolution.
2385
+ // Unparseable on either side yields NO stale problem: an unreadable
2386
+ // timestamp is a required-key defect the single-statement validator already
2387
+ // owns, and reporting it twice from two places gives an operator two
2388
+ // problems for one cause.
2389
+ const planAt = Date.parse(traceGeneratedAtOf(planStatement));
2390
+ const summaryAt = Date.parse(traceGeneratedAtOf(summaryStatement));
2391
+ if (Number.isFinite(planAt) && Number.isFinite(summaryAt) && summaryAt < planAt) {
2392
+ problems.push({
2393
+ file,
2394
+ kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_STALE,
2395
+ // SHORTNESS WAS THE MITIGATION; THE TAIL IS NOW THE MECHANISM (04-11,
2396
+ // CR-05). The previous reasoning, quoted verbatim so this reads as the
2397
+ // upgrade it is rather than as a loss:
2398
+ //
2399
+ // "THE DETAIL IS DELIBERATELY SHORT, and the reason is CR-02's, not
2400
+ // style. The router bounds every detail at `TRACE_DETAIL_CAP` before
2401
+ // it reaches the message, so a detail that runs long loses its TAIL
2402
+ // — and the tail is the remedy. A stale-pair block whose remedy was
2403
+ // truncated away is exactly the trap D-11 exists to prevent, so the
2404
+ // prose names the plan IDENTIFIER rather than repeating the plan's
2405
+ // path (the summary's own path is already the problem's `file`),
2406
+ // keeping the whole detail inside the cap with the remedy intact."
2407
+ //
2408
+ // Every sentence of that was true and the detail still overflowed. A
2409
+ // budget argument is not a mechanism: the three values interpolated
2410
+ // here are read straight out of an artifact, `boundedToken` allows 80
2411
+ // characters apiece, V8's `Date.parse` accepts trailing parenthesised
2412
+ // text so a 62-character timestamp is trivially reachable, and the
2413
+ // measured worst case came to 418 against the 399-character budget. The
2414
+ // remedy was the part that fell off.
2415
+ //
2416
+ // THIS WAS THE THIRD OCCURRENCE OF ONE BUG. 04-07 shipped a
2417
+ // 480-character stale-pair detail against the same cap and lost the
2418
+ // same remedy; a guard was added. 04-08 built `traceDetail`'s head/tail
2419
+ // split and two cap tests. EACH FIX ADDRESSED ONE SITE — 04-08's guard
2420
+ // measures the tails the ROUTER writes, and a remedy living in a leaf's
2421
+ // HEAD was invisible to it. The prose below is still short, because a
2422
+ // message a reader can read is worth having, but shortness no longer
2423
+ // protects the command: the command is in the tail, concatenated after
2424
+ // the truncation has already happened, so prose and remedy no longer
2425
+ // compete for the same 400 characters at all.
2426
+ detail: `this summary's trace was generated at ` +
2427
+ `"${collapseControlChars(boundedToken(traceGeneratedAtOf(summaryStatement)))}", BEFORE the ` +
2428
+ `plan trace it answers at ` +
2429
+ `"${collapseControlChars(boundedToken(traceGeneratedAtOf(planStatement)))}", so it records ` +
2430
+ `an earlier version of plan ` +
2431
+ `"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}".`,
2432
+ tail: ` Regenerate the execute-stage trace in place, against the file named here: ` +
2433
+ TRACE_REGENERATE_COMMAND,
2434
+ });
2435
+ }
2436
+ return problems;
2437
+ }
2438
+ catch (e) {
2439
+ return [
2440
+ {
2441
+ file,
2442
+ kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
2443
+ detail: `the coverage comparison could not complete: ${boundedToken(String(e?.message ?? e))}`,
2444
+ },
2445
+ ];
2446
+ }
2447
+ }
2448
+ // ─── The body block, read back against the ledger (D-05-07) ─
2449
+ //
2450
+ // A vault-enabled plan carries TWO surfaces: the `vault:` frontmatter LEDGER
2451
+ // (the audit record) and the `<vault_practices>` BODY BLOCK (the practice text
2452
+ // the executor re-anchors against per task). One CLI emits both from one
2453
+ // matcher run, which is what makes a cross-check cheap — and until this section
2454
+ // nothing performed it. `docs/vault-trace-format.md` § "The two blocks" records
2455
+ // the history: the check was named as Phase 4's, then Phase 5's, then recorded
2456
+ // UNASSIGNED (D-05-07) because no requirement named it. The operator's request
2457
+ // of 2026-10-03, after the 2026-10-02 probe, is the amendment that assigns it.
2458
+ //
2459
+ // PURE, like the validator above it: artifact TEXT and the PARSED STATEMENT in,
2460
+ // problems out. It opens no file, reads no manifest and runs no matcher. Text
2461
+ // is verified against the `textSha256` the ledger RECORDS, never by re-rendering
2462
+ // — a second matcher run would make the verdict a function of this machine's
2463
+ // vault cache, which D-01 removed from this gate on purpose, and would block
2464
+ // every machine whose cache has moved past the pin.
2465
+ const PRACTICES_BLOCK_OPEN = '<vault_practices>';
2466
+ const PRACTICES_BLOCK_CLOSE = '</vault_practices>';
2467
+ /** A sha256 hex digest, and nothing else, is a text digest. */
2468
+ const TEXT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
2469
+ /**
2470
+ * The body-block family as a SET, so the router's message footer can tell a
2471
+ * body remedy from a ledger remedy without spelling the kinds a second time.
2472
+ */
2473
+ const PRACTICES_BLOCK_KINDS = new Set([
2474
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT,
2475
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE,
2476
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH,
2477
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY,
2478
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING,
2479
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED,
2480
+ TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH,
2481
+ ]);
2482
+ /**
2483
+ * The command that regenerates a body block, named in every body-block remedy.
2484
+ * ONE constant, exported, for the reason `TRACE_REGENERATE_COMMAND` is one.
2485
+ */
2486
+ const PRACTICES_BLOCK_REGENERATE_COMMAND = 'pp_run vault practices match <files_modified...> --block';
2487
+ /**
2488
+ * The trusted tail every body-block problem carries. Author-written, constant,
2489
+ * independent of every fixture input (T-4-43), and measured against the ONE
2490
+ * detail cap: 148 characters, leaving the head 251.
2491
+ */
2492
+ const PRACTICES_BLOCK_TAIL = ` Regenerate the block and embed the output verbatim, from ${PRACTICES_BLOCK_OPEN} to ` +
2493
+ `${PRACTICES_BLOCK_CLOSE}: ${PRACTICES_BLOCK_REGENERATE_COMMAND}`;
2494
+ /**
2495
+ * The normalisation BOTH digest sites apply, so copy drift that changes no
2496
+ * character of the practice text — CRLF line endings, trailing whitespace a
2497
+ * planner's editor stripped or added, blank lines at either end — does not read
2498
+ * as a content change, while any edit to the text itself does.
2499
+ */
2500
+ function normalizePracticeText(text) {
2501
+ return (typeof text === 'string' ? text : '')
2502
+ .replace(/\r\n?/g, '\n')
2503
+ .split('\n')
2504
+ .map((line) => line.replace(/[ \t]+$/, ''))
2505
+ .join('\n')
2506
+ .trim();
2507
+ }
2508
+ /** sha256 hex of the normalised practice text. */
2509
+ function practiceTextDigest(text) {
2510
+ return node_crypto_1.default.createHash('sha256').update(normalizePracticeText(text), 'utf8').digest('hex');
2511
+ }
2512
+ /**
2513
+ * Read the body block(s) out of an artifact.
2514
+ *
2515
+ * The frontmatter is skipped first: the block is a BODY construct and a `---`
2516
+ * document can carry anything in a YAML block scalar. Outside a block, a
2517
+ * markdown code fence and an HTML comment are skipped too — a tag quoted as an
2518
+ * example or commented out is not a block the executor reads as one, and
2519
+ * counting it would let an example satisfy the presence check (plan-checker
2520
+ * remark 2 on D-05-07). Tags are matched at column 0 only, which is where
2521
+ * the renderer puts them and where the planner reference says they go; an
2522
+ * entry is a fenced region whose FIRST `id:` line
2523
+ * precedes its `ask:`/`excerpt:` label — the degraded and refused sections are
2524
+ * fenced too, but render `- **id** — …` lines and never an `id:` line, so they
2525
+ * are not entries. Everything after the label up to the fence end is the text.
2526
+ */
2527
+ function parsePracticesBlock(content) {
2528
+ const lines = String(content ?? '').replace(/\r\n?/g, '\n').split('\n');
2529
+ let start = 0;
2530
+ if (lines[0] === '---') {
2531
+ const close = lines.indexOf('---', 1);
2532
+ if (close > 0)
2533
+ start = close + 1;
2534
+ }
2535
+ const out = { blocks: 0, unterminated: false, entries: [], prose: [] };
2536
+ let i = start;
2537
+ let inCodeFence = false;
2538
+ let inComment = false;
2539
+ while (i < lines.length) {
2540
+ const head = lines[i].trimEnd();
2541
+ if (inComment) {
2542
+ if (head.includes('-->'))
2543
+ inComment = false;
2544
+ i += 1;
2545
+ continue;
2546
+ }
2547
+ if (inCodeFence) {
2548
+ if (/^(`{3,}|~{3,})/.test(head))
2549
+ inCodeFence = false;
2550
+ i += 1;
2551
+ continue;
2552
+ }
2553
+ if (/^(`{3,}|~{3,})/.test(head)) {
2554
+ inCodeFence = true;
2555
+ i += 1;
2556
+ continue;
2557
+ }
2558
+ if (head.startsWith('<!--') && !head.includes('-->')) {
2559
+ inComment = true;
2560
+ i += 1;
2561
+ continue;
2562
+ }
2563
+ if (head !== PRACTICES_BLOCK_OPEN) {
2564
+ i += 1;
2565
+ continue;
2566
+ }
2567
+ let j = i + 1;
2568
+ while (j < lines.length && lines[j].trimEnd() !== PRACTICES_BLOCK_CLOSE)
2569
+ j += 1;
2570
+ if (j >= lines.length) {
2571
+ out.unterminated = true;
2572
+ return out;
2573
+ }
2574
+ out.blocks += 1;
2575
+ let cur = null;
2576
+ for (let k = i + 1; k < j; k += 1) {
2577
+ const t = lines[k].trimEnd();
2578
+ if (t === practicesMod.EXTERNAL_CONTENT_FENCE_START) {
2579
+ cur = { id: '', labelled: false, text: [] };
2580
+ continue;
2581
+ }
2582
+ if (t === practicesMod.EXTERNAL_CONTENT_FENCE_END) {
2583
+ if (cur !== null && cur.id !== '') {
2584
+ out.entries.push({ id: cur.id, text: cur.labelled ? cur.text.join('\n') : null });
2585
+ }
2586
+ cur = null;
2587
+ continue;
2588
+ }
2589
+ if (cur !== null) {
2590
+ if (cur.labelled) {
2591
+ cur.text.push(lines[k]);
2592
+ }
2593
+ else if (cur.id === '' && t.startsWith('id: ')) {
2594
+ cur.id = t.slice(4).trim();
2595
+ }
2596
+ else if (cur.id !== '' && (t === 'ask:' || t === 'excerpt:')) {
2597
+ cur.labelled = true;
2598
+ }
2599
+ continue;
2600
+ }
2601
+ // Prose is what is left: not a heading, not a quote, not an HTML comment
2602
+ // (the renderer may stamp the block's first line with a `<!-- … -->`
2603
+ // provenance comment; it is neither an entry nor a sentence).
2604
+ const bare = t.trim();
2605
+ if (bare !== '' && !bare.startsWith('#') && !bare.startsWith('>') && !bare.startsWith('<!--')) {
2606
+ out.prose.push(bare);
2607
+ }
2608
+ }
2609
+ i = j + 1;
2610
+ }
2611
+ return out;
2612
+ }
2613
+ /** Does a prose line state that the vault itself was never read? */
2614
+ function statesVaultNotRead(line) {
2615
+ return (line === practicesMod.PRACTICES_VAULT_ABSENT_SENTENCE
2616
+ || line === practicesMod.PRACTICES_VAULT_UNREADABLE_SENTENCE
2617
+ || line.startsWith('Practices could not be checked'));
2618
+ }
2619
+ /**
2620
+ * Compare ONE plan artifact's `<vault_practices>` body block against its parsed
2621
+ * `vault:` ledger statement. Returns problems, never a boolean, on the
2622
+ * validator's reasoning; NEVER THROWS, on the validator's reasoning too.
2623
+ *
2624
+ * THE FIVE QUESTIONS, in order, each one short-circuiting the next:
2625
+ * 1. Is there exactly one block? → absent / duplicate
2626
+ * 2. Does the block agree with the ledger about whether the vault was READ?
2627
+ * A loaded ledger beside a vault-not-read sentence → state_mismatch
2628
+ * 3. Does it deliver ANY entry when the ledger promises ≥1? → empty
2629
+ * 4. Do the two id sets agree, both ways? → id_missing / id_unexpected
2630
+ * 5. For every entry: with a `textSha256` on its ledger line, does the text
2631
+ * under `ask:`/`excerpt:` digest to it; without one, is there any text
2632
+ * there at all? → text_mismatch
2633
+ *
2634
+ * An absent block is a defect WHATEVER the ledger lists, including nothing: the
2635
+ * block is written in every degraded state too, and `docs/practices-format.md`
2636
+ * is explicit that a silent omission is indistinguishable from the capability
2637
+ * being off. The `empty` question is asked only when the ledger promises
2638
+ * something, because a block that correctly says "none apply" over an empty
2639
+ * ledger has delivered exactly what was recorded.
2640
+ *
2641
+ * Question 5's DIGEST half is SKIPPED, not failed, for a ledger entry without a
2642
+ * digest: the key is optional (a minor addition), a ledger written before it
2643
+ * existed is a correct record, and blocking it on text alone would halt every
2644
+ * phase planned on an earlier engine at ship time. What such an entry still
2645
+ * must have is SOME text — the renderer never emits a labelled entry with none,
2646
+ * so a blank or label-less entry is a defect whatever the ledger's age. A
2647
+ * placeholder like "(see vault)" on a pre-digest ledger is NOT caught, and is
2648
+ * recorded as the one shape this check leaves open (plan-checker remark 1).
2649
+ * Questions 1–4 need no digest and run on every ledger.
2650
+ */
2651
+ function validatePracticesBody(content, statement, options) {
2652
+ const opts = isPlainObject(options) ? options : {};
2653
+ const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
2654
+ const problems = [];
2655
+ const add = (kind, detail) => {
2656
+ problems.push({ file, kind, detail, tail: PRACTICES_BLOCK_TAIL });
2657
+ };
2658
+ const safe = (v) => collapseControlChars(boundedToken(v));
2659
+ try {
2660
+ if (opts.stage !== 'plan')
2661
+ return problems;
2662
+ if (!isPlainObject(statement))
2663
+ return problems;
2664
+ // The ledger's half: ids in recorded order, de-duplicated (a duplicated id
2665
+ // is its own defect with its own kind), and the digest each carries.
2666
+ const ledgerIds = [];
2667
+ const digests = new Map();
2668
+ const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2669
+ const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
2670
+ for (const raw of practices) {
2671
+ const entry = isPlainObject(raw) ? raw : {};
2672
+ const id = typeof entry['id'] === 'string' ? entry['id'] : '';
2673
+ if (id === '' || ledgerIds.includes(id))
2674
+ continue;
2675
+ ledgerIds.push(id);
2676
+ const digest = entry['textSha256'];
2677
+ if (typeof digest === 'string' && digest !== '')
2678
+ digests.set(id, digest);
2679
+ }
2680
+ const promised = ledgerIds.length;
2681
+ const promisedText = `${promised} practice id${promised === 1 ? '' : 's'}`;
2682
+ const parsed = parsePracticesBlock(typeof content === 'string' ? content : '');
2683
+ // ── 1. exactly one block ──────────────────────────────────────────────
2684
+ if (parsed.unterminated) {
2685
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} opens a ${PRACTICES_BLOCK_OPEN} block that is never closed, so no executor can tell ` +
2686
+ `where the client's practice text ends; its vault: ledger records ${promisedText}.`);
2687
+ return problems;
2688
+ }
2689
+ if (parsed.blocks === 0) {
2690
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} carries a vault: ledger recording ${promisedText} but no ${PRACTICES_BLOCK_OPEN} block, ` +
2691
+ `so the executor that reads this plan receives no practice text at all — the ledger records ` +
2692
+ `governance the plan does not deliver.`);
2693
+ return problems;
2694
+ }
2695
+ if (parsed.blocks > 1) {
2696
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE, `${file} carries ${parsed.blocks} ${PRACTICES_BLOCK_OPEN} blocks where a plan carries exactly one, ` +
2697
+ `so which practice text governs this plan is ambiguous.`);
2698
+ return problems;
2699
+ }
2700
+ // ── 2. the block and the ledger agree about whether the vault was READ ─
2701
+ //
2702
+ // One direction only, and argued: a `degraded` ledger beside a block full
2703
+ // of entries is CONSISTENT (a stale cache still matches), and a `degraded`
2704
+ // ledger beside the absent sentence is consistent too. The disagreement
2705
+ // that can mislead is a block telling the executor the vault was never
2706
+ // fetched about a run whose ledger records a loaded pin — the "fake
2707
+ // degraded" shape the probe planted. Fires whatever the ledger promises,
2708
+ // including nothing: it is a statement about the run, not about a count.
2709
+ if (traceVaultStateOf(statement) === 'loaded' && parsed.prose.some(statesVaultNotRead)) {
2710
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH, `the ${PRACTICES_BLOCK_OPEN} block in ${file} states that the client vault was not read, while ` +
2711
+ `its vault: ledger records state "loaded" with ${promisedText}. The block tells the executor ` +
2712
+ `nothing was fetched about a run that recorded a pin, so the two halves of this plan's record ` +
2713
+ `disagree about what happened.`);
2714
+ return problems;
2715
+ }
2716
+ // ── 3. the block delivers something when the ledger promises something ─
2717
+ if (promised > 0 && parsed.entries.length === 0) {
2718
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY, `the ${PRACTICES_BLOCK_OPEN} block in ${file} renders no practice entry while its vault: ledger ` +
2719
+ `records ${promisedText}. The executor applies what the block delivers, so these practices ` +
2720
+ `govern nothing.`);
2721
+ return problems;
2722
+ }
2723
+ // ── 4. the two id sets agree, both ways ───────────────────────────────
2724
+ const bodyIds = [];
2725
+ for (const e of parsed.entries)
2726
+ if (!bodyIds.includes(e.id))
2727
+ bodyIds.push(e.id);
2728
+ const bodySet = new Set(bodyIds);
2729
+ const ledgerSet = new Set(ledgerIds);
2730
+ for (const id of ledgerIds) {
2731
+ if (bodySet.has(id))
2732
+ continue;
2733
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING, `practice "${safe(id)}" is recorded in the vault: ledger of ${file} and has no entry in its ` +
2734
+ `${PRACTICES_BLOCK_OPEN} block, so the executor never sees its text.`);
2735
+ }
2736
+ for (const id of bodyIds) {
2737
+ if (ledgerSet.has(id))
2738
+ continue;
2739
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED, `the ${PRACTICES_BLOCK_OPEN} block in ${file} carries an entry for practice "${safe(id)}" that its ` +
2740
+ `vault: ledger does not record, so the two halves of this plan's record disagree about which ` +
2741
+ `practices apply.`);
2742
+ }
2743
+ // ── 5. the text is the text the matcher rendered — or, without a digest,
2744
+ // is at least THERE ────────────────────────────────────────────────
2745
+ const checked = new Set();
2746
+ for (const e of parsed.entries) {
2747
+ if (checked.has(e.id) || !ledgerSet.has(e.id))
2748
+ continue;
2749
+ checked.add(e.id);
2750
+ const want = digests.get(e.id);
2751
+ const blank = e.text === null || normalizePracticeText(e.text) === '';
2752
+ if (want === undefined) {
2753
+ // The digest-free floor. The renderer always emits the label and the
2754
+ // text under it, so an entry with neither is not a copy of anything
2755
+ // the matcher produced.
2756
+ if (!blank)
2757
+ continue;
2758
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} carries no practice ` +
2759
+ `text under ask:/excerpt:, so the executor is handed an identifier and nothing to apply.`);
2760
+ continue;
2761
+ }
2762
+ const got = blank ? '' : practiceTextDigest(e.text);
2763
+ if (got === want)
2764
+ continue;
2765
+ add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `the text under practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} is not ` +
2766
+ `the text the matcher rendered when its vault: ledger was written` +
2767
+ (e.text === null
2768
+ ? ' (the entry carries no ask:/excerpt: section)'
2769
+ : ' (it was altered, shortened or replaced)') +
2770
+ `, so the executor is not reading the client's practice.`);
2771
+ }
2772
+ return problems;
2773
+ }
2774
+ catch (e) {
2775
+ return [
2776
+ {
2777
+ file,
2778
+ kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
2779
+ detail: `the body-block check could not complete: ${boundedToken(String(e?.message ?? e))}`,
2780
+ },
2781
+ ];
2782
+ }
2783
+ }
2784
+ module.exports = {
2785
+ buildTraceStatement,
2786
+ serializeTraceBlock,
2787
+ renderPracticesApplied,
2788
+ parseVaultTraceBlock,
2789
+ validateTraceStatement,
2790
+ // 04-07 (D-09): the pair comparison. Exported because the RELATION is the
2791
+ // router's to compose — the leaf compares one pair at a time and returns
2792
+ // problems, and the router enumerates, pairs and folds them into its
2793
+ // envelope. Keeping it here rather than in the handler keeps every structural
2794
+ // judgement about a trace in the leaf, which is the property that stops a
2795
+ // second, drifting implementation from growing in an adapter.
2796
+ comparePracticeCoverage,
2797
+ // D-05-07: the body-block check, its digest and its remedy
2798
+ // command. The router composes the problems exactly as it composes the
2799
+ // validator's; the two digest sites (builder and body check) share
2800
+ // `practiceTextDigest`, so the normalisation is written once.
2801
+ validatePracticesBody,
2802
+ practiceTextDigest,
2803
+ PRACTICES_BLOCK_REGENERATE_COMMAND,
2804
+ PRACTICES_BLOCK_KINDS,
2805
+ TRACE_REQUIRED_KEYS,
2806
+ TRACE_PROBLEM_KIND,
2807
+ TRACE_BLOCK_KEY,
2808
+ TRACE_STATEMENT_TYPE,
2809
+ TRACE_PREDICATE_TYPE,
2810
+ TRACE_SCHEMA_VERSION,
2811
+ TRACE_STATUS_VALUES,
2812
+ TRACE_ID_PATTERN,
2813
+ // 10-08 (MECH-03, D-10-05): the analyzer-provenance lead. Exported so the
2814
+ // test that PINS it against its owner — `AUDIT_ENFORCED_BY_LEAD` in
2815
+ // `src/vault-audit.cts` — can compare the two shipped strings rather than a
2816
+ // copy of either. See the constant's own comment for why this module holds a
2817
+ // second spelling instead of importing the first.
2818
+ TRACE_ENFORCED_BY_LEAD,
2819
+ // Moved here from src/check-command-router.cts by 04-05 and exported: the
2820
+ // validator's missing-universe remedy and the handler's block message must
2821
+ // name the SAME command, and the handler already imports this module.
2822
+ TRACE_REGENERATE_COMMAND,
2823
+ PRACTICES_APPLIED_HEADING,
2824
+ // The reader allowlist: pp-core's own predicate type plus any the org
2825
+ // profile lists (same major). The validator takes the list through its
2826
+ // options, so a gate passes `acceptedPredicateTypes(root)`.
2827
+ acceptedPredicateTypes,
2828
+ TRACE_WORKTREE_CACHE_NOTE,
2829
+ // The recorded check: census, read back for the mechanical audit.
2830
+ readRecordedCensus,
2831
+ };