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,549 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-unwired-features.mjs — census of DECLARED-BUT-UNREAD Session Config keys.
4
+ *
5
+ * ## The defect class
6
+ *
7
+ * This repo's recurring systemic failure is not a broken feature — it is a
8
+ * feature that was built, documented, schema-validated and tested, and then
9
+ * never switched on. One 2026-08-08 analysis found three independent instances:
10
+ * `efficiency.output-level` (10 test files, 0 runtime consumers, ~15 months
11
+ * dead), `issue-budget` (complete with a PreToolUse hook and an overflow
12
+ * collector, never entered in the live Session Config), and
13
+ * `compact-nudge` / `goal-integration` (0 `.mjs` read sites — schema + prose
14
+ * only). Prose cannot catch this class; every one of those keys was documented
15
+ * exactly as prescribed. A mechanical census can.
16
+ *
17
+ * ## What this check owns — and what it deliberately does NOT
18
+ *
19
+ * The declared config surface has THREE faces, and only one of the three edges
20
+ * between them was previously guarded:
21
+ *
22
+ * template ↔ live Session Config → owned by `claude-md-drift-check`
23
+ * Check 6 (`session-config-parity`).
24
+ * NOT duplicated here.
25
+ * template ↔ code ┐
26
+ * live cfg ↔ code ┘ → owned by THIS check.
27
+ *
28
+ * So: a key that appears in `docs/session-config-template.md` and/or in the
29
+ * live `## Session Config` block, but that NO `.mjs` under `scripts/` or
30
+ * `hooks/` ever reads, is reported here. That is the `compact-nudge` class.
31
+ *
32
+ * ## Two signals, because a mention is not a read
33
+ *
34
+ * S1 `unwired-config-key` — zero non-comment mentions in the consumer
35
+ * corpus. The blunt case.
36
+ * S2 `parser-orphan-config-key` — the key IS mentioned, but no file in the
37
+ * config-parser layer (`PARSER_PATHS`) knows
38
+ * it, so nothing turns the YAML into a value.
39
+ *
40
+ * S2 exists because S1 alone is fooled by a mention that reads nothing.
41
+ * `express-path.enabled` passes S1 on the strength of ONE line —
42
+ * `scripts/lib/state-md/body-sections.mjs:699`, a log-message template literal
43
+ * that interpolates a value its caller already had. No parser resolves
44
+ * `express-path` from config at all; the gate lives entirely in
45
+ * `skills/session-start/phase-8-5-express-path.md` prose. S1 called that wired;
46
+ * S2 calls it what it is.
47
+ *
48
+ * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
49
+ * parent — and its premise is structural: every Session Config key has to pass
50
+ * through the parser layer to become a value. Measured 2026-08-08: 84 of 89
51
+ * top-level keys satisfy it, so the 5 that do not are signal, not noise.
52
+ *
53
+ * ## Consumer scope, and why "prose-only" is a finding rather than an error
54
+ *
55
+ * Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
56
+ * with every `tests/` path excluded — a key read only by its own tests is
57
+ * exactly the dead surface this check hunts.
58
+ *
59
+ * Skill bodies are NOT consumers for this purpose. A key consumed only by
60
+ * markdown prose is real (an LLM reads the instruction), but it is a WEAKER
61
+ * wiring than code: nothing fails when the prose is reworded or the skill is
62
+ * retired. Those keys are legitimate — they belong on the allowlist below with
63
+ * their prose consumer named, which turns an invisible assumption into a
64
+ * reviewable line.
65
+ *
66
+ * ## Allowlisting (how, and the standing requirement)
67
+ *
68
+ * Add an entry to `ALLOWLIST` keyed by the FULL dotted key path, whose value is
69
+ * a non-empty reason naming the actual consumer:
70
+ *
71
+ * 'auto-skill-dispatch': 'prose-only consumer: skills/using-orchestrator/SKILL.md',
72
+ *
73
+ * Every entry needs a reason — an empty or whitespace-only one is itself
74
+ * reported (`allowlist-missing-reason`), so the escape hatch cannot be used to
75
+ * silence a key without saying why. The list also drains itself: an entry is
76
+ * reported as `allowlist-stale` both when its key has left every config surface
77
+ * AND when the key stops triggering a finding (i.e. it finally got wired), so a
78
+ * fixed key does not leave a permanent exemption behind.
79
+ *
80
+ * ## Mode: WARN, not blocking (v1)
81
+ *
82
+ * Findings never fail the process; exit is 0 whenever the scan completed
83
+ * (2 only on a filesystem/tool error). This is deliberate. The repo currently
84
+ * ships 14 of 27 Session Config switches set to `false`; a blocking gate on
85
+ * that inventory would be red from day one, and a gate that is red on day one
86
+ * gets disabled — which is the same disease this file exists to treat, one
87
+ * level up. Warn first, ratchet later once the census is at zero.
88
+ *
89
+ * ## Named residuals (so nobody over-reads the coverage claim)
90
+ *
91
+ * - **Token-level, not dataflow.** A key whose value is parsed and then never
92
+ * consumed downstream reads as WIRED here. `efficiency.output-level` is that
93
+ * shape (parsed by `scripts/lib/owner-yaml.mjs`, consumed by nothing) and
94
+ * would NOT be caught — nor is it in scope, being an `owner.yaml` key rather
95
+ * than a Session Config one. Catching it needs an import/dataflow graph.
96
+ * - **Unused `export`s under `scripts/lib/` are NOT censused here.** A naive
97
+ * token census of that second axis produced 441 candidates over 356 files on
98
+ * 2026-08-08, with false positives in the first screenful (`validateAgentOutput`
99
+ * among them) — a list that size is switched off, not acted on. That axis
100
+ * needs a real import graph; `knip` is not a dependency of this repo
101
+ * (`grep -n knip package.json` → no match) and adopting it is issue #977.
102
+ * - **A comment mention counts as a non-read.** Matches are tallied per line;
103
+ * a key named only in comments is reported, with the comment count attached.
104
+ * - Files are read with `readFileSync`, never a `grep` spawn: one NUL byte
105
+ * makes a text file invisible to grep-based audits (see
106
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
107
+ * which would silently drop a consumer and manufacture a false positive.
108
+ *
109
+ * Import-safety: importing this module exposes the collector and runner only;
110
+ * the CLI path is guarded at the bottom of the file.
111
+ */
112
+
113
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
114
+ import path from 'node:path';
115
+ import { pathToFileURL } from 'node:url';
116
+
117
+ /** Documented config surface — every `yaml` fence in this file is a declaration. */
118
+ const TEMPLATE_REL = 'docs/session-config-template.md';
119
+
120
+ /** Live config surface. First existing file wins (CLAUDE.md beats AGENTS.md). */
121
+ const INSTRUCTION_FILES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
122
+
123
+ /** Directories whose code counts as a runtime consumer. */
124
+ const CONSUMER_DIRS = Object.freeze(['scripts', 'hooks']);
125
+
126
+ /** Extensions that can hold a runtime read site. */
127
+ const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
128
+
129
+ /** Directory names excluded from the consumer scan at any depth. */
130
+ const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'tests', 'test', '__tests__']);
131
+
132
+ /**
133
+ * This file excludes ITSELF from the consumer corpus. Load-bearing: every
134
+ * `ALLOWLIST` key is a string literal here, so without the exclusion each
135
+ * allowlist entry becomes its own read site and the check reports the key as
136
+ * wired — silently blinding itself to exactly the keys an operator flagged as
137
+ * needing review. (Observed on first run: 2 allowlisted keys reported as 0.)
138
+ */
139
+ const SELF_REL = path.join('scripts', 'lib', 'validate', 'check-unwired-features.mjs');
140
+
141
+ /**
142
+ * The config-parser layer: the files a Session Config key must pass through to
143
+ * become a runtime value. Signal S2 (see header) checks top-level keys against
144
+ * this subset. Directories are walked; plain files are taken as-is.
145
+ */
146
+ const PARSER_PATHS = Object.freeze([
147
+ path.join('scripts', 'lib', 'config'),
148
+ path.join('scripts', 'lib', 'config.mjs'),
149
+ path.join('scripts', 'lib', 'config-schema.mjs'),
150
+ path.join('scripts', 'parse-config.mjs'),
151
+ ]);
152
+
153
+ /**
154
+ * Declared-but-unread keys accepted on purpose. Key = full dotted path,
155
+ * value = REASON naming the real consumer. See the header for the contract:
156
+ * an empty reason, a key that left every config surface, and a key that got
157
+ * wired are all reported so the list stays short and true.
158
+ */
159
+ const ALLOWLIST = Object.freeze({
160
+ 'auto-skill-dispatch':
161
+ 'prose-only consumer — skills/using-orchestrator/SKILL.md + skills/_shared/bootstrap-gate.md read this key as an LLM instruction; there is no .mjs gate by design',
162
+ 'auto-commit-per-wave':
163
+ 'prose-only consumer — skills/wave-executor/wave-loop.md gates the per-wave commit step on this key; the commit itself is a coordinator action, not a script',
164
+ 'instruction-budget':
165
+ 'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
166
+ webhooks:
167
+ 'dedicated reader outside the parser layer — scripts/lib/webhook-url.mjs resolves these URLs env-first (S2 exemption only; S1 evidence is real)',
168
+ });
169
+
170
+ /**
171
+ * @typedef {{
172
+ * key: string,
173
+ * leaf: string,
174
+ * root: string,
175
+ * sources: string[],
176
+ * }} DeclaredKey
177
+ */
178
+
179
+ /**
180
+ * @typedef {{
181
+ * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
182
+ * | 'allowlist-stale' | 'tool-error',
183
+ * key: string,
184
+ * message: string,
185
+ * }} Finding
186
+ */
187
+
188
+ /**
189
+ * Recursively collect code files, skipping symlinks and excluded directories.
190
+ *
191
+ * @param {string} directory absolute directory path
192
+ * @param {string[]} [acc]
193
+ * @returns {string[]} absolute file paths, sorted
194
+ */
195
+ function walkCode(directory, acc = []) {
196
+ if (!existsSync(directory)) return acc;
197
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
198
+ if (entry.isSymbolicLink()) continue;
199
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
200
+ const fullPath = path.join(directory, entry.name);
201
+ if (entry.isDirectory()) walkCode(fullPath, acc);
202
+ else if (entry.isFile() && CODE_EXTENSIONS.includes(path.extname(entry.name))) acc.push(fullPath);
203
+ }
204
+ return acc;
205
+ }
206
+
207
+ /**
208
+ * Extract dotted key paths from YAML-ish lines.
209
+ *
210
+ * Indentation drives nesting; a `- ` list-item prefix is treated as one extra
211
+ * level so `custom-phases: [- name: …]` yields `custom-phases.name`. Comment
212
+ * lines and inline `# …` trailers are stripped first, so a commented-out key
213
+ * (e.g. the deliberately-disabled `# bash-write-guard: true`) is NOT counted as
214
+ * declared — commenting a key out IS the documented way to leave it unset.
215
+ *
216
+ * @param {string[]} lines raw YAML lines
217
+ * @param {(key: string, leaf: string, rootKey: string) => void} emit
218
+ * @returns {void}
219
+ */
220
+ function extractKeyLines(lines, emit) {
221
+ /** @type {{name: string, indent: number}[]} */
222
+ const stack = [];
223
+ for (const raw of lines) {
224
+ const trimmed = raw.trim();
225
+ if (!trimmed || trimmed.startsWith('#')) continue;
226
+ const withoutComment = raw.replace(/\s+#.*$/, '');
227
+ let indent = (withoutComment.match(/^(\s*)/) ?? ['', ''])[1].length;
228
+ let body = withoutComment.trim();
229
+ if (body.startsWith('- ')) {
230
+ body = body.slice(2).trim();
231
+ indent += 2;
232
+ }
233
+ const matched = body.match(/^([A-Za-z0-9_.-]+):(?:\s.*)?$/);
234
+ if (!matched) continue;
235
+ const name = matched[1];
236
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
237
+ const parents = stack.map((frame) => frame.name);
238
+ emit([...parents, name].join('.'), name, parents[0] ?? name);
239
+ stack.push({ name, indent });
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Collect every key declared in the template's ```yaml fences and in the live
245
+ * `## Session Config` block(s) of the instruction file.
246
+ *
247
+ * The live-config scan runs to the next `## ` heading at column 0, so the
248
+ * parity-exempt `## Skill Evolution` / `## Dispatcher Autonomy` blocks are
249
+ * picked up as their own declaration sources rather than silently skipped —
250
+ * their keys are just as capable of going unread.
251
+ *
252
+ * @param {string} pluginRoot absolute plugin root
253
+ * @returns {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}}
254
+ */
255
+ export function collectDeclaredKeys(pluginRoot) {
256
+ /** @type {Map<string, DeclaredKey>} */
257
+ const keys = new Map();
258
+ /** @type {string[]} */
259
+ const sourcesScanned = [];
260
+
261
+ /** @type {(source: string) => (key: string, leaf: string, rootKey: string) => void} */
262
+ const emitter = (source) => (key, leaf, rootKey) => {
263
+ const existing = keys.get(key);
264
+ if (existing) {
265
+ if (!existing.sources.includes(source)) existing.sources.push(source);
266
+ return;
267
+ }
268
+ keys.set(key, { key, leaf, root: rootKey, sources: [source] });
269
+ };
270
+
271
+ const templatePath = path.join(pluginRoot, TEMPLATE_REL);
272
+ if (existsSync(templatePath)) {
273
+ sourcesScanned.push(TEMPLATE_REL);
274
+ const emit = emitter(TEMPLATE_REL);
275
+ let inYamlFence = false;
276
+ /** @type {string[]} */
277
+ let fence = [];
278
+ for (const raw of readFileSync(templatePath, 'utf8').split('\n')) {
279
+ if (raw.trim().startsWith('```')) {
280
+ if (inYamlFence) extractKeyLines(fence, emit);
281
+ inYamlFence = raw.trim().startsWith('```yaml');
282
+ fence = [];
283
+ continue;
284
+ }
285
+ if (inYamlFence) fence.push(raw);
286
+ }
287
+ if (inYamlFence) extractKeyLines(fence, emit);
288
+ }
289
+
290
+ for (const candidate of INSTRUCTION_FILES) {
291
+ const instructionPath = path.join(pluginRoot, candidate);
292
+ if (!existsSync(instructionPath)) continue;
293
+ sourcesScanned.push(candidate);
294
+ const emit = emitter(candidate);
295
+ const lines = readFileSync(instructionPath, 'utf8').split('\n');
296
+ let inConfigBlock = false;
297
+ /** @type {string[]} */
298
+ let block = [];
299
+ for (const raw of lines) {
300
+ if (/^## /.test(raw)) {
301
+ if (inConfigBlock) extractKeyLines(block, emit);
302
+ block = [];
303
+ inConfigBlock = /^## (Session Config|Skill Evolution|Dispatcher Autonomy)\b/.test(raw);
304
+ continue;
305
+ }
306
+ // `>` blockquote prose inside a config block carries no declarations.
307
+ if (inConfigBlock && !raw.trim().startsWith('>')) block.push(raw);
308
+ }
309
+ if (inConfigBlock) extractKeyLines(block, emit);
310
+ break; // CLAUDE.md wins ties (skills/_shared/instruction-file-resolution.md)
311
+ }
312
+
313
+ return { keys, sourcesScanned };
314
+ }
315
+
316
+ /**
317
+ * Build a word-boundary matcher for a config-key token.
318
+ *
319
+ * `-` is part of the boundary class so `mode` does not match `mode-x`, and
320
+ * `$` so `enabled` does not match `isEnabled$`.
321
+ *
322
+ * @param {string} token
323
+ * @returns {RegExp}
324
+ */
325
+ function tokenMatcher(token) {
326
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
327
+ return new RegExp(`(?<![A-Za-z0-9_$-])${escaped}(?![A-Za-z0-9_$-])`);
328
+ }
329
+
330
+ /** @param {string} line @returns {boolean} whether the line is comment-only */
331
+ function isCommentLine(line) {
332
+ const trimmed = line.trim();
333
+ return (
334
+ trimmed.startsWith('//') ||
335
+ trimmed.startsWith('*') ||
336
+ trimmed.startsWith('/*') ||
337
+ trimmed.startsWith('#')
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Count read sites for one declared key across the consumer corpus.
343
+ *
344
+ * Matching is on the LEAF token, because a nested key is read as
345
+ * `cfg['memory']?.banner?.enabled` — the dotted path never appears verbatim.
346
+ * That alone would make a generic leaf (`enabled`, `mode`, `url`) match
347
+ * everywhere, so a nested key with a NON-DISTINCTIVE leaf (no hyphen — i.e. a
348
+ * token that could be a plain JS identifier) additionally requires its
349
+ * top-level ancestor token in the SAME file.
350
+ *
351
+ * A hyphenated leaf is exempt from that ancestor requirement on purpose:
352
+ * `enforcement-gates.path-guard` is read in `hooks/enforce-scope.mjs` as
353
+ * `scope.gates?.['path-guard']`, a file that never names `enforcement-gates`.
354
+ * Requiring the ancestor there produced a false positive on a genuinely-wired
355
+ * gate — and false positives are what get this check switched off.
356
+ *
357
+ * @param {DeclaredKey} declared
358
+ * @param {{relative: string, lines: string[], body: string}[]} corpus
359
+ * @returns {{code: number, comment: number, files: string[]}}
360
+ */
361
+ export function countReadSites(declared, corpus) {
362
+ const leafRe = tokenMatcher(declared.leaf);
363
+ const rootRe = tokenMatcher(declared.root);
364
+ const scopedByAncestor = declared.key !== declared.leaf && !declared.leaf.includes('-');
365
+ let code = 0;
366
+ let comment = 0;
367
+ /** @type {string[]} */
368
+ const files = [];
369
+
370
+ for (const file of corpus) {
371
+ if (scopedByAncestor && !rootRe.test(file.body)) continue;
372
+ if (!leafRe.test(file.body)) continue;
373
+ let codeInFile = 0;
374
+ for (const line of file.lines) {
375
+ if (!leafRe.test(line)) continue;
376
+ if (isCommentLine(line)) comment += 1;
377
+ else codeInFile += 1;
378
+ }
379
+ if (codeInFile > 0) files.push(file.relative);
380
+ code += codeInFile;
381
+ }
382
+
383
+ return { code, comment, files };
384
+ }
385
+
386
+ /**
387
+ * Run the full census.
388
+ *
389
+ * @param {string} pluginRoot absolute plugin root
390
+ * @returns {{
391
+ * ok: boolean,
392
+ * summary: {declaredKeys: number, consumerFiles: number, unwired: number, allowlisted: number},
393
+ * sourcesScanned: string[],
394
+ * findings: Finding[],
395
+ * toolError: boolean,
396
+ * }}
397
+ */
398
+ export function inspectUnwiredFeatures(pluginRoot) {
399
+ /** @type {Finding[]} */
400
+ const findings = [];
401
+ const result = {
402
+ ok: false,
403
+ summary: { declaredKeys: 0, consumerFiles: 0, unwired: 0, allowlisted: 0 },
404
+ /** @type {string[]} */
405
+ sourcesScanned: [],
406
+ findings,
407
+ toolError: false,
408
+ };
409
+
410
+ /** @type {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}} */
411
+ let declared;
412
+ /** @type {{relative: string, lines: string[], body: string}[]} */
413
+ let corpus;
414
+ /** @type {string} */
415
+ let parserBody;
416
+ try {
417
+ declared = collectDeclaredKeys(pluginRoot);
418
+ corpus = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
419
+ .sort()
420
+ .filter((absolute) => path.relative(pluginRoot, absolute) !== SELF_REL)
421
+ .map((absolute) => {
422
+ const body = readFileSync(absolute, 'utf8');
423
+ return { relative: path.relative(pluginRoot, absolute), lines: body.split('\n'), body };
424
+ });
425
+ parserBody = PARSER_PATHS.flatMap((relative) => {
426
+ const absolute = path.join(pluginRoot, relative);
427
+ if (!existsSync(absolute)) return [];
428
+ return CODE_EXTENSIONS.includes(path.extname(absolute)) ? [absolute] : walkCode(absolute);
429
+ })
430
+ .map((absolute) => readFileSync(absolute, 'utf8'))
431
+ .join('\n');
432
+ } catch (error) {
433
+ result.toolError = true;
434
+ findings.push({
435
+ kind: 'tool-error',
436
+ key: '-',
437
+ message: `cannot enumerate config surface: ${error instanceof Error ? error.message : String(error)}`,
438
+ });
439
+ return result;
440
+ }
441
+
442
+ result.sourcesScanned = declared.sourcesScanned;
443
+ result.summary.declaredKeys = declared.keys.size;
444
+ result.summary.consumerFiles = corpus.length;
445
+
446
+ /** @type {Set<string>} */
447
+ const flagged = new Set();
448
+
449
+ for (const key of [...declared.keys.keys()].sort()) {
450
+ const meta = /** @type {DeclaredKey} */ (declared.keys.get(key));
451
+ const { code, comment } = countReadSites(meta, corpus);
452
+
453
+ /** @type {Finding | null} */
454
+ let issue = null;
455
+ if (code === 0) {
456
+ const commentNote = comment > 0 ? ` (${comment} comment-only mention(s))` : '';
457
+ issue = {
458
+ kind: 'unwired-config-key',
459
+ key,
460
+ message:
461
+ `declared in ${meta.sources.join(' + ')} but no .mjs under ${CONSUMER_DIRS.join('/ or ')}/ ` +
462
+ `reads it${commentNote} — wire it, delete it, or allowlist it with a reason`,
463
+ };
464
+ } else if (key === meta.root && !tokenMatcher(key).test(parserBody)) {
465
+ issue = {
466
+ kind: 'parser-orphan-config-key',
467
+ key,
468
+ message:
469
+ `mentioned in ${CONSUMER_DIRS.join('/ or ')}/ but unknown to the config-parser layer ` +
470
+ `(${PARSER_PATHS.join(', ')}) — nothing turns this YAML into a value; the mention may be ` +
471
+ 'a log string or a comment-adjacent reference',
472
+ };
473
+ }
474
+ if (!issue) continue;
475
+ flagged.add(key);
476
+
477
+ if (Object.prototype.hasOwnProperty.call(ALLOWLIST, key)) {
478
+ result.summary.allowlisted += 1;
479
+ if (String(ALLOWLIST[key] ?? '').trim() === '') {
480
+ findings.push({
481
+ kind: 'allowlist-missing-reason',
482
+ key,
483
+ message: 'allowlist entry has no reason — name the actual consumer or remove the entry',
484
+ });
485
+ }
486
+ continue;
487
+ }
488
+
489
+ result.summary.unwired += 1;
490
+ findings.push(issue);
491
+ }
492
+
493
+ for (const key of Object.keys(ALLOWLIST).sort()) {
494
+ if (flagged.has(key)) continue;
495
+ findings.push({
496
+ kind: 'allowlist-stale',
497
+ key,
498
+ message: declared.keys.has(key)
499
+ ? 'allowlisted key no longer triggers a finding (it is wired now) — remove the entry'
500
+ : 'allowlisted key is no longer declared in any config surface — remove the entry',
501
+ });
502
+ }
503
+
504
+ result.ok = !result.toolError && findings.length === 0;
505
+ return result;
506
+ }
507
+
508
+ /**
509
+ * Run the human-readable validator CLI.
510
+ *
511
+ * WARN-ONLY: findings print as WARN and still exit 0. See the header for why a
512
+ * blocking gate would be red on day one on this repo's current inventory.
513
+ *
514
+ * @param {string} pluginRoot absolute plugin root
515
+ * @returns {number} 0 = scan completed (with or without findings), 2 = tool error
516
+ */
517
+ export function runCheckUnwiredFeatures(pluginRoot) {
518
+ console.log('--- Check: unwired config keys (declared-but-unread census, WARN-only) ---');
519
+ const inspection = inspectUnwiredFeatures(pluginRoot);
520
+
521
+ if (inspection.toolError) {
522
+ for (const item of inspection.findings) console.log(` FAIL: ${item.key} — ${item.message}`);
523
+ console.log('');
524
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
525
+ return 2;
526
+ }
527
+
528
+ const { declaredKeys, consumerFiles, unwired, allowlisted } = inspection.summary;
529
+ for (const item of inspection.findings) {
530
+ console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
531
+ }
532
+ console.log(
533
+ ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
534
+ `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted`,
535
+ );
536
+ console.log('');
537
+ console.log('Results: 1 passed, 0 failed');
538
+ return 0;
539
+ }
540
+
541
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
542
+ if (isMain) {
543
+ const pluginRoot = process.argv[2];
544
+ if (!pluginRoot) {
545
+ console.error('Usage: check-unwired-features.mjs <plugin-root>');
546
+ process.exit(2);
547
+ }
548
+ process.exit(runCheckUnwiredFeatures(path.resolve(pluginRoot)));
549
+ }