vigiles 22.0.0 → 24.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/dist/cli.js CHANGED
@@ -12,8 +12,11 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.specLoadFailureReason = specLoadFailureReason;
15
+ exports.lintTotals = lintTotals;
16
+ exports.discoverNestedBundles = discoverNestedBundles;
15
17
  const node_fs_1 = require("node:fs");
16
18
  const node_path_1 = require("node:path");
19
+ const minimatch_1 = require("minimatch");
17
20
  const node_child_process_1 = require("node:child_process");
18
21
  const glob_1 = require("glob");
19
22
  const generate_types_js_1 = require("./core/generate-types.js");
@@ -316,7 +319,20 @@ function compileClaudeToFile(spec, specPath, config, dialect) {
316
319
  if (errors.length > 0) {
317
320
  console.log(`\n✗ ${specPath} — ${String(errors.length)} error(s)`);
318
321
  printErrors(specPath, errors);
319
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, primaryOutput), markdown);
322
+ // 🔴 NOTHING IS WRITTEN ON A FAILED COMPILE (#173).
323
+ //
324
+ // It used to write the artifact anyway, and the result was the exact
325
+ // false-confidence object this tool exists to prevent: a `CLAUDE.md`
326
+ // carrying refs already KNOWN to be dead, stamped with a VALID integrity
327
+ // hash. `lint` then verified the hash, found it intact, and exited 0 — so
328
+ // the command the README calls "the CI gate … broken refs" went green over
329
+ // breakage `compile` had printed minutes earlier. Compile locally, get
330
+ // distracted, commit: CI never mentions it again.
331
+ //
332
+ // Not writing leaves the LAST GOOD artifact in place, which is strictly
333
+ // better than replacing it with a broken one: the error is on screen, the
334
+ // exit code is 1, and no green hash is minted over a known-bad file.
335
+ console.log(` → ${primaryOutput} was NOT written; the previous version is left in place.`);
320
336
  return false;
321
337
  }
322
338
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, primaryOutput), markdown);
@@ -788,6 +804,55 @@ async function verifyMarkdownMcpRefs(files, silent) {
788
804
  return errors;
789
805
  }
