session-orchestrator 3.19.0 → 3.20.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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-learning-provenance.mjs — census of DANGLING provenance pointers in
4
+ * `.claude/rules/*.md`.
5
+ *
6
+ * ## The defect class
7
+ *
8
+ * Every rule the reconciliation engine emits carries a `## Provenance` block
9
+ * naming the learning it was derived from:
10
+ *
11
+ * - learning-key: `anti-pattern/some-subject`
12
+ * - learning-id: `70c9c7b7-d8f3-4363-b170-0b8973d52df3`
13
+ *
14
+ * That pointer is the ONLY link between a live, always-injected instruction and
15
+ * the evidence that justified it. When the pointer rots, the rule becomes
16
+ * unauditable: nobody can re-confidence it, expire it on purpose, or check
17
+ * whether the evidence it cites still holds. It keeps loading forever on the
18
+ * strength of a citation that resolves to nothing.
19
+ *
20
+ * Measured on this repo at 2026-08-12 (`main`, issue #1017): of 13 rule files
21
+ * carrying provenance, 11 cite an id present in NEITHER the live store NOR the
22
+ * archive — 85% of the pointers point at nothing. The rot is invisible to every
23
+ * other gate because a dangling id is still perfectly well-formed Markdown.
24
+ *
25
+ * This check is deterministic — set membership over two JSONL stores — which is
26
+ * exactly why it is worth having: it needs no model, no network, and no
27
+ * judgement call.
28
+ *
29
+ * ## Resolution: live store OR archive
30
+ *
31
+ * A record legitimately MOVES from `learnings.jsonl` into
32
+ * `learnings-archive.jsonl` when it expires past its grace window
33
+ * (`scripts/sweep-expired-learnings.mjs --apply`). Resolving against the live
34
+ * store alone would therefore report every correctly-archived record as rot.
35
+ * Only "present in neither" is a finding.
36
+ *
37
+ * ## Two axes, because an id is not the only pointer
38
+ *
39
+ * The id is the record's UUID; the key (`${type}/${kebab(title||subject)}`,
40
+ * derived by the shared `learnings/kebab.mjs::learningKeyOf`) is
41
+ * its LOGICAL identity, stable across a re-write that mints a new UUID. Checking
42
+ * both separates two findings with very different remedies:
43
+ *
44
+ * `dangling-learning-id` — neither the id nor the key resolves. The evidence
45
+ * is genuinely gone; the rule needs re-derivation
46
+ * or retirement.
47
+ * `superseded-learning-id` — the id does not resolve but the key DOES. The
48
+ * record was re-created under a new UUID; the fix
49
+ * is a one-line re-stamp of the id, not a
50
+ * re-derivation.
51
+ * `dangling-learning-key` — a key-only provenance block whose key resolves
52
+ * nowhere (same rot, no id to re-stamp).
53
+ *
54
+ * On 2026-08-12 all 11 findings are `dangling-learning-id` (0 superseded), but
55
+ * `superseded` is the state any backfill that re-mints ids lands in, so the two
56
+ * are distinguished at the point where the operator reads the output.
57
+ *
58
+ * ## Mode: WARN, never blocking
59
+ *
60
+ * Findings print as `WARN:` and the runner returns 0. This is deliberate, and
61
+ * mirrors the rationale already written down in `check-unwired-features.mjs`:
62
+ * 11 of 13 pointers dangle at HEAD, so a blocking gate would be red on arrival,
63
+ * and a gate that is red on arrival gets disabled — the same disease this file
64
+ * exists to treat, one level up. It also decouples the gate's green-ness from a
65
+ * backfill landing: the census reports the number, the operator decides.
66
+ * Only a genuine tool error (an unreadable rules directory) prints `FAIL:` and
67
+ * returns 2 — a check that could not run must be visible.
68
+ *
69
+ * ## Defined behaviour for every degenerate input
70
+ *
71
+ * - No `.claude/rules/` directory, or no `.md` files → PASS, nothing to audit.
72
+ * - No rule carries provenance → PASS, nothing to audit.
73
+ * - BOTH stores missing → a single `stores-absent` WARN instead of N dangling
74
+ * findings. With no evidence corpus present, "dangling" and "not checkable
75
+ * here" are indistinguishable, and claiming the former would be a lie in any
76
+ * consumer repo that has not started collecting learnings.
77
+ * - One store missing → the other still resolves; absence is reported in the
78
+ * summary and in `stores.*.present`.
79
+ * - Unparseable JSONL line → skipped by `readLearnings` and COUNTED into
80
+ * `summary.malformedStoreLines`, which is surfaced in the summary line. The
81
+ * rest of the store is still used; a corrupt line never silently swallows
82
+ * the corpus, and never crashes the gate.
83
+ * - Provenance block with a key but no id → audited on the key axis.
84
+ * - A single rule file unreadable → skipped with a per-file `tool-error`
85
+ * finding (printed as WARN, since the census still completes over the rest).
86
+ * Only the DIRECTORY being unenumerable sets `toolError` and prints FAIL.
87
+ *
88
+ * ## Named residuals (so nobody over-reads the coverage claim)
89
+ *
90
+ * - **Structured pointers only.** Scope is the list-item form
91
+ * (`- learning-id:` / `- learning-key:`), which is what the emitter writes.
92
+ * A HAND-WRITTEN prose citation is not covered — e.g. `.claude/rules/testing.md`
93
+ * cites `learning id \`mac-gitlab-runner-cpu-starvation-...\`` mid-sentence
94
+ * for a record that exists nowhere in the repo. Catching that class needs a
95
+ * free-text scanner with a false-positive budget; this check deliberately
96
+ * stays on the machine-written form it can resolve exactly.
97
+ * - **First pointer wins.** The first `- learning-id:` and the first
98
+ * `- learning-key:` line in a file are audited (mirroring the `grep -m1`
99
+ * measurement the finding was reported with). Every emitted rule carries
100
+ * exactly one of each; a hypothetical second id is not audited.
101
+ * - **Existence, not agreement.** A resolving id whose record has since been
102
+ * re-worded, re-confidenced, or contradicted still reads as resolved. This
103
+ * check answers "does the evidence exist", not "does the rule still match
104
+ * it".
105
+ *
106
+ * ## Read discipline
107
+ *
108
+ * Files are read with `readFileSync`, never via a `grep` spawn. One NUL byte
109
+ * makes a text file invisible to grep-based audits (exit 1, no output, no
110
+ * warning — see the `anti-pattern-a-nul-byte-in-a-tracked-production-file-...`
111
+ * rule; `scripts/lib/reconcile/emitter.mjs` in this very repo is such a file),
112
+ * and a silently-skipped file reads exactly like a file that passed.
113
+ *
114
+ * ## Usage
115
+ *
116
+ * check-learning-provenance.mjs [<plugin-root>] [--json] [--help]
117
+ *
118
+ * Exit codes:
119
+ * 0 — census completed (findings are WARN-only and do NOT change this)
120
+ * 1 — usage error (unknown flag)
121
+ * 2 — tool error (the rules directory could not be enumerated)
122
+ *
123
+ * Import-safety: importing this module exposes the inspector and runner only;
124
+ * the CLI path is guarded at the bottom of the file.
125
+ */
126
+
127
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
128
+ import path from 'node:path';
129
+ import { pathToFileURL } from 'node:url';
130
+ import { readLearnings } from '../learnings/io.mjs';
131
+ // THE shared key derivation. This check resolves a rule's STAMPED
132
+ // `learning-key` against keys it derives from the store, so it is the one place
133
+ // where writer-vs-reader disagreement shows up as a false `dangling-learning-key`
134
+ // finding — it must derive the key with the emitter's function, not a copy of
135
+ // the emitter's formula.
136
+ import { learningKeyOf } from '../learnings/kebab.mjs';
137
+
138
+ /** Directory holding the rule corpus, relative to the plugin root. */
139
+ const RULES_REL = path.join('.claude', 'rules');
140
+
141
+ /** Live learnings store, relative to the plugin root. */
142
+ const LIVE_STORE_REL = path.join('.orchestrator', 'metrics', 'learnings.jsonl');
143
+
144
+ /** Append-only archive sidecar the expiry sweep moves records into. */
145
+ const ARCHIVE_STORE_REL = path.join('.orchestrator', 'metrics', 'learnings-archive.jsonl');
146
+
147
+ /** First `- learning-id: <value>` list item in a rule body. */
148
+ const LEARNING_ID_RE = /^[-*][ \t]+learning-id:[ \t]*(.+)$/m;
149
+
150
+ /** First `- learning-key: <value>` list item in a rule body. */
151
+ const LEARNING_KEY_RE = /^[-*][ \t]+learning-key:[ \t]*(.+)$/m;
152
+
153
+ /**
154
+ * @typedef {{
155
+ * kind: 'dangling-learning-id' | 'superseded-learning-id' | 'dangling-learning-key'
156
+ * | 'stores-absent' | 'tool-error',
157
+ * file: string,
158
+ * learningId: string | null,
159
+ * learningKey: string | null,
160
+ * message: string,
161
+ * }} Finding
162
+ */
163
+
164
+ /**
165
+ * @typedef {{
166
+ * ok: boolean,
167
+ * summary: {
168
+ * rulesScanned: number,
169
+ * rulesWithProvenance: number,
170
+ * resolved: number,
171
+ * dangling: number,
172
+ * superseded: number,
173
+ * malformedStoreLines: number,
174
+ * },
175
+ * stores: {
176
+ * live: {path: string, present: boolean, records: number},
177
+ * archive: {path: string, present: boolean, records: number},
178
+ * },
179
+ * findings: Finding[],
180
+ * toolError: boolean,
181
+ * }} Inspection
182
+ */
183
+
184
+ /**
185
+ * Display form of a store path: repo-relative when it sits inside the plugin
186
+ * root, absolute otherwise (a test override or an out-of-tree store).
187
+ *
188
+ * @param {string} pluginRoot
189
+ * @param {string} absolutePath
190
+ * @returns {string}
191
+ */
192
+ function displayPath(pluginRoot, absolutePath) {
193
+ const rel = path.relative(pluginRoot, absolutePath);
194
+ return rel === '' || rel.startsWith('..') || path.isAbsolute(rel) ? absolutePath : rel;
195
+ }
196
+
197
+ /**
198
+ * Strip the decoration the emitter writes around a provenance value: trailing
199
+ * CR (CRLF files), surrounding backticks, surrounding quotes, and whitespace.
200
+ *
201
+ * @param {string} raw
202
+ * @returns {string}
203
+ */
204
+ function cleanValue(raw) {
205
+ return raw
206
+ .replace(/\r$/, '')
207
+ .trim()
208
+ .replace(/^[`'"]+|[`'"]+$/g, '')
209
+ .trim();
210
+ }
211
+
212
+ /**
213
+ * Extract the first structured provenance pointer pair from a rule body.
214
+ *
215
+ * @param {string} body raw rule-file contents
216
+ * @returns {{id: string|null, key: string|null}}
217
+ */
218
+ export function extractProvenance(body) {
219
+ const idMatch = LEARNING_ID_RE.exec(body);
220
+ const keyMatch = LEARNING_KEY_RE.exec(body);
221
+ const id = idMatch ? cleanValue(idMatch[1]) : '';
222
+ const key = keyMatch ? cleanValue(keyMatch[1]) : '';
223
+ return { id: id === '' ? null : id, key: key === '' ? null : key };
224
+ }
225
+
226
+ /**
227
+ * Read one JSONL store into id + logical-key index sets.
228
+ *
229
+ * Never throws: a missing file yields an empty, `present: false` index, and an
230
+ * unparseable line is counted rather than fatal (`readLearnings` isolates it).
231
+ *
232
+ * @param {string} absolutePath
233
+ * @returns {Promise<{present: boolean, records: number, malformed: number, ids: Set<string>, keys: Set<string>}>}
234
+ */
235
+ async function indexStore(absolutePath) {
236
+ const present = existsSync(absolutePath);
237
+ /** @type {Set<string>} */
238
+ const ids = new Set();
239
+ /** @type {Set<string>} */
240
+ const keys = new Set();
241
+ if (!present) return { present, records: 0, malformed: 0, ids, keys };
242
+
243
+ /** @type {{entries: Record<string, unknown>[], malformed: string[]}} */
244
+ let read;
245
+ try {
246
+ read = await readLearnings(absolutePath);
247
+ } catch {
248
+ // An unreadable-but-existing store (permissions, a directory in its place)
249
+ // must not crash the gate. Report it as present-but-empty; every pointer
250
+ // then resolves against the OTHER store, and the zero record count in the
251
+ // summary line is the visible signal that something is wrong here.
252
+ return { present, records: 0, malformed: 0, ids, keys };
253
+ }
254
+
255
+ const { entries, malformed } = read;
256
+ for (const entry of entries) {
257
+ if (entry && typeof entry.id === 'string' && entry.id !== '') ids.add(entry.id);
258
+ const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
259
+ if (key !== null) keys.add(key);
260
+ }
261
+ return { present, records: entries.length, malformed: malformed.length, ids, keys };
262
+ }
263
+
264
+ /**
265
+ * Census every `.claude/rules/*.md` provenance pointer against the live store
266
+ * and the archive. Pure with respect to the repo: reads only, writes nothing.
267
+ *
268
+ * @param {string} pluginRoot absolute plugin root
269
+ * @param {{livePath?: string, archivePath?: string}} [opts] store overrides (tests)
270
+ * @returns {Promise<Inspection>}
271
+ */
272
+ export async function inspectLearningProvenance(pluginRoot, opts = {}) {
273
+ /** @type {Finding[]} */
274
+ const findings = [];
275
+ const livePath = opts.livePath ?? path.join(pluginRoot, LIVE_STORE_REL);
276
+ const archivePath = opts.archivePath ?? path.join(pluginRoot, ARCHIVE_STORE_REL);
277
+
278
+ /** @type {Inspection} */
279
+ const result = {
280
+ ok: false,
281
+ summary: {
282
+ rulesScanned: 0,
283
+ rulesWithProvenance: 0,
284
+ resolved: 0,
285
+ dangling: 0,
286
+ superseded: 0,
287
+ malformedStoreLines: 0,
288
+ },
289
+ stores: {
290
+ live: { path: displayPath(pluginRoot, livePath), present: false, records: 0 },
291
+ archive: { path: displayPath(pluginRoot, archivePath), present: false, records: 0 },
292
+ },
293
+ findings,
294
+ toolError: false,
295
+ };
296
+
297
+ const rulesDir = path.join(pluginRoot, RULES_REL);
298
+ if (!existsSync(rulesDir)) {
299
+ result.ok = true;
300
+ return result;
301
+ }
302
+
303
+ /** @type {string[]} */
304
+ let ruleFiles;
305
+ try {
306
+ ruleFiles = readdirSync(rulesDir)
307
+ .filter((name) => name.endsWith('.md'))
308
+ .sort();
309
+ } catch (error) {
310
+ result.toolError = true;
311
+ findings.push({
312
+ kind: 'tool-error',
313
+ file: RULES_REL,
314
+ learningId: null,
315
+ learningKey: null,
316
+ message: `cannot enumerate the rules directory: ${error instanceof Error ? error.message : String(error)}`,
317
+ });
318
+ return result;
319
+ }
320
+ result.summary.rulesScanned = ruleFiles.length;
321
+
322
+ // Collect the pointers BEFORE touching the stores: a corpus with no
323
+ // provenance at all needs no store read.
324
+ /** @type {{file: string, id: string|null, key: string|null}[]} */
325
+ const pointers = [];
326
+ for (const name of ruleFiles) {
327
+ let body;
328
+ try {
329
+ // readFileSync, never a grep spawn — see § Read discipline in the header.
330
+ body = readFileSync(path.join(rulesDir, name), 'utf8');
331
+ } catch (error) {
332
+ findings.push({
333
+ kind: 'tool-error',
334
+ file: path.join(RULES_REL, name),
335
+ learningId: null,
336
+ learningKey: null,
337
+ message: `cannot read rule file: ${error instanceof Error ? error.message : String(error)}`,
338
+ });
339
+ continue;
340
+ }
341
+ const { id, key } = extractProvenance(body);
342
+ if (id === null && key === null) continue;
343
+ pointers.push({ file: path.join(RULES_REL, name), id, key });
344
+ }
345
+ result.summary.rulesWithProvenance = pointers.length;
346
+
347
+ const [live, archive] = await Promise.all([indexStore(livePath), indexStore(archivePath)]);
348
+ result.stores.live.present = live.present;
349
+ result.stores.live.records = live.records;
350
+ result.stores.archive.present = archive.present;
351
+ result.stores.archive.records = archive.records;
352
+ result.summary.malformedStoreLines = live.malformed + archive.malformed;
353
+
354
+ if (pointers.length === 0) {
355
+ result.ok = findings.length === 0;
356
+ return result;
357
+ }
358
+
359
+ // No evidence corpus at all → "dangling" is not a claim this check can honestly
360
+ // make. Report the absence once instead of N times.
361
+ if (!live.present && !archive.present) {
362
+ findings.push({
363
+ kind: 'stores-absent',
364
+ file: RULES_REL,
365
+ learningId: null,
366
+ learningKey: null,
367
+ message:
368
+ `${pointers.length} rule file(s) carry provenance but neither ${result.stores.live.path} nor ` +
369
+ `${result.stores.archive.path} exists — pointers cannot be resolved here (not reported as dangling)`,
370
+ });
371
+ return result;
372
+ }
373
+
374
+ const idResolves = (/** @type {string} */ id) => live.ids.has(id) || archive.ids.has(id);
375
+ const keyResolves = (/** @type {string} */ key) => live.keys.has(key) || archive.keys.has(key);
376
+
377
+ for (const { file, id, key } of pointers) {
378
+ if (id !== null) {
379
+ if (idResolves(id)) {
380
+ result.summary.resolved += 1;
381
+ continue;
382
+ }
383
+ if (key !== null && keyResolves(key)) {
384
+ result.summary.superseded += 1;
385
+ findings.push({
386
+ kind: 'superseded-learning-id',
387
+ file,
388
+ learningId: id,
389
+ learningKey: key,
390
+ message:
391
+ `learning-id \`${id}\` resolves in neither store, but learning-key \`${key}\` does — the record was ` +
392
+ 're-created under a new id; re-stamp the learning-id from the record carrying this key',
393
+ });
394
+ continue;
395
+ }
396
+ result.summary.dangling += 1;
397
+ findings.push({
398
+ kind: 'dangling-learning-id',
399
+ file,
400
+ learningId: id,
401
+ learningKey: key,
402
+ message:
403
+ `learning-id \`${id}\`${key === null ? '' : ` (key \`${key}\`)`} resolves in neither the live store ` +
404
+ 'nor the archive',
405
+ });
406
+ continue;
407
+ }
408
+
409
+ // Key-only provenance block: audit the axis that IS present.
410
+ const presentKey = /** @type {string} */ (key);
411
+ if (keyResolves(presentKey)) {
412
+ result.summary.resolved += 1;
413
+ continue;
414
+ }
415
+ result.summary.dangling += 1;
416
+ findings.push({
417
+ kind: 'dangling-learning-key',
418
+ file,
419
+ learningId: null,
420
+ learningKey: presentKey,
421
+ message:
422
+ `learning-key \`${presentKey}\` matches no record in the live store or the archive, and the block ` +
423
+ 'carries no learning-id to resolve instead',
424
+ });
425
+ }
426
+
427
+ result.ok = findings.length === 0;
428
+ return result;
429
+ }
430
+
431
+ /**
432
+ * Run the human-readable validator CLI.
433
+ *
434
+ * WARN-ONLY: findings print as WARN and still return 0. See § Mode in the
435
+ * header for why a blocking gate would be red on arrival on this repo.
436
+ *
437
+ * @param {string} pluginRoot absolute plugin root
438
+ * @returns {Promise<number>} 0 = census completed, 2 = tool error
439
+ */
440
+ export async function runCheckLearningProvenance(pluginRoot) {
441
+ console.log('--- Check: learning provenance pointers in .claude/rules/ (WARN-only) ---');
442
+ const inspection = await inspectLearningProvenance(pluginRoot);
443
+
444
+ if (inspection.toolError) {
445
+ for (const item of inspection.findings) {
446
+ console.log(` FAIL: ${item.file} — ${item.message}`);
447
+ }
448
+ console.log('');
449
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
450
+ return 2;
451
+ }
452
+
453
+ for (const item of inspection.findings) {
454
+ console.log(` WARN: [${item.kind}] ${item.file} — ${item.message}`);
455
+ }
456
+
457
+ const { rulesScanned, rulesWithProvenance, resolved, dangling, superseded, malformedStoreLines } =
458
+ inspection.summary;
459
+ if (dangling > 0) {
460
+ console.log(
461
+ ` WARN: ${dangling} of ${rulesWithProvenance} provenance pointer(s) resolve in neither ` +
462
+ `${inspection.stores.live.path} nor ${inspection.stores.archive.path} — those rules cite evidence ` +
463
+ 'that no longer exists and cannot be re-confidenced, audited, or expired on purpose',
464
+ );
465
+ }
466
+ const storeNote =
467
+ `${inspection.stores.live.records} live + ${inspection.stores.archive.records} archived record(s)` +
468
+ (malformedStoreLines > 0 ? `, ${malformedStoreLines} unparseable store line(s) skipped` : '');
469
+ console.log(
470
+ ` PASS: checked ${rulesWithProvenance} provenance pointer(s) across ${rulesScanned} rule file(s) ` +
471
+ `against ${storeNote} — ${resolved} resolved, ${dangling} dangling, ${superseded} superseded`,
472
+ );
473
+ console.log('');
474
+ console.log('Results: 1 passed, 0 failed');
475
+ return 0;
476
+ }
477
+
478
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
479
+ if (isMain) {
480
+ const argv = process.argv.slice(2);
481
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
482
+ const positional = argv.filter((a) => !a.startsWith('--'));
483
+ const usage =
484
+ 'Usage: check-learning-provenance.mjs [<plugin-root>] [--json]\n' +
485
+ ' --json emit the inspection envelope as a single JSON object on stdout\n' +
486
+ 'Exit: 0 census completed (findings are WARN-only) · 1 usage error · 2 tool error';
487
+
488
+ if (flags.has('--help')) {
489
+ console.log(usage);
490
+ process.exitCode = 0;
491
+ } else {
492
+ const unknown = [...flags].filter((f) => f !== '--json' && f !== '--help');
493
+ if (unknown.length > 0) {
494
+ console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
495
+ process.exitCode = 1;
496
+ } else {
497
+ const pluginRoot = path.resolve(positional[0] ?? process.cwd());
498
+ if (flags.has('--json')) {
499
+ const inspection = await inspectLearningProvenance(pluginRoot);
500
+ // Data on stdout, diagnostics on stderr (cli-design.md).
501
+ console.log(JSON.stringify(inspection, null, 2));
502
+ process.exitCode = inspection.toolError ? 2 : 0;
503
+ } else {
504
+ process.exitCode = await runCheckLearningProvenance(pluginRoot);
505
+ }
506
+ }
507
+ }
508
+ // Deliberately NOT `process.exit()`: on a pipe, exiting discards stdout writes
509
+ // still queued in the async write buffer (the `--json` envelope can outgrow the
510
+ // ~64 KiB pipe capacity). Setting exitCode lets the writes drain first.
511
+ }
@@ -781,8 +781,6 @@ const textFiles = allFiles.filter(isTextFile);
781
781
  // - .orchestrator/audits/** never scanned (A.2/A.4-5)
782
782
  // - This guard's own source file (pattern-doc-comments define the scanner — not leaks).
783
783
  // - This guard's own test file (string-literal fixtures exercise the detector — not leaks).
784
- // - Persona content-lint tests (assert template files don't contain leakage strings;
785
- // the assertion literals themselves match the scanner regex — fixtures, not leaks).
786
784
  // Self-exclusions are the design-time fix for the latent bug exposed when scanner
787
785
  // fixture files transition from untracked → tracked in the same commit that tightens
788
786
  // detection (commit a68e94f for the original two; commit 95c8237 deep-3 W4 added the
@@ -791,7 +789,9 @@ const textFiles = allFiles.filter(isTextFile);
791
789
  const SELF_EXCLUSIONS = new Set([
792
790
  'scripts/lib/validate/check-owner-leakage.mjs',
793
791
  'tests/lib/validate/check-owner-leakage.test.mjs',
794
- 'tests/templates/personas/content-lint.test.mjs',
792
+ // content-lint.test.mjs entry removed with the file itself (#985 Tier A,
793
+ // 2026-08-05) — a dangling exclusion would pre-authorize any future file
794
+ // at that path to bypass this scanner.
795
795
  'tests/husky/pre-commit-owner-leakage.test.mjs',
796
796
  // #634: encoding-contract fixtures (`-Users-bernhardg-` expected-value literals
797
797
  // are load-bearing for the resolveMemoryDir() assertions; P9 would self-flag them)
@@ -23,6 +23,13 @@
23
23
  // identical `hasEmptyGlobs` fix below.
24
24
  // (b) learning-key must be present (traceability back to the emitter source).
25
25
  // (c) expires-at must be present (auto-generated rules must have a TTL).
26
+ // Plus, ahead of the auto/handwritten split and binding on EVERY rule file
27
+ // regardless of cohort:
28
+ // (d) the frontmatter must PARSE (#1015). An unparseable file used to be
29
+ // skipped as "not auditable"; rule-loader.mjs, however, treats a
30
+ // parse error as always-on with empty meta, so the skipped file is
31
+ // exactly the one that loads everywhere and clears every gate. See
32
+ // the inline rationale at the parse site below.
26
33
  //
27
34
  // (2) HANDWRITTEN rules (no `auto-generated: true` — #880 FA5, WARN-only,
28
35
  // NEVER affects the exit code). The auto-generated brandmauer above only
@@ -154,9 +161,9 @@ if (mdFiles.length === 0) {
154
161
  }
155
162
 
156
163
  // Parse every rule file once, splitting into the auto-generated and
157
- // handwritten cohorts. Malformed frontmatter → skipped from BOTH checks
158
- // (matches pre-#880 behaviour: a rule whose frontmatter cannot be parsed is
159
- // neither PASS'd nor FAIL'd).
164
+ // handwritten cohorts. Malformed frontmatter → a cohort-independent hard FAIL
165
+ // (#1015). It was previously skipped from BOTH checks ("neither PASS'd nor
166
+ // FAIL'd"); see the parse site below for why that abstention was a blind spot.
160
167
  const autoGeneratedEntries = [];
161
168
  const handwrittenEntries = [];
162
169
 
@@ -173,8 +180,27 @@ for (const name of mdFiles.sort()) {
173
180
  let parsed;
174
181
  try {
175
182
  parsed = parseGlobsFrontmatter(contents);
176
- } catch {
177
- // Malformed frontmatter not auditable by either branch, skip.
183
+ } catch (err) {
184
+ // MALFORMED FRONTMATTER IS A HARD FAIL (#1015) it used to `continue`.
185
+ //
186
+ // The skip looked like a neutral abstention ("not auditable by either
187
+ // branch") but was in fact this validator's single blind spot, and it was
188
+ // blind to precisely the worst state. rule-loader.mjs catches the SAME
189
+ // throw (~:500-507), falls back to `globs = null, meta = {}, parseError =
190
+ // true`, and then (~:519-530) pushes the entry with `alwaysOn: true`.
191
+ // Empty meta means applyGates() has nothing to gate on, so the file also
192
+ // clears tier/host-class/mode/EXPIRY gating by design ("a rule is never
193
+ // silently dropped"). Net effect: the one file this branch declined to
194
+ // audit is the one file the loader loads ALWAYS-ON, in every context,
195
+ // forever — the exact state the never-always-on invariant (#668/#687)
196
+ // exists to forbid, and the landing state of a frontmatter-injection whose
197
+ // payload happens to be colon-less (an injected `\n` + a line with no `:`).
198
+ // Unparseable therefore means UNSAFE, not "unknown": FAIL, never skip.
199
+ fail(
200
+ `.claude/rules/${name} — frontmatter does not parse (${err.message}) — rule-loader.mjs treats a parse ` +
201
+ 'error as ALWAYS-ON with EMPTY meta, so this file loads in every context and clears every gate ' +
202
+ '(no expiry, no tier, no host-class, no mode). Fix the frontmatter or remove the file.',
203
+ );
178
204
  continue;
179
205
  }
180
206