vigiles 17.0.1 → 18.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.
@@ -303,12 +303,45 @@ function tested(r) {
303
303
  "your own test setup detected — vigiles-native skill coverage is optional",
304
304
  ]
305
305
  : findings;
306
+ // 🔴 COVERED BY PLACEMENT ALONE — the execution tier, finally said out loud.
307
+ //
308
+ // `colocated` evidence means a test file NAMED after the surface sits BESIDE it. That is
309
+ // a claim about the filesystem, and the provenance line already admits it ("this says the
310
+ // file EXISTS, not that it ran"). Nobody reads a provenance line. When `executed` is zero
311
+ // across the whole corpus, EVERY surface counted here rests on a filename, and the number
312
+ // above reads as health it has not earned.
313
+ //
314
+ // Measured in a real consumer 2026-08-18: 26 colocated, 0 executed — while its CI invoked
315
+ // exactly those harnesses in a job that never installed the `claude` CLI, so each one
316
+ // called `skip()` and the step reported success. Coverage looked fine the entire time.
317
+ //
318
+ // Deliberately NOT "your CI does not run these": that needs parsing CI config, and the
319
+ // grep-shaped version accuses a healthy repo, because real workflows invoke harnesses by
320
+ // GLOB and name no file. This says only what the run records say, so it cannot be wrong
321
+ // about a repo it has not looked at.
322
+ //
323
+ // ⚠️ KNOWN LIMIT, stated rather than hidden: the threshold is CORPUS-WIDE, so ONE recorded
324
+ // run anywhere silences it for every surface. That is what the data supports — the evidence
325
+ // this receives is an aggregate tally, not a per-surface verdict — and the same consumer
326
+ // demonstrates the cost: it read 0 executed / 26 colocated in the morning and 16 / 26 by
327
+ // evening, after which twenty-six surfaces resting on a filename would no longer be named.
328
+ // Sharpening this means carrying evidence per surface, which is a change to the producer,
329
+ // not to this sentence. Until then it catches the state that actually shipped (a corpus
330
+ // where the tier is entirely absent) and stays quiet the moment the tier exists at all.
331
+ const ev = r.coverageEvidence;
332
+ const placementOnly = ev && ev.executed === 0 && ev.colocated > 0
333
+ ? [
334
+ ...contextualized,
335
+ `${String(ev.colocated)} surface(s) counted as covered by PLACEMENT only — ` +
336
+ `no run on record ever exercised one`,
337
+ ]
338
+ : contextualized;
306
339
  return {
307
340
  key: "Tested",
308
341
  score,
309
342
  weight: 1,
310
343
  advisory: true,
311
- findings: contextualized,
344
+ findings: placementOnly,
312
345
  };
313
346
  }
314
347
  /**
package/dist/cli.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * vigiles CLI — compile typed specs to instruction files.
3
+ * vigiles CLI — verify your agent harness is real, and prove it works.
4
4
  *
5
- * Commands:
6
- * vigiles init — scaffold a spec from scratch
7
- * vigiles compile compile .spec.ts .md with linter verification
8
- * vigiles lint — verify hashes, report coverage, detect duplicates
9
- * vigiles generate types — emit .d.ts with types from project state
5
+ * The verbs and their one-liners live in ONE place, `COMMAND_HELP` + `HELP_GROUPS`
6
+ * near the bottom of this file, and `--help` prints from that table. A second list
7
+ * here would be a copy that rots this docblock WAS that copy: it named four
8
+ * commands and omitted `audit`, `test`, `eval` and `eject`, four of the eight, and
9
+ * `self-command-refs.test.ts` did not catch it because it guards against refs to
10
+ * REMOVED commands, not against a list that merely stops growing.
10
11
  */
11
12
  export {};
12
13
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * vigiles CLI — compile typed specs to instruction files.
4
+ * vigiles CLI — verify your agent harness is real, and prove it works.
5
5
  *