790
806
  /** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
807
+ /**
808
+ * The run's totals, derived from the report itself.
809
+ *
810
+ * 🔴 ONE SOURCE, because the two numbers disagreeing IS the bug (#183). The
811
+ * human-readable log had no total, and counting its `⚠` lines gave a different
812
+ * number from the JSON — 21 against 88 on a real repo — because some checks print
813
+ * one line per finding and others one line carrying a count. Both numbers were
814
+ * right and nothing said why they differed, so "vigiles reports 21 warnings" and
815
+ * "88 warnings" were equally defensible readings of one run.
816
+ *
817
+ * Counted GENERICALLY off the `*Issues` / `*Errors` / count keys rather than a
818
+ * hand-maintained list, so a rule added later is included by existing, not by
819
+ * somebody remembering. `orphanCount` and `duplicatePairs` are named explicitly
820
+ * only because they predate the `*Issues` convention (#181).
821
+ */
822
+ function lintTotals(report) {
823
+ let errors = 0;
824
+ let findings = 0;
825
+ for (const [key, value] of Object.entries(report)) {
826
+ if (typeof value !== "number" || value === 0)
827
+ continue;
828
+ if (key === "files")
829
+ continue;
830
+ // Informational counters: not findings, they describe the corpus.
831
+ if (key === "inlineRules" ||
832
+ key === "frontmatterRules" ||
833
+ key === "coverageEnabled" ||
834
+ key === "coverageDocumented" ||
835
+ key === "strengthenSuggestions")
836
+ continue;
837
+ if (key.endsWith("Errors")) {
838
+ errors += value;
839
+ findings += value;
840
+ continue;
841
+ }
842
+ // `*Issues` counts EVERY finding of that rule; when the rule is at "error"
843
+ // the same findings are also in `*Errors`, so they must not be counted twice.
844
+ if (key.endsWith("Issues")) {
845
+ const paired = report[`${key.slice(0, -"Issues".length)}Errors`];
846
+ findings += paired && paired > 0 ? 0 : value;
847
+ continue;
848
+ }
849
+ if (key === "orphanCount" ||
850
+ key === "duplicatePairs" ||
851
+ key === "untestedSurfaces")
852
+ findings += value;
853
+ }
854
+ return { findings, errors, warnings: findings - errors };
855
+ }
791
856
  function lintExitCode(report) {
792
857
  if (report.hashErrors > 0 ||
793
858
  report.validationErrors > 0 ||
@@ -822,9 +887,15 @@ function lintExitCode(report) {
822
887
  // it to "error", so it belongs in the hard tier with every other explicit
823
888
  // error — it used to sit at exit 1 because it fired unasked and could not be
824
889
  // turned off.
825
- report.docRefErrors > 0)
890
+ report.docRefErrors > 0 ||
891
+ report.specRefErrors > 0)
826
892
  return 2;
827
- if (report.duplicatePairs > 0 || report.orphanCount > 0)
893
+ // Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
894
+ // change the exit code. Both used to feed the exit directly, which is what
895
+ // made them the only untierable findings in the tool.
896
+ if (report.orphanCount > 0 && report.orphanSeverity === "error")
897
+ return 1;
898
+ if (report.duplicatePairs > 0 && report.duplicateSeverity === "error")
828
899
  return 1;
829
900
  // Guidance counts are informational, not failures
830
901
  return 0;
@@ -1072,6 +1143,157 @@ function sharedDirsRootFor(scanTarget) {
1072
1143
  const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
1073
1144
  return underCwd ? cwd : target;
1074
1145
  }
1146
+ /**
1147
+ * Nested plugin bundles under a lint root — a directory that is itself a harness
1148
+ * (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
1149
+ * root being linted.
1150
+ *
1151
+ * 🔴 WHY THIS EXISTS. Every per-surface check reads ONE root, so in a monorepo
1152
+ * holding `skills/` plus `plugins/ * /skills/` the nested skills were never scored
1153
+ * and nothing said so. Measured on a fixture: 4 skills over the description
1154
+ * budget, `lint .` reported 2, exit 0 — a repo reads that as green-with-2 while
1155
+ * the other 2 carry the same defect (#185). The failure is silent, which is the
1156
+ * shape this repo treats as worse than a loud one.
1157
+ *
1158
+ * Deliberately shallow (one level under a container dir): deep recursion would
1159
+ * sweep vendored corpora — this repo's own `test/dogfood/` holds real pinned
1160
+ * third-party plugins — and scoring someone else's vendored plugin as if it were
1161
+ * yours is the false-positive that gets a gate switched off.
1162
+ */
1163
+ function discoverNestedBundles(root, exclude = []) {
1164
+ const out = [];
1165
+ const skip = new Set([
1166
+ "node_modules",
1167
+ ".git",
1168
+ "dist",
1169
+ "coverage",
1170
+ ".vigiles",
1171
+ ]);
1172
+ const isBundle = (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".claude-plugin", "plugin.json")) ||
1173
+ (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "skills"));
1174
+ const excluded = (rel) => exclude.some((pattern) => rel === pattern ||
1175
+ rel.startsWith(`${pattern}/`) ||
1176
+ (0, minimatch_1.minimatch)(rel, pattern) ||
1177
+ (0, minimatch_1.minimatch)(rel, `${pattern}/**`));
1178
+ let entries;
1179
+ try {
1180
+ entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
1181
+ .filter((e) => e.isDirectory() && !skip.has(e.name) && !e.name.startsWith("."))
1182
+ .map((e) => e.name);
1183
+ }
1184
+ catch {
1185
+ return out;
1186
+ }
1187
+ for (const name of entries) {
1188
+ const dir = (0, node_path_1.join)(root, name);
1189
+ if (excluded(name))
1190
+ continue;
1191
+ // A container (`plugins/`) holds bundles; a bundle may also sit directly.
1192
+ if (isBundle(dir)) {
1193
+ out.push(dir);
1194
+ continue;
1195
+ }
1196
+ let inner;
1197
+ try {
1198
+ inner = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
1199
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1200
+ .map((e) => e.name);
1201
+ }
1202
+ catch {
1203
+ continue;
1204
+ }
1205
+ for (const child of inner) {
1206
+ const sub = (0, node_path_1.join)(dir, child);
1207
+ if (excluded(`${name}/${child}`))
1208
+ continue;
1209
+ if (isBundle(sub))
1210
+ out.push(sub);
1211
+ }
1212
+ }
1213
+ return out.sort();
1214
+ }
1215
+ /**
1216
+ * Run one per-surface check over EVERY root and sum its counters.
1217
+ *
1218
+ * The checks all share `(config, silent, adapter, root)` and return a small
1219
+ * record of numbers, so one wrapper covers all twenty rather than twenty edits —
1220
+ * and a check added later is swept in by using it, not by remembering to.
1221
+ */
1222
+ function overBundles(fn, config, silent, adapter, roots) {
1223
+ const [first, ...rest] = roots;
1224
+ const total = { ...fn(config, silent, adapter, first) };
1225
+ for (const root of rest) {
1226
+ const next = fn(config, silent, adapter, root);
1227
+ for (const key of Object.keys(next))
1228
+ total[key] =
1229
+ (total[key] ?? 0) + (next[key] ?? 0);
1230
+ }
1231
+ return total;
1232
+ }
1233
+ /**
1234
+ * Re-derive a compiled artifact's references from its SPEC, and report the dead
1235
+ * ones — the half of #173 that deleting the write-on-error did not close.
1236
+ *
1237
+ * 🔴 THE HOLE. `lint` verifies the integrity HASH, which answers "is this file
1238
+ * still what the spec compiled to" and says nothing about whether the things it
1239
+ * NAMES still exist. So a `CLAUDE.md` committed while its refs were live stays
1240
+ * green forever after the referenced file is deleted: `compile` errors, `lint`
1241
+ * prints "hash valid — All compiled files intact" and exits 0. Reproduced:
1242
+ *
1243
+ * $ vigiles compile CLAUDE.md.spec.ts
1244
+ * ✗ [stale-file] File not found: "docs/guide.md"
1245
+ * $ vigiles lint .
1246
+ * ✓ CLAUDE.md — hash valid # exit 0
1247
+ *
1248
+ * The reporter named this residue himself when filing #173 and I deferred it;
1249
+ * it is the gap between what `README.md` promises of `lint` ("the CI gate …
1250
+ * broken refs") and what it checked. A hash is an integrity claim, not a
1251
+ * reference claim, and the two were being read as one.
1252
+ *
1253
+ * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1254
+ * so a repo with no specs does no extra work at all.
1255
+ */
1256
+ async function checkSpecRefs(config, silent, dialect) {
1257
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1258
+ if (!sev)
1259
+ return { issues: 0, errors: 0 };
1260
+ const found = [];
1261
+ for (const specPath of findSpecs()) {
1262
+ const target = specPath.replace(/\.spec\.ts$/, "");
1263
+ if (!(0, node_fs_1.existsSync)(target))
1264
+ continue; // never compiled — `compile` reports it
1265
+ const spec = await loadSpec(specPath);
1266
+ if (!spec || spec._specType !== "claude")
1267
+ continue;
1268
+ try {
1269
+ const { errors } = (0, compile_js_1.compileClaude)(spec, {
1270
+ basePath: process.cwd(),
1271
+ specFile: specPath,
1272
+ dialect,
1273
+ maxRules: config?.maxRules,
1274
+ maxTokens: config?.maxTokens,
1275
+ maxSectionLines: config?.maxSectionLines,
1276
+ catalogOnly: config?.catalogOnly,
1277
+ linters: config?.linters,
1278
+ });
1279
+ for (const e of errors)
1280
+ found.push(`${target}: ${e.message} (from ${specPath})`);
1281
+ }
1282
+ catch {
1283
+ // A spec that will not load is `compile`'s finding, not this one's —
1284
+ // reporting it here would double-report and blame the wrong command.
1285
+ continue;
1286
+ }
1287
+ }
1288
+ if (found.length > 0 && !silent) {
1289
+ console.log("\nSpec reference check:\n");
1290
+ for (const msg of found) {
1291
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
1292
+ ghAnnotate(sev === "error" ? "error" : "warning", msg);
1293
+ }
1294
+ }
1295
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1296
+ }
1075
1297
  async function runLint(restArgs, flags, config) {
1076
1298
  const summary = flags.includes("--summary");
1077
1299
  const json = flags.includes("--json");
@@ -1104,6 +1326,30 @@ async function runLint(restArgs, flags, config) {
1104
1326
  configHarness: (0, adapter_registry_js_1.normalizeHarnessList)(config?.harness),
1105
1327
  });
