vigiles 27.1.7 β 27.3.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 +15 -3
- 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/claude-code/run-scripts.js +47 -8
- package/dist/adapters/codex/dialect.js +19 -0
- package/dist/cli-main.js +100 -11
- 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/linters.js +3 -3
- package/dist/core/test-utils.d.ts +1 -2
- package/dist/core/test-utils.js +7 -9
- package/dist/core/tmp-root.d.ts +12 -0
- package/dist/core/tmp-root.js +59 -0
- package/dist/core/vocabulary-consistency.js +10 -0
- package/dist/eval.js +6 -5
- package/dist/harness-test.js +2 -2
- 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/run-script.js +3 -3
- package/dist/sandbox.js +2 -2
- package/dist/scan-behavioral.js +2 -2
- 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/dist/test.d.ts +1 -0
- package/dist/test.js +17 -2
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +7 -1
- package/skills/edit-spec/SKILL.md +13 -0
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/run-script.js
CHANGED
|
@@ -33,12 +33,12 @@ exports.runScript = runScript;
|
|
|
33
33
|
*/
|
|
34
34
|
const node_child_process_1 = require("node:child_process");
|
|
35
35
|
const node_fs_1 = require("node:fs");
|
|
36
|
-
const node_os_1 = require("node:os");
|
|
37
36
|
const node_path_1 = require("node:path");
|
|
38
37
|
const egress_js_1 = require("./egress.js");
|
|
39
38
|
const sandbox_js_1 = require("./sandbox.js");
|
|
40
39
|
const check_count_js_1 = require("./check-count.js");
|
|
41
40
|
const coverage_probe_js_1 = require("./coverage-probe.js");
|
|
41
|
+
const tmp_root_js_1 = require("./core/tmp-root.js");
|
|
42
42
|
/**
|
|
43
43
|
* True when the exit code is the shell's own report that it never reached the
|
|
44
44
|
* program β so nothing of the harness ran and no surface may be credited.
|
|
@@ -260,7 +260,7 @@ function snapshotTree(dir) {
|
|
|
260
260
|
return out;
|
|
261
261
|
}
|
|
262
262
|
function sandboxedSpawn(command, stdin, opts) {
|
|
263
|
-
const ioDir = (0,
|
|
263
|
+
const ioDir = (0, tmp_root_js_1.makeTmpDir)("hook-sbx");
|
|
264
264
|
const home = (0, node_path_1.join)(ioDir, "home");
|
|
265
265
|
(0, node_fs_1.mkdirSync)(home);
|
|
266
266
|
// The hook's confined writable work dir: the caller's cwd if given, else a
|
|
@@ -354,7 +354,7 @@ function readEgressResult(resultFile) {
|
|
|
354
354
|
: { status: 1, signal: null, stdout: "", stderr: "", counters: "" };
|
|
355
355
|
}
|
|
356
356
|
function egressSpawn(command, stdin, opts) {
|
|
357
|
-
const ioDir = (0,
|
|
357
|
+
const ioDir = (0, tmp_root_js_1.makeTmpDir)("hook-egr");
|
|
358
358
|
const home = (0, node_path_1.join)(ioDir, "home");
|
|
359
359
|
(0, node_fs_1.mkdirSync)(home);
|
|
360
360
|
const work = opts.cwd ?? (0, node_path_1.join)(ioDir, "work");
|
package/dist/sandbox.js
CHANGED
|
@@ -33,9 +33,9 @@ exports.runSandboxed = runSandboxed;
|
|
|
33
33
|
*/
|
|
34
34
|
const node_child_process_1 = require("node:child_process");
|
|
35
35
|
const node_fs_1 = require("node:fs");
|
|
36
|
-
const node_os_1 = require("node:os");
|
|
37
36
|
const node_path_1 = require("node:path");
|
|
38
37
|
const runtime_js_1 = require("./adapters/claude-code/runtime.js");
|
|
38
|
+
const tmp_root_js_1 = require("./core/tmp-root.js");
|
|
39
39
|
let cachedAvailable;
|
|
40
40
|
/**
|
|
41
41
|
* Whether this environment can ACTUALLY confine untrusted code under bubblewrap.
|
|
@@ -247,7 +247,7 @@ const WRAPPER = [
|
|
|
247
247
|
bwrap-backed integration test (skipped without bwrap), not the unit gate β
|
|
248
248
|
the pure policy/args/parse helpers above carry the testable logic. */
|
|
249
249
|
function runSandboxed(opts) {
|
|
250
|
-
const ioDir = (0,
|
|
250
|
+
const ioDir = (0, tmp_root_js_1.makeTmpDir)("sbx");
|
|
251
251
|
const home = (0, node_path_1.join)(ioDir, "home");
|
|
252
252
|
(0, node_fs_1.mkdirSync)(home);
|
|
253
253
|
const scriptF = (0, node_path_1.join)(ioDir, "script.json");
|
package/dist/scan-behavioral.js
CHANGED
|
@@ -34,7 +34,6 @@ exports.formatGateReport = formatGateReport;
|
|
|
34
34
|
const node_fs_1 = require("node:fs");
|
|
35
35
|
const foreign_runner_js_1 = require("./core/foreign-runner.js");
|
|
36
36
|
const eval_load_phase_js_1 = require("./core/eval-load-phase.js");
|
|
37
|
-
const node_os_1 = require("node:os");
|
|
38
37
|
const node_path_1 = require("node:path");
|
|
39
38
|
const node_child_process_1 = require("node:child_process");
|
|
40
39
|
const scan_js_1 = require("./scan.js");
|
|
@@ -46,6 +45,7 @@ const harness_test_js_1 = require("./harness-test.js");
|
|
|
46
45
|
const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
|
|
47
46
|
const eval_js_2 = require("./adapters/codex/eval.js");
|
|
48
47
|
const driver_js_1 = require("./adapters/codex/driver.js");
|
|
48
|
+
const tmp_root_js_1 = require("./core/tmp-root.js");
|
|
49
49
|
function buildProbe(dir, harness) {
|
|
50
50
|
if (harness === "codex") {
|
|
51
51
|
return {
|
|
@@ -529,7 +529,7 @@ function gateRubric(gate) {
|
|
|
529
529
|
}
|
|
530
530
|
/** Run ONE attack against the unstubbed plugin β the agent's output (or errored). */
|
|
531
531
|
async function runGateAttack(dir, job, deps, model) {
|
|
532
|
-
const cwd = (0,
|
|
532
|
+
const cwd = (0, tmp_root_js_1.makeTmpDir)("gate");
|
|
533
533
|
try {
|
|
534
534
|
const out = await deps.driver.runner({
|
|
535
535
|
task: job.attack,
|
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/dist/test.d.ts
CHANGED
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
* distinction in its config rather than in its imports.
|
|
52
52
|
*/
|
|
53
53
|
export { recordCheck } from "./check-count.js";
|
|
54
|
+
export { makeTmpDir, cleanupTmpDir } from "./core/tmp-root.js";
|
|
54
55
|
export { runScript } from "./run-script.js";
|
|
55
56
|
export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
|
|
56
57
|
export { runHook, parseHookOutput, decideHook, propertyHook, fileToolEvents, egressRoutes, } from "./run-hook.js";
|
package/dist/test.js
CHANGED
|
@@ -66,8 +66,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
66
66
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
67
67
|
};
|
|
68
68
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
69
|
-
exports.
|
|
70
|
-
exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = void 0;
|
|
69
|
+
exports.parseSubagents = exports.parseToolCalls = exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.experimental_hookState = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.cleanupTmpDir = exports.makeTmpDir = exports.recordCheck = void 0;
|
|
70
|
+
exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = void 0;
|
|
71
71
|
// --- reporting: how much did this script actually do? ---
|
|
72
72
|
// `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
|
|
73
73
|
// prints the same `β` as one that ran and passed (measured 2026-08-08 on a file
|
|
@@ -76,6 +76,21 @@ exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = ex
|
|
|
76
76
|
// visible to the runner too. See check-count.ts.
|
|
77
77
|
var check_count_js_1 = require("./check-count.js");
|
|
78
78
|
Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
|
|
79
|
+
// --- fixture roots: a temp directory whose two spellings agree ---
|
|
80
|
+
// π΄ DO NOT hand-roll `mkdtempSync(join(tmpdir(), β¦))` in a harness. On macOS
|
|
81
|
+
// `/var` is a symlink to `/private/var`, so that shape hands you a directory with
|
|
82
|
+
// TWO spellings: Node resolves a module's own URL to the realpath but leaves
|
|
83
|
+
// `process.argv[1]` and any path you composed as typed. Every assertion that
|
|
84
|
+
// compares them is then red on macOS and green on Linux, for a reason that
|
|
85
|
+
// belongs to neither the test nor the code under test β measured three times in
|
|
86
|
+
// one consumer suite (#241, zernie/research-paper-pipeline#9).
|
|
87
|
+
//
|
|
88
|
+
// `makeTmpDir` resolves the root once, at creation. The trap is unreachable from
|
|
89
|
+
// anything built under it, including a symlink the harness creates ITSELF to test
|
|
90
|
+
// symlink handling β that stays a genuine test, because it is explicit.
|
|
91
|
+
var tmp_root_js_1 = require("./core/tmp-root.js");
|
|
92
|
+
Object.defineProperty(exports, "makeTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.makeTmpDir; } });
|
|
93
|
+
Object.defineProperty(exports, "cleanupTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.cleanupTmpDir; } });
|
|
79
94
|
// --- the process primitives: runScript (any program) + runHook (plus a decision) ---
|
|
80
95
|
// `runScript` runs any program and reports what it DID (exit, both streams,
|
|
81
96
|
// writes, egress). `runHook` is that plus the hook protocol: event to stdin,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.3.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
|
|