vigiles 27.1.7 → 27.2.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.
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * How heavy are the instructions this harness loads WITHOUT BEING ASKED — and
4
+ * what does the harness do when that is too much.
5
+ *
6
+ * WHY THIS IS NOT "the size of CLAUDE.md". Measured 2026-09-16 in a consumer
7
+ * repo: a 4 101-line root instruction file was "cut" to 2 665 lines by moving
8
+ * 225 837 characters of it into a sibling directory, and the cost of a request
9
+ * did not move at all — because the harness loads that directory
10
+ * unconditionally too. Splitting a file that is loaded either way relocates
11
+ * bytes; it does not remove them. So the number that matters is the SUM over
12
+ * everything loaded without a decision, and a per-file check silently INVITES
13
+ * the evasion (it rewards the split that changes nothing).
14
+ *
15
+ * WHY THE UNIT IS PER-HARNESS AND NOT TOKENS. The harnesses measure different
16
+ * things and neither gates on tokens:
17
+ *
18
+ * - Claude Code counts CHARACTERS and WARNS ("Large file will impact
19
+ * performance"); the instructions still reach the model.
20
+ * - Codex counts BYTES (`project_doc_max_bytes`, default 32 KiB) and
21
+ * TRUNCATES — silently. Its own source says so: "Maximum number of bytes of
22
+ * the documentation that will be embedded. Larger files are *silently
23
+ * truncated*" (openai/codex#7138, CLOSED AS NOT PLANNED, so this is the
24
+ * standing behaviour rather than a bug in flight).
25
+ *
26
+ * That asymmetry is the whole point of reporting `onExceed`: over budget on
27
+ * Claude Code costs money and attention, over budget on Codex means some of
28
+ * your rules DO NOT EXIST for the model and nothing tells you which. The same
29
+ * number carries a different severity per harness, so the harness must supply
30
+ * it — hence a port field, not a constant.
31
+ *
32
+ * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
33
+ * roughly four times the Claude Code threshold. A rule that fails every real
34
+ * repo on day one is switched off on day one (`lint-rule-calibration`: severity
35
+ * tracks confidence, and a check nobody leaves on catches nothing). So the
36
+ * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
37
+ * exists that it would not immediately fail.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.sizeIn = sizeIn;
41
+ exports.weighInstructions = weighInstructions;
42
+ /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
43
+ function sizeIn(text, unit) {
44
+ return unit === "chars" ? text.length : Buffer.byteLength(text, "utf8");
45
+ }
46
+ /**
47
+ * Match a path against one glob. Deliberately tiny: the patterns here are
48
+ * `alwaysLoaded` entries an ADAPTER writes, not user input — `CLAUDE.md`,
49
+ * `.claude/rules/**`. `*` stops at a separator, `**` crosses them.
50
+ */
51
+ function matchesGlob(path, glob) {
52
+ const rx = glob
53
+ .split(/(\*\*\/|\*\*|\*)/)
54
+ .map((part) => part === "**/"
55
+ ? "(?:.*/)?"
56
+ : part === "**"
57
+ ? ".*"
58
+ : part === "*"
59
+ ? "[^/]*"
60
+ : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
61
+ .join("");
62
+ return new RegExp(`^${rx}$`).test(path);
63
+ }
64
+ /**
65
+ * Weigh every unconditionally-loaded file in a file map.
66
+ *
67
+ * Takes a MAP rather than a directory so the same function serves the CLI and
68
+ * the browser engine (the `scan-files.ts` split), and so a test states its
69
+ * input instead of building a tree.
70
+ */
71
+ function weighInstructions(files, budget) {
72
+ const weighed = Object.entries(files)
73
+ .filter(([path]) => budget.alwaysLoaded.some((g) => matchesGlob(path, g)))
74
+ .map(([path, text]) => ({ path, size: sizeIn(text, budget.unit) }))
75
+ .sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
76
+ const total = weighed.reduce((sum, f) => sum + f.size, 0);
77
+ return {
78
+ unit: budget.unit,
79
+ limit: budget.limit,
80
+ onExceed: budget.onExceed,
81
+ files: weighed,
82
+ total,
83
+ overBy: total > budget.limit ? total - budget.limit : null,
84
+ };
85
+ }
86
+ //# sourceMappingURL=instruction-weight.js.map
@@ -22,17 +22,27 @@ function dialectVocabularyProblems(dialect) {
22
22
  // A block-semantics subset that names an event the dialect doesn't fire is a
23
23
  // rule about nothing.
24
24
  const events = new Set(dialect.hookEvents);
25
+ // 🔴 READ THE DECLARED FIELDS, not the effective answer. This check is about
26
+ // junk IN a dialect's own declarations, so routing it through the
27
+ // capability-table readers (which prefer the table) made it stop looking at
28
+ // the very field it polices — caught by `vocabulary.test.ts` the moment the
29
+ // Claude Code dialect gained a table. The readers are for CONSUMERS asking
30
+ // "what does this harness do?"; a consistency check is not one of those.
31
+ /* eslint-disable @typescript-eslint/no-deprecated -- policing the legacy
32
+ fields themselves is this function's entire job. */
25
33
  for (const [field, list] of [
26
34
  ["noEffectHookEvents", dialect.noEffectHookEvents ?? []],
27
35
  [
28
36
  "permissionDecisionHookEvents",
29
37
  dialect.permissionDecisionHookEvents ?? [],
30
38
  ],
39
+ ["eventCapabilities", Object.keys(dialect.eventCapabilities?.events ?? {})],
31
40
  ])
32
41
  for (const event of list)
33
42
  if (!events.has(event))
34
43
  problems.push(`hook event "${event}" is in ${field} but not in hookEvents — ` +
35
44
  `it describes an event this dialect says never fires`);
45
+ /* eslint-enable @typescript-eslint/no-deprecated */
36
46
  return problems;
37
47
  }
38
48
  /**
@@ -17,7 +17,7 @@ interface HookEntry {
17
17
  /** The CC-shaped structured block a compiled hook program carries. */
18
18
  export type CompiledHooks = Record<string, readonly HookEntry[]>;
19
19
  interface SettingsJson {
20
- hooks?: Record<string, HookEntry[]>;
20
+ hooks?: Readonly<Record<string, readonly HookEntry[]>>;
21
21
  [k: string]: unknown;
22
22
  }
23
23
  /**
@@ -58,17 +58,19 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
58
58
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
59
59
  /**
60
60
  * Idempotently merge a compiled hook's block into an existing `settings.json`
61
- * object. Entries managed by THIS hook file (the runtime command references
62
- * `hookPath`) are replaced; every unrelated entry — including the user's own
63
- * hand-written hooks — is preserved.
61
+ * object. Commands managed by THIS hook file (the runtime command references
62
+ * `hookPath`) are replaced; every unrelated command — including the user's own
63
+ * hand-written hooks SHARING A MATCHER BLOCK with ours — is preserved. See
64
+ * {@link withoutHookCommands} for why the granularity is the command and not
65
+ * the entry.
64
66
  */
65
67
  export declare function mergeHooksJson(existing: SettingsJson, compiled: CompiledHooks, hookPath: string): SettingsJson;
66
68
  interface TomlHookEntry {
67
- matcher?: string;
68
- command: string;
69
+ readonly matcher?: string;
70
+ readonly command: string;
69
71
  }
70
72
  interface ConfigToml {
71
- hooks?: Record<string, TomlHookEntry[]>;
73
+ hooks?: Readonly<Record<string, readonly TomlHookEntry[]>>;
72
74
  [k: string]: unknown;
73
75
  }
74
76
  /** The TOML sibling of {@link mergeHooksJson} (Codex `[[hooks.<event>]]`). */
@@ -149,19 +149,70 @@ function bareToken(token) {
149
149
  const unquoted = token.replace(/^["']/, "").replace(/["']$/, "");
150
150
  return unquoted.replace(/^\$\{?CLAUDE_PROJECT_DIR\}?[/\\]/, "");
151
151
  }
152
+ /**
153
+ * Drop only the COMMANDS this hook file owns from one entry, keeping the rest.
154
+ *
155
+ * Returns the entry UNCHANGED (same reference) when it owns nothing here, a
156
+ * narrowed copy when it owns some, and `null` when the entry is left empty and
157
+ * should disappear.
158
+ *
159
+ * 🔴 THE GRANULARITY IS THE WHOLE POINT, and getting it wrong cost a consumer
160
+ * repo half its hook wiring. Claude Code's shape is
161
+ * `{matcher, hooks: [command, command, …]}` — SEVERAL commands share one
162
+ * matcher block — so "is this entry mine?" is the wrong question: an entry can
163
+ * be partly mine. The previous merge asked exactly that (`managesHook(e)` →
164
+ * drop `e`), which is true when ANY command matches, and then deleted the
165
+ * block wholesale.
166
+ *
167
+ * Measured 2026-09-15 on a real `.claude/settings.json`: its
168
+ * `PostToolUse`/`Edit|Write|MultiEdit` entry held SIX commands — four vigiles
169
+ * hooks and the user's own `kb-lint.mjs post` and `paper-lint.mjs post`.
170
+ * Recompiling any ONE of the four took all six, so two hand-written checks
171
+ * silently stopped running. Silently is the operative word: the file stayed
172
+ * valid JSON, the remaining hooks kept firing, and nothing reported a loss —
173
+ * the same failure mode this repo has already paid for three times (a step
174
+ * that stops executing without saying so).
175
+ *
176
+ * The cost asymmetry that decides the rule: a MISS leaves a duplicate block
177
+ * (visible, harmless, fixed by the next recompile), an OVER-MATCH deletes a
178
+ * hook the user wrote (invisible, unrecoverable from the file itself). So the
179
+ * filter is per-command, and an entry is removed only when we emptied it.
180
+ */
181
+ function withoutHookCommands(entry, hookPath) {
182
+ if (!managesHook(entry, hookPath))
183
+ return entry;
184
+ const kept = entry.hooks.filter((h) => !managesHook({ matcher: entry.matcher, hooks: [h] }, hookPath));
185
+ if (kept.length === entry.hooks.length)
186
+ return entry;
187
+ return kept.length === 0 ? null : { ...entry, hooks: kept };
188
+ }
152
189
  /**
153
190
  * Idempotently merge a compiled hook's block into an existing `settings.json`
154
- * object. Entries managed by THIS hook file (the runtime command references
155
- * `hookPath`) are replaced; every unrelated entry — including the user's own
156
- * hand-written hooks — is preserved.
191
+ * object. Commands managed by THIS hook file (the runtime command references
192
+ * `hookPath`) are replaced; every unrelated command — including the user's own
193
+ * hand-written hooks SHARING A MATCHER BLOCK with ours — is preserved. See
194
+ * {@link withoutHookCommands} for why the granularity is the command and not
195
+ * the entry.
157
196
  */
158
197
  function mergeHooksJson(existing, compiled, hookPath) {
159
- const hooks = { ...(existing.hooks ?? {}) };
160
- for (const [event, entries] of Object.entries(compiled)) {
161
- const kept = (hooks[event] ?? []).filter((e) => !managesHook(e, hookPath));
162
- hooks[event] = [...kept, ...entries];
163
- }
164
- return { ...existing, hooks };
198
+ // No keyed assignment into a shallow copy, and the containers are `readonly`.
199
+ // That copy would SHARE its arrays with the caller's object, so the purity of
200
+ // the old loop rested on every future author reaching for `hooks[e] = [...]`
201
+ // rather than `hooks[e].push(...)` — one is fine, the other silently mutates
202
+ // the argument, and nothing told them apart. `readonly` makes the bad one a
203
+ // tsc error instead of a convention (ts-essentials: irrepresentable beats
204
+ // remembered).
205
+ const before = existing.hooks ?? {};
206
+ const rewritten = Object.fromEntries(Object.entries(compiled).map(([event, entries]) => [
207
+ event,
208
+ [
209
+ ...(before[event] ?? [])
210
+ .map((e) => withoutHookCommands(e, hookPath))
211
+ .filter((e) => e !== null),
212
+ ...entries,
213
+ ],
214
+ ]));
215
+ return { ...existing, hooks: { ...before, ...rewritten } };
165
216
  }
166
217
  /** Flatten a CC-shaped entry to Codex's flat `{matcher?, command}` form. */
167
218
  function toTomlEntries(entries) {
@@ -171,13 +222,21 @@ function toTomlEntries(entries) {
171
222
  }
172
223
  /** The TOML sibling of {@link mergeHooksJson} (Codex `[[hooks.<event>]]`). */
173
224
  function mergeHooksToml(existing, compiled, hookPath) {
174
- const hooks = { ...(existing.hooks ?? {}) };
175
- for (const [event, entries] of Object.entries(compiled)) {
176
- // Same canonical-path keying as the JSON merge (one flat command per entry).
177
- const kept = (hooks[event] ?? []).filter((e) => !managesHook({ hooks: [{ type: "command", command: e.command }] }, hookPath));
178
- hooks[event] = [...kept, ...toTomlEntries(entries)];
179
- }
180
- return { ...existing, hooks };
225
+ // Same canonical-path keying as the JSON merge, and the same no-assignment
226
+ // shape. No per-command narrowing is needed HERE, and that is a fact about the
227
+ // format rather than an oversight: Codex's `[[hooks.<event>]]` carries ONE
228
+ // command per entry (see `toTomlEntries`), so entry- and command-granularity
229
+ // coincide. The CC shape nests several commands under one matcher, which is
230
+ // where the loss happened.
231
+ const before = existing.hooks ?? {};
232
+ const rewritten = Object.fromEntries(Object.entries(compiled).map(([event, entries]) => [
233
+ event,
234
+ [
235
+ ...(before[event] ?? []).filter((e) => !managesHook({ hooks: [{ type: "command", command: e.command }] }, hookPath)),
236
+ ...toTomlEntries(entries),
237
+ ],
238
+ ]));
239
+ return { ...existing, hooks: { ...before, ...rewritten } };
181
240
  }
182
241
  /** Serialize a merged config back to its on-disk text (with trailing newline). */
183
242
  function serializeConfig(merged, format) {
@@ -51,6 +51,7 @@ const hook_providers_js_1 = require("./core/hook-providers.js");
51
51
  const hook_state_store_js_1 = require("./hook-state-store.js");
52
52
  const observe_js_1 = require("./observe.js");
53
53
  const load_hook_js_1 = require("./load-hook.js");
54
+ const event_capability_js_1 = require("./core/event-capability.js");
54
55
  /**
55
56
  * Which events accept injected context, from the ACTIVE adapter — the one
56
57
  * harness-specific fact a react needs, read through the `HookProtocol` port so
@@ -63,7 +64,9 @@ const load_hook_js_1 = require("./load-hook.js");
63
64
  */
64
65
  function injectableEventsFor(root) {
65
66
  const { resolveAdapter } = require("./adapter-registry.js");
66
- return resolveAdapter(root).hookProtocol?.injectableEvents ?? [];
67
+ const adapter = resolveAdapter(root);
68
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the legacy list is the FALLBACK an adapter without a capability table still relies on; reading it here is the point
69
+ return (0, event_capability_js_1.injectableEventsOf)(adapter.dialect, adapter.hookProtocol?.injectableEvents); // prettier-ignore
67
70
  }
68
71
  /**
69
72
  * Load a compiled-hook program's default export — the SHARED loader, also the
@@ -20,7 +20,7 @@
20
20
  * behavioural divergence from `node:path` (which the disk-vs-browser parity gate
21
21
  * relies on), so the metric rules are disabled for this file only.
22
22
  */
23
- /* eslint-disable complexity, max-depth, sonarjs/cognitive-complexity, sonarjs/nested-control-flow */
23
+ /* eslint-disable complexity, max-depth, sonarjs/cognitive-complexity, sonarjs/nested-control-flow, no-param-reassign -- verbatim Node `path.js` port: it reassigns its own string parameters, and a string is copied by value so nothing escapes to the caller. Rewriting that away would diverge from the algorithm the parity gate pins. */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.isAbsolute = isAbsolute;
26
26
  exports.normalize = normalize;
@@ -42,6 +42,7 @@ const posix_path_js_1 = require("./posix-path.js");
42
42
  const toml_1 = require("@iarna/toml");
43
43
  const layout_js_1 = require("./adapters/claude-code/layout.js");
44
44
  const dialect_js_1 = require("./adapters/claude-code/dialect.js");
45
+ const instruction_weight_js_1 = require("./core/instruction-weight.js");
45
46
  const hook_normalize_js_1 = require("./core/hook-normalize.js");
46
47
  const hook_events_js_1 = require("./core/hook-events.js");
47
48
  const mcp_config_js_1 = require("./core/mcp-config.js");
@@ -59,6 +60,7 @@ const scan_core_js_1 = require("./scan-core.js");
59
60
  const skill_refs_js_1 = require("./skill-refs.js");
60
61
  const merge_conflict_js_1 = require("./core/merge-conflict.js");
61
62
  const scan_core_js_2 = require("./scan-core.js");
63
+ const event_capability_js_1 = require("./core/event-capability.js");
62
64
  /**
63
65
  * The synthetic absolute root every path in a browser scan resolves against. A
64
66
  * pure, deterministic string (never `process.cwd()`), so `join`/`relative` stay
@@ -550,10 +552,10 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
550
552
  skillFenceIssues: skillFenceFindings,
551
553
  pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, posix_path_js_1.join)(exports.BROWSER_ROOT, (0, posix_path_js_1.dirname)(lay.manifestPath)), [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: exists, isDirectory: mapIsDirectory(files) }),
552
554
  delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
553
- hookBlockFindings: dialect.noEffectHookEvents
555
+ hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
554
556
  ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, exports.BROWSER_ROOT, lay.pluginRootToken, exists), {
555
- noEffectEvents: new Set(dialect.noEffectHookEvents),
556
- permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
557
+ noEffectEvents: new Set((0, event_capability_js_1.blockIneffectiveEventsOf)(dialect)),
558
+ permissionDecisionEvents: new Set((0, event_capability_js_1.permissionDecisionEventsOf)(dialect)),
557
559
  readFileSync: mapReadFile(files),
558
560
  })
559
561
  : [],
@@ -566,6 +568,11 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
566
568
  ...loaded.warnings,
567
569
  ...(0, merge_conflict_js_1.conflictedHarnessConfigs)((f) => files[f]).map(merge_conflict_js_1.mergeConflictWarning),
568
570
  ],
571
+ // The browser side needs no directory walk — the file map IS the repo, so
572
+ // the glob filter inside weighInstructions does the whole job.
573
+ instructionWeight: dialect.instructionBudget
574
+ ? (0, instruction_weight_js_1.weighInstructions)(files, dialect.instructionBudget)
575
+ : null,
569
576
  untested: coverage.untested.length,
570
577
  untestedHarness: coverage.harness.untested.length,
571
578
  unevaluated: coverage.evals.untested.length,
package/dist/scan.d.ts CHANGED
@@ -30,6 +30,7 @@ import { type HookBlockFinding } from "./core/hook-block-ineffective.js";
30
30
  import { type HookMatcherFinding } from "./core/hook-matcher.js";
31
31
  import { type EvidenceCounts } from "./coverage-evidence.js";
32
32
  import type { PurityLevel, EffectSurface } from "./core/effects.js";
33
+ import { type InstructionWeight } from "./core/instruction-weight.js";
33
34
  export * from "./scan-core.js";
34
35
  export interface ScanSkill {
35
36
  readonly name: string;
@@ -304,6 +305,14 @@ export interface ScanReport {
304
305
  * `scan` and the `hook-matcher` lint rule (one detector, no drift).
305
306
  */
306
307
  readonly hookMatcherFindings: readonly HookMatcherFinding[];
308
+ /**
309
+ * What the harness loads WITHOUT being asked, weighed in ITS OWN unit — and
310
+ * what it does when that is too much. `null` when the adapter declares no
311
+ * budget. Reported, never gated: both corpora this was built against sit near
312
+ * four times the Claude Code threshold, and a rule that fails every real repo
313
+ * on day one is switched off on day one.
314
+ */
315
+ readonly instructionWeight: InstructionWeight | null;
307
316
  /** Skills/agents whose `---` block isn't valid YAML — informational (may still load via salvage). */
308
317
  readonly malformedFrontmatter: readonly FrontmatterParseIssue[];
309
318
  readonly warnings: readonly string[];
@@ -430,6 +439,5 @@ export declare function inspectMarketplace(dir: string, layout?: PluginLayout):
430
439
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
431
440
  */
432
441
  export declare function expandMarketplace(dir: string, layout?: PluginLayout): string[] | null;
433
- /** Format a scan report as human-readable text. */
434
442
  export declare function formatScanReport(r: ScanReport): string;
435
443
  //# sourceMappingURL=scan.d.ts.map
package/dist/scan.js CHANGED
@@ -54,6 +54,8 @@ const hook_matcher_js_1 = require("./core/hook-matcher.js");
54
54
  const test_coverage_js_1 = require("./test-coverage.js");
55
55
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
56
56
  const scan_core_js_1 = require("./scan-core.js");
57
+ const instruction_weight_js_1 = require("./core/instruction-weight.js");
58
+ const event_capability_js_1 = require("./core/event-capability.js");
57
59
  // Re-export the pure detectors (and their public types: SurfaceClassifier,
58
60
  // SkillScanContext, isManagedHookCommand, preferCompiledHooksMessage, ...) that
59
61
  // moved to the node-free `./scan-core.js`, so every existing consumer of
@@ -233,10 +235,10 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
233
235
  // — derive its first segment and dedupe so the detector watches it as well.
234
236
  [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: node_fs_1.existsSync, isDirectory: nodeIsDirectory }),
235
237
  delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
236
- hookBlockFindings: dialect.noEffectHookEvents
238
+ hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
237
239
  ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken, node_fs_1.existsSync), {
238
- noEffectEvents: new Set(dialect.noEffectHookEvents),
239
- permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
240
+ noEffectEvents: new Set((0, event_capability_js_1.blockIneffectiveEventsOf)(dialect)),
241
+ permissionDecisionEvents: new Set((0, event_capability_js_1.permissionDecisionEventsOf)(dialect)),
240
242
  readFileSync: nodeReadFile,
241
243
  })
242
244
  : [],
@@ -255,6 +257,9 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
255
257
  return (0, node_fs_1.existsSync)(p) ? nodeReadFile(p) : undefined;
256
258
  }).map(merge_conflict_js_1.mergeConflictWarning),
