vigiles 16.0.1 → 16.1.1

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.
@@ -654,6 +654,14 @@ function renderSkillFrontmatter(spec, profile = "claude-code") {
654
654
  // ambiguous scalar, so the restriction was lost on the CC round-trip. (#107)
655
655
  fm.push(`allowed-tools: [${spec.tools.join(", ")}]`);
656
656
  }
657
+ if (spec.disallowedTools && spec.disallowedTools.length > 0) {
658
+ // 🔴 `disallowed-tools`, HYPHENATED — that is a skill's fence. A subagent's
659
+ // key is `disallowedTools:` (camelCase) and a different reader parses it, so
660
+ // writing the agent spelling here emits a key nothing looks at: inert, and
661
+ // inert in the direction that reads as protection. Same class as the #107
662
+ // defect two lines up, where `tools:` on a skill silently lost the contract.
663
+ fm.push(`disallowed-tools: [${spec.disallowedTools.join(", ")}]`);
664
+ }
657
665
  }
658
666
  fm.push("", "---");
659
667
  return fm.join("\n");
@@ -779,6 +787,15 @@ function compileSkill(spec, options = {}) {
779
787
  errors.push({ type: "purity-violation", message: v.message });
780
788
  }
781
789
  }
790
+ // A fence entry that is a close typo of a real tool blocks NOTHING while reading
791
+ // as protection — the same high-precision check a subagent's `disallowedTools`
792
+ // gets. Skipped without a dialect, like the purity check above, because the tool
793
+ // catalog is what a typo is measured against.
794
+ if (spec.disallowedTools && options.dialect) {
795
+ for (const issue of (0, tool_contract_js_1.disallowedToolIssues)(spec.disallowedTools, options.dialect)) {
796
+ errors.push({ type: "unknown-tool", message: issue.message });
797
+ }
798
+ }
782
799
  const sections = renderSkillSections(spec);
783
800
  // Over-long inline code blocks are WARNINGS, not errors — they don't block
784
801
  // compilation (so adoption always compiles), just nudge toward file().
