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,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
|
+
}
|