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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- 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
|
-
|
|
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 →
|
|
158
|
-
// (
|
|
159
|
-
//
|
|
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
|
-
//
|
|
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
|
|