6
- * Commands:
7
- * vigiles init — scaffold a spec from scratch
8
- * vigiles compile compile .spec.ts .md with linter verification
9
- * vigiles lint — verify hashes, report coverage, detect duplicates
10
- * vigiles generate types — emit .d.ts with types from project state
6
+ * The verbs and their one-liners live in ONE place, `COMMAND_HELP` + `HELP_GROUPS`
7
+ * near the bottom of this file, and `--help` prints from that table. A second list
8
+ * here would be a copy that rots this docblock WAS that copy: it named four
9
+ * commands and omitted `audit`, `test`, `eval` and `eject`, four of the eight, and
10
+ * `self-command-refs.test.ts` did not catch it because it guards against refs to
11
+ * REMOVED commands, not against a list that merely stops growing.
11
12
  */
12
13
  Object.defineProperty(exports, "__esModule", { value: true });
13
14
  const node_fs_1 = require("node:fs");
@@ -4291,6 +4292,32 @@ async function handleRunScripts(kind, args, restArgs) {
4291
4292
  process.exit(1);
4292
4293
  }
4293
4294
  if (files.length === 0) {
4295
+ // 🔴 ASKING FOR SOMETHING AND GETTING NOTHING IS A FAILURE; FINDING NOTHING IS NOT.
4296
+ // The two cases were collapsed into one silent exit 0, and the collapse cost a real
4297
+ // repository three days of green CI verifying zero files: a named step ran
4298
+ // `vigiles test .claude/pipeline/skills.harness.mjs` after that file had been split
4299
+ // into one-per-skill, printed "No **/*.harness.* files found" and passed, right next
4300
+ // to a step that was red for the same root cause.
4301
+ //
4302
+ // They are different states. A POSITIONAL argument is a claim that something is there —
4303
+ // when nothing matches it, the path is stale, the glob is wrong, or the run never
4304
+ // reached its target, and every one of those is a defect. Bare discovery finding
4305
+ // nothing is just an empty repository, which is a legitimate place to stand and must
4306
+ // stay quiet.
4307
+ //
4308
+ // This is the default the field settled on: Jest and Vitest FAIL on no tests found and
4309
+ // make you opt in with `--passWithNoTests`; pytest exits 5. `--min=0` remains the
4310
+ // explicit opt-out here, so no new flag is introduced by this change.
4311
+ // `minFlag`, not `minRequired`: 0 is both the DEFAULT and the explicit opt-out, so the
4312
+ // VALUE cannot tell them apart — only the flag's presence can. (Caught by a control:
4313
+ // the first version read `minRequired === 0` and made `--min=0` do nothing.)
4314
+ if (restArgs.length > 0 && minFlag === undefined) {
4315
+ console.error(`✗ vigiles ${kind}: ${String(restArgs.length)} target(s) given and NOTHING matched — ` +
4316
+ `${restArgs.join(", ")}\n` +
4317
+ ` Nothing ran. A stale path, a wrong glob, or a moved file all look like this.\n` +
4318
+ ` If an empty match is expected here, say so with --min=0.`);
4319
+ process.exit(1);
4320
+ }
4294
4321
  console.log(`No ${defaultGlob} files found.`);
4295
4322
  return;
4296
4323
  }
@@ -4380,35 +4407,42 @@ function capabilitiesOfReport(report, dialect) {
4380
4407
  }