257
259
  ],
260
+ instructionWeight: dialect.instructionBudget
261
+ ? (0, instruction_weight_js_1.weighInstructions)(readAlwaysLoaded(dir, dialect.instructionBudget), dialect.instructionBudget)
262
+ : null,
258
263
  untested: coverage.untested.length,
259
264
  untestedHarness: coverage.harness.untested.length,
260
265
  unevaluated: coverage.evals.untested.length,
@@ -448,6 +453,90 @@ function agentLines(a) {
448
453
  return lines;
449
454
  }
450
455
  /** Format a scan report as human-readable text. */
456
+ /**
457
+ * Read every unconditionally-loaded instruction file off disk.
458
+ *
459
+ * Separate from `loadPlugin` on purpose: that materializes the harness's
460
+ * SURFACES (skills, agents, hooks), while this reads what the harness loads
461
+ * before any surface is involved — including `.claude/rules/**`, which is
462
+ * exactly the directory a repo relocates into when it wants the root file to
463
+ * look smaller.
464
+ */
465
+ function readAlwaysLoaded(dir, budget) {
466
+ const out = {};
467
+ const walk = (rel) => {
468
+ const abs = (0, node_path_1.join)(dir, rel);
469
+ if (!(0, node_fs_1.existsSync)(abs) || !(0, node_fs_1.statSync)(abs).isDirectory())
470
+ return;
471
+ for (const entry of (0, node_fs_1.readdirSync)(abs)) {
472
+ const child = `${rel}/${entry}`;
473
+ if ((0, node_fs_1.statSync)((0, node_path_1.join)(dir, child)).isDirectory())
474
+ walk(child);
475
+ else
476
+ out[child] = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, child), "utf-8");
477
+ }
478
+ };
479
+ // `**/NAME` — Codex reads nested AGENTS.md root-to-leaf and they all pay into
480
+ // the SAME budget, so leaving them out under-reports in the one direction that
481
+ // matters: the harness truncates silently, and an under-report reads as "you
482
+ // are fine". Skipped dirs are the ones that are never the user's instructions
483
+ // and would dominate the walk.
484
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "vendor"]);
485
+ const findNested = (name, rel = "") => {
486
+ const abs = rel === "" ? dir : (0, node_path_1.join)(dir, rel);
487
+ if (!(0, node_fs_1.existsSync)(abs))
488
+ return;
489
+ for (const entry of (0, node_fs_1.readdirSync)(abs)) {
490
+ if (SKIP.has(entry) || entry.startsWith("."))
491
+ continue;
492
+ const child = rel === "" ? entry : `${rel}/${entry}`;
493
+ const childAbs = (0, node_path_1.join)(dir, child);
494
+ if ((0, node_fs_1.statSync)(childAbs).isDirectory())
495
+ findNested(name, child);
496
+ else if (entry === name && out[child] === undefined)
497
+ out[child] = (0, node_fs_1.readFileSync)(childAbs, "utf-8");
498
+ }
499
+ };
500
+ for (const glob of budget.alwaysLoaded) {
501
+ if (!glob.includes("*")) {
502
+ const abs = (0, node_path_1.join)(dir, glob);
503
+ if ((0, node_fs_1.existsSync)(abs) && (0, node_fs_1.statSync)(abs).isFile())
504
+ out[glob] = (0, node_fs_1.readFileSync)(abs, "utf-8");
505
+ }
506
+ else if (glob.endsWith("/**"))
507
+ walk(glob.slice(0, -3));
508
+ else if (glob.startsWith("**/"))
509
+ findNested(glob.slice(3));
510
+ }
511
+ return out;
512
+ }
513
+ /**
514
+ * The weight report. States the SUM first and the per-file breakdown second,
515
+ * because the sum is the number a reader can act on and the breakdown is only
516
+ * where to start.
517
+ *
518
+ * The verb changes with the harness on purpose: over budget on Claude Code is
519
+ * "warns" (costs money, rules still arrive), on Codex it is "truncates" (rules
520
+ * silently do not arrive). Same number, different emergency.
521
+ */
522
+ function instructionWeightLines(w) {
523
+ const g = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
524
+ const head = `Always-loaded instructions: ${g(w.total)} ${w.unit} (budget ${g(w.limit)})`;
525
+ if (w.overBy === null)
526
+ return [head];
527
+ const factor = (w.total / w.limit).toFixed(1);
528
+ const consequence = w.onExceed === "truncates"
529
+ ? "past the budget is SILENTLY TRUNCATED — those rules never reach the model"
530
+ : "the harness warns; the rules still reach the model, you pay for them every request";
531
+ return [
532
+ `${head} — ${factor}x OVER by ${g(w.overBy)} ${w.unit}`,
533
+ ` ${consequence}`,
534
+ ...w.files.slice(0, 5).map((f) => ` ${g(f.size).padStart(9)} ${f.path}`),
535
+ ...(w.files.length > 5
536
+ ? [` …and ${String(w.files.length - 5)} more`]
537
+ : []),
538
+ ];
539
+ }
451
540
  function formatScanReport(r) {
452
541
  const out = [`Scan: ${r.dir}`, ""];
453
542
  if (r.instructions) {
@@ -456,6 +545,10 @@ function formatScanReport(r) {
456
545
  : "hand-written, no spec";
457
546
  out.push(`Instructions: ${r.instructions.file} (${tag})`, "");
458
547
  }
548
+ // Right under the instruction file, because that is the line a reader is
549
+ // already looking at when they wonder what it costs.
550
+ if (r.instructionWeight)
551
+ out.push(...instructionWeightLines(r.instructionWeight), ""); // prettier-ignore
459
552
  out.push(...section("Skills", r.skills.map(skillLine)));
460
553
  out.push(...section("Agents", r.agents.flatMap(agentLines), r.agents.length));
461
554
  const hookMark = {
@@ -64,6 +64,14 @@ export interface ParsedSetupArgs {
64
64
  * `init` is unchanged), so this is opt-IN — it never buries the richer layers
65
65
  * behind a default flip. (Named for what it sets up — the CI check — not the
66
66
  * internal "integrity gate" concept.)
67
+ *
68
+ * THE DISCOVERY FAILURE THIS FLAG IS THE WORKED EXAMPLE OF: it shipped in the
69
+ * CLI and was invisible on the README, the site, the recommended agent prompt
70
+ * and the internal doc, so an agent taking the full default never learned it
71
+ * existed. Working code is not a delivered capability — a flag nobody can FIND
72
+ * is not done. The fix was a pointer in the non-interactive summary, `--help`,
73
+ * docs/agent-setup.md and the README; the standing rule it produced is the
74
+ * DISCOVERY row of `cohesive-feature-delivery`.
67
75
  */
68
76
  ciOnly: boolean;
69
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "27.1.7",
3
+ "version": "27.2.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -67,7 +67,13 @@ export default claude({
67
67
  },
68
68
 
69
69
  keyFiles: {
70
- // Key files here
70
+ // Key files here. A path maps to ONE LINE saying what the file is for —
71
+ // aim for 120 characters, never exceed ~200. The entry is a pointer, not a
72
+ // summary: how the file works belongs in its own header comment, which is
73
+ // read when someone opens it. This file is loaded on every request, so a
74
+ // paragraph here is paid for every turn. Prune a stale neighbour whenever
75
+ // you add one; `vigiles audit` prints the running total as
76
+ // `Always-loaded instructions`.
71
77
  },
72
78
 
73
79
  commands: {
@@ -111,6 +111,19 @@ Based on what the user asked for:
111
111
  - Add to `keyFiles` or `commands`. The compiler verifies these exist at compile time
112
112
  - For commands: must match a script in `package.json`
113
113
  - For key files: must exist on disk
114
+ - 🔴 **KEEP THE DESCRIPTION TO ONE LINE — aim for 120 characters, never exceed
115
+ ~200.** A `keyFiles` entry is a POINTER: what the file is for, so a reader knows
116
+ whether to open it. The explanation of how it works belongs in that file's own
117
+ header comment, where it is read when someone is actually in the file. An
118
+ instruction file is loaded on EVERY request, so a paragraph here is paid for
119
+ every turn, forever, by every reader — including the ones who never touch that
120
+ file.
121
+ - **This list only ever grows unless you shrink it.** Every session adds entries
122
+ and none removes them, so before adding, check whether a NEARBY entry is now
123
+ stale (the file moved, the role changed, the description restates its header)
124
+ and fix it in the same edit. Adding without ever pruning is how an instruction
125
+ file reaches four times its harness's budget — `vigiles audit` reports that
126
+ number as `Always-loaded instructions`, so check it when you touch this list.
114
127
 
115
128
  ### Step 4: Compile
116
129