vigiles 17.0.0 → 17.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4436,15 +4436,37 @@ function printHelpEntry(v) {
4436
4436
  * The loud "there is nothing here to audit" block. Deliberately says WHAT was
4437
4437
  * looked at and WHY it found nothing, because the commonest cause is that the
4438
4438
  * target isn't the directory the operator thinks it is.
4439
+ *
4440
+ * Two causes are named apart from the generic one, because they are the two the
4441
+ * generic wording actively MISDESCRIBES:
4442
+ *
4443
+ * - the path does not exist — "no surface was found there" reads as a verdict
4444
+ * on a real directory, so a typo'd dir in a multi-dir leaderboard run looked
4445
+ * like a legitimately empty repo. One `existsSync` separates them.
4446
+ * - the path is a CURATED marketplace — it has members, they are all external,
4447
+ * and the useful next step is to clone one. This advice used to live in a
4448
+ * branch that pre-empted the audit entirely (see the `audit` case); it
4449
+ * belongs here, where the directory has genuinely been looked at first.
4439
4450
  */
4440
- function formatNothingToAudit(root, harness) {
4441
- return [
4451
+ function formatNothingToAudit(root, harness, market) {
4452
+ if (!(0, node_fs_1.existsSync)(root)) {
4453
+ return [
4454
+ `✗ vigiles audit: ${root} does not exist`,
4455
+ " Nothing was scanned — this is NOT a grade, and NOT an empty repo.",
4456
+ " Check the path, and that a flag didn't swallow it",
4457
+ " (flags take values with `=`: `--out=dir`, never `--out dir`).",
4458
+ ].join("\n");
4459
+ }
4460
+ const lines = [
4442
4461
  `✗ vigiles audit: nothing to audit in ${root}`,
4443
4462
  ` No instruction file and no ${harness} surface (skills / subagents / hooks / commands / MCP) was found there.`,
4444
4463
  " This is NOT a grade — there was nothing to measure, so no score is reported.",
4445
- " Check that the path is the repo you meant, and that a flag didn't swallow it",
4446
- " (flags take values with `=`: `--out=dir`, never `--out dir`).",
4447
- ].join("\n");
4464
+ ];
4465
+ if (market && market.onDisk.length === 0 && market.total > 0) {
4466
+ lines.push(` It is the marketplace "${market.name}": ${String(market.total)} plugin(s), all external (url/git sources, not on disk).`, " Clone a member plugin and scan that, or scan a marketplace that vendors its plugins in-tree.");
4467
+ }
4468
+ lines.push(" Check that the path is the repo you meant, and that a flag didn't swallow it", " (flags take values with `=`: `--out=dir`, never `--out dir`).");
4469
+ return lines.join("\n");
4448
4470
  }
4449
4471
  /** `vigiles <verb> --help` — that verb's entry plus its complete flag list. */
4450
4472
  function printCommandHelp(command) {
@@ -6151,26 +6173,36 @@ async function main() {
6151
6173
  // A single dir that's a marketplace (e.g. wshobson/agents' 80+ plugins
6152
6174
  // under one marketplace.json) expands into its members and ranks them.
6153
6175
  const market = dirs.length === 1 ? (0, scan_js_1.inspectMarketplace)((0, node_path_1.resolve)(dirs[0])) : null;
6176
+ // A marketplace whose members are all EXTERNAL expands to nothing, and the
6177
+ // fallback below already says what to do about that: the target is the
6178
+ // directory itself.
6179
+ //
6180
+ // 🔴 THERE USED TO BE A BRANCH HERE that printed "nothing to scan" and
6181
+ // returned at exit 0 whenever the expansion came back empty — before
6182
+ // looking at the directory at all. A `marketplace.json` is a statement
6183
+ // about OTHER directories; it says nothing about this one, and a directory
6184
+ // is free to be a plugin AND ship a marketplace listing external members.
6185
+ // Measured 2026-08-18 on `nyldn/claude-octopus` @ `57cfb9b0` (3,979 stars):
6186
+ // the branch suppressed the whole audit — 59 skills, 50 subagents — because
6187
+ // `.claude-plugin/` held a `marketplace.json` NEXT TO the `plugin.json`
6188
+ // naming that same directory. It now grades D (60/100), and the check that
6189
+ // this branch is really gone is that deleting the `marketplace.json` gives
6190
+ // the byte-identical report: the file no longer changes the outcome at all.
6191
+ //
6192
+ // (An earlier journal recorded that delete-one-file experiment as B (85/100).
6193
+ // That was measured before `agents/` was read recursively, when all 50 of
6194
+ // this plugin's subagents lived in subdirectories and none were visible. Both
6195
+ // numbers are real; they differ by the other fix, not by this one.)
6196
+ //
6197
+ // Exit 0 was the other half: a repo that was never scanned exited
6198
+ // byte-identically to a repo that was scanned and found clean, so an
6199
+ // automated leaderboard dropped it silently and CI went green. A genuinely
6200
+ // curated marketplace now reaches the `score.empty` branch below, which
6201
+ // carries `market` into its explanation and exits 2 — this repo's own rule
6202
+ // that 1 is "I measured, and it's bad" and 2 is "I could not do what you
6203
+ // asked". Nothing was measured here, so it is a 2.
6154
6204
  const targets = market && market.onDisk.length > 0 ? [...market.onDisk] : dirs;
6155
- if (market && market.onDisk.length === 0 && market.total > 0) {
6156
- // A CURATED marketplace — every member is an external git/url plugin, so
6157
- // there's nothing on disk to scan. Say so honestly instead of falling
6158
- // through to a misleading "empty machine / no structural issues" report
6159
- // (obra/superpowers-marketplace, anthropics/claude-plugins-community).
6160
- if (json) {
6161
- console.log(JSON.stringify((0, audit_report_js_1.buildMarketplaceReport)(market, {
6162
- vigilesVersion: getVersion(),
6163
- dir: (0, node_path_1.resolve)(dirs[0]),
6164
- }), null, 2));
6165
- }
6166
- else {
6167
- console.log(`Marketplace "${market.name}": ${String(market.total)} plugin(s), all external ` +
6168
- `(url/git sources, not on disk).\n` +
6169
- `Nothing to scan here — clone a member plugin and scan that, or scan a ` +
6170
- `marketplace that vendors its plugins in-tree.`);
6171
- }
6172
- }
6173
- else if (targets.length > 1) {
6205
+ if (targets.length > 1) {
6174
6206
  // Multiple targets → rank them (the leaderboard engine). `--md` emits the
6175
6207
  // publishable Markdown table (a README / gist / the leaderboard site).
6176
6208
  const scores = (0, leaderboard_js_1.rankPlugins)(targets);
@@ -6304,9 +6336,20 @@ async function main() {
6304
6336
  // `score.empty: true`), so a machine consumer keeps its contract; the
6305
6337
  // human-readable explanation goes to stderr either way.
6306
6338
  if (sc.empty) {
6339
+ // A curated marketplace lands here now that the early return is gone:
6340
+ // it really has nothing of its own to audit, which is what this branch
6341
+ // is for. `--json` keeps emitting the `kind:"marketplace"` envelope for
6342
+ // exactly that case, so the discriminant still describes what it always
6343
+ // described — a marketplace with no on-disk members — and no longer
6344
+ // doubles as "a directory we declined to look at".
6307
6345
  if (json)
6308
- console.log(JSON.stringify(auditReportBase, null, 2));
6309
- console.error(formatNothingToAudit(root, adapter.name));
6346
+ console.log(JSON.stringify(market && market.onDisk.length === 0 && market.total > 0
6347
+ ? (0, audit_report_js_1.buildMarketplaceReport)(market, {
6348
+ vigilesVersion: getVersion(),
6349
+ dir: root,
6350
+ })
6351
+ : auditReportBase, null, 2));
6352
+ console.error(formatNothingToAudit(root, adapter.name, market));
6310
6353
  process.exit(2);
6311
6354
  }
6312
6355
  const plan = (0, optimize_js_1.optimize)(report);
@@ -42,7 +42,12 @@ export interface PluginLayout {
42
42
  readonly userSurfaceRoot?: string;
43
43
  /** Skills dir, holding the nested `<dir>/<name>/SKILL.md`, e.g. `skills`. */
44
44
  readonly skillDir: string;
45
- /** Subagents dir, holding flat `<dir>/<name>.md`, e.g. `agents` (`""` = none). */
45
+ /**
46
+ * Subagents dir, holding `<dir>/<name>.md` at ANY depth, e.g. `agents`
47
+ * (`""` = none). The depth rule, and the identifier that depth implies, are
48
+ * stated once in {@link AGENT_FILE_LEAF_RE} and {@link agentSurfaceName} —
49
+ * read those before writing a fourth thing that walks this dir.
50
+ */
46
51
  readonly agentDir: string;
47
52
  /** Slash-commands dir, holding flat `<dir>/<name>.md`, e.g. `commands`. */
48
53
  readonly commandDir: string;
@@ -70,4 +75,52 @@ export interface PluginLayout {
70
75
  /** Dirs scanned for dangling intra-plugin file references. */
71
76
  readonly intraRefDirs: readonly string[];
72
77
  }
78
+ /**
79
+ * How DEEP a harness reads its {@link PluginLayout.agentDir} — the one statement
80
+ * of that rule, as a RegExp source fragment matching the part of a path AFTER
81
+ * `<agentDir>/`. Anchor-free on purpose, so each caller can bound it its own way
82
+ * (`(?:^|/)agents/` + this + `$` in the scan classifier; `^<prefix>/` + this +
83
+ * `$` in the coverage discoverers).
84
+ *
85
+ * 🔴 IT USED TO SAY `[^/]+`, in THREE independent places, and the vendor
86
+ * documents the opposite. Verbatim from `https://code.claude.com/docs/en/sub-agents`:
87
+ *
88
+ * > Claude Code scans `.claude/agents/` and `~/.claude/agents/` **recursively**,
89
+ * > so you can organize definitions into subfolders such as `agents/review/` or
90
+ * > `agents/research/`.
91
+ *
92
+ * > **Plugin `agents/` directories are also scanned recursively.** Unlike project
93
+ * > and user scopes, a subfolder inside a plugin's `agents/` directory becomes
94
+ * > part of the scoped identifier: a file at `agents/review/security.md` in
95
+ * > plugin `my-plugin` registers as `my-plugin:review:security`.
96
+ *
97
+ * Measured 2026-08-18 on `rsmdt/the-startup` @ `88d447c7`: 16 agent files under
98
+ * `plugins/team/agents/`, 2 read. The plugin was still GRADED — B (80/100) over
99
+ * 12.5% of its subagents — so the number was not merely incomplete, it was
100
+ * flattering. Twelve real malformed-frontmatter defects sat in the unread 87.5%.
101
+ *
102
+ * The three readers are the scan classifier (`makeClassifier`, scan-core.ts) and
103
+ * the two coverage discoverers (`test-coverage.ts`, `test-coverage-files.ts`).
104
+ * They disagreed silently because each spelled the rule itself; they now quote
105
+ * this. A fourth reader that hard-codes a depth is the defect coming back.
106
+ */
107
+ export declare const AGENT_FILE_LEAF_RE = "(?:.+/)?[^/]+\\.md";
108
+ /**
109
+ * A subagent's identity, per the same docs paragraph: the path under
110
+ * `<agentDir>/` with `/` → `:` and the `.md` dropped, so plugin
111
+ * `agents/review/security.md` is `review:security` (the scoped identifier minus
112
+ * its plugin prefix, which the scan of a single plugin dir does not know).
113
+ *
114
+ * 🔴 NOT COSMETIC — it is what keeps recursion from introducing a defect of its
115
+ * own. A basename cannot be unique once the dir is read recursively:
116
+ * `agents/a/review.md` and `agents/b/review.md` would both be "review", and the
117
+ * delegation graph keys agents BY NAME (`pathByName`, `delegatesTo`), so one
118
+ * would silently swallow the other's path and neither would delegate to its
119
+ * namesake. A path-derived name is unique by construction, so that collision has
120
+ * nowhere to live. Degenerates to today's basename for a top-level agent, which
121
+ * is why no existing report changes.
122
+ *
123
+ * Returns null when `path` holds no `<agentDir>/` segment (not an agent file).
124
+ */
125
+ export declare function agentSurfaceName(path: string, agentDir: string): string | null;
73
126
  //# sourceMappingURL=layout.d.ts.map
@@ -1,3 +1,78 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_FILE_LEAF_RE = void 0;
4
+ exports.agentSurfaceName = agentSurfaceName;
5
+ /**
6
+ * How DEEP a harness reads its {@link PluginLayout.agentDir} — the one statement
7
+ * of that rule, as a RegExp source fragment matching the part of a path AFTER
8
+ * `<agentDir>/`. Anchor-free on purpose, so each caller can bound it its own way
9
+ * (`(?:^|/)agents/` + this + `$` in the scan classifier; `^<prefix>/` + this +
10
+ * `$` in the coverage discoverers).
11
+ *
12
+ * 🔴 IT USED TO SAY `[^/]+`, in THREE independent places, and the vendor
13
+ * documents the opposite. Verbatim from `https://code.claude.com/docs/en/sub-agents`:
14
+ *
15
+ * > Claude Code scans `.claude/agents/` and `~/.claude/agents/` **recursively**,
16
+ * > so you can organize definitions into subfolders such as `agents/review/` or
17
+ * > `agents/research/`.
18
+ *
19
+ * > **Plugin `agents/` directories are also scanned recursively.** Unlike project
20
+ * > and user scopes, a subfolder inside a plugin's `agents/` directory becomes
21
+ * > part of the scoped identifier: a file at `agents/review/security.md` in
22
+ * > plugin `my-plugin` registers as `my-plugin:review:security`.
23
+ *
24
+ * Measured 2026-08-18 on `rsmdt/the-startup` @ `88d447c7`: 16 agent files under
25
+ * `plugins/team/agents/`, 2 read. The plugin was still GRADED — B (80/100) over
26
+ * 12.5% of its subagents — so the number was not merely incomplete, it was
27
+ * flattering. Twelve real malformed-frontmatter defects sat in the unread 87.5%.
28
+ *
29
+ * The three readers are the scan classifier (`makeClassifier`, scan-core.ts) and
30
+ * the two coverage discoverers (`test-coverage.ts`, `test-coverage-files.ts`).
31
+ * They disagreed silently because each spelled the rule itself; they now quote
32
+ * this. A fourth reader that hard-codes a depth is the defect coming back.
33
+ */
34
+ exports.AGENT_FILE_LEAF_RE = "(?:.+/)?[^/]+\\.md";
35
+ /**
36
+ * A subagent's identity, per the same docs paragraph: the path under
37
+ * `<agentDir>/` with `/` → `:` and the `.md` dropped, so plugin
38
+ * `agents/review/security.md` is `review:security` (the scoped identifier minus
39
+ * its plugin prefix, which the scan of a single plugin dir does not know).
40
+ *
41
+ * 🔴 NOT COSMETIC — it is what keeps recursion from introducing a defect of its
42
+ * own. A basename cannot be unique once the dir is read recursively:
43
+ * `agents/a/review.md` and `agents/b/review.md` would both be "review", and the
44
+ * delegation graph keys agents BY NAME (`pathByName`, `delegatesTo`), so one
45
+ * would silently swallow the other's path and neither would delegate to its
46
+ * namesake. A path-derived name is unique by construction, so that collision has
47
+ * nowhere to live. Degenerates to today's basename for a top-level agent, which
48
+ * is why no existing report changes.
49
+ *
50
+ * Returns null when `path` holds no `<agentDir>/` segment (not an agent file).
51
+ */
52
+ function agentSurfaceName(path, agentDir) {
53
+ if (!agentDir)
54
+ return null;
55
+ const marker = `${agentDir}/`;
56
+ // The FIRST occurrence sitting at a real path boundary — start-of-path or just
57
+ // after a `/`. Both halves matter and one of them is easy to get wrong:
58
+ // requiring the boundary stops `my-agents/x.md` being read as `agents/x.md`,
59
+ // and CONTINUING the search past a non-boundary hit is what keeps this
60
+ // agreeing with the classifier, whose `(?:^|/)agents/` skips the same way.
61
+ // Taking `indexOf` once and rejecting it would return null for
62
+ // `myagents/x/agents/y.md` — a path the classifier calls an agent — so the two
63
+ // would disagree about the very file they are both looking at.
64
+ let at = -1;
65
+ for (let i = path.indexOf(marker); i !== -1; i = path.indexOf(marker, i + 1)) {
66
+ if (i === 0 || path[i - 1] === "/") {
67
+ at = i;
68
+ break;
69
+ }
70
+ }
71
+ if (at === -1)
72
+ return null;
73
+ const tail = path.slice(at + marker.length);
74
+ if (tail === "" || !tail.endsWith(".md"))
75
+ return null;
76
+ return tail.slice(0, -".md".length).split("/").join(":");
77
+ }
3
78
  //# sourceMappingURL=layout.js.map
@@ -2,7 +2,7 @@ import type { HookEventIssue } from "./core/hook-events.js";
2
2
  import { type DescriptionOverlap } from "./core/description-overlap.js";
3
3
  import { type DescriptionBudgetIssue } from "./core/skill-description-budget.js";
4
4
  import type { SkillRefSource } from "./skill-refs.js";
5
- import type { PluginLayout } from "./core/layout.js";
5
+ import { type PluginLayout } from "./core/layout.js";
6
6
  import type { HarnessDialect } from "./core/dialect.js";
7
7
  import type { HookRegistration } from "./core/hook-normalize.js";
8
8
  import type { HookScriptEntry } from "./core/hook-block-ineffective.js";
@@ -22,6 +22,14 @@ export interface SurfaceClassifier {
22
22
  readonly isSkill: (f: string) => boolean;
23
23
  readonly isAgent: (f: string) => boolean;
24
24
  readonly isCommand: (f: string) => boolean;
25
+ /**
26
+ * The subagent identity for a path `isAgent` accepts — the layout-scoped name
27
+ * from {@link agentSurfaceName}. Lives here because the classifier is already
28
+ * the one thing holding the layout's surface dirs; a caller deriving the name
29
+ * itself would need the layout too, and would be free to derive a different
30
+ * one. Null for a path this classifier does not call an agent.
31
+ */
32
+ readonly agentName: (f: string) => string | null;
25
33
  }
26
34
  export declare function makeClassifier(layout: PluginLayout): SurfaceClassifier;
27
35
  /** The plugin-root + materialize-root + dialect context skill scanning needs. */
package/dist/scan-core.js CHANGED
@@ -52,6 +52,7 @@ const skill_missing_fence_js_1 = require("./core/skill-missing-fence.js");
52
52
  const delegation_trifecta_js_1 = require("./core/delegation-trifecta.js");
53
53
  const effects_js_1 = require("./core/effects.js");
54
54
  const agent_tools_js_1 = require("./adapters/claude-code/agent-tools.js");
55
+ const layout_js_1 = require("./core/layout.js");
55
56
  // A script-path token, matched against a WHOLE shell WORD. The token class is
56
57
  // `\S` MINUS the glob metacharacters `*` and `?` (dogfood D1): a real, resolvable
57
58
  // hook path never contains them, but a command that merely MENTIONS a glob — e.g.
@@ -146,18 +147,21 @@ function makeClassifier(layout) {
146
147
  const agent = at(layout.agentDir);
147
148
  const command = at(layout.commandDir);
148
149
  const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
149
- const agentRe = agent ? new RegExp(`${agent}[^/]+\\.md$`) : null;
150
+ const agentRe = agent ? new RegExp(`${agent}${layout_js_1.AGENT_FILE_LEAF_RE}$`) : null;
150
151
  const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
151
- // A subagent lives at the plugin's TOP-LEVEL `agents/` dir (e.g. `agents/foo.md`
152
- // or `.claude/agents/foo.md`), never recursively under ANOTHER surface dir. Two
153
- // real-world nesting traps are excluded as false positives:
152
+ // A subagent lives under the plugin's `agents/` dir AT ANY DEPTH (the harness
153
+ // reads it recursively see AGENT_FILE_LEAF_RE for the vendor's wording and
154
+ // the measurement), but never under ANOTHER surface dir. Two real-world
155
+ // nesting traps are excluded as false positives:
154
156
  // - `skills/<x>/agents/…` — skill-internal worker docs (Anthropic's skill-creator)
155
157
  // - `commands/agents/…` — a COMMAND namespaced `/agents:…` (ruvnet/claude-flow),
156
158
  // incl. a `README.md`; these are commands, not dispatchable subagents.
157
159
  // Flagging either as a subagent missing frontmatter is a false positive (it
158
- // mis-graded a real plugin F). A genuine top-level `agents/foo.md` still
159
- // matches. Both excluded dirs are read from the layout (adapter-agnostic). See
160
- // scan.test.ts for the regressions.
160
+ // mis-graded a real plugin F). A genuine `agents/foo.md` — or `agents/x/foo.md`
161
+ // — still matches. Both excluded dirs are read from the layout
162
+ // (adapter-agnostic), and both patterns already tolerate depth on BOTH sides of
163
+ // the `agents/` segment, so the recursion above does not leak through them.
164
+ // See scan.test.ts for the regressions.
161
165
  const nestedUnder = [
162
166
  layout.skillDir &&
163
167
  `${escapeRe(layout.skillDir)}/.+/${escapeRe(layout.agentDir)}/`,
@@ -170,10 +174,24 @@ function makeClassifier(layout) {
170
174
  const isAgent = (f) => (agentRe?.test(f) ?? false) &&
171
175
  !f.endsWith(".spec.ts") &&
172
176
  !(nestedAgentRe?.test(f) ?? false);
177
+ // The MIRROR of the rule above, and it exists because reading `agents/`
178
+ // recursively made a new shape reachable: `agents/<x>/skills/<y>/SKILL.md` now
179
+ // matches the agent pattern, and it always matched the skill pattern, so the
180
+ // one file would be counted as BOTH — inflating two surface counts and grading
181
+ // it twice. The harness resolves this the same way the existing exclusion
182
+ // does: it reads skills from the plugin's OWN `skills/` dir, and reads every
183
+ // `.md` under `agents/` recursively — so this file is a subagent, and is not a
184
+ // skill. Excluding it here (rather than excluding it from agents) is what keeps
185
+ // the two classifiers disjoint AND agreeing with the harness.
186
+ const nestedSkillRe = layout.skillDir && layout.agentDir
187
+ ? new RegExp(`(?:^|/)${escapeRe(layout.agentDir)}/(?:.+/)?${escapeRe(layout.skillDir)}/`)
188
+ : null;
189
+ const isSkill = (f) => (skillRe?.test(f) ?? false) && !(nestedSkillRe?.test(f) ?? false);
173
190
  return {
174
- isSkill: (f) => skillRe?.test(f) ?? false,
191
+ isSkill,
175
192
  isAgent,
176
193
  isCommand: (f) => commandRe?.test(f) ?? false,
194
+ agentName: (f) => isAgent(f) ? (0, layout_js_1.agentSurfaceName)(f, layout.agentDir) : null,
177
195
  };
178
196
  }
179
197
  function skillName(path) {
@@ -443,7 +461,10 @@ ctx) {
443
461
  // so they cannot disagree about what the vocabulary said.
444
462
  const vocabIssues = tools ? (0, tool_contract_js_1.verifyToolContract)(tools, dialect) : [];
445
463
  out.push({
446
- name: (0, posix_path_js_1.basename)(path, ".md"),
464
+ // The layout-scoped identity, NOT the basename — see `agentSurfaceName`
465
+ // for why recursion makes a basename unsafe here. Identical to the
466
+ // basename for a top-level agent, so no existing report moves.
467
+ name: cls.agentName(path) ?? (0, posix_path_js_1.basename)(path, ".md"),
447
468
  path: ctx
448
469
  ? reportedSurfacePath(path, ctx.sources?.[path], ctx.root)
449
470
  : path,
@@ -1,4 +1,4 @@
1
- import type { PluginLayout } from "./core/layout.js";
1
+ import { type PluginLayout } from "./core/layout.js";
2
2
  import type { CoverageDecision, CoverageTier, Surface } from "./test-coverage.js";
3
3
  /**
4
4
  * The untested harness surfaces (skills / agents / hooks) in a file map — the
@@ -32,6 +32,7 @@ exports.findUntestedSurfacesInFiles = findUntestedSurfacesInFiles;
32
32
  * would report a measurement nobody in this process made.
33
33
  */
34
34
  const posix_path_js_1 = require("./posix-path.js");
35
+ const layout_js_1 = require("./core/layout.js");
35
36
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
36
37
  // Mirrors src/test-coverage.ts constants. VALUES are re-declared, never imported
37
38
  // — test-coverage.ts pulls in node:fs/glob, and this twin must stay browser-safe.
@@ -123,11 +124,15 @@ function discoverAgents(files, layout) {
123
124
  if (!layout.agentDir)
124
125
  return out;
125
126
  const prefixes = surfacePrefixes(layout.agentDir, layout.materializeRoot);
126
- for (const path of matchSurface(files, prefixes, "[^/]+\\.md")) {
127
+ // Same depth rule as the scan classifier — quoted from AGENT_FILE_LEAF_RE, not
128
+ // respelled. This discoverer feeds the `Tested` metric; when it disagreed with
129
+ // the classifier, `audit` printed a subagent count and an untested-surface
130
+ // count derived from two different sets of files.
131
+ for (const path of matchSurface(files, prefixes, layout_js_1.AGENT_FILE_LEAF_RE)) {
127
132
  if (path.endsWith(".spec.ts"))
128
133
  continue;
129
134
  const content = files[path];
130
- const name = (0, posix_path_js_1.basename)(path, ".md");
135
+ const name = (0, layout_js_1.agentSurfaceName)(path, layout.agentDir) ?? (0, posix_path_js_1.basename)(path, ".md");
131
136
  const dir = (0, posix_path_js_1.dirname)(path);
132
137
  out.push({
133
138
  kind: "agent",
@@ -60,7 +60,7 @@
60
60
  * magnitude in cost without saying which. {@link UntestedReport} therefore carries
61
61
  * a per-tier {@link CoverageTier} alongside the (unchanged) union fields.
62
62
  */
63
- import type { PluginLayout } from "./core/layout.js";
63
+ import { type PluginLayout } from "./core/layout.js";
64
64
  import { type CoverageEvidence, type EvidenceCounts } from "./coverage-evidence.js";
65
65
  export type SurfaceKind = "skill" | "agent" | "hook";
66
66
  export interface Surface {
@@ -75,7 +75,8 @@ const test_file_ext_js_1 = require("./core/test-file-ext.js");
75
75
  const assert_never_js_1 = require("./core/assert-never.js");
76
76
  const ts_runner_caps_js_1 = require("./ts-runner-caps.js");
77
77
  const glob_1 = require("glob");
78
- const layout_js_1 = require("./adapters/claude-code/layout.js");
78
+ const layout_js_1 = require("./core/layout.js");
79
+ const layout_js_2 = require("./adapters/claude-code/layout.js");
79
80
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
80
81
  const coverage_artifact_js_1 = require("./coverage-artifact.js");
81
82
  /**
@@ -193,12 +194,33 @@ function discoverSkills(basePath, ignore, layout) {
193
194
  const LEGACY_COVERS = "vigiles:covers";
194
195
  function discoverAgents(basePath, ignore, layout) {
195
196
  const out = [];
196
- const found = (0, glob_1.globSync)(surfaceGlobs(layout.agentDir, "*.md", layout.materializeRoot), { cwd: basePath, ignore });
197
+ // The glob is a COARSE FETCH deliberately wider than the rule — and
198
+ // AGENT_FILE_LEAF_RE decides. Spelling the depth rule a second time in glob
199
+ // dialect is exactly how these two discoverers drifted from the scan
200
+ // classifier in the first place, so only one dialect is authoritative and the
201
+ // other is allowed to over-match.
202
+ const found = (0, glob_1.globSync)(surfaceGlobs(layout.agentDir, "**/*.md", layout.materializeRoot), { cwd: basePath, ignore });
203
+ const prefixes = layout.materializeRoot
204
+ ? [layout.agentDir, `${layout.materializeRoot}/${layout.agentDir}`]
205
+ : [layout.agentDir];
206
+ const isAgentFile = layout.agentDir
207
+ ? new RegExp(`^(?:${[...new Set(prefixes)]
208
+ .map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
209
+ .join("|")})/${layout_js_1.AGENT_FILE_LEAF_RE}$`)
210
+ : null;
197
211
  for (const path of found.sort()) {
198
212
  if (path.endsWith(".spec.ts"))
199
213
  continue;
214
+ // Both the depth rule and the scoped name are written in POSIX (`/`), so
215
+ // normalize ONCE and derive both from the same string. Normalizing for one
216
+ // and not the other is how a Windows checkout would match the pattern and
217
+ // then fail to find `agents/` when naming — silently falling back to a
218
+ // basename, re-introducing the collision the scoped name exists to prevent.
219
+ const rel = path.split(node_path_1.sep).join("/");
220
+ if (isAgentFile && !isAgentFile.test(rel))
221
+ continue;
200
222
  const content = read((0, node_path_1.join)(basePath, path));
201
- const name = (0, node_path_1.basename)(path, ".md");
223
+ const name = (0, layout_js_1.agentSurfaceName)(rel, layout.agentDir) ?? (0, node_path_1.basename)(rel, ".md");
202
224
  const dir = (0, node_path_1.dirname)(path);
203
225
  out.push({
204
226
  kind: "agent",
@@ -381,7 +403,7 @@ function tierOf(considered, tests, index, tier) {
381
403
  */
382
404
  function findUntestedSurfaces(options = {}) {
383
405
  const basePath = options.basePath ?? process.cwd();
384
- const layout = options.layout ?? layout_js_1.claudeCodeLayout;
406
+ const layout = options.layout ?? layout_js_2.claudeCodeLayout;
385
407
  const ignore = [...DEFAULT_IGNORE, ...(options.exclude ?? [])];
386
408
  const globs = options.testGlobs ?? DEFAULT_TEST_GLOBS;
387
409
  const surfaces = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "17.0.0",
3
+ "version": "17.0.2",
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",