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.
- package/dist/adapter-conformance.js +13 -1
- package/dist/adapters/claude-code/dialect.js +30 -0
- package/dist/adapters/claude-code/event-capability.d.ts +20 -0
- package/dist/adapters/claude-code/event-capability.js +81 -0
- package/dist/adapters/claude-code/hook-protocol.js +26 -3
- package/dist/adapters/codex/dialect.js +19 -0
- package/dist/cli-main.js +58 -7
- package/dist/core/adopt.js +23 -5
- package/dist/core/compile.d.ts +40 -1
- package/dist/core/compile.js +76 -2
- package/dist/core/dialect.d.ts +43 -1
- package/dist/core/event-capability.d.ts +128 -0
- package/dist/core/event-capability.js +114 -0
- package/dist/core/hook-program.d.ts +38 -0
- package/dist/core/hook-program.js +103 -0
- package/dist/core/hook-protocol.d.ts +19 -3
- package/dist/core/instruction-weight.d.ts +86 -0
- package/dist/core/instruction-weight.js +86 -0
- package/dist/core/vocabulary-consistency.js +10 -0
- package/dist/hook-install.d.ts +9 -7
- package/dist/hook-install.js +75 -16
- package/dist/hook-runtime.js +4 -1
- package/dist/posix-path.js +1 -1
- package/dist/scan-files.js +10 -3
- package/dist/scan.d.ts +9 -1
- package/dist/scan.js +96 -3
- package/dist/setup-plan.d.ts +8 -0
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +7 -1
- package/skills/edit-spec/SKILL.md +13 -0
|
@@ -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
|
/**
|
package/dist/hook-install.d.ts
CHANGED
|
@@ -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.
|
|
62
|
-
* `hookPath`) are replaced; every unrelated
|
|
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>]]`). */
|
package/dist/hook-install.js
CHANGED
|
@@ -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.
|
|
155
|
-
* `hookPath`) are replaced; every unrelated
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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) {
|
package/dist/hook-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/posix-path.js
CHANGED
|
@@ -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;
|
package/dist/scan-files.js
CHANGED
|
@@ -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.
|
|
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(
|
|
556
|
-
permissionDecisionEvents: new Set(
|
|
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.
|
|
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(
|
|
239
|
-
permissionDecisionEvents: new Set(
|
|
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 = {
|
package/dist/setup-plan.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|