vigiles 22.0.0 → 23.0.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/README.md CHANGED
@@ -163,7 +163,7 @@ Every one of these is **valid markdown** — parses fine, does the wrong thing.
163
163
  | `test` | Does the harness behave? | No — a scripted stand-in | Every commit |
164
164
  | `eval` | Does a skill actually help? | Yes — your subscription | On demand |
165
165
 
166
- **One engine, two doors.** `audit` is the local report; **`lint` is the CI gate** that fails the build on the same deterministic checks — broken refs, bad tool contracts, dead hooks, skill collisions (Proofs 1–2). `test` and `eval` go further: past _does it exist_ to _does it work_. (`init` / `compile` / `eject` manage the optional typed-spec layer for the structural rules no linter can express — a graduation step you rarely run by hand.) [How the verbs relate →](docs/commands-and-how-they-relate.md)
166
+ **One engine, two doors.** `audit` is the local report; **`lint` is the CI gate** that fails the build on the same deterministic checks — broken refs, bad tool contracts, dead hooks, skill collisions (Proofs 1–2). `test` and `eval` go further: past _does it exist_ to _does it work_. (`init` / `compile` / `eject` manage the optional typed-spec layer for the structural rules no linter can express — a graduation step you rarely run by hand. If you use a spec, run `compile` in CI too: it is what re-derives that spec's refs, while `lint` verifies the compiled file is intact.) [How the verbs relate →](docs/commands-and-how-they-relate.md)
167
167
 
168
168
  ### 🔎 Lint — your instructions stop lying
169
169
 
@@ -6,6 +6,9 @@ exports.claudeCodeHookProtocol = {
6
6
  blockExitCode: 2,
7
7
  denyDecisionValues: ["block", "deny"],
8
8
  eventEnvVars: [],
9
+ // `{"continue": false}` stops the turn outright and returns `stopReason` to
10
+ // the agent — a stronger stop than a per-call deny, and a documented one.
11
+ haltsTurnField: "continue",
9
12
  // Events that honor `hookSpecificOutput.additionalContext` (developer-context
10
13
  // injection). Covers vigiles's shipped inject hooks: the SessionStart lint
11
14
  // summary and the PostToolUse refs / eval-lock nudges.
@@ -16,6 +16,8 @@ exports.claudeCodeLayout = {
16
16
  skillDir: "skills",
17
17
  agentDir: "agents",
18
18
  commandDir: "commands",
19
+ // `.claude/rules/*.md` — path-scoped project instructions (see PluginLayout).
20
+ rulesDir: "rules",
19
21
  materializeRoot: ".claude",
20
22
  pluginRootToken: "${CLAUDE_PLUGIN_ROOT}",
21
23
  // Both names Claude Code uses for the project root (mirrors the
@@ -95,6 +95,7 @@ exports.COMMAND_FLAGS = {
95
95
  audit: [
96
96
  "--json",
97
97
  "--md",
98
+ "--single",
98
99
  "--out=",
99
100
  "--no-html",
100
101
  "--no-json",
package/dist/cli.js CHANGED
@@ -316,7 +316,20 @@ function compileClaudeToFile(spec, specPath, config, dialect) {
316
316
  if (errors.length > 0) {
317
317
  console.log(`\n✗ ${specPath} — ${String(errors.length)} error(s)`);
318
318
  printErrors(specPath, errors);
319
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, primaryOutput), markdown);
319
+ // 🔴 NOTHING IS WRITTEN ON A FAILED COMPILE (#173).
320
+ //
321
+ // It used to write the artifact anyway, and the result was the exact
322
+ // false-confidence object this tool exists to prevent: a `CLAUDE.md`
323
+ // carrying refs already KNOWN to be dead, stamped with a VALID integrity
324
+ // hash. `lint` then verified the hash, found it intact, and exited 0 — so
325
+ // the command the README calls "the CI gate … broken refs" went green over
326
+ // breakage `compile` had printed minutes earlier. Compile locally, get
327
+ // distracted, commit: CI never mentions it again.
328
+ //
329
+ // Not writing leaves the LAST GOOD artifact in place, which is strictly
330
+ // better than replacing it with a broken one: the error is on screen, the
331
+ // exit code is 1, and no green hash is minted over a known-bad file.
332
+ console.log(` → ${primaryOutput} was NOT written; the previous version is left in place.`);
320
333
  return false;
321
334
  }
322
335
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, primaryOutput), markdown);
@@ -1937,6 +1950,15 @@ function scaffoldSpec(args) {
1937
1950
  (0, node_fs_1.writeFileSync)(specAbs, source);
1938
1951
  console.log(`Adopted ${target} → ${specPath} (${tier}, ${String(sectionCount)} section${sectionCount === 1 ? "" : "s"}). ` +
1939
1952
  `Run \`vigiles compile\` and review the diff; the \`/strengthen\` skill upgrades prose to verified rules.`);
1953
+ // Adoption is faithful by design: it infers NO rules and extracts NO refs,
1954
+ // so a raw adoption verifies nothing on its own. Saying so is the whole
1955
+ // fix — the cost (the file becomes a build artifact, edits move into TS)
1956
+ // lands immediately, and without this line the benefit reads as zero
1957
+ // rather than as not-yet-claimed. Deliberately not a heuristic extractor:
1958
+ // guessing refs out of prose is what got `doc-refs` disabled.
1959
+ if (tier === "raw")
1960
+ console.log(` ℹ 0 refs extracted — this spec verifies nothing yet. Wrap paths in \`file()\` ` +
1961
+ `and commands in \`cmd()\` to make \`compile\` check them.`);
1940
1962
  }
1941
1963
  return;
1942
1964
  }
@@ -3566,7 +3588,12 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
3566
3588
  if (found.length > 0 && !silent) {
3567
3589
  console.log("\nSkill-resource check:\n");
3568
3590
  for (const s of found) {
3569
- const msg = `${s.name}: bundled resource "${s.finding.ref}" (line ${String(s.finding.line)}) is referenced but missing — the agent reads the instruction and gets nothing.`;
3591
+ const msg = `${s.name}: bundled resource "${s.finding.ref}" (line ${String(s.finding.line)}) is referenced but missing — the agent reads the instruction and gets nothing.` +
3592
+ // The main false-positive source in a skills monorepo, where a SKILL.md
3593
+ // legitimately names a repo-root path. The fix already exists and works;
3594
+ // it was documented only in docs/skills-monorepo.md, so a CI log gave no
3595
+ // hint and the rule read as broken rather than misconfigured.
3596
+ ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
3570
3597
  console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
3571
3598
  ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
3572
3599
  }
@@ -4587,6 +4614,7 @@ const COMMAND_HELP = {
4587
4614
  usage: " vigiles audit [dir...] Grade it on your machine. Reports everything, fails nothing.",
4588
4615
  detail: [
4589
4616
  " 2+ dirs → a leaderboard. Writes vigiles-report.html + .json (auto-gitignored).",
4617
+ " --single audit this dir as ONE harness, even if it holds many bundles",
4590
4618
  " The executing checks (run your hooks · live MCP · do skills fire?) run only",
4591
4619
  " interactively — audit asks once and remembers; automation uses the testing API.",
4592
4620
  ],
@@ -4721,7 +4749,12 @@ function printUsage(command) {
4721
4749
  console.log("New here? Start with `vigiles audit .`");
4722
4750
  if (command && command !== "--help") {
4723
4751
  console.log(`\nUnknown command: "${command}"`);
4724
- process.exit(1);
4752
+ // 2, not 1 — `docs/cli.md` fixes the contract as 1 = "I ran, and what you
4753
+ // asked about is bad", 2 = "I could not do what you asked". A typo'd verb is
4754
+ // the second, and the unknown-FLAG path (cli-flag-check.ts) already exits 2.
4755
+ // A script telling "found problems" from "could not start" by exit code read
4756
+ // a mistyped command as a finding.
4757
+ process.exit(2);
4725
4758
  }
4726
4759
  }
4727
4760
  // ---------------------------------------------------------------------------
@@ -5914,6 +5947,14 @@ async function runHookProgramCommand(file) {
5914
5947
  function ensureReportGitignored(cwd, entries) {
5915
5948
  if (entries.length === 0)
5916
5949
  return;
5950
+ // An `--out` outside the repo produced entries like
5951
+ // `../../../../private/tmp/x/vigiles-report.json`, which ignore NOTHING —
5952
+ // .gitignore does not reach outside its own tree — and accumulate one dead
5953
+ // block per output path. Worse in principle than in practice: `audit` is
5954
+ // documented as a read-only report, and this made it edit a tracked file for
5955
+ // no benefit at all. Inside the repo the write is expected and documented.
5956
+ if (entries.some((e) => e.startsWith("..") || (0, node_path_1.isAbsolute)(e)))
5957
+ return;
5917
5958
  const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
5918
5959
  try {
5919
5960
  if (!(0, node_fs_1.existsSync)(gi)) {
@@ -6429,10 +6470,37 @@ async function main() {
6429
6470
  // carries `market` into its explanation and exits 2 — this repo's own rule
6430
6471
  // that 1 is "I measured, and it's bad" and 2 is "I could not do what you
6431
6472
  // asked". Nothing was measured here, so it is a 2.
6432
- const targets = market && market.onDisk.length > 0 ? [...market.onDisk] : dirs;
6433
- if (targets.length > 1) {
6473
+ // `--single` pins the SINGLE-harness reading of the given directory, whatever
6474
+ // is nested inside it. Without it, a repo holding many bundles auto-switches
6475
+ // to the leaderboard and there is no way back — so the full ring report for
6476
+ // the ROOT was simply unreachable, and the reported workaround was a CI job
6477
+ // looping `audit` over 29 directories. The mode branch already existed; this
6478
+ // only stops it being decided for you.
6479
+ const single = args.includes("--single");
6480
+ // `--single` names ONE harness, so more than one explicit directory is a
6481
+ // contradiction. Refuse it (exit 2 = "could not do what you asked") rather
6482
+ // than auditing the first and dropping the rest — silently honouring half
6483
+ // an argument list is the same defect class as the ignored `--out` above.
6484
+ if (single && dirs.length > 1) {
6485
+ console.error(`--single audits ONE directory as one harness, but ${String(dirs.length)} were given. ` +
6486
+ `Drop --single for a leaderboard, or pass a single directory.`);
6487
+ process.exit(2);
6488
+ }
6489
+ const targets = !single && market && market.onDisk.length > 0
6490
+ ? [...market.onDisk]
6491
+ : dirs;
6492
+ if (!single && targets.length > 1) {
6434
6493
  // Multiple targets → rank them (the leaderboard engine). `--md` emits the
6435
6494
  // publishable Markdown table (a README / gist / the leaderboard site).
6495
+ //
6496
+ // `--out` writes nothing here: the per-bundle HTML/JSON report is built
6497
+ // in the single-target branch below, and this one only prints a table.
6498
+ // SAY SO. A silent no-op is how a CI job ships an empty artifact and
6499
+ // stays green — which is exactly how this was found, and the same
6500
+ // never-fail-silently shape as the rest of this file.
6501
+ if (args.some((a) => a.startsWith("--out=")) && !json)
6502
+ console.log(`⚠ --out is ignored here: ${String(targets.length)} bundles → leaderboard mode, ` +
6503
+ `which produces no per-bundle report. Run audit per directory to write one.`);
6436
6504
  const scores = (0, leaderboard_js_1.rankPlugins)(targets);
6437
6505
  const text = args.includes("--md")
6438
6506
  ? (0, leaderboard_js_1.formatLeaderboardMarkdown)(scores)
@@ -79,6 +79,24 @@ const DECISION_BLOCK = /"decision"\s*:\s*"(block|deny)"/;
79
79
  * (Both require a structured response, as opposed to the legacy field.)
80
80
  */
81
81
  const PERMISSION_DENY = /"permissionDecision"\s*:\s*"(deny|ask)"/;
82
+ /**
83
+ * A `"continue": false` halt — the OTHER documented way a Claude Code hook stops
84
+ * an action (it ends the turn and returns `stopReason` to the agent).
85
+ *
86
+ * Read ONLY as a SUPPRESSOR of `wrong-field`, never as a block ATTEMPT, and the
87
+ * asymmetry is the whole point. #174 proposed adding it alongside the three
88
+ * mechanisms above; doing that would have made `wrong-event` fire on a hook that
89
+ * works, because a halt is NOT event-scoped — it stops the turn from
90
+ * `SessionStart` just as it does from `PreToolUse`, which is precisely the set
91
+ * `wrong-event` flags. For a rule that can be wired at `error`, a false positive
92
+ * costs more than a miss: it fails a correct build, and a rule that fails
93
+ * correct builds gets switched off rather than fixed.
94
+ *
95
+ * What it legitimately fixes is the reverse: a hook on a permission-gated event
96
+ * that pairs a legacy `"decision":"block"` with a real halt was told "nothing is
97
+ * blocked" while it blocked.
98
+ */
99
+ const CONTINUE_FALSE = /"continue"\s*:\s*false/;
82
100
  // ---------------------------------------------------------------------------
83
101
  // Detector
84
102
  // ---------------------------------------------------------------------------
@@ -125,6 +143,8 @@ function hookBlockIssues(entries, opts) {
125
143
  const hasExit2 = EXIT_2.test(text) || EXIT_2_CODE.test(text);
126
144
  const hasDecisionBlock = DECISION_BLOCK.test(text);
127
145
  const hasPermissionDeny = PERMISSION_DENY.test(text);
146
+ const hasContinueFalse = CONTINUE_FALSE.test(text);
147
+ // Deliberately NOT `|| hasContinueFalse` — see CONTINUE_FALSE.
128
148
  const triesBlock = hasExit2 || hasDecisionBlock || hasPermissionDeny;
129
149
  if (!triesBlock)
130
150
  continue;
@@ -143,7 +163,10 @@ function hookBlockIssues(entries, opts) {
143
163
  }
144
164
  else if (permissionDecisionEvents.has(entry.event) &&
145
165
  hasDecisionBlock &&
146
- !hasPermissionDeny) {
166
+ !hasPermissionDeny &&
167
+ // A halt alongside the legacy field DOES stop the action, so the legacy
168
+ // field being ignored costs nothing. Flagging it would be a false alarm.
169
+ !hasContinueFalse) {
147
170
  // wrong-field: on a permission-gated event, uses the legacy field.
148
171
  kind = "wrong-field";
149
172
  message =
@@ -46,5 +46,19 @@ export interface HookProtocol {
46
46
  * shell-hook harness declares a non-empty set.
47
47
  */
48
48
  readonly injectableEvents: readonly string[];
49
+ /**
50
+ * The boolean stdout field whose `false` value HALTS THE WHOLE TURN, if the
51
+ * harness has one (Claude Code: `"continue"`). Distinct from a deny: a deny
52
+ * refuses one tool call, this stops the iteration and hands `stopReason` back
53
+ * to the agent as text — so authors reach for it exactly when they want to
54
+ * explain themselves, and a guard written that way still prevents the action.
55
+ *
56
+ * Optional (additive, non-breaking) and per-harness on purpose. It is
57
+ * DOCUMENTED for Claude Code and UNVERIFIED for Codex, whose protocol notes
58
+ * only record the shared exit-2 / `decision` / `permissionDecision` model — so
59
+ * Codex leaves it unset rather than inheriting a claim nobody measured. Read
60
+ * by `decideHook`; absent ⇒ no field halts the turn on this harness.
61
+ */
62
+ readonly haltsTurnField?: string;
49
63
  }
50
64
  //# sourceMappingURL=hook-protocol.d.ts.map
@@ -51,6 +51,21 @@ export interface PluginLayout {
51
51
  readonly agentDir: string;
52
52
  /** Slash-commands dir, holding flat `<dir>/<name>.md`, e.g. `commands`. */
53
53
  readonly commandDir: string;
54
+ /**
55
+ * Path-scoped RULES dir, holding flat `<dir>/<name>.md`, e.g. `rules`
56
+ * (`""` or absent = this harness has no such layer).
57
+ *
58
+ * Claude Code loads `.claude/rules/*.md` as project instructions, scoped by a
59
+ * `paths:` frontmatter key. It is an INSTRUCTION surface — often where a
60
+ * team's hardest policies actually live — and until now no layout named it, so
61
+ * `frontmatter-valid` and the rule map simply never saw those files. An
62
+ * adopter reported five such files arriving in a session labelled "project
63
+ * instructions" while `lint` did not mention them at all (#175.3).
64
+ *
65
+ * Optional and additive: a layout that omits it behaves exactly as before, so
66
+ * this adds a directory to the existing checks rather than a new check.
67
+ */
68
+ readonly rulesDir?: string;
54
69
  /** Dir the surfaces are materialized under, e.g. `.claude`. */
55
70
  readonly materializeRoot: string;
56
71
  /** Env token expanded to the plugin's absolute root in hook commands. */
@@ -8,7 +8,9 @@
8
8
  * conventions: one is about a process that ran, the other about a directory
9
9
  * listing. See the module header.
10
10
  */
11
- export type CoverageEvidence = "executed" | "colocated";
11
+ export type CoverageEvidence = "executed" | "colocated" | "configured";
12
+ /** Is `a` stronger evidence than `b`? */
13
+ export declare function strongerEvidence(a: CoverageEvidence, b: CoverageEvidence): boolean;
12
14
  /** The minimum a surface must expose to be matched — structural, no import cycle. */
13
15
  export interface CoverableSurface {
14
16
  /** Repo-relative path of the surface file (SKILL.md / agent .md / hook script). */
@@ -100,12 +102,56 @@ export declare function hookScriptRefs(manifestText: string | undefined, layout:
100
102
  * `colocated` is passed in because placement is a path question the two twins
101
103
  * answer with their own (disk vs POSIX-string) path helpers.
102
104
  */
103
- export declare function evidenceFor(_surface: CoverableSurface, _test: PreparedTest, colocated: boolean): CoverageEvidence | null;
105
+ export declare function evidenceFor(_surface: CoverableSurface, _test: PreparedTest, colocated: boolean, configured?: boolean): CoverageEvidence | null;
106
+ /**
107
+ * The `{surface}` placeholder in a user's `testGlobs` — the ONE thing that makes
108
+ * a centralized test layout expressible without weakening what coverage MEANS.
109
+ *
110
+ * The retired `declared` and `name-mentioned` tiers died because they could
111
+ * credit a surface no test touched: a mention is a substring, and a substring
112
+ * matched this file's own fixtures. `{surface}` cannot do that. The user writes
113
+ * `tests/{surface}/evals/promptfooconfig*.yaml`, and the placeholder is replaced
114
+ * with the surface's NAME before matching — so the binding between test and
115
+ * surface is still the name, exactly as under colocation. Only the PLACE moves.
116
+ *
117
+ * What it costs, stated plainly because it is the argument colocation was chosen
118
+ * on: `ls` beside the skill no longer answers "is this tested?" — you have to
119
+ * know where the project keeps its tests. That is a real loss, and it is why
120
+ * this is opt-in per repo rather than a second default. A project that has
121
+ * already centralized its suites has paid that cost anyway.
122
+ */
123
+ export declare const SURFACE_TOKEN = "{surface}";
124
+ /** Does this glob delegate its surface binding to the placeholder? */
125
+ export declare function hasSurfaceToken(glob: string): boolean;
126
+ /**
127
+ * The pattern to DISCOVER files with: the placeholder widened to `*` so one
128
+ * glob pass finds every candidate. Narrowing back to the right surface happens
129
+ * at match time — discovery must stay surface-agnostic or it would be one glob
130
+ * pass per surface.
131
+ */
132
+ export declare function discoveryGlob(glob: string): string;
133
+ /**
134
+ * Does this test file sit at a `{surface}` path configured FOR THIS SURFACE?
135
+ *
136
+ * The placeholder is replaced with the surface's own name, so
137
+ * `tests/{surface}/evals/*.yaml` credits `tests/mysql-designer/evals/x.yaml` to
138
+ * `mysql-designer` and to nothing else. A glob WITHOUT the placeholder returns
139
+ * false here on purpose: a plain custom glob widens what counts as a test file,
140
+ * which it always did, but it says nothing about WHICH surface the file is for
141
+ * — and inferring that from a substring is exactly the retired `name-mentioned`
142
+ * tier that credited surfaces nothing had touched.
143
+ *
144
+ * `minimatch` (already a direct dependency, pure JS) so the browser twin can
145
+ * share this instead of growing a second matcher that disagrees.
146
+ */
147
+ export declare function matchesSurfaceGlob(surface: Pick<CoverableSurface, "name">, testPath: string, globs: readonly string[]): boolean;
104
148
  /** Per-evidence tallies — the provenance summary the report prints. */
105
149
  export interface EvidenceCounts {
106
150
  /** Decided by a recorded run against this version of the surface. */
107
151
  readonly executed: number;
108
152
  readonly colocated: number;
153
+ /** Decided by a `{surface}` testGlob — the name still binds, the place moved. */
154
+ readonly configured: number;
109
155
  }
110
156
  /** Tally a list of decisions by evidence kind. */
111
157
  export declare function countEvidence(decisions: readonly {
@@ -1,10 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SURFACE_TOKEN = void 0;
4
+ exports.strongerEvidence = strongerEvidence;
3
5
  exports.isColocatedTest = isColocatedTest;
4
6
  exports.prepareTest = prepareTest;
5
7
  exports.isEvalScript = isEvalScript;
6
8
  exports.hookScriptRefs = hookScriptRefs;
7
9
  exports.evidenceFor = evidenceFor;
10
+ exports.hasSurfaceToken = hasSurfaceToken;
11
+ exports.discoveryGlob = discoveryGlob;
12
+ exports.matchesSurfaceGlob = matchesSurfaceGlob;
8
13
  exports.countEvidence = countEvidence;
9
14
  exports.formatEvidence = formatEvidence;
10
15
  exports.declaredSurfaceName = declaredSurfaceName;
@@ -92,8 +97,31 @@ exports.declaredSurfaceName = declaredSurfaceName;
92
97
  * impossible there and its count is 0 — see `test-coverage-files.ts`.
93
98
  */
94
99
  const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
100
+ const minimatch_1 = require("minimatch");
95
101
  const posix_path_js_1 = require("./posix-path.js");
96
102
  const source_refs_js_1 = require("./core/source-refs.js");
103
+ /**
104
+ * Rank, strongest first — what "the STRONGEST evidence" is measured against.
105
+ *
106
+ * It exists because `coverageOf` promised strongest-not-first-found while
107
+ * actually keeping the first match, which was harmless only while `colocated`
108
+ * was the sole surviving tier: one tier cannot be out-ranked. Adding
109
+ * `configured` made the promise load-bearing again — a surface with both a
110
+ * colocated harness and a configured suite would otherwise be reported as
111
+ * whichever the glob list happened to yield first, so the provenance summary
112
+ * would depend on the ORDER of a config array. Colocation ranks higher because
113
+ * it is the stronger statement: the filesystem enforces the binding rather than
114
+ * a pattern asserting it.
115
+ */
116
+ const EVIDENCE_RANK = {
117
+ executed: 0,
118
+ colocated: 1,
119
+ configured: 2,
120
+ };
121
+ /** Is `a` stronger evidence than `b`? */
122
+ function strongerEvidence(a, b) {
123
+ return EVIDENCE_RANK[a] < EVIDENCE_RANK[b];
124
+ }
97
125
  /**
98
126
  * Is `testPath` the colocated test OF this surface — NAMED after it, SITTING
99
127
  * BESIDE it?
@@ -252,20 +280,80 @@ function hookScriptRefs(manifestText, layout, exists) {
252
280
  * `colocated` is passed in because placement is a path question the two twins
253
281
  * answer with their own (disk vs POSIX-string) path helpers.
254
282
  */
255
- function evidenceFor(_surface, _test, colocated) {
256
- return colocated ? "colocated" : null;
283
+ function evidenceFor(_surface, _test, colocated, configured = false) {
284
+ if (colocated)
285
+ return "colocated";
286
+ return configured ? "configured" : null;
287
+ }
288
+ /**
289
+ * The `{surface}` placeholder in a user's `testGlobs` — the ONE thing that makes
290
+ * a centralized test layout expressible without weakening what coverage MEANS.
291
+ *
292
+ * The retired `declared` and `name-mentioned` tiers died because they could
293
+ * credit a surface no test touched: a mention is a substring, and a substring
294
+ * matched this file's own fixtures. `{surface}` cannot do that. The user writes
295
+ * `tests/{surface}/evals/promptfooconfig*.yaml`, and the placeholder is replaced
296
+ * with the surface's NAME before matching — so the binding between test and
297
+ * surface is still the name, exactly as under colocation. Only the PLACE moves.
298
+ *
299
+ * What it costs, stated plainly because it is the argument colocation was chosen
300
+ * on: `ls` beside the skill no longer answers "is this tested?" — you have to
301
+ * know where the project keeps its tests. That is a real loss, and it is why
302
+ * this is opt-in per repo rather than a second default. A project that has
303
+ * already centralized its suites has paid that cost anyway.
304
+ */
305
+ exports.SURFACE_TOKEN = "{surface}";
306
+ /** Does this glob delegate its surface binding to the placeholder? */
307
+ function hasSurfaceToken(glob) {
308
+ return glob.includes(exports.SURFACE_TOKEN);
309
+ }
310
+ /**
311
+ * The pattern to DISCOVER files with: the placeholder widened to `*` so one
312
+ * glob pass finds every candidate. Narrowing back to the right surface happens
313
+ * at match time — discovery must stay surface-agnostic or it would be one glob
314
+ * pass per surface.
315
+ */
316
+ function discoveryGlob(glob) {
317
+ return glob.split(exports.SURFACE_TOKEN).join("*");
318
+ }
319
+ /**
320
+ * Does this test file sit at a `{surface}` path configured FOR THIS SURFACE?
321
+ *
322
+ * The placeholder is replaced with the surface's own name, so
323
+ * `tests/{surface}/evals/*.yaml` credits `tests/mysql-designer/evals/x.yaml` to
324
+ * `mysql-designer` and to nothing else. A glob WITHOUT the placeholder returns
325
+ * false here on purpose: a plain custom glob widens what counts as a test file,
326
+ * which it always did, but it says nothing about WHICH surface the file is for
327
+ * — and inferring that from a substring is exactly the retired `name-mentioned`
328
+ * tier that credited surfaces nothing had touched.
329
+ *
330
+ * `minimatch` (already a direct dependency, pure JS) so the browser twin can
331
+ * share this instead of growing a second matcher that disagrees.
332
+ */
333
+ function matchesSurfaceGlob(surface, testPath, globs) {
334
+ const test = posixly(testPath);
335
+ return globs.some((g) => {
336
+ if (!hasSurfaceToken(g))
337
+ return false;
338
+ return (0, minimatch_1.minimatch)(test, g.split(exports.SURFACE_TOKEN).join(surface.name), {
339
+ dot: true,
340
+ });
341
+ });
257
342
  }
258
343
  /** Tally a list of decisions by evidence kind. */
259
344
  function countEvidence(decisions) {
260
345
  let executed = 0;
261
346
  let colocated = 0;
347
+ let configured = 0;
262
348
  for (const d of decisions) {
263
349
  if (d.evidence === "executed")
264
350
  executed += 1;
351
+ else if (d.evidence === "configured")
352
+ configured += 1;
265
353
  else
266
354
  colocated += 1;
267
355
  }
268
- return { executed, colocated };
356
+ return { executed, colocated, configured };
269
357
  }
270
358
  /**
271
359
  * One line naming how the coverage was established. Printed wherever a coverage
@@ -287,6 +375,11 @@ function formatEvidence(counts) {
287
375
  parts.push(`${String(counts.colocated)} colocated — a test NAMED after the surface, ` +
288
376
  `in the surface's own place. This says the file EXISTS, not that it ran`);
289
377
  }
378
+ if (counts.configured > 0) {
379
+ parts.push(`${String(counts.configured)} configured — a test NAMED after the surface ` +
380
+ `at a \`{surface}\` path you configured. Same name binding as colocation, ` +
381
+ `different place; still only says the file EXISTS`);
382
+ }
290
383
  if (parts.length === 0)
291
384
  return "";
292
385
  return `How coverage was decided: ${parts.join("; ")}.`;
package/dist/eval.d.ts CHANGED
@@ -724,6 +724,16 @@ export interface TriggerRateReport {
724
724
  * description). A non-zero count is the whole-harness measurement.
725
725
  */
726
726
  readonly competitors: number;
727
+ /**
728
+ * The plugin namespace the skills actually installed under — the `<plugin>`
729
+ * half of the `<plugin>:<skill>` id `skillResolved` matches.
730
+ *
731
+ * Reported because with `skillsDir` the name is chosen by the packager, not by
732
+ * the caller, so the single most common cause of a 0% run was a value the
733
+ * caller had no way to know. Optional so a report recorded before this field
734
+ * still parses.
735
+ */
736
+ readonly namespace?: string;
727
737
  /**
728
738
  * Runs EXCLUDED because the turn errored / was rate-limited (detected by the
729
739
  * driver's `runError`), present only when > 0. These are NOT counted in `n` or
package/dist/eval.js CHANGED
@@ -1577,6 +1577,7 @@ function resolveTriggerPluginDir(spec) {
1577
1577
  pluginDir,
1578
1578
  packaged,
1579
1579
  competitors: Math.max(0, countSkills(pluginDir) - 1),
1580
+ namespace: underTestSource(spec).name,
1580
1581
  };
1581
1582
  }
1582
1583
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
@@ -1683,7 +1684,7 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
1683
1684
  throw new Error(`measureTriggerRate: model "${model}" is below the minimum "${minModel}" — ` +
1684
1685
  "trigger-rate under-measures selection on a weaker model " +
1685
1686
  "(raise the model, or lower `minModel` for a deliberately cheap run).");
1686
- const { pluginDir, packaged, competitors } = resolveTriggerPluginDir(spec);
1687
+ const { pluginDir, packaged, competitors, namespace } = resolveTriggerPluginDir(spec);
1687
1688
  const cfg = {
1688
1689
  trials: spec.trials ?? 1,
1689
1690
  // Sonnet, not haiku: trigger-rate is a selection measurement and haiku
@@ -1729,6 +1730,7 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
1729
1730
  n: relevant.n,
1730
1731
  perPrompt: relevant.perPrompt,
1731
1732
  competitors,
1733
+ namespace,
1732
1734
  errored: positiveOrUndefined(relevant.errored),
1733
1735
  usage: aggregateUsage(relevant.usages),
1734
1736
  };
@@ -1833,8 +1835,17 @@ function formatTriggerRateReport(report) {
1833
1835
  // must not be second-guessed; a checker that hedges on good data gets ignored.
1834
1836
  if (report.n > 0 && report.rate === 0)
1835
1837
  lines.push("⚠ nothing fired on ANY prompt. That is usually SETUP, not the description — check, in order:\n" +
1836
- " 1. the id in `fired` `skillResolved` matches the NAMESPACED id " +
1837
- "(`<plugin>:<skill>`); a bare name silently never matches;\n" +
1838
+ // The runtime RESOLVED the namespace before spending a token, so it
1839
+ // prints the id that should have matched instead of telling the reader
1840
+ // to go work it out. With `skillsDir` the name is not even the user's
1841
+ // choice — the packager picks it — so "check the id" was advice about a
1842
+ // value they had never seen.
1843
+ (report.namespace !== undefined
1844
+ ? " 1. the id in `fired` — your skills installed under " +
1845
+ `\`${report.namespace}\`, so \`skillResolved\` matches ` +
1846
+ `\`${report.namespace}:<skill>\`; a bare name silently never matches;\n`
1847
+ : " 1. the id in `fired` — `skillResolved` matches the NAMESPACED id " +
1848
+ "(`<plugin>:<skill>`); a bare name silently never matches;\n") +
1838
1849
  " 2. the install field — a loose `.claude/skills` dir needs `skillsDir`, " +
1839
1850
  "not `pluginDir` (which wants a full plugin manifest);\n" +
1840
1851
  " 3. the `fixture` — a run starts in an EMPTY cwd, so a prompt about a " +
@@ -254,6 +254,40 @@ function materializeSurfaces(root, layout, files, sources) {
254
254
  counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
255
255
  }
256
256
  };
257
+ /**
258
+ * Read the path-scoped RULES dir (`.claude/rules/*.md` on Claude Code).
259
+ *
260
+ * DELIBERATELY NOT a `surfaceDirs` entry, and the distinction is the whole
261
+ * design: `surfaceDirs` decides whether a directory counts as a LOADABLE
262
+ * MACHINE, and rules are instructions, not an invocable surface — folding them
263
+ * in would silently change what "an empty machine" means for every harness.
264
+ * So they are read here, added to `files` for the checks that read text
265
+ * (frontmatter-valid, the rule map), and left out of `counts` and
266
+ * `hasLoadable`. A layout with no `rulesDir` reads nothing and behaves exactly
267
+ * as before. Closes #175.3: a whole instruction layer the audit could not see.
268
+ */
269
+ const materializeRules = () => {
270
+ const dir = layout.rulesDir;
271
+ if (!dir)
272
+ return;
273
+ // Read BOTH candidate bases, and NOT the resolved `scopes`. Rules are not
274
+ // tied to where the invocable surfaces live: a repo can keep its skills at
275
+ // the root (a published plugin) while its rules sit under `.claude/`, and
276
+ // keying off scopes then read the wrong directory and found nothing —
277
+ // measured on exactly that shape while building this.
278
+ //
279
+ // Each base keys at its own real path (`rules/…` and `.claude/rules/…`), so
280
+ // a repo with both loses neither and nothing collides.
281
+ const bases = [
282
+ "",
283
+ ...(layout.userSurfaceRoot !== undefined ? [layout.userSurfaceRoot] : []),
284
+ ];
285
+ for (const base of bases) {
286
+ const tree = surfaceTree((0, node_path_1.join)(root, base, dir));
287
+ for (const [rel, content] of Object.entries(tree))
288
+ add((0, node_path_1.join)(base, dir, rel), content, (0, node_path_1.join)(root, base, dir, rel));
289
+ }
290
+ };
257
291
  const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
258
292
  hasRootSkillFile: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md")),
259
293
  skillName: (0, node_path_1.basename)(root),
@@ -280,6 +314,7 @@ function materializeSurfaces(root, layout, files, sources) {
280
314
  (0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
281
315
  for (const scope of source.scopes)
282
316
  materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
317
+ materializeRules();
283
318
  return { counts, scopes: source.scopes };
284
319
  }
285
320
  /* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
@@ -163,10 +163,30 @@ export interface HookRunResult extends ScriptRunResult {
163
163
  /** Parsed stdout JSON if the hook emitted a JSON decision, else null. */
164
164
  readonly json: HookOutput | null;
165
165
  /**
166
- * Normalized decision: a deny/block via exit 2, `decision:"block"`, or
167
- * `permissionDecision:"deny"` all set `blocked = true`.
166
+ * Normalized decision: the dangerous call did NOT go through. Set by exit 2,
167
+ * `decision:"block"`, `permissionDecision:"deny"`, or the harness's
168
+ * halt-the-turn field (Claude Code `{"continue": false}` — see
169
+ * {@link HookRunResult.haltsTurn}).
170
+ *
171
+ * The halt case was missing until 2026-08-31 (#174), and the shape of that bug
172
+ * is worth keeping written down: `verifyGuardrail` reads this field, so a real
173
+ * `PreToolUse` guard that stopped every one of the disaster battery's commands
174
+ * was reported by `assertBlocksDisasters` as blocking NONE of them. A tool
175
+ * whose stated job is catching a guard that looks fine and silently does
176
+ * nothing said the opposite about a working guard — the same false-confidence
177
+ * failure, with the sign flipped.
168
178
  */
169
179
  readonly blocked: boolean;
180
+ /**
181
+ * The hook halted the WHOLE TURN rather than denying one call — the harness's
182
+ * `haltsTurnField` came back `false`.
183
+ *
184
+ * Reported separately because it is strictly stronger than a deny and the two
185
+ * are worth telling apart when a test asks WHICH mechanism fired. `blocked`
186
+ * stays the question nearly every caller means ("did the action happen?"), so
187
+ * a halt sets both.
188
+ */
189
+ readonly haltsTurn: boolean;
170
190
  /**
171
191
  * The decision the hook expressed, preferring the structured
172
192
  * `permissionDecision` ("allow"|"deny"|"ask") then legacy `decision`
@@ -183,6 +203,7 @@ export declare function parseHookOutput(stdout: string): HookOutput | null;
183
203
  export declare function decideHook(exitCode: number, json: HookOutput | null, protocol?: HookProtocol): {
184
204
  blocked: boolean;
185
205
  decision: HookRunResult["decision"];
206
+ haltsTurn: boolean;
186
207
  };
187
208
  /**
188
209
  * The hook layer over {@link runScriptWith}: serialize the event to stdin, run
package/dist/run-hook.js CHANGED
@@ -130,9 +130,16 @@ function parseHookOutput(stdout) {
130
130
  function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHookProtocol) {
131
131
  const permission = json?.hookSpecificOutput?.permissionDecision;
132
132
  const decision = permission ?? json?.decision;
133
+ // The halt field is read from the PORT, never hard-coded: `"continue"` is a
134
+ // documented Claude Code fact and an unverified one for Codex, so the harness
135
+ // that has it declares it (core ⊄ adapter). `=== false` and not falsy —
136
+ // an absent field must not read as a halt.
137
+ const haltField = protocol.haltsTurnField;
138
+ const haltsTurn = haltField !== undefined && json?.[haltField] === false;
133
139
  const blocked = exitCode === protocol.blockExitCode ||
140
+ haltsTurn ||
134
141
  (decision !== undefined && protocol.denyDecisionValues.includes(decision));
135
- return { blocked, decision };
142
+ return { blocked, decision, haltsTurn };
136
143
  }
137
144
  /**
138
145
  * The hook layer over {@link runScriptWith}: serialize the event to stdin, run
@@ -143,8 +150,8 @@ function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHook
143
150
  function runHookWith(command, input, opts, deps) {
144
151
  const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
145
152
  const json = parseHookOutput(res.stdout);
146
- const { blocked, decision } = decideHook(res.exitCode, json);
147
- return { ...res, json, blocked, decision };
153
+ const { blocked, decision, haltsTurn } = decideHook(res.exitCode, json);
154
+ return { ...res, json, blocked, decision, haltsTurn };
148
155
  }
149
156
  /**
150
157
  * Run a hook command, piping `input` as JSON to its stdin, and report the exit
@@ -30,6 +30,11 @@ export interface SurfaceClassifier {
30
30
  * one. Null for a path this classifier does not call an agent.
31
31
  */
32
32
  readonly agentName: (f: string) => string | null;
33
+ /**
34
+ * A path-scoped RULES file (`<rulesDir>/<name>.md`) — an INSTRUCTION surface,
35
+ * not an invocable one. Always false for a layout with no `rulesDir`.
36
+ */
37
+ readonly isRule: (f: string) => boolean;
33
38
  }
34
39
  export declare function makeClassifier(layout: PluginLayout): SurfaceClassifier;
35
40
  /** The plugin-root + materialize-root + dialect context skill scanning needs. */
package/dist/scan-core.js CHANGED
@@ -146,9 +146,13 @@ function makeClassifier(layout) {
146
146
  const skill = at(layout.skillDir);
147
147
  const agent = at(layout.agentDir);
148
148
  const command = at(layout.commandDir);
149
+ const rules = at(layout.rulesDir ?? "");
149
150
  const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
150
151
  const agentRe = agent ? new RegExp(`${agent}${layout_js_1.AGENT_FILE_LEAF_RE}$`) : null;
151
152
  const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
153
+ // Flat `<rulesDir>/<name>.md`, like commands. A layout without a rules dir
154
+ // yields null and every path below answers false — the additive default.
155
+ const ruleRe = rules ? new RegExp(`${rules}[^/]+\\.md$`) : null;
152
156
  // A subagent lives under the plugin's `agents/` dir AT ANY DEPTH (the harness
153
157
  // reads it recursively — see AGENT_FILE_LEAF_RE for the vendor's wording and
154
158
  // the measurement), but never under ANOTHER surface dir. Two real-world
@@ -191,6 +195,7 @@ function makeClassifier(layout) {
191
195
  isSkill,
192
196
  isAgent,
193
197
  isCommand: (f) => commandRe?.test(f) ?? false,
198
+ isRule: (f) => ruleRe?.test(f) ?? false,
194
199
  agentName: (f) => isAgent(f) ? (0, layout_js_1.agentSurfaceName)(f, layout.agentDir) : null,
195
200
  };
196
201
  }
@@ -746,7 +751,11 @@ function frontmatterValueIssuesFor(files, cls) {
746
751
  function malformedFrontmatterFor(files, cls) {
747
752
  const out = [];
748
753
  for (const [path, md] of Object.entries(files)) {
749
- if (!cls.isSkill(path) && !cls.isAgent(path))
754
+ // Rules join skills + agents here: `.claude/rules/*.md` carries a `paths:`
755
+ // frontmatter key that SCOPES the instruction, so unparseable YAML there
756
+ // silently changes which files the rule applies to — the same defect this
757
+ // check exists for, on a surface no layout named until now (#175.3).
758
+ if (!cls.isSkill(path) && !cls.isAgent(path) && !cls.isRule(path))
750
759
  continue;
751
760
  if (!(0, frontmatter_read_js_1.readFrontmatter)(md).malformed)
752
761
  continue;
@@ -202,26 +202,28 @@ function isColocated(surface, testPath) {
202
202
  return (0, coverage_evidence_js_1.isColocatedTest)(surface, testPath);
203
203
  }
204
204
  /** Mirror of test-coverage.ts `coverageOf` — strongest evidence across tests. */
205
- function coverageOf(surface, tests) {
205
+ function coverageOf(surface, tests, globs) {
206
206
  let best = null;
207
207
  for (const t of tests) {
208
208
  if (t.path === surface.path)
209
209
  continue;
210
- const ev = (0, coverage_evidence_js_1.evidenceFor)(surface, t, isColocated(surface, t.path));
210
+ const ev = (0, coverage_evidence_js_1.evidenceFor)(surface, t, isColocated(surface, t.path), (0, coverage_evidence_js_1.matchesSurfaceGlob)(surface, t.path, globs));
211
211
  if (!ev)
212
212
  continue;
213
- if (!best)
213
+ // Rank, do not first-win: with a colocated harness AND a configured
214
+ // suite the reported provenance must not depend on glob order.
215
+ if (!best || (0, coverage_evidence_js_1.strongerEvidence)(ev, best.evidence))
214
216
  best = { surface, evidence: ev, by: t.path };
215
217
  }
216
218
  return best;
217
219
  }
218
220
  /** Mirror of test-coverage.ts `tierOf` — one tier's covered/untested split. */
219
- function tierOf(considered, tests) {
221
+ function tierOf(considered, tests, globs) {
220
222
  const covered = [];
221
223
  const untested = [];
222
224
  const decisions = [];
223
225
  for (const s of considered) {
224
- const decision = coverageOf(s, tests);
226
+ const decision = coverageOf(s, tests, globs);
225
227
  if (decision) {
226
228
  covered.push(s);
227
229
  decisions.push(decision);
@@ -252,12 +254,19 @@ repoName) {
252
254
  ];
253
255
  const considered = surfaces.filter((s) => !s.ignored);
254
256
  const tests = discoverTests(files);
255
- const union = tierOf(considered, tests);
257
+ // NO configured `{surface}` globs here, and that is a property of this twin
258
+ // rather than a gap: the browser engine reads a file MAP, not a repo, so there
259
+ // is no `.vigilesrc.json` for a user to have configured. Passing an empty list
260
+ // makes `matchesSurfaceGlob` false for everything, so this path behaves exactly
261
+ // as it did before the tier existed. Said out loud because a silent asymmetry
262
+ // between the twins is how they drift.
263
+ const noConfiguredGlobs = [];
264
+ const union = tierOf(considered, tests, noConfiguredGlobs);
256
265
  return {
257
266
  untested: [...union.untested],
258
267
  decisions: union.decisions,
259
- harness: tierOf(considered, tests.filter((t) => !(0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path)))),
260
- evals: tierOf(considered, tests.filter((t) => (0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path)))),
268
+ harness: tierOf(considered, tests.filter((t) => !(0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path))), noConfiguredGlobs),
269
+ evals: tierOf(considered, tests.filter((t) => (0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path))), noConfiguredGlobs),
261
270
  };
262
271
  }
263
272
  //# sourceMappingURL=test-coverage-files.js.map
@@ -273,7 +273,14 @@ function discoverTests(basePath, globs, ignore) {
273
273
  // skill (matched by the explicit-dot `.claude/skills/*/SKILL.md` pattern) is
274
274
  // still discovered — so the surface looks untested even after the user adds
275
275
  // exactly the suggested file. DEFAULT_IGNORE still drops .git/node_modules/etc.
276
- const found = (0, glob_1.globSync)([...globs], { cwd: basePath, ignore, dot: true });
276
+ // `{surface}` is widened to `*` for DISCOVERY so one pass finds every
277
+ // candidate; the narrowing back to a specific surface happens at match time
278
+ // (matchesSurfaceGlob). Globbing once per surface would be quadratic in I/O.
279
+ const found = (0, glob_1.globSync)(globs.map(coverage_evidence_js_1.discoveryGlob), {
280
+ cwd: basePath,
281
+ ignore,
282
+ dot: true,
283
+ });
277
284
  // Prepared ONCE per file (comment-strip + declaration parse), not once per
278
285
  // (surface × file) pair — the matching below is quadratic by nature.
279
286
  return found.map((path) => (0, coverage_evidence_js_1.prepareTest)(path));
@@ -318,15 +325,17 @@ function isColocated(surface, testPath) {
318
325
  * explicitly declared is reported as declared; otherwise the provenance summary
319
326
  * would depend on glob order.
320
327
  */
321
- function coverageOf(surface, tests) {
328
+ function coverageOf(surface, tests, globs) {
322
329
  let best = null;
323
330
  for (const t of tests) {
324
331
  if (t.path === surface.path)
325
332
  continue;
326
- const ev = (0, coverage_evidence_js_1.evidenceFor)(surface, t, isColocated(surface, t.path));
333
+ const ev = (0, coverage_evidence_js_1.evidenceFor)(surface, t, isColocated(surface, t.path), (0, coverage_evidence_js_1.matchesSurfaceGlob)(surface, t.path, globs));
327
334
  if (!ev)
328
335
  continue;
329
- if (!best)
336
+ // Rank, do not first-win: with a colocated harness AND a configured suite
337
+ // the reported provenance must not depend on glob order.
338
+ if (!best || (0, coverage_evidence_js_1.strongerEvidence)(ev, best.evidence))
330
339
  best = { surface, evidence: ev, by: t.path };
331
340
  }
332
341
  return best;
@@ -370,12 +379,12 @@ function executedOf(surface, index, tier) {
370
379
  * first, a colocated test second. Execution outranks the name because the name
371
380
  * was only ever a stand-in for it.
372
381
  */
373
- function tierOf(considered, tests, index, tier) {
382
+ function tierOf(considered, tests, index, tier, globs) {
374
383
  const covered = [];
375
384
  const untested = [];
376
385
  const decisions = [];
377
386
  for (const s of considered) {
378
- const decision = executedOf(s, index, tier) ?? coverageOf(s, tests);
387
+ const decision = executedOf(s, index, tier) ?? coverageOf(s, tests, globs);
379
388
  if (decision) {
380
389
  covered.push(s);
381
390
  decisions.push(decision);
@@ -432,7 +441,7 @@ function findUntestedSurfaces(options = {}) {
432
441
  // file has several legitimate spellings (`x.mjs`, `./x.mjs`, absolute), and
433
442
  // the artifact records whichever one was typed.
434
443
  (by) => (0, node_fs_1.existsSync)((0, node_path_1.join)(basePath, (0, coverage_artifact_js_1.canonicalScript)(by, basePath))));
435
- const union = tierOf(considered, tests, runIndex, undefined);
444
+ const union = tierOf(considered, tests, runIndex, undefined, globs);
436
445
  return {
437
446
  total: considered.length,
438
447
  covered: union.covered,
@@ -456,8 +465,8 @@ function findUntestedSurfaces(options = {}) {
456
465
  .filter((path) => read((0, node_path_1.join)(basePath, path)).includes(LEGACY_COVERS)),
457
466
  retiredTestNames: retiredTestNamesFor(basePath, union.untested),
458
467
  decisions: union.decisions,
459
- harness: tierOf(considered, split.harness, runIndex, "harness"),
460
- evals: tierOf(considered, split.evals, runIndex, "eval"),
468
+ harness: tierOf(considered, split.harness, runIndex, "harness", globs),
469
+ evals: tierOf(considered, split.evals, runIndex, "eval", globs),
461
470
  };
462
471
  }
463
472
  /**
@@ -813,13 +822,17 @@ function formatUntestedReport(report) {
813
822
  lines.push(` ${provenance}`);
814
823
  lines.push(...coverageCaveats(report));
815
824
  // Already testing these another way (a promptfoo suite, a home-grown evals
816
- // file)? Point `testGlobs` at it so it counts toward coverage (issue #113)
817
- // and put the file NEXT TO the surface, which is the only placement that
818
- // counts now. See docs/rules/untested-skill.md.
819
- lines.push(` Testing these another way (promptfoo / a home-grown eval loop)? Add its ` +
820
- `files to \`testGlobs\` in .vigilesrc.json AND name each after the surface ` +
821
- `it covers, next to it (\`<surface>/<surface>.eval.mjs\`) placement says ` +
822
- `where a file sits, the name says what it is about. ` +
825
+ // file)? Two shapes are accepted, and the message names BOTH it used to
826
+ // name only `testGlobs`, which does not by itself make a centralized suite
827
+ // count, so a reader who followed it exactly saw the number not move (#175.2).
828
+ // See docs/rules/untested-skill.md.
829
+ lines.push(` Testing these another way (promptfoo / a home-grown eval loop)? Either ` +
830
+ `put the file NEXT TO the surface and name it after it ` +
831
+ `(\`<surface>/<surface>.eval.mjs\`), or for a centralized layout point ` +
832
+ `\`testGlobs\` at it USING THE \`{surface}\` placeholder, e.g. ` +
833
+ `\`"tests/{surface}/evals/promptfooconfig*.yaml"\`. A \`testGlobs\` entry ` +
834
+ `WITHOUT \`{surface}\` widens what counts as a test file but never says ` +
835
+ `which surface it covers, so it credits nothing on its own. ` +
823
836
  `See docs/rules/untested-skill.md.`);
824
837
  return lines.join("\n");
825
838
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "22.0.0",
3
+ "version": "23.0.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",