4381
4408
  const COMMAND_HELP = {
4382
4409
  init: {
4383
- usage: " vigiles init [flags] Setup project (--ci-only for the CI gate only; --lint, --test, --harness=, --strict, --report-only, --no-gha, --force)",
4410
+ usage: " vigiles init [flags] Set up this repo specs, plugin, and CI.",
4384
4411
  },
4385
- compile: { usage: " vigiles compile [files...] Compile .spec.ts → .md" },
4412
+ compile: { usage: " vigiles compile [files...] Compile .spec.ts → .md" },
4386
4413
  eject: {
4387
- usage: " vigiles eject [file] Un-manage a compiled file plain hand-owned markdown (--keep-spec)",
4414
+ usage: " vigiles eject [file] Hand a compiled file back as plain markdown.",
4388
4415
  },
4389
4416
  lint: {
4390
- usage: " vigiles lint [files...] Verify references, find gaps in instruction files",
4417
+ usage: " vigiles lint [files...] Gate it in CI. The same checks — but a finding fails the build.",
4391
4418
  },
4392
4419
  audit: {
4393
- usage: " vigiles audit [dir...] Lighthouse for your harness a LOCAL report: rings + what's broken + fixes (a deterministic read; 2+ dirs → leaderboard)",
4420
+ // "Reports everything, fails nothing" states always-exit-0 as the FEATURE it is. The line
4421
+ // it replaces had to end with "NOT a CI step — use `vigiles lint` in CI", and a help text
4422
+ // that must say what a command ISN'T is a description that failed. Deleting that sentence
4423
+ // was the checkable success criterion for this rewrite.
4424
+ usage: " vigiles audit [dir...] Grade it on your machine. Reports everything, fails nothing.",
4394
4425
  detail: [
4395
- " writes vigiles-report.html + .json (auto-gitignored; --out=<dir> · --no-html/--no-json · --no-open · --json for machine output). NOT a CI step — use `vigiles lint` in CI.",
4396
- " the executing checks (run your hooks · live MCP · do skills fire?) run only interactively — `audit` asks once (remembered); automation uses the vigiles testing API",
4397
- " --serve opens a LIVE local report whose buttons create specs in one click (own repo only; loopback + token-guarded) · --no-serve to skip the prompt",
4426
+ " 2+ dirs → a leaderboard. Writes vigiles-report.html + .json (auto-gitignored).",
4427
+ " The executing checks (run your hooks · live MCP · do skills fire?) run only",
4428
+ " interactively audit asks once and remembers; automation uses the testing API.",
4398
4429
  ],
4399
4430
  },
4400
4431
  test: {
4401
- usage: " vigiles test [files...] Run *.harness.mjs deterministic harness tests",
4432
+ // "Free, no API key" is the CONSEQUENCE; "deterministic" was the mechanism, and a reader
4433
+ // deciding whether to put this in CI needs the cost, not the implementation.
4434
+ usage: " vigiles test [files...] Against a scripted stand-in model. Free, no API key — every commit.",
4402
4435
  },
4403
4436
  eval: {
4404
- usage: " vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)",
4437
+ usage: " vigiles eval [files...] Against a real model. Spends your subscription on demand.",
4405
4438
  detail: [
4406
- " --update records each named eval's result to a committed lock (run locally on your subscription)",
4407
- " --check verifies committed eval results against current inputs WITHOUT a model — the CI staleness gate",
4439
+ " --update records each named eval's result to a committed lock (run it locally).",
4440
+ " --check verifies those committed results against current inputs with NO model —",
4441
+ " the CI-safe half.",
4408
4442
  ],
4409
4443
  },
4410
4444
  generate: {
4411
- usage: " vigiles generate <kind> Emit a dev-toolchain artifact: types (.d.ts) · schema (JSON Schema) · harness (harness.gen.ts)",
4445
+ usage: " vigiles generate <kind> Emit a dev-toolchain artifact: types · schema · harness",
4412
4446
  detail: [
4413
4447
  " vigiles generate <kind> --check Verify the generated file is up to date",
4414
4448
  ],
@@ -4418,57 +4452,110 @@ const COMMAND_HELP = {
4418
4452
  },
4419
4453
  };
4420
4454
  /** Display order of the human-facing verbs in the banner's "Commands:" block. */
4421
- const HELP_ORDER = [
4422
- "init",
4423
- "compile",
4424
- "eject",
4425
- "lint",
4426
- "audit",
4427
- "test",
4428
- "eval",
4455
+ /**
4456
+ * The top-level help, as GROUPS. One table, so the printer cannot drift from the
4457
+ * grouping and a new verb cannot quietly land outside both.
4458
+ */
4459
+ const HELP_GROUPS = [
4460
+ {
4461
+ heading: "Set up and manage your specs:",
4462
+ verbs: ["init", "compile", "eject"],
4463
+ },
4464
+ {
4465
+ heading: "Check your harness (reads your files — nothing is executed):",
4466
+ verbs: ["audit", "lint"],
4467
+ },
4468
+ {
4469
+ heading: "Run your harness (drives it and watches what happens):",
4470
+ verbs: ["test", "eval"],
4471
+ },
4429
4472
  ];
4430
- function printHelpEntry(v) {
4473
+ /**
4474
+ * One command's line. `detail` is the per-flag prose and appears ONLY in
4475
+ * `vigiles <verb> --help`, never in the top-level list.
4476
+ *
4477
+ * That split is the second half of this rewrite. `audit`'s entry used to carry four
4478
+ * wrapped lines naming nine flags inline, and that single entry was most of the felt
4479
+ * crowding in a CLI whose verb count (8) is the smallest of every comparable tool
4480
+ * measured — vitest ships 8 verbs and 164 flags, cargo 48 verbs, git 166. None of them
4481
+ * thinned their surface by removing verbs; they tiered the help. This does the same.
4482
+ */
4483
+ function printHelpEntry(v, opts = {}) {
4431
4484
  console.log(COMMAND_HELP[v].usage);
4432
- for (const line of COMMAND_HELP[v].detail ?? [])
4433
- console.log(line);
4485
+ if (opts.detail)
4486
+ for (const line of COMMAND_HELP[v].detail ?? [])
4487
+ console.log(line);
4434
4488
  }
4435
4489
  /**
4436
4490
  * The loud "there is nothing here to audit" block. Deliberately says WHAT was
4437
4491
  * looked at and WHY it found nothing, because the commonest cause is that the
4438
4492
  * target isn't the directory the operator thinks it is.
4493
+ *
4494
+ * Two causes are named apart from the generic one, because they are the two the
4495
+ * generic wording actively MISDESCRIBES:
4496
+ *
4497
+ * - the path does not exist — "no surface was found there" reads as a verdict
4498
+ * on a real directory, so a typo'd dir in a multi-dir leaderboard run looked
4499
+ * like a legitimately empty repo. One `existsSync` separates them.
4500
+ * - the path is a CURATED marketplace — it has members, they are all external,
4501
+ * and the useful next step is to clone one. This advice used to live in a
4502
+ * branch that pre-empted the audit entirely (see the `audit` case); it
4503
+ * belongs here, where the directory has genuinely been looked at first.
4439
4504
  */
4440
- function formatNothingToAudit(root, harness) {
4441
- return [
4505
+ function formatNothingToAudit(root, harness, market) {
4506
+ if (!(0, node_fs_1.existsSync)(root)) {
4507
+ return [
4508
+ `✗ vigiles audit: ${root} does not exist`,
4509
+ " Nothing was scanned — this is NOT a grade, and NOT an empty repo.",
4510
+ " Check the path, and that a flag didn't swallow it",
4511
+ " (flags take values with `=`: `--out=dir`, never `--out dir`).",
4512
+ ].join("\n");
4513
+ }
4514
+ const lines = [
4442
4515
  `✗ vigiles audit: nothing to audit in ${root}`,
4443
4516
  ` No instruction file and no ${harness} surface (skills / subagents / hooks / commands / MCP) was found there.`,
4444
4517
  " 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");
4518
+ ];
4519
+ if (market && market.onDisk.length === 0 && market.total > 0) {
4520
+ 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.");
4521
+ }
4522
+ 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`).");
4523
+ return lines.join("\n");
4448
4524
  }
4449
4525
  /** `vigiles <verb> --help` — that verb's entry plus its complete flag list. */
4450
4526
  function printCommandHelp(command) {
4451
- printHelpEntry(command);
4527
+ printHelpEntry(command, { detail: true });
4452
4528
  const flags = (0, cli_flag_check_js_1.knownFlagsFor)(command);
4453
4529
  console.log("");
4454
4530
  console.log(`Flags: ${[...flags].sort().join(" ")}`);
4455
4531
  console.log("(`vigiles --help` lists every command.)");
4456
4532
  }
4533
+ /**
4534
+ * The top-level help, grouped. The grouping is load-bearing, not cosmetic: four verbs
4535
+ * (`audit`, `lint`, `test`, `eval`) all read as "check my stuff", and a flat list left the
4536
+ * reader to work out the difference from four independent sentences. The headings state the
4537
+ * shared trait, which frees each verb's own line to state only what makes it different, so
4538
+ * the four form a 2x2 that survives one pass:
4539
+ *
4540
+ * no consequence has a consequence
4541
+ * read the files audit (fails nothing) lint (fails the build)
4542
+ * run the harness test (free) eval (spends money)
4543
+ */
4457
4544
  function printUsage(command) {
4458
- console.log("vigiles — compile typed specs to instruction files");
4459
- console.log("");
4460
- console.log("Commands:");
4461
- for (const v of HELP_ORDER)
4462
- printHelpEntry(v);
4463
- console.log("");
4464
- console.log("Examples:");
4465
- console.log(" vigiles init Auto-detect project, create specs, wire CI");
4466
- console.log(" vigiles compile Compile all .spec.ts files");
4467
- console.log(" vigiles lint Verify references, hashes, coverage + suggestions");
4545
+ console.log("vigiles — verify your agent harness is real, and prove it works");
4468
4546
  console.log("");
4547
+ for (const g of HELP_GROUPS) {
4548
+ console.log(g.heading);
4549
+ for (const v of g.verbs)
4550
+ printHelpEntry(v);
4551
+ console.log("");
4552
+ }
4469
4553
  console.log("Plumbing:");
4470
4554
  printHelpEntry("generate");
4471
- console.log(" vigiles --version Print the version number");
4555
+ console.log(" vigiles --version Print the version number");
4556
+ console.log("");
4557
+ console.log("Flags live in `vigiles <command> --help`.");
4558
+ console.log("New here? Start with `vigiles audit .`");
4472
4559
  if (command && command !== "--help") {
4473
4560
  console.log(`\nUnknown command: "${command}"`);
4474
4561
  process.exit(1);
@@ -6151,26 +6238,36 @@ async function main() {
6151
6238
  // A single dir that's a marketplace (e.g. wshobson/agents' 80+ plugins
6152
6239
  // under one marketplace.json) expands into its members and ranks them.
6153
6240
  const market = dirs.length === 1 ? (0, scan_js_1.inspectMarketplace)((0, node_path_1.resolve)(dirs[0])) : null;
6241
+ // A marketplace whose members are all EXTERNAL expands to nothing, and the
6242
+ // fallback below already says what to do about that: the target is the
6243
+ // directory itself.
6244
+ //
6245
+ // 🔴 THERE USED TO BE A BRANCH HERE that printed "nothing to scan" and
6246
+ // returned at exit 0 whenever the expansion came back empty — before
6247
+ // looking at the directory at all. A `marketplace.json` is a statement
6248
+ // about OTHER directories; it says nothing about this one, and a directory
6249
+ // is free to be a plugin AND ship a marketplace listing external members.
6250
+ // Measured 2026-08-18 on `nyldn/claude-octopus` @ `57cfb9b0` (3,979 stars):
6251
+ // the branch suppressed the whole audit — 59 skills, 50 subagents — because
6252
+ // `.claude-plugin/` held a `marketplace.json` NEXT TO the `plugin.json`
6253
+ // naming that same directory. It now grades D (60/100), and the check that
6254
+ // this branch is really gone is that deleting the `marketplace.json` gives
6255
+ // the byte-identical report: the file no longer changes the outcome at all.
6256
+ //
6257
+ // (An earlier journal recorded that delete-one-file experiment as B (85/100).
6258
+ // That was measured before `agents/` was read recursively, when all 50 of
6259
+ // this plugin's subagents lived in subdirectories and none were visible. Both
6260
+ // numbers are real; they differ by the other fix, not by this one.)
6261
+ //
6262
+ // Exit 0 was the other half: a repo that was never scanned exited
6263
+ // byte-identically to a repo that was scanned and found clean, so an
6264
+ // automated leaderboard dropped it silently and CI went green. A genuinely
6265
+ // curated marketplace now reaches the `score.empty` branch below, which
6266
+ // carries `market` into its explanation and exits 2 — this repo's own rule
6267
+ // that 1 is "I measured, and it's bad" and 2 is "I could not do what you
6268
+ // asked". Nothing was measured here, so it is a 2.
6154
6269
  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) {
6270
+ if (targets.length > 1) {
6174
6271
  // Multiple targets → rank them (the leaderboard engine). `--md` emits the
6175
6272
  // publishable Markdown table (a README / gist / the leaderboard site).
6176
6273
  const scores = (0, leaderboard_js_1.rankPlugins)(targets);
@@ -6304,9 +6401,20 @@ async function main() {
6304
6401
  // `score.empty: true`), so a machine consumer keeps its contract; the
6305
6402
  // human-readable explanation goes to stderr either way.
6306
6403
  if (sc.empty) {
6404
+ // A curated marketplace lands here now that the early return is gone:
6405
+ // it really has nothing of its own to audit, which is what this branch
6406
+ // is for. `--json` keeps emitting the `kind:"marketplace"` envelope for
6407
+ // exactly that case, so the discriminant still describes what it always
6408
+ // described — a marketplace with no on-disk members — and no longer
6409
+ // doubles as "a directory we declined to look at".
6307
6410
  if (json)
6308
- console.log(JSON.stringify(auditReportBase, null, 2));
6309
- console.error(formatNothingToAudit(root, adapter.name));
6411
+ console.log(JSON.stringify(market && market.onDisk.length === 0 && market.total > 0
6412
+ ? (0, audit_report_js_1.buildMarketplaceReport)(market, {
6413
+ vigilesVersion: getVersion(),
6414
+ dir: root,
6415
+ })
6416
+ : auditReportBase, null, 2));
6417
+ console.error(formatNothingToAudit(root, adapter.name, market));
6310
6418
  process.exit(2);
6311
6419
  }
6312
6420
  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.1",
3
+ "version": "18.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",
@@ -326,6 +326,20 @@ We do **not** show "% of your subscription" — Anthropic doesn't expose a plan'
326
326
  quota, so any percentage would be invented. Tokens + API-equivalent `$` + the
327
327
  billed-to line is the honest, complete picture. Keep the user's cost visible, always.
328
328
 
329
+ ## CI — don't hand-write the steps
330
+
331
+ These tiers belong in CI, and there is a published Action for it. Run `vigiles init`: it
332
+ writes `.github/workflows/vigiles.yml`, wiring the Action (`zernie/vigiles@v1`) for the jobs
333
+ that can use it plus a plain `npx vigiles test` job for this tier — that one needs
334
+ repo-local `node_modules`, which the Action does not install, so it stays hand-rolled on
335
+ purpose.
336
+
337
+ If the repo already has a workflow, the Action's inputs are documented in
338
+ [docs/github-action.md](../../docs/github-action.md). Read them there rather than guessing:
339
+ the input list is defined in `action.yml`, and a copy of it here would be a second source of
340
+ truth that goes stale without anything noticing — which is exactly what happened to this
341
+ file's own sibling docs and to a consumer's CI comment, both measured on 2026-08-18.
342
+
329
343
  ## Step 5 — Lock the eval so CI stays honest (you do this automatically)
330
344
 
331
345
  Real-model evals run on the user's subscription — locally, never in CI. So **as