@@ -448,6 +448,25 @@ export interface SkillSpec {
448
448
  * against that floor — compile rejects a tool looser than the declared level.
449
449
  */
450
450
  readonly tools?: readonly string[];
451
+ /**
452
+ * Tools this skill may NEVER use — rendered to the `disallowed-tools:`
453
+ * frontmatter key (hyphenated for skills; a subagent's key is `disallowedTools:`,
454
+ * and they are read by different parsers, so the spelling is not interchangeable).
455
+ *
456
+ * 🔴 This is NOT the symmetric twin of `tools` and the asymmetry is the whole
457
+ * reason it exists. `allowed-tools:` on a skill is a PRE-APPROVAL — it waives the
458
+ * permission prompt for what it lists and removes nothing from the pool, so a
459
+ * skill that declares only `Read` can still call `Bash`. `disallowed-tools:` is
460
+ * the only key measured to actually take a tool away. A skill without it inherits
461
+ * every tool the session grants, which is why `audit` reports each such skill as
462
+ * holding all three lethal-trifecta legs — including skills whose `tools` list
463
+ * looks tightly scoped.
464
+ *
465
+ * Entries are checked like a subagent's: a close typo of a real tool name blocks
466
+ * nothing and is reported, because a fence with a misspelled name reads as
467
+ * protection while granting everything.
468
+ */
469
+ readonly disallowedTools?: readonly string[];
451
470
  /**
452
471
  * Declare this skill's purity floor — compile rejects a tool contract looser
453
472
  * than it. `"pure"` allows only read-only tools; `"bounded"` also allows
@@ -12,6 +12,26 @@ export declare const COVERAGE_ARTIFACT_FILE = "coverage.json";
12
12
  * silence "nothing ever measured whether this fires".
13
13
  */
14
14
  export type CoverageTierName = "harness" | "eval";
15
+ /**
16
+ * How a RECORD came to name its surface.
17
+ *
18
+ * The three {@link ProbeOrigin} values are what the run REPORTED about itself
19
+ * from inside its own process — a command line it executed, a transcript it got
20
+ * back. `colocated` is the fourth and is different in kind: nothing inside the
21
+ * test said it. The RUNNER observed that the script it just executed is the
22
+ * surface's colocated test, by the same {@link isColocatedTest} rule that is
23
+ * already willing to credit that file for merely existing.
24
+ *
25
+ * 🔴 THE SPLIT IS THE POINT, AND IT IS WHY THIS IS NOT ON {@link ProbeOrigin}.
26
+ * `ProbeOrigin` is the wire vocabulary: `parseCheckReport` reads it out of a
27
+ * scratch file the CHILD writes. Putting `colocated` there would let a harness
28
+ * hand-write `{"how":"colocated","ref":"…"}` and mint execution coverage for a
29
+ * surface by declaring it — which is `vigiles:covers` returning through the back
30
+ * door, the tier this file's header records being deleted for exactly that. Kept
31
+ * off `ProbeOrigin`, a script cannot spell it at all: `toProbe` has no branch
32
+ * that produces it, so the only producer in the program is {@link recordsFrom}.
33
+ */
34
+ export type RunAttribution = ProbeOrigin | "colocated";
15
35
  /** One surface, exercised by one script, at one moment, against one version. */
16
36
  export interface CoverageRun {
17
37
  readonly kind: SurfaceKind;
@@ -19,8 +39,8 @@ export interface CoverageRun {
19
39
  readonly path: string;
20
40
  readonly name: string;
21
41
  readonly tier: CoverageTierName;
22
- /** How the run named it — see {@link ProbeOrigin}. */
23
- readonly how: ProbeOrigin;
42
+ /** How the run named it — see {@link RunAttribution}. */
43
+ readonly how: RunAttribution;
24
44
  /** The script file that did the exercising. */
25
45
  readonly by: string;
26
46
  /** ISO-8601 timestamp of the run. */
@@ -192,6 +212,18 @@ export interface ScriptRunRecord {
192
212
  /** The script file that ran. */
193
213
  readonly file: string;
194
214
  readonly probes: readonly SurfaceProbe[];
215
+ /**
216
+ * How many checks the run REPORTED, or `undefined` when it reported nothing.
217
+ *
218
+ * The distinction is load-bearing and is not this file's invention — see
219
+ * `statusFor` in run-scripts.ts. `undefined` means the script never imported
220
+ * vigiles and therefore *could not* report; that silence is the legacy branch
221
+ * and is never a verdict. A positive number is the script saying, through the
222
+ * library, that it made an observation. Only the second is evidence, which is
223
+ * why {@link recordsFrom} attributes colocation on `> 0` rather than on
224
+ * "it exited 0".
225
+ */
226
+ readonly checks?: number;
195
227
  }
196
228
  /**
197
229
  * The runs worth recording, out of a whole `vigiles test` / `vigiles eval`.
@@ -226,10 +258,26 @@ export interface ScriptRunRecord {
226
258
  * CLI would destroy a real result. A skip must not write, because it did not
227
259
  * finish. Both follow from "a skip is not an execution"; only the direction of
228
260
  * the conclusion differs.
261
+ *
262
+ * ## 🔴 A RUN WITH NO PROBES IS NO LONGER DISCARDED
263
+ *
264
+ * The second clause used to be `surfaces.length > 0`: a passing run that named no
265
+ * surface contributed nothing. That threw away the ordinary case. MEASURED on a
266
+ * 52-surface consumer repo, 23 of its 27 covered surfaces were carried by a
267
+ * colocated `*.harness.mjs` that `vigiles test` RUNS on every push — and every one
268
+ * of them reported `colocated` evidence, *"this says the file EXISTS, not that it
269
+ * ran"*, because a harness asserting through `node:assert` reports a CHECK COUNT
270
+ * and no probe. The run happened, the runner watched it happen, and the metric
271
+ * described it as a directory listing.
272
+ *
273
+ * So a passing run that reported at least one CHECK is kept even with no probes:
274
+ * {@link recordsFrom} can attribute it by where the script sits. `checks` travels
275
+ * with it because "exited 0" is not the bar — see {@link ScriptRunRecord.checks}.
229
276
  */
230
277
  export declare function runsFromResults(results: readonly {
231
278
  readonly file: string;
232
279
  readonly status: string;
280
+ readonly checks?: number;
233
281
  readonly surfaces?: readonly SurfaceProbe[];
234
282
  }[]): ScriptRunRecord[];
235
283
  /**
@@ -238,6 +286,12 @@ export declare function runsFromResults(results: readonly {
238
286
  * run); a surface it cannot read is skipped — an unhashable record could never
239
287
  * be checked for staleness and would therefore be permanent, unfalsifiable
240
288
  * coverage.
289
+ *
290
+ * Two sources of attribution, in one pass: the probes a run reported, and — for a
291
+ * run that reported a check — the surface its own script is colocated with
292
+ * ({@link colocatedSurface}). The dedupe key already carries `how`, so a run that
293
+ * BOTH probed a surface and sits beside it records once per attribution and the
294
+ * report can still say which kinds of evidence exist.
241
295
  */
242
296
  export declare function recordsFrom(opts: ResolveOptions & {
243
297
  readonly runs: readonly ScriptRunRecord[];
@@ -57,10 +57,26 @@ exports.indexRuns = indexRuns;
57
57
  const node_crypto_1 = require("node:crypto");
58
58
  const node_fs_1 = require("node:fs");
59
59
  const node_path_1 = require("node:path");
60
+ const coverage_evidence_js_1 = require("./coverage-evidence.js");
60
61
  /** Bumped when the record shape changes in a non-additive way. */
61
62
  exports.COVERAGE_ARTIFACT_VERSION = 1;
62
63
  /** The artifact filename under `.vigiles/`. */
63
64
  exports.COVERAGE_ARTIFACT_FILE = "coverage.json";
65
+ /**
66
+ * Every {@link RunAttribution}, as data — the artifact's validator reads it, so
67
+ * the accepted set and the type cannot drift apart into a record shape the
68
+ * program can produce and the reader silently discards. The `satisfies` is the
69
+ * lock: drop a member and this stops compiling.
70
+ */
71
+ const ATTRIBUTIONS = new Set([
72
+ "command",
73
+ "fired",
74
+ "dispatched",
75
+ "colocated",
76
+ ]);
77
+ function isAttribution(value) {
78
+ return typeof value === "string" && ATTRIBUTIONS.has(value);
79
+ }
64
80
  /** Content hash of a surface file — the staleness key. */
65
81
  function surfaceSha(content) {
66
82
  return (0, node_crypto_1.createHash)("sha256").update(content).digest("hex").slice(0, 16);
@@ -347,11 +363,83 @@ function only(matches) {
347
363
  * CLI would destroy a real result. A skip must not write, because it did not
348
364
  * finish. Both follow from "a skip is not an execution"; only the direction of
349
365
  * the conclusion differs.
366
+ *
367
+ * ## 🔴 A RUN WITH NO PROBES IS NO LONGER DISCARDED
368
+ *
369
+ * The second clause used to be `surfaces.length > 0`: a passing run that named no
370
+ * surface contributed nothing. That threw away the ordinary case. MEASURED on a
371
+ * 52-surface consumer repo, 23 of its 27 covered surfaces were carried by a
372
+ * colocated `*.harness.mjs` that `vigiles test` RUNS on every push — and every one
373
+ * of them reported `colocated` evidence, *"this says the file EXISTS, not that it
374
+ * ran"*, because a harness asserting through `node:assert` reports a CHECK COUNT
375
+ * and no probe. The run happened, the runner watched it happen, and the metric
376
+ * described it as a directory listing.
377
+ *
378
+ * So a passing run that reported at least one CHECK is kept even with no probes:
379
+ * {@link recordsFrom} can attribute it by where the script sits. `checks` travels
380
+ * with it because "exited 0" is not the bar — see {@link ScriptRunRecord.checks}.
350
381
  */
351
382
  function runsFromResults(results) {
352
383
  return results
353
- .filter((r) => r.status === "pass" && (r.surfaces?.length ?? 0) > 0)
354
- .map((r) => ({ file: r.file, probes: r.surfaces ?? [] }));
384
+ .filter((r) => r.status === "pass" &&
385
+ ((r.surfaces?.length ?? 0) > 0 || (r.checks ?? 0) > 0))
386
+ .map((r) => ({
387
+ file: r.file,
388
+ probes: r.surfaces ?? [],
389
+ ...(r.checks === undefined ? {} : { checks: r.checks }),
390
+ }));
391
+ }
392
+ /**
393
+ * The surface a RUN's own script is the colocated test of, or `null`.
394
+ *
395
+ * This is the fourth attribution ({@link RunAttribution}), and the only one the
396
+ * script does not report about itself. The runner knows two facts the script
397
+ * cannot: which file it just executed, and which surfaces this repo has. Put
398
+ * together by {@link isColocatedTest} — the SAME predicate that already grants
399
+ * `colocated` evidence for that file merely existing — they say which surface the
400
+ * run was about.
401
+ *
402
+ * ## The bar is a REPORTED CHECK, not exit zero
403
+ *
404
+ * 🔴 WITHOUT THAT, THIS WOULD BE THE ORIGINAL DEFECT WEARING THE STRONGER LABEL.
405
+ * Measured 2026-08-17 on a two-skill fixture: `touch
406
+ * .claude/skills/argument-arc/argument-arc.harness.mjs` — a ZERO-BYTE file —
407
+ * drops the untested count by one, and `vigiles test` then RUNS it and prints
408
+ * `✓ … passed`. An empty script exits 0. Attributing on "it ran and exited 0"
409
+ * would promote that file from `colocated` ("the file EXISTS, not that it ran")
410
+ * to "MEASURED BY A RUN", which is strictly worse than the hole being closed:
411
+ * the same emptiness, now wearing execution's name.
412
+ *
413
+ * A zero-byte file cannot report a check — it never imports vigiles — so its
414
+ * `checks` is `undefined` and it earns nothing here, falling back to colocation
415
+ * exactly as before. This is `statusFor`'s distinction reused, not a new one:
416
+ * silence is the legacy branch, `0` is `vacuous` (never a `pass`, so it never
417
+ * reaches this function), and a positive count is the script saying through the
418
+ * library that it observed something.
419
+ *
420
+ * ⚠️ WHAT THIS STILL CANNOT SEE, stated rather than implied: a harness that
421
+ * asserts entirely on its own and never imports vigiles reports nothing, so it is
422
+ * indistinguishable here from the empty file. It keeps its colocated credit and
423
+ * gains no execution credit. That is deliberate and it is `check-count.ts`'s
424
+ * standing decision — force-loading the counter into every child was considered
425
+ * and rejected there, because it would report `0` for a blameless hand-rolled
426
+ * harness. The cost is a missed upgrade; the alternative was a false one.
427
+ *
428
+ * 🔴 AMBIGUITY RESOLVES TO NOTHING, the same rule {@link only} applies to probes.
429
+ * Two surfaces in one directory can both claim a script when one name prefixes
430
+ * the other (`foo` and `foo.bar` both match `foo.bar.harness.mjs`, since
431
+ * colocation asks only that the basename START with `<name>.`). Exactly one of
432
+ * them is what the test is about, and nothing here can say which — so crediting
433
+ * both would INVENT a record, which is the failure the rest of this file spends
434
+ * its length refusing.
435
+ */
436
+ function colocatedSurface(run, surfaces, alreadyProbed) {
437
+ if ((run.checks ?? 0) <= 0)
438
+ return null;
439
+ const matches = surfaces.filter((s) => s.path !== run.file &&
440
+ !alreadyProbed.has(s.path) &&
441
+ (0, coverage_evidence_js_1.isColocatedTest)(s, run.file));
442
+ return matches.length === 1 ? (matches[0] ?? null) : null;
355
443
  }
356
444
  /**
357
445
  * Turn resolved probes into records. `readSurface` supplies the current content
@@ -359,15 +447,45 @@ function runsFromResults(results) {
359
447
  * run); a surface it cannot read is skipped — an unhashable record could never
360
448
  * be checked for staleness and would therefore be permanent, unfalsifiable
361
449
  * coverage.
450
+ *
451
+ * Two sources of attribution, in one pass: the probes a run reported, and — for a
452
+ * run that reported a check — the surface its own script is colocated with
453
+ * ({@link colocatedSurface}). The dedupe key already carries `how`, so a run that
454
+ * BOTH probed a surface and sits beside it records once per attribution and the
455
+ * report can still say which kinds of evidence exist.
362
456
  */
363
457
  function recordsFrom(opts) {
364
458
  const out = [];
365
459
  const seen = new Set();
366
- const pairs = opts.runs.flatMap((run) => run.probes.flatMap((probe) => resolveProbe(probe, opts.surfaces, opts).map((surface) => ({
367
- run,
368
- probe,
369
- surface,
370
- }))));
460
+ const pairs = [];
461
+ for (const run of opts.runs) {
462
+ const probed = new Set();
463
+ for (const probe of run.probes)
464
+ for (const surface of resolveProbe(probe, opts.surfaces, opts)) {
465
+ pairs.push({ run, probe, surface });
466
+ probed.add(surface.path);
467
+ }
468
+ // 🔴 COLOCATION IS THE FALLBACK ATTRIBUTION, NOT AN ADDITIONAL ONE, and
469
+ // skipping what this run already named is what keeps it from DOWNGRADING the
470
+ // record. MEASURED 2026-08-17 on a 52-surface repo the first time this tier
471
+ // ran: `.claude/hooks/paper-lint.mjs` went from `command` — the harness
472
+ // literally executed that path — to `colocated`, and three `fired` skill
473
+ // activations went the same way. The cause is one key: `mergeRuns` dedupes on
474
+ // (surface, tier, script) and does NOT include `how`, so a second attribution
475
+ // of the same pair does not sit beside the first, it REPLACES it — and with
476
+ // both records stamped the same `at`, `held.at <= run.at` hands the win to
477
+ // whichever was pushed last.
478
+ //
479
+ // The ordering is the same one this file already applies one level up: a
480
+ // direct observation of the surface being exercised outranks an inference
481
+ // from where a file sits. Emitting both and teaching the merge to keep the
482
+ // stronger was the alternative; it costs a wider key, a second record per
483
+ // pair, and a ranking function — to store a fact that adds nothing, since
484
+ // both resolve to the same `executed` evidence.
485
+ const beside = colocatedSurface(run, opts.surfaces, probed);
486
+ if (beside)
487
+ pairs.push({ run, probe: { how: "colocated" }, surface: beside });
488
+ }
371
489
  for (const { run, probe, surface } of pairs) {
372
490
  const key = `${surface.path}\u0000${run.file}\u0000${probe.how}`;
373
491
  if (seen.has(key))
@@ -492,7 +610,7 @@ function isCoverageRun(value) {
492
610
  typeof r.path === "string" &&
493
611
  typeof r.name === "string" &&
494
612
  (r.tier === "harness" || r.tier === "eval") &&
495
- (r.how === "command" || r.how === "fired" || r.how === "dispatched") &&
613
+ isAttribution(r.how) &&
496
614
  typeof r.by === "string" &&
497
615
  typeof r.at === "string" &&
498
616
  typeof r.sha === "string");
@@ -27,6 +27,39 @@ export interface CoverableSurface {
27
27
  export interface PreparedTest {
28
28
  readonly path: string;
29
29
  }
30
+ /**
31
+ * Is `testPath` the colocated test OF this surface — NAMED after it, SITTING
32
+ * BESIDE it?
33
+ *
34
+ * 🔴 THIS USED TO BE WRITTEN TWICE, and the second copy said so in its own
35
+ * comment: `test-coverage-files.ts` carried a function whose docstring was
36
+ * *"Mirror of test-coverage.ts `isColocated`"*. A mirror is a promise that two
37
+ * bodies stay equal, kept by whoever remembers — and this file already documents
38
+ * three separate occasions where a change landed in one report builder and not
39
+ * the other ({@link declaredSurfaceName}, {@link hookScriptRefs}). The rule now
40
+ * has ONE body and three callers: the disk detector, the browser twin, and the
41
+ * runner that turns an executed script into an execution record
42
+ * (`coverage-artifact.ts`). There is nothing left to mirror wrong.
43
+ *
44
+ * That third caller is why this became shared rather than merely deduplicated.
45
+ * Colocation and execution were answering the same question — *which surface is
46
+ * this test about?* — with two different pieces of code, so a script vigiles had
47
+ * just RUN could not be attributed by the very rule that was already willing to
48
+ * credit it for existing.
49
+ *
50
+ * SEPARATORS ARE NORMALISED FIRST, then POSIX semantics apply. The disk detector
51
+ * feeds paths from `globSync`, which yields `\` on Windows; the browser twin
52
+ * feeds file-map keys, which are always `/`. Folding `\` to `/` makes one body
53
+ * correct for both instead of asking each caller to bring its own path module —
54
+ * which is exactly the seam the mirror lived in.
55
+ *
56
+ * ⚠️ THE COST, STATED: a POSIX file whose NAME literally contains a backslash is
57
+ * now read as a directory boundary. That is not reachable for either input here —
58
+ * a skill directory or a test filename with a `\` in it does not survive the glob
59
+ * patterns that discover it in the first place — and it is the same trade
60
+ * `hookScriptRefs` already makes one function up.
61
+ */
62
+ export declare function isColocatedTest(surface: Pick<CoverableSurface, "name" | "path">, testPath: string): boolean;
30
63
  /** Wrap a discovered path. Kept as a function so both twins share one shape. */
31
64
  export declare function prepareTest(path: string): PreparedTest;
32
65
  /** @param filename a BASENAME — callers strip the directory with their own path
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isColocatedTest = isColocatedTest;
3
4
  exports.prepareTest = prepareTest;
4
5
  exports.isEvalScript = isEvalScript;
5
6
  exports.hookScriptRefs = hookScriptRefs;
@@ -91,6 +92,52 @@ exports.declaredSurfaceName = declaredSurfaceName;
91
92
  * impossible there and its count is 0 — see `test-coverage-files.ts`.
92
93
  */
93
94
  const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
95
+ const posix_path_js_1 = require("./posix-path.js");
96
+ /**
97
+ * Is `testPath` the colocated test OF this surface — NAMED after it, SITTING
98
+ * BESIDE it?
99
+ *
100
+ * 🔴 THIS USED TO BE WRITTEN TWICE, and the second copy said so in its own
101
+ * comment: `test-coverage-files.ts` carried a function whose docstring was
102
+ * *"Mirror of test-coverage.ts `isColocated`"*. A mirror is a promise that two
103
+ * bodies stay equal, kept by whoever remembers — and this file already documents
104
+ * three separate occasions where a change landed in one report builder and not
105
+ * the other ({@link declaredSurfaceName}, {@link hookScriptRefs}). The rule now
106
+ * has ONE body and three callers: the disk detector, the browser twin, and the
107
+ * runner that turns an executed script into an execution record
108
+ * (`coverage-artifact.ts`). There is nothing left to mirror wrong.
109
+ *
110
+ * That third caller is why this became shared rather than merely deduplicated.
111
+ * Colocation and execution were answering the same question — *which surface is
112
+ * this test about?* — with two different pieces of code, so a script vigiles had
113
+ * just RUN could not be attributed by the very rule that was already willing to
114
+ * credit it for existing.
115
+ *
116
+ * SEPARATORS ARE NORMALISED FIRST, then POSIX semantics apply. The disk detector
117
+ * feeds paths from `globSync`, which yields `\` on Windows; the browser twin
118
+ * feeds file-map keys, which are always `/`. Folding `\` to `/` makes one body
119
+ * correct for both instead of asking each caller to bring its own path module —
120
+ * which is exactly the seam the mirror lived in.
121
+ *
122
+ * ⚠️ THE COST, STATED: a POSIX file whose NAME literally contains a backslash is
123
+ * now read as a directory boundary. That is not reachable for either input here —
124
+ * a skill directory or a test filename with a `\` in it does not survive the glob
125
+ * patterns that discover it in the first place — and it is the same trade
126
+ * `hookScriptRefs` already makes one function up.
127
+ */
128
+ function isColocatedTest(surface, testPath) {
129
+ const test = posixly(testPath);
130
+ const home = posixly(surface.path);
131
+ if (!(0, posix_path_js_1.basename)(test).startsWith(`${surface.name}.`))
132
+ return false;
133
+ // A root `SKILL.md` (single-skill-dir target) lives at ".", and discovery
134
+ // returns top-level files without a "./" prefix — so `dirname` is "." on both
135
+ // sides and the comparison holds without a special case.
136
+ return (0, posix_path_js_1.dirname)(test) === (0, posix_path_js_1.dirname)(home);
137
+ }
138
+ function posixly(p) {
139
+ return p.replaceAll("\\", "/");
140
+ }
94
141
  /** Wrap a discovered path. Kept as a function so both twins share one shape. */
95
142
  function prepareTest(path) {
96
143
  return { path };
@@ -50,12 +50,20 @@
50
50
  * file is named by anyone. That API contract is the feature — it is how you
51
51
  * test a hook you did not write, verbatim as its plugin ships it.
52
52
  *
53
- * - **The in-process tier CAN, and currently reports nothing.** `loadHook(file)`
54
- * resolves the path itself, and `harness-assert.ts` / `load-hook.ts` contain
55
- * ZERO `recordSurfaceProbe` calls — so the tier where attribution needs no
56
- * parsing at all is the tier that attributes nothing. That is a real gap and a
57
- * better source of truth than any parse, but it ADDS a source; it cannot
58
- * retire this one while `runHook(command)` remains public.
53
+ * - **The in-process tier CAN and since 2026-08-13 it DOES.** ⚠️ This bullet
54
+ * used to read *"`harness-assert.ts` / `load-hook.ts` contain ZERO
55
+ * `recordSurfaceProbe` calls — the tier where attribution needs no parsing at
56
+ * all is the tier that attributes nothing"*, and that is no longer true:
57
+ * `runCountedHookProgram` in harness-assert.ts records a `command` probe for a
58
+ * hook it is about to evaluate, when `loadHook` knew the file. Verified by
59
+ * count, not by memory — `harness-assert.ts` has the call, `load-hook.ts`
60
+ * still does not, and deliberately so (loading a hook is not running it; see
61
+ * that function's note). Left corrected rather than deleted because a header
62
+ * that advertises a gap somebody already closed sends the next reader to build
63
+ * a second recorder.
64
+ *
65
+ * It did not retire this parser, as predicted: it ADDS a source, and
66
+ * `runHook(command)` is still public.
59
67
  *
60
68
  * So parsing stays, and the lesson taken instead is about DIRECTION. The scan was
61
69
  * a deny-list — skip what we recognise, attribute what is left — so every gap in
@@ -56,12 +56,20 @@ exports.probeTrace = probeTrace;
56
56
  * file is named by anyone. That API contract is the feature — it is how you
57
57
  * test a hook you did not write, verbatim as its plugin ships it.
58
58
  *
59
- * - **The in-process tier CAN, and currently reports nothing.** `loadHook(file)`
60
- * resolves the path itself, and `harness-assert.ts` / `load-hook.ts` contain
61
- * ZERO `recordSurfaceProbe` calls — so the tier where attribution needs no
62
- * parsing at all is the tier that attributes nothing. That is a real gap and a
63
- * better source of truth than any parse, but it ADDS a source; it cannot
64
- * retire this one while `runHook(command)` remains public.
59
+ * - **The in-process tier CAN and since 2026-08-13 it DOES.** ⚠️ This bullet
60
+ * used to read *"`harness-assert.ts` / `load-hook.ts` contain ZERO
61
+ * `recordSurfaceProbe` calls — the tier where attribution needs no parsing at
62
+ * all is the tier that attributes nothing"*, and that is no longer true:
63
+ * `runCountedHookProgram` in harness-assert.ts records a `command` probe for a
64
+ * hook it is about to evaluate, when `loadHook` knew the file. Verified by
65
+ * count, not by memory — `harness-assert.ts` has the call, `load-hook.ts`
66
+ * still does not, and deliberately so (loading a hook is not running it; see
67
+ * that function's note). Left corrected rather than deleted because a header
68
+ * that advertises a gap somebody already closed sends the next reader to build
69
+ * a second recorder.
70
+ *
71
+ * It did not retire this parser, as predicted: it ADDS a source, and
72
+ * `runHook(command)` is still public.
65
73
  *
66
74
  * So parsing stays, and the lesson taken instead is about DIRECTION. The scan was
67
75
  * a deny-list — skip what we recognise, attribute what is left — so every gap in
@@ -184,11 +184,17 @@ function discoverTests(files) {
184
184
  }
185
185
  return out;
186
186
  }
187
- /** Mirror of test-coverage.ts `isColocated` — named after the surface, beside it. */
187
+ /**
188
+ * Named after the surface, beside it — the SHARED rule, no longer a copy of it.
189
+ *
190
+ * 🔴 THIS WAS A MIRROR, and its own docstring said so. Two bodies, one promise
191
+ * that they stay equal, kept by whoever remembers — and this file's header
192
+ * already records the last time a coverage change landed in one engine and not
193
+ * the other. It now calls `isColocatedTest`, which the disk detector and the
194
+ * run-record builder call too.
195
+ */
188
196
  function isColocated(surface, testPath) {
189
- if (!(0, posix_path_js_1.basename)(testPath).startsWith(`${surface.name}.`))
190
- return false;
191
- return (0, posix_path_js_1.dirname)(testPath) === (0, posix_path_js_1.dirname)(surface.path);
197
+ return (0, coverage_evidence_js_1.isColocatedTest)(surface, testPath);
192
198
  }
193
199
  /** Mirror of test-coverage.ts `coverageOf` — strongest evidence across tests. */
194
200
  function coverageOf(surface, tests) {
@@ -287,12 +287,7 @@ function discoverTests(basePath, globs, ignore) {
287
287
  * to remove.
288
288
  */
289
289
  function isColocated(surface, testPath) {
290
- if (!(0, node_path_1.basename)(testPath).startsWith(`${surface.name}.`))
291
- return false;
292
- // A root `SKILL.md` (single-skill-dir target) lives at ".", and globSync returns
293
- // top-level files without a "./" prefix — so `dirname` is "." on both sides and
294
- // the comparison holds without a special case.
295
- return (0, node_path_1.dirname)(testPath) === (0, node_path_1.dirname)(surface.path);
290
+ return (0, coverage_evidence_js_1.isColocatedTest)(surface, testPath);
296
291
  }
297
292
  /**
298
293
  * The STRONGEST evidence any discovered test provides for this surface, or null.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "16.0.1",
3
+ "version": "16.1.1",
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",