1106
1328
  const adapter = lintSelection.adapter;
1329
+ // 🔴 WHICH ROOTS GET SCORED, and saying so either way (#185).
1330
+ //
1331
+ // Every per-surface check reads ONE root, so a monorepo with `skills/` plus
1332
+ // `plugins/*/skills/` scored only the first and said nothing — measured at 2
1333
+ // findings reported against 4 real ones, exit 0. A skipped surface that is not
1334
+ // announced reads as a clean surface.
1335
+ //
1336
+ // Default stays ROOT-ONLY on purpose: descending by default would start
1337
+ // scoring vendored third-party corpora (this repo's own `test/dogfood/` holds
1338
+ // pinned real plugins), and scoring someone else's plugin as if it were yours
1339
+ // is the false positive that gets a gate turned off. So the DEFAULT fixes the
1340
+ // SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
1341
+ // whole repo, which is what a CI gate needs.
1342
+ const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
1343
+ const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1344
+ const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1345
+ if (!silent && nestedBundles.length > 0) {
1346
+ const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1347
+ console.log(scoreAll
1348
+ ? `\nScoring ${String(lintRoots.length)} bundles: the root + ${rel.join(", ")}`
1349
+ : `\n⚠ ${String(nestedBundles.length)} nested bundle(s) discovered but NOT scored: ${rel.join(", ")}\n` +
1350
+ ` Their skills/agents/hooks are not in the counters below. Add \`"bundles": "all"\` to ` +
1351
+ `.vigilesrc.json (or pass --bundles=all) to score them in this run.`);
1352
+ }
1107
1353
  // Discover the compiled files whose integrity is verified. Include the active
