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.
Files changed (43) hide show
  1. package/dist/adapter-conformance.js +15 -3
  2. package/dist/adapters/claude-code/dialect.js +30 -0
  3. package/dist/adapters/claude-code/event-capability.d.ts +20 -0
  4. package/dist/adapters/claude-code/event-capability.js +81 -0
  5. package/dist/adapters/claude-code/hook-protocol.js +26 -3
  6. package/dist/adapters/claude-code/run-scripts.js +47 -8
  7. package/dist/adapters/codex/dialect.js +19 -0
  8. package/dist/cli-main.js +100 -11
  9. package/dist/core/adopt.js +23 -5
  10. package/dist/core/compile.d.ts +40 -1
  11. package/dist/core/compile.js +76 -2
  12. package/dist/core/dialect.d.ts +43 -1
  13. package/dist/core/event-capability.d.ts +128 -0
  14. package/dist/core/event-capability.js +114 -0
  15. package/dist/core/hook-program.d.ts +38 -0
  16. package/dist/core/hook-program.js +103 -0
  17. package/dist/core/hook-protocol.d.ts +19 -3
  18. package/dist/core/instruction-weight.d.ts +86 -0
  19. package/dist/core/instruction-weight.js +86 -0
  20. package/dist/core/linters.js +3 -3
  21. package/dist/core/test-utils.d.ts +1 -2
  22. package/dist/core/test-utils.js +7 -9
  23. package/dist/core/tmp-root.d.ts +12 -0
  24. package/dist/core/tmp-root.js +59 -0
  25. package/dist/core/vocabulary-consistency.js +10 -0
  26. package/dist/eval.js +6 -5
  27. package/dist/harness-test.js +2 -2
  28. package/dist/hook-install.d.ts +9 -7
  29. package/dist/hook-install.js +75 -16
  30. package/dist/hook-runtime.js +4 -1
  31. package/dist/posix-path.js +1 -1
  32. package/dist/run-script.js +3 -3
  33. package/dist/sandbox.js +2 -2
  34. package/dist/scan-behavioral.js +2 -2
  35. package/dist/scan-files.js +10 -3
  36. package/dist/scan.d.ts +9 -1
  37. package/dist/scan.js +96 -3
  38. package/dist/setup-plan.d.ts +8 -0
  39. package/dist/test.d.ts +1 -0
  40. package/dist/test.js +17 -2
  41. package/package.json +1 -1
  42. package/skills/adopt-spec/SKILL.md +7 -1
  43. package/skills/edit-spec/SKILL.md +13 -0
@@ -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;
@@ -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, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-hook-sbx-"));
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, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-hook-egr-"));
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, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-sbx-"));
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");
@@ -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, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-gate-"));
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,
@@ -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/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.parseOutput = exports.parseResultEvent = 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.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 = 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.1.7",
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