1108
1354
  // harness's subagent dir (dogfood E2): a compiled `agents/<name>.md` carries a
1109
1355
  // vigiles hash, but the default glob only matched CLAUDE/AGENTS/SKILL, so a
@@ -1157,7 +1403,14 @@ async function runLint(restArgs, flags, config) {
1157
1403
  // declares the block; its `include` defaults to docs/ (research/ etc. are
1158
1404
  // opted into explicitly). `enforce("vigiles/orphan-docs")` in a spec only
1159
1405
  // validates the rule NAME — the block is what drives the scan.
1160
- const orphansCfg = config?.orphans;
1406
+ // 🔴 SEVERITY IS READ, not just the block's presence (#181). `orphan-docs` had
1407
+ // a RULE_META entry and a documented severity, and nothing ever read it: both
1408
+ // `"warn"` and `"off"` still exited 1, so a repo could only choose between an
1409
+ // always-blocking check and deleting the `orphans` block. `warn` now reports
1410
+ // without touching the exit code and `false`/`"off"` skips the scan, exactly
1411
+ // like every other rule.
1412
+ const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
1413
+ const orphansCfg = orphanSeverity ? config?.orphans : undefined;
1161
1414
  if (!silent)
1162
1415
  console.log("\nOrphan docs check:\n");
1163
1416
  let orphanReport = {
@@ -1188,72 +1441,75 @@ async function runLint(restArgs, flags, config) {
1188
1441
  // 7b. Untested-surface check — skills/agents/hooks shipping without a test or
1189
1442
  // eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
1190
1443
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1191
- const untested = checkUntestedSurfaces(config, silent, adapter, scanRoot);
1444
+ // A compiled artifact's refs, re-derived from its spec — the hash says the file
1445
+ // is unchanged, not that what it names still exists (#173).
1446
+ const specRefs = await checkSpecRefs(config, silent, adapter.dialect);
1447
+ const untested = overBundles(checkUntestedSurfaces, config, silent, adapter, lintRoots);
1192
1448
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1193
1449
  // rail against the harness catalog (the moat). n/a on a harness with no
1194
1450
  // subagents. Off by default unless a severity is configured; warning surfaces
1195
1451
  // a typo/never-available tool, error gates CI.
1196
- const toolContract = checkSubagentToolContracts(config, silent, adapter, scanRoot);
1452
+ const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
1197
1453
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1198
1454
  // define never fires. High-precision (close typos only). Off unless configured.
1199
- const hookEvents = checkHookEvents(config, silent, adapter, scanRoot);
1455
+ const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
1200
1456
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1201
1457
  // (name + description) won't register. n/a on a harness with no subagents.
1202
- const frontmatter = checkFrontmatterSchema(config, silent, adapter, scanRoot);
1458
+ const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
1203
1459
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1204
- const mcpConfig = checkMcpConfig(config, silent, adapter, scanRoot);
1460
+ const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
1205
1461
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1206
1462
  // reliable trigger surface). Best-practice nudge; skills load without it.
1207
- const skillFm = checkSkillFrontmatter(config, silent, adapter, scanRoot);
1463
+ const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
1208
1464
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1209
1465
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1210
- const mcpToolResolves = checkMcpToolResolves(config, silent, adapter, scanRoot);
1466
+ const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
1211
1467
  // 7i. Hook-script existence — a hook command referencing a missing script file
1212
1468
  // never runs (matches Anthropic's own `claude plugin validate`).
1213
- const hookScripts = checkHookScriptExists(config, silent, adapter, scanRoot);
1469
+ const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
1214
1470
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1215
1471
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1216
- const disallowedTools = checkDisallowedTools(config, silent, adapter, scanRoot);
1472
+ const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
1217
1473
  // 7k. Description-overlap — two model-invocable skills with near-identical
1218
1474
  // descriptions collide in the selector (deterministic NCD precision proxy).
1219
- const descriptionOverlap = checkDescriptionOverlap(config, silent, adapter, scanRoot);
1475
+ const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
1220
1476
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1221
1477
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1222
1478
  // precision). Generous 500-char budget; warn-tier, never gates.
1223
- const descriptionBudget = checkDescriptionBudget(config, silent, adapter, scanRoot);
1479
+ const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
1224
1480
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1225
1481
  // stricter than some loaders, so verify before enforcing).
1226
- const frontmatterValid = checkFrontmatterValid(config, silent, adapter, scanRoot);
1482
+ const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
1227
1483
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1228
1484
  // targets an undeclared server (the moat applied to the hook surface).
1229
- const mcpHookTargets = checkMcpHookTargets(config, silent, adapter, scanRoot);
1485
+ const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
1230
1486
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1231
1487
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1232
- const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter, scanRoot);
1488
+ const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
1233
1489
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1234
1490
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1235
1491
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1236
- const lethalTrifecta = checkLethalTrifecta(config, silent, adapter, scanRoot);
1492
+ const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
1237
1493
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1238
1494
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1239
- const skillResources = checkSkillResourceResolves(config, silent, adapter, scanRoot);
1495
+ const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
1240
1496
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1241
1497
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1242
- const skillFence = checkSkillMissingFence(config, silent, adapter, scanRoot);
1498
+ const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
1243
1499
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1244
1500
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1245
- const pluginLayout = checkPluginDirLayout(config, silent, adapter, scanRoot);
1501
+ const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
1246
1502
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1247
1503
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1248
- const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter, scanRoot);
1504
+ const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
1249
1505
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1250
1506
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1251
1507
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1252
- const hookBlock = checkHookBlockIneffective(config, silent, adapter, scanRoot);
1508
+ const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
1253
1509
  // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1254
1510
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1255
1511
  // server naming, or an undeclared MCP server).
1256
- const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1512
+ const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
1257
1513
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1258
1514
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1259
1515
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1329,13 +1585,17 @@ async function runLint(restArgs, flags, config) {
1329
1585
  inlineRules,
1330
1586
  frontmatterErrors,
1331
1587
  frontmatterRules,
1588
+ specRefIssues: specRefs.issues,
1589
+ specRefErrors: specRefs.errors,
1332
1590
  duplicatePairs: dups.pairCount,
1591
+ duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1333
1592
  coverageEnabled: coverage.enabled,
1334
1593
  coverageDocumented: coverage.documented,
1335
1594
  strengthenSuggestions: guidanceCount,
1336
1595
  integrityErrors,
1337
1596
  coverageErrors,
1338
1597
  orphanCount: orphanReport.orphans.length,
1598
+ orphanSeverity: orphanSeverity,
1339
1599
  untestedSurfaces: untested.untested,
1340
1600
  untestedErrors: untested.errors,
1341
1601
  toolContractIssues: toolContract.issues,
@@ -1386,13 +1646,41 @@ async function runLint(restArgs, flags, config) {
1386
1646
  mcpRefErrors,
1387
1647
  files,
1388
1648
  };
1649
+ // The totals both surfaces quote, computed ONCE (#183). Attaching them to the
1650
+ // report is what makes the log line and `--json` incapable of disagreeing —
1651
+ // the previous gap was not a wrong number, it was two right numbers with
1652
+ // nothing explaining the difference.
1653
+ const totals = lintTotals(report);
1654
+ const reported = { ...report, totals };
1655
+ // `--json-out=<file>` writes the JSON to disk while stdout keeps the
1656
+ // human-readable run — one scan, both artefacts (#182). A CI job needed both
1657
+ // (the log is what a human opens; the JSON is what the PR comment is built
1658
+ // from) and had to scan the repo TWICE to get them, which is the same work
1659
+ // done twice and grows with the corpus.
1660
+ const jsonOutFlag = flags.find((f) => f.startsWith("--json-out="));
1661
+ if (jsonOutFlag) {
1662
+ const dest = (0, node_path_1.resolve)(jsonOutFlag.slice("--json-out=".length));
1663
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
1664
+ (0, node_fs_1.writeFileSync)(dest, `${JSON.stringify(reported, null, 2)}\n`);
1665
+ if (!silent)
1666
+ console.log(`\n✓ JSON report written to ${dest}`);
1667
+ }
1389
1668
  if (summary) {
1390
- printLintSummary(report);
1669
+ printLintSummary(reported);
1391
1670
  }
1392
1671
  else if (json) {
1393
- console.log(JSON.stringify(report, null, 2));
1672
+ console.log(JSON.stringify(reported, null, 2));
1673
+ }
1674
+ else {
1675
+ // The one number a reader can quote. Counting `⚠` lines gives a DIFFERENT
1676
+ // number, because some checks print one line per finding and others one line
1677
+ // carrying a count — so the log now states the finding total outright rather
1678
+ // than leaving the reader to infer it from line shapes.
1679
+ const code = lintExitCode(reported);
1680
+ console.log(`\n${String(totals.findings)} finding(s): ${String(totals.errors)} error, ` +
1681
+ `${String(totals.warnings)} warning — exit ${String(code)}`);
1394
1682
  }
1395
- return report;
1683
+ return reported;
1396
1684
  }
1397
1685
  /** Single-line lint summary for SessionStart hooks — minimal token cost. */
1398
1686
  function printLintSummary(report) {
@@ -1937,6 +2225,15 @@ function scaffoldSpec(args) {
1937
2225
  (0, node_fs_1.writeFileSync)(specAbs, source);
1938
2226
  console.log(`Adopted ${target} → ${specPath} (${tier}, ${String(sectionCount)} section${sectionCount === 1 ? "" : "s"}). ` +
1939
2227
  `Run \`vigiles compile\` and review the diff; the \`/strengthen\` skill upgrades prose to verified rules.`);
2228
+ // Adoption is faithful by design: it infers NO rules and extracts NO refs,
2229
+ // so a raw adoption verifies nothing on its own. Saying so is the whole
2230
+ // fix — the cost (the file becomes a build artifact, edits move into TS)
2231
+ // lands immediately, and without this line the benefit reads as zero
2232
+ // rather than as not-yet-claimed. Deliberately not a heuristic extractor:
2233
+ // guessing refs out of prose is what got `doc-refs` disabled.
2234
+ if (tier === "raw")
2235
+ console.log(` ℹ 0 refs extracted — this spec verifies nothing yet. Wrap paths in \`file()\` ` +
2236
+ `and commands in \`cmd()\` to make \`compile\` check them.`);
1940
2237
  }
1941
2238
  return;
1942
2239
  }
@@ -3566,7 +3863,12 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
3566
3863
  if (found.length > 0 && !silent) {
3567
3864
  console.log("\nSkill-resource check:\n");
3568
3865
  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.`;
3866
+ 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.` +
3867
+ // The main false-positive source in a skills monorepo, where a SKILL.md
3868
+ // legitimately names a repo-root path. The fix already exists and works;
3869
+ // it was documented only in docs/skills-monorepo.md, so a CI log gave no
3870
+ // hint and the rule read as broken rather than misconfigured.
3871
+ ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
3570
3872
  console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
3571
3873
  ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
3572
3874
  }
@@ -4587,6 +4889,7 @@ const COMMAND_HELP = {
4587
4889
  usage: " vigiles audit [dir...] Grade it on your machine. Reports everything, fails nothing.",
4588
4890
  detail: [
4589
4891
  " 2+ dirs → a leaderboard. Writes vigiles-report.html + .json (auto-gitignored).",
4892
+ " --single audit this dir as ONE harness, even if it holds many bundles",
4590
4893
  " The executing checks (run your hooks · live MCP · do skills fire?) run only",
4591
4894
  " interactively — audit asks once and remembers; automation uses the testing API.",
4592
4895
  ],
@@ -4721,7 +5024,12 @@ function printUsage(command) {
4721
5024
  console.log("New here? Start with `vigiles audit .`");
4722
5025
  if (command && command !== "--help") {
4723
5026
  console.log(`\nUnknown command: "${command}"`);
4724
- process.exit(1);
5027
+ // 2, not 1 — `docs/cli.md` fixes the contract as 1 = "I ran, and what you
5028
+ // asked about is bad", 2 = "I could not do what you asked". A typo'd verb is
5029
+ // the second, and the unknown-FLAG path (cli-flag-check.ts) already exits 2.
5030
+ // A script telling "found problems" from "could not start" by exit code read
5031
+ // a mistyped command as a finding.
5032
+ process.exit(2);
4725
5033
  }
4726
5034
  }
4727
5035
  // ---------------------------------------------------------------------------
@@ -5914,6 +6222,14 @@ async function runHookProgramCommand(file) {
5914
6222
  function ensureReportGitignored(cwd, entries) {
5915
6223
  if (entries.length === 0)
5916
6224
  return;
6225
+ // An `--out` outside the repo produced entries like
6226
+ // `../../../../private/tmp/x/vigiles-report.json`, which ignore NOTHING —
6227
+ // .gitignore does not reach outside its own tree — and accumulate one dead
6228
+ // block per output path. Worse in principle than in practice: `audit` is
6229
+ // documented as a read-only report, and this made it edit a tracked file for
6230
+ // no benefit at all. Inside the repo the write is expected and documented.
6231
+ if (entries.some((e) => e.startsWith("..") || (0, node_path_1.isAbsolute)(e)))
6232
+ return;
5917
6233
  const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
5918
6234
  try {
5919
6235
  if (!(0, node_fs_1.existsSync)(gi)) {
@@ -6429,10 +6745,37 @@ async function main() {
6429
6745
  // carries `market` into its explanation and exits 2 — this repo's own rule
6430
6746
  // that 1 is "I measured, and it's bad" and 2 is "I could not do what you
6431
6747
  // 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) {
6748
+ // `--single` pins the SINGLE-harness reading of the given directory, whatever
6749
+ // is nested inside it. Without it, a repo holding many bundles auto-switches
6750
+ // to the leaderboard and there is no way back — so the full ring report for
6751
+ // the ROOT was simply unreachable, and the reported workaround was a CI job
6752
+ // looping `audit` over 29 directories. The mode branch already existed; this
6753
+ // only stops it being decided for you.
6754
+ const single = args.includes("--single");
6755
+ // `--single` names ONE harness, so more than one explicit directory is a
6756
+ // contradiction. Refuse it (exit 2 = "could not do what you asked") rather
6757
+ // than auditing the first and dropping the rest — silently honouring half
6758
+ // an argument list is the same defect class as the ignored `--out` above.
6759
+ if (single && dirs.length > 1) {
6760
+ console.error(`--single audits ONE directory as one harness, but ${String(dirs.length)} were given. ` +
6761
+ `Drop --single for a leaderboard, or pass a single directory.`);
6762
+ process.exit(2);
6763
+ }
6764
+ const targets = !single && market && market.onDisk.length > 0
6765
+ ? [...market.onDisk]
6766
+ : dirs;
6767
+ if (!single && targets.length > 1) {
6434
6768
  // Multiple targets → rank them (the leaderboard engine). `--md` emits the
6435
6769
  // publishable Markdown table (a README / gist / the leaderboard site).
6770
+ //
6771
+ // `--out` writes nothing here: the per-bundle HTML/JSON report is built
6772
+ // in the single-target branch below, and this one only prints a table.
6773
+ // SAY SO. A silent no-op is how a CI job ships an empty artifact and
6774
+ // stays green — which is exactly how this was found, and the same
6775
+ // never-fail-silently shape as the rest of this file.
6776
+ if (args.some((a) => a.startsWith("--out=")) && !json)
6777
+ console.log(`⚠ --out is ignored here: ${String(targets.length)} bundles → leaderboard mode, ` +
6778
+ `which produces no per-bundle report. Run audit per directory to write one.`);
6436
6779
  const scores = (0, leaderboard_js_1.rankPlugins)(targets);
6437
6780
  const text = args.includes("--md")
6438
6781
  ? (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