vigiles 25.1.0 → 26.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -16,10 +16,10 @@ exports.lintTotals = lintTotals;
16
16
  exports.discoverNestedBundles = discoverNestedBundles;
17
17
  const node_fs_1 = require("node:fs");
18
18
  const node_path_1 = require("node:path");
19
- const minimatch_1 = require("minimatch");
20
19
  const repo_path_js_1 = require("./core/repo-path.js");
21
20
  const node_child_process_1 = require("node:child_process");
22
21
  const glob_1 = require("glob");
22
+ const exclude_js_1 = require("./exclude.js");
23
23
  const generate_types_js_1 = require("./core/generate-types.js");
24
24
  const generate_harness_js_1 = require("./core/generate-harness.js");
25
25
  const capability_diff_js_1 = require("./core/capability-diff.js");
@@ -87,18 +87,29 @@ const load_hook_js_1 = require("./load-hook.js");
87
87
  // ---------------------------------------------------------------------------
88
88
  // Constants
89
89
  // ---------------------------------------------------------------------------
90
- const IGNORE_NODE_MODULES = ["node_modules/**"];
90
+ // The always-excluded floor (node_modules/dist/.git/.vigiles) lives in
91
+ // src/exclude.ts, folded into every ExcludeSet — no walk carries a private copy.
91
92
  // ---------------------------------------------------------------------------
92
93
  // Spec loading
93
94
  // ---------------------------------------------------------------------------
94
- function findSpecs(pattern) {
95
+ /**
96
+ * Every `*.md.spec.ts` under cwd that `.vigilesrc.json#exclude` does not drop.
97
+ *
98
+ * 🔴 `excludes` IS REQUIRED, NOT DEFAULTED (#192). This function hard-coded its
99
+ * own ignore list for a year while the loaded config's `exclude` never reached
100
+ * it — so a repo that excluded a directory of frozen, un-loadable fixtures got
101
+ * "Compilation complete with errors" on every recompile hook. Eight call sites
102
+ * each had the config in scope; none passed it. An optional parameter is how
103
+ * that happens again; a required one makes the ninth call site a tsc error.
104
+ */
105
+ function findSpecs(excludes, pattern) {
95
106
  const glob = pattern ?? "**/*.md.spec.ts";
96
107
  return (0, glob_1.globSync)(glob, {
97
108
  // `dot: true` so specs that live in a sync tool's source slot (e.g.
98
109
  // `.ruler/AGENTS.md.spec.ts`, the redirect target) are discovered by
99
110
  // compile/lint/the recompile hook — not just root-level specs.
100
111
  dot: true,
101
- ignore: [...IGNORE_NODE_MODULES, "dist/**", ".git/**"],
112
+ ignore: excludes.globIgnore,
102
113
  cwd: process.cwd(),
103
114
  });
104
115
  }
@@ -464,16 +475,16 @@ function compileRailwayToFile(spec, specPath, knownAgents) {
464
475
  return false;
465
476
  }
466
477
  /** Names of every compiled agent spec in the project — resolves delegate() targets. */
467
- async function collectAgentNames() {
478
+ async function collectAgentNames(excludes) {
468
479
  const names = [];
469
- for (const p of findSpecs()) {
480
+ for (const p of findSpecs(excludes)) {
470
481
  const s = await loadSpec(p);
471
482
  if (s && s._specType === "agent")
472
483
  names.push(s.name);
473
484
  }
474
485
  return names;
475
486
  }
476
- async function compile(specPaths, config, opts = {}) {
487
+ async function compile(specPaths, config, excludes, opts = {}) {
477
488
  let allValid = true;
478
489
  // Parse the declared harness set ONCE (alias-normalized) and feed both the
479
490
  // dialect pick and the mirror from it — no re-parsing, no cwd-sniffing in the
@@ -540,7 +551,7 @@ async function compile(specPaths, config, opts = {}) {
540
551
  allValid = false;
541
552
  }
542
553
  else if (spec._specType === "railway") {
543
- knownAgents ??= await collectAgentNames();
554
+ knownAgents ??= await collectAgentNames(excludes);
544
555
  if (!compileRailwayToFile(spec, specPath, knownAgents))
545
556
  allValid = false;
546
557
  }
@@ -663,12 +674,12 @@ function check(filePaths, silent = false) {
663
674
  * Catches spec bloat — rules that likely say the same thing in different words.
664
675
  * Uses information-theoretic distance (gzip-based) — no LLM, fully deterministic.
665
676
  */
666
- async function findDuplicateRules(threshold = 0.3, silent = false, scopeFiles) {
677
+ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, scopeFiles) {
667
678
  const log = (msg) => {
668
679
  if (!silent)
669
680
  console.log(msg);
670
681
  };
671
- const allSpecs = findSpecs();
682
+ const allSpecs = findSpecs(excludes);
672
683
  // If lint was invoked with explicit file arguments, only scan the specs
673
684
  // for those files — otherwise an unrelated duplicate elsewhere in the
674
685
  // repo would fail a targeted CI check (e.g. `vigiles lint path/foo.md`).
@@ -1173,7 +1184,7 @@ function sharedDirsRootFor(scanTarget) {
1173
1184
  * third-party plugins — and scoring someone else's vendored plugin as if it were
1174
1185
  * yours is the false-positive that gets a gate switched off.
1175
1186
  */
1176
- function discoverNestedBundles(root, exclude = []) {
1187
+ function discoverNestedBundles(root, excludes) {
1177
1188
  const out = [];
1178
1189
  const skip = new Set([
1179
1190
  "node_modules",
@@ -1184,12 +1195,12 @@ function discoverNestedBundles(root, exclude = []) {
1184
1195
  ]);
1185
1196
  const isBundle = (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".claude-plugin", "plugin.json")) ||
1186
1197
  (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "skills"));
1187
- const excluded = (rel) => exclude.some((pattern) => rel === pattern ||
1188
- rel.startsWith(`${pattern}/`) ||
1189
- (0, minimatch_1.minimatch)(rel, pattern) ||
1190
- (0, minimatch_1.minimatch)(rel, `${pattern}/**`));
1198
+ // Root-relative to the REPO (excludes.root), not to `root`: `lint some/dir`
1199
+ // still honours a repo-root `exclude`. One predicate for every walk (#192).
1200
+ const excluded = (dir) => excludes.matches((0, node_path_1.relative)(excludes.root, dir));
1191
1201
  let entries;
1192
1202
  try {
1203
+ // eslint-disable-next-line no-restricted-syntax -- the nested-bundle walk: every entry is filtered by excludes.matches() below
1193
1204
  entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
1194
1205
  .filter((e) => e.isDirectory() && !skip.has(e.name) && !e.name.startsWith("."))
1195
1206
  .map((e) => e.name);
@@ -1199,7 +1210,7 @@ function discoverNestedBundles(root, exclude = []) {
1199
1210
  }
1200
1211
  for (const name of entries) {
1201
1212
  const dir = (0, node_path_1.join)(root, name);
1202
- if (excluded(name))
1213
+ if (excluded(dir))
1203
1214
  continue;
1204
1215
  // A container (`plugins/`) holds bundles; a bundle may also sit directly.
1205
1216
  if (isBundle(dir)) {
@@ -1208,6 +1219,7 @@ function discoverNestedBundles(root, exclude = []) {
1208
1219
  }
1209
1220
  let inner;
1210
1221
  try {
1222
+ // eslint-disable-next-line no-restricted-syntax -- the nested-bundle walk: every entry is filtered by excludes.matches() below
1211
1223
  inner = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
1212
1224
  .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1213
1225
  .map((e) => e.name);
@@ -1217,7 +1229,7 @@ function discoverNestedBundles(root, exclude = []) {
1217
1229
  }
1218
1230
  for (const child of inner) {
1219
1231
  const sub = (0, node_path_1.join)(dir, child);
1220
- if (excluded(`${name}/${child}`))
1232
+ if (excluded(sub))
1221
1233
  continue;
1222
1234
  if (isBundle(sub))
1223
1235
  out.push(sub);
@@ -1266,29 +1278,25 @@ function overBundles(fn, config, silent, adapter, roots) {
1266
1278
  * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1267
1279
  * so a repo with no specs does no extra work at all.
1268
1280
  */
1269
- async function checkSpecRefs(config, silent, dialect) {
1281
+ async function checkSpecRefs(excludes, config, silent, dialect) {
1270
1282
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1271
1283
  if (!sev)
1272
1284
  return { issues: 0, errors: 0 };
1273
1285
  const found = [];
1274
- for (const specPath of findSpecs()) {
1286
+ let knownAgents;
1287
+ for (const specPath of findSpecs(excludes)) {
1275
1288
  const target = specPath.replace(/\.spec\.ts$/, "");
1276
1289
  if (!(0, node_fs_1.existsSync)(target))
1277
1290
  continue; // never compiled — `compile` reports it
1278
1291
  const spec = await loadSpec(specPath);
1279
- if (!spec || spec._specType !== "claude")
1292
+ if (!spec)
1280
1293
  continue;
1281
1294
  try {
1282
- const { errors } = (0, compile_js_1.compileClaude)(spec, {
1283
- basePath: process.cwd(),
1284
- specFile: specPath,
1285
- dialect,
1286
- maxRules: config?.maxRules,
1287
- maxTokens: config?.maxTokens,
1288
- maxSectionLines: config?.maxSectionLines,
1289
- catalogOnly: config?.catalogOnly,
1290
- linters: config?.linters,
1291
- });
1295
+ // Railway is the one type whose validation needs the sibling agent names;
1296
+ // collected lazily so a repo with no railway spec never pays for the walk.
1297
+ if (spec._specType === "railway")
1298
+ knownAgents ??= await collectAgentNames(excludes);
1299
+ const errors = specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []);
1292
1300
  for (const e of errors)
1293
1301
  found.push(`${target}: ${e.message} (from ${specPath})`);
1294
1302
  }
@@ -1307,6 +1315,53 @@ async function checkSpecRefs(config, silent, dialect) {
1307
1315
  }
1308
1316
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1309
1317
  }
1318
+ /**
1319
+ * The ONE spec-type dispatcher, in the only shape both directions can share:
1320
+ * errors, no writing. `compile` reaches its compilers through the
1321
+ * `compile*ToFile` wrappers (which write and print); `checkSpecRefs` reaches the
1322
+ * SAME compilers through this, because a read must never write.
1323
+ *
1324
+ * Why it exists (#190): `checkSpecRefs` used to call `compileClaude` directly and
1325
+ * skip every other spec type, so a SKILL.md / subagent / railway artifact
1326
+ * committed with a since-deleted reference hashed cleanly against its own header
1327
+ * forever — `lint` green, `compile` red. Measured on this repo:
1328
+ * `examples/SKILL.md.spec.ts` names `skills/enforce-rules-format/SKILL.md`, which
1329
+ * does not exist (the skill lives under `.claude/skills/`), and `lint` reported
1330
+ * `hash valid`.
1331
+ *
1332
+ * A switch rather than a per-type "ref accessor": all four compilers already
1333
+ * return the same `errors: CompileError[]`, so there is nothing to normalize —
1334
+ * only the options differ. Exhaustive over `_specType`, so a FIFTH spec type is
1335
+ * a tsc error here instead of a silent skip, which is the failure this closes.
1336
+ */
1337
+ function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1338
+ const basePath = process.cwd();
1339
+ switch (spec._specType) {
1340
+ case "claude":
1341
+ return (0, compile_js_1.compileClaude)(spec, {
1342
+ basePath,
1343
+ specFile: specPath,
1344
+ dialect,
1345
+ maxRules: config?.maxRules,
1346
+ maxTokens: config?.maxTokens,
1347
+ maxSectionLines: config?.maxSectionLines,
1348
+ catalogOnly: config?.catalogOnly,
1349
+ linters: config?.linters,
1350
+ }).errors;
1351
+ case "skill":
1352
+ return (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })
1353
+ .errors;
1354
+ case "agent":
1355
+ return (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })
1356
+ .errors;
1357
+ case "railway":
1358
+ return (0, compile_js_1.compileRailway)(spec, { specFile: specPath, knownAgents }).errors;
1359
+ default:
1360
+ // A `pipeline` spec compiles through its underlying railway, so it has no
1361
+ // artifact of its own to re-derive refs for.
1362
+ return [];
1363
+ }
1364
+ }
1310
1365
  /**
1311
1366
  * Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
1312
1367
  * that failed to compile cannot reach the disk — there is no stamp to pass.
@@ -1319,7 +1374,7 @@ async function checkSpecRefs(config, silent, dialect) {
1319
1374
  function writeArtifact(outputPath, artifact) {
1320
1375
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1321
1376
  }
1322
- async function runLint(restArgs, flags, config) {
1377
+ async function runLint(restArgs, flags, excludes, config) {
1323
1378
  const summary = flags.includes("--summary");
1324
1379
  const json = flags.includes("--json");
1325
1380
  const silent = summary || json;
@@ -1364,7 +1419,7 @@ async function runLint(restArgs, flags, config) {
1364
1419
  // is the false positive that gets a gate turned off. So the DEFAULT fixes the
1365
1420
  // SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
1366
1421
  // whole repo, which is what a CI gate needs.
1367
- const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
1422
+ const nestedBundles = discoverNestedBundles(scanRoot, excludes);
1368
1423
  const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1369
1424
  const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1370
1425
  if (!silent && nestedBundles.length > 0) {
@@ -1381,7 +1436,7 @@ async function runLint(restArgs, flags, config) {
1381
1436
  // hand-edit of a compiled subagent slipped past `lint`. A hand-written agent
1382
1437
  // (no hash header) stays a no-op, and require-instructions-spec is filename-
1383
1438
  // scoped to CLAUDE/AGENTS so agents are never falsely flagged as spec-less.
1384
- const files = findInstructionFiles(restArgs, config?.exclude, adapter.layout.agentDir);
1439
+ const files = findInstructionFiles(restArgs, excludes, adapter.layout.agentDir);
1385
1440
  // 1. Verify hashes and structure
1386
1441
  if (!silent) {
1387
1442
  if (files.length > 0) {
@@ -1401,15 +1456,15 @@ async function runLint(restArgs, flags, config) {
1401
1456
  // 2. Coverage gaps (discover)
1402
1457
  if (!silent)
1403
1458
  console.log("\nLinter rule coverage:\n");
1404
- const coverage = discover(silent);
1459
+ const coverage = discover(excludes, silent);
1405
1460
  // 3. Duplicate rule detection (NCD). Scope to the requested files when
1406
1461
  // lint was invoked with explicit paths, so targeted CI checks don't
1407
1462
  // fail on unrelated duplicates elsewhere in the repo.
1408
1463
  if (!silent)
1409
1464
  console.log("\nDuplicate rule detection:\n");
1410
- const dups = await findDuplicateRules(0.3, silent, restArgs.length > 0 ? files : undefined);
1465
+ const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined);
1411
1466
  // 4. Guidance rule count (strengthen suggestions moved to /strengthen skill)
1412
- const guidanceCount = await countGuidanceRules(silent);
1467
+ const guidanceCount = await countGuidanceRules(excludes, silent);
1413
1468
  // 5. Integrity check (hand-edit detection via SHA-256 hash)
1414
1469
  const integritySeverity = config?.rules.integrity ?? "warn";
1415
1470
  let integrityErrors = 0;
@@ -1419,7 +1474,7 @@ async function runLint(restArgs, flags, config) {
1419
1474
  integrityErrors = checkIntegrityForFiles(files, integritySeverity, silent);
1420
1475
  }
1421
1476
  // 6. Coverage thresholds (gates CI when severity is "error")
1422
- const coverageErrors = await checkCoverageThresholds(coverage, config, silent);
1477
+ const coverageErrors = await checkCoverageThresholds(excludes, coverage, config, silent);
1423
1478
  // 7. Orphan docs check — OPT-IN (the `orphans` block in .vigilesrc.json is
1424
1479
  // the on-switch). "Unreferenced" only means "rot" for a hand-cross-linked
1425
1480
  // corpus; on a nav-managed doc site (Docusaurus/MkDocs) the page graph lives
@@ -1449,6 +1504,10 @@ async function runLint(restArgs, flags, config) {
1449
1504
  basePath: process.cwd(),
1450
1505
  include: orphansCfg.include,
1451
1506
  exclude: orphansCfg.exclude,
1507
+ // The repo-wide `exclude` is the FLOOR under the rule's own `exclude`
1508
+ // (union, never override): an excluded corpus is neither an orphan
1509
+ // candidate nor a source of references that keep a doc alive (#192).
1510
+ repoExclude: excludes.ignore,
1452
1511
  // Exempt every registered harness's surface files (instruction file,
1453
1512
  // SKILL.md, subagents, commands) as orphan candidates — layout-driven so
1454
1513
  // core carries no harness literal (see src/core/orphans.ts).
@@ -1468,8 +1527,8 @@ async function runLint(restArgs, flags, config) {
1468
1527
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1469
1528
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1470
1529
  // is unchanged, not that what it names still exists (#173).
1471
- const specRefs = await checkSpecRefs(config, silent, adapter.dialect);
1472
- const untested = overBundles(checkUntestedSurfaces, config, silent, adapter, lintRoots);
1530
+ const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1531
+ const untested = overBundles((c, s, a, r) => checkUntestedSurfaces(excludes, c, s, a, r), config, silent, adapter, lintRoots);
1473
1532
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1474
1533
  // rail against the harness catalog (the moat). n/a on a harness with no
1475
1534
  // subagents. Off by default unless a severity is configured; warning surfaces
@@ -1572,7 +1631,7 @@ async function runLint(restArgs, flags, config) {
1572
1631
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1573
1632
  // grew to do the gating instead. One unpassed argument, that whole chain.
1574
1633
  const docRefReport = docRefSeverity
1575
- ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: config?.exclude })
1634
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.ignore })
1576
1635
  : {
1577
1636
  filesScanned: 0,
1578
1637
  filesIgnored: 0,
@@ -1744,10 +1803,10 @@ function printLintSummary(report) {
1744
1803
  console.log(`vigiles: ${parts.join(" / ")}`);
1745
1804
  }
1746
1805
  }
1747
- function collectDocumentedRules() {
1806
+ function collectDocumentedRules(excludes) {
1748
1807
  const documented = new Set();
1749
1808
  const mdFiles = (0, glob_1.globSync)("**/CLAUDE.md", {
1750
- ignore: IGNORE_NODE_MODULES,
1809
+ ignore: excludes.globIgnore,
1751
1810
  cwd: process.cwd(),
1752
1811
  });
1753
1812
  for (const mdFile of mdFiles) {
@@ -1789,14 +1848,14 @@ function printLinterCoverage(linter, documentedRules, silent = false) {
1789
1848
  log("");
1790
1849
  return { enabled: linter.rules.length, documented: documented.length };
1791
1850
  }
1792
- function discover(silent = false) {
1851
+ function discover(excludes, silent = false) {
1793
1852
  const log = (msg) => {
1794
1853
  if (!silent)
1795
1854
  console.log(msg);
1796
1855
  };
1797
1856
  log("Scanning project for linter rules...\n");
1798
1857
  const result = (0, generate_types_js_1.generateTypes)({ basePath: process.cwd() });
1799
- const documentedRules = collectDocumentedRules();
1858
+ const documentedRules = collectDocumentedRules(excludes);
1800
1859
  log("Detected linters:\n");
1801
1860
  let totalEnabled = 0;
1802
1861
  let totalDocumented = 0;
@@ -1889,6 +1948,7 @@ function discoverAdoptableSurfaces(cwd) {
1889
1948
  const abs = (0, node_path_1.resolve)(cwd, root);
1890
1949
  if (!(0, node_fs_1.existsSync)(abs))
1891
1950
  continue;
1951
+ // eslint-disable-next-line no-restricted-syntax -- init's shallow adoptable-surface sweep, top level only (exclude.ts exceptions)
1892
1952
  for (const e of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1893
1953
  const rel = `${root}/${e.name}/SKILL.md`;
1894
1954
  if (e.isDirectory() && unspecced(rel))
@@ -1899,6 +1959,7 @@ function discoverAdoptableSurfaces(cwd) {
1899
1959
  const abs = (0, node_path_1.resolve)(cwd, root);
1900
1960
  if (!(0, node_fs_1.existsSync)(abs))
1901
1961
  continue;
1962
+ // eslint-disable-next-line no-restricted-syntax -- init's shallow adoptable-surface sweep, top level only (exclude.ts exceptions)
1902
1963
  for (const e of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1903
1964
  const rel = `${root}/${e.name}`;
1904
1965
  if (e.isFile() && e.name.endsWith(".md") && unspecced(rel))
@@ -1970,6 +2031,7 @@ const RULE_INVENTORY_SKIP_DIRS = new Set([
1970
2031
  /** readdir that returns [] instead of throwing (perms, races). */
1971
2032
  function safeReaddir(dir) {
1972
2033
  try {
2034
+ // eslint-disable-next-line no-restricted-syntax -- lint-config collection for the linter catalog, not repo policing (exclude.ts exceptions)
1973
2035
  return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
1974
2036
  }
1975
2037
  catch {
@@ -2011,11 +2073,11 @@ function collectLintConfigText(root) {
2011
2073
  * file(s) + lint-config TEXT (never executed) and map documented intents to
2012
2074
  * off-the-shelf rules + whether they're already configured. Best-effort, fs-only;
2013
2075
  * NO model, NO config execution — safe on any repo. Composition-root. */
2014
- function computeRuleInventory(root, instructionFile) {
2076
+ function computeRuleInventory(root, instructionFile, excludes) {
2015
2077
  try {
2016
2078
  // The inventory maps intents → rules; it has no per-file line provenance, so
2017
2079
  // the concatenated text is fine here (unlike the routing preview below).
2018
- const instructionText = gatherInstructionFiles(root, instructionFile)
2080
+ const instructionText = gatherInstructionFiles(root, instructionFile, excludes)
2019
2081
  .map((f) => f.text)
2020
2082
  .join("\n");
2021
2083
  if (!instructionText.trim())
@@ -2034,7 +2096,7 @@ function computeRuleInventory(root, instructionFile) {
2034
2096
  * `research/CLAUDE.md`, …), skipping fixture/demo/build/test dirs (`isFixturePath`)
2035
2097
  * so a repo's real memory is read without the test-fixture noise. `.claude/` rule
2036
2098
  * sources remain a future source. research/rule-enforcer-multilang-design.md §0. */
2037
- function gatherInstructionFiles(root, instructionFile) {
2099
+ function gatherInstructionFiles(root, instructionFile, excludes) {
2038
2100
  const raw = [];
2039
2101
  const collect = (rel) => {
2040
2102
  const p = (0, node_path_1.resolve)(root, rel);
@@ -2049,11 +2111,14 @@ function gatherInstructionFiles(root, instructionFile) {
2049
2111
  // Root instruction files first (stable, deterministic order).
2050
2112
  for (const name of new Set([instructionFile, "CLAUDE.md", "AGENTS.md"]))
2051
2113
  collect(name);
2052
- // Nested subdirectory memory, minus fixture/demo/build/test noise.
2114
+ // Nested subdirectory memory, minus fixture/demo/build/test noise. Two
2115
+ // filters, deliberately: `isFixturePath` is the zero-config floor (audit
2116
+ // reads any repo with no .vigilesrc.json), and the configured `exclude` is
2117
+ // unioned ON TOP for the dirs the heuristic cannot guess (#192).
2053
2118
  try {
2054
2119
  const nested = (0, glob_1.globSync)(["**/CLAUDE.md", "**/AGENTS.md"], {
2055
2120
  cwd: root,
2056
- ignore: [...IGNORE_NODE_MODULES, "dist/**", ".git/**"],
2121
+ ignore: excludes.globIgnore,
2057
2122
  })
2058
2123
  .filter((rel) => !(0, instruction_sources_js_1.isFixturePath)(rel))
2059
2124
  .sort();
@@ -2111,9 +2176,9 @@ function hasPythonSurface(root) {
2111
2176
  }
2112
2177
  return false;
2113
2178
  }
2114
- function computeRuleRouting(root, instructionFile) {
2179
+ function computeRuleRouting(root, instructionFile, excludes) {
2115
2180
  try {
2116
- const files = gatherInstructionFiles(root, instructionFile);
2181
+ const files = gatherInstructionFiles(root, instructionFile, excludes);
2117
2182
  if (files.every((f) => !f.text.trim()))
2118
2183
  return undefined;
2119
2184
  // Own-repo + consented → enumerate the live rule catalog of whichever
@@ -2246,16 +2311,23 @@ function scaffoldSpec(args) {
2246
2311
  logAdoptedSurface(target, specPath, "subagent", unmappedKeys);
2247
2312
  }
2248
2313
  else {
2249
- const { source, tier, sectionCount } = (0, adopt_js_1.adoptMarkdown)(md, (0, node_path_1.basename)(target));
2314
+ const { source, tier, sectionCount, adoptedRefs } = (0, adopt_js_1.adoptMarkdown)(md, (0, node_path_1.basename)(target),
2315
+ // RESOLVE-NOW, not a heuristic. The undecidable question is "is this OUR
2316
+ // path or one inside the repo this document DESCRIBES" — the wall that
2317
+ // disabled `doc-refs`. Asking "does it resolve here, right now?" sidesteps
2318
+ // it: a described repo's path does not exist locally, so only already-green
2319
+ // refs are emitted and adoption can never turn a passing file red.
2320
+ { exists: (ref) => (0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), ref)) });
2250
2321
  (0, node_fs_1.writeFileSync)(specAbs, source);
2251
2322
  console.log(`Adopted ${target} → ${specPath} (${tier}, ${String(sectionCount)} section${sectionCount === 1 ? "" : "s"}). ` +
2323
+ (adoptedRefs && adoptedRefs.length > 0
2324
+ ? `${String(adoptedRefs.length)} reference${adoptedRefs.length === 1 ? "" : "s"} resolved and became verified \`file()\` calls — they now fail the build if the file moves. `
2325
+ : "") +
2252
2326
  `Run \`vigiles compile\` and review the diff; the \`/strengthen\` skill upgrades prose to verified rules.`);
2253
- // Adoption is faithful by design: it infers NO rules and extracts NO refs,
2254
- // so a raw adoption verifies nothing on its own. Saying so is the whole
2255
- // fix — the cost (the file becomes a build artifact, edits move into TS)
2256
- // lands immediately, and without this line the benefit reads as zero
2257
- // rather than as not-yet-claimed. Deliberately not a heuristic extractor:
2258
- // guessing refs out of prose is what got `doc-refs` disabled.
2327
+ // Adoption infers NO RULES (that stays `strengthen`'s job) but it DOES now
2328
+ // extract refs that resolve — measured 2026-08-28 on a 51-skill monorepo,
2329
+ // where extracting nothing meant the cost landed at once (build artifact,
2330
+ // edits into TS) while the payoff waited on a manual pass nobody ran.
2259
2331
  if (tier === "raw")
2260
2332
  console.log(` ℹ 0 refs extracted — this spec verifies nothing yet. Wrap paths in \`file()\` ` +
2261
2333
  `and commands in \`cmd()\` to make \`compile\` check them.`);
@@ -2401,6 +2473,7 @@ function specReferencedElsewhere(specFile, ejectedFile) {
2401
2473
  const dir = stack.pop();
2402
2474
  let entries;
2403
2475
  try {
2476
+ // eslint-disable-next-line no-restricted-syntax -- eject's is-this-spec-compiled-elsewhere safety check; wider is safer (exclude.ts exceptions)
2404
2477
  entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
2405
2478
  }
2406
2479
  catch {
@@ -2904,15 +2977,17 @@ async function setupPillar1(detected, targetValue, harnesses) {
2904
2977
  // run `npm install` yet, so compiling would just error; defer it with a clear
2905
2978
  // next step instead of a scary stack-traceless "failed to load".
2906
2979
  const canCompile = canResolveVigiles(cwd);
2980
+ const initConfig = (0, validate_js_1.loadConfig)();
2981
+ const excludes = (0, exclude_js_1.excludeSet)(cwd, initConfig.exclude);
2907
2982
  const specs = canCompile
2908
- ? findSpecs().filter((s) => {
2983
+ ? findSpecs(excludes).filter((s) => {
2909
2984
  const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
2910
2985
  return !(0, node_fs_1.existsSync)(tf) || targetHasHash(tf);
2911
2986
  })
2912
2987
  : [];
2913
2988
  if (specs.length > 0) {
2914
2989
  console.log("\nCompiling specs...");
2915
- await compile(specs, (0, validate_js_1.loadConfig)());
2990
+ await compile(specs, initConfig, excludes);
2916
2991
  }
2917
2992
  else if (!canCompile) {
2918
2993
  // Honest, project-type-aware guidance. A JS repo just needs `npm install`
@@ -3476,7 +3551,7 @@ function untestedRules(config) {
3476
3551
  * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
3477
3552
  * untested count plus the severity-gated error count.
3478
3553
  */
3479
- function checkUntestedSurfaces(config, silent, adapter, scanRoot) {
3554
+ function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3480
3555
  const { severity: sevFor, anyEnabled, options } = untestedRules(config);
3481
3556
  if (!anyEnabled)
3482
3557
  return { untested: 0, errors: 0 };
@@ -3484,6 +3559,10 @@ function checkUntestedSurfaces(config, silent, adapter, scanRoot) {
3484
3559
  ...options,
3485
3560
  basePath: scanRoot,
3486
3561
  layout: adapter.layout,
3562
+ // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
3563
+ // it. Union, never override — a rule option must not re-admit a vendored
3564
+ // corpus the repo excluded (#192).
3565
+ exclude: [...(options.exclude ?? []), ...excludes.ignore],
3487
3566
  });
3488
3567
  if (!silent) {
3489
3568
  console.log("\nUntested surfaces:\n");
@@ -4155,7 +4234,7 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4155
4234
  * avoids depending on a pre-built `dist/` tree, which the setup-generated
4156
4235
  * CI step doesn't guarantee.
4157
4236
  */
4158
- async function checkCoverageThresholds(coverage, config, silent) {
4237
+ async function checkCoverageThresholds(excludes, coverage, config, silent) {
4159
4238
  const severity = (0, types_js_1.ruleSeverity)(config?.rules.coverage);
4160
4239
  if (!severity)
4161
4240
  return 0;
@@ -4181,9 +4260,9 @@ async function checkCoverageThresholds(coverage, config, silent) {
4181
4260
  }
4182
4261
  if (opts.scripts !== undefined) {
4183
4262
  // Load all claude specs so coverage doesn't depend on a built dist/.
4184
- const loaded = await Promise.all(findSpecs().map(loadSpec));
4263
+ const loaded = await Promise.all(findSpecs(excludes).map(loadSpec));
4185
4264
  const claudeSpecs = loaded.filter((s) => s?._specType === "claude");
4186
- const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs);
4265
+ const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs, excludes.ignore);
4187
4266
  const ok = metric.passing;
4188
4267
  if (!ok)
4189
4268
  failing++;
@@ -4192,8 +4271,8 @@ async function checkCoverageThresholds(coverage, config, silent) {
4192
4271
  }
4193
4272
  return severity === "error" ? failing : 0;
4194
4273
  }
4195
- async function countGuidanceRules(silent = false) {
4196
- const specs = findSpecs();
4274
+ async function countGuidanceRules(excludes, silent = false) {
4275
+ const specs = findSpecs(excludes);
4197
4276
  if (specs.length === 0)
4198
4277
  return 0;
4199
4278
  let count = 0;
@@ -4214,7 +4293,22 @@ async function countGuidanceRules(silent = false) {
4214
4293
  // ---------------------------------------------------------------------------
4215
4294
  // Command handlers for main()
4216
4295
  // ---------------------------------------------------------------------------
4217
- function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4296
+ /**
4297
+ * An explicitly named path is processed even when `exclude` matches it, and ONE
4298
+ * line says so. `exclude` filters DISCOVERY; an argument is an instruction.
4299
+ * Measured 2026-09-03: ripgrep and tsc do this silently, ESLint skips the file
4300
+ * with a warning, prettier skips it and reports "all files use Prettier code
4301
+ * style" — the silent no-op. We take rg/tsc's semantics with ESLint's loudness.
4302
+ * Returns the path unchanged so it composes inside a `.map`.
4303
+ */
4304
+ function noteExplicitOverride(excludes, path, verb) {
4305
+ const pattern = excludes.explain((0, node_path_1.relative)(excludes.root, (0, node_path_1.resolve)(path)));
4306
+ if (pattern !== null) {
4307
+ console.log(`note: ${path} matches exclude "${pattern}" — ${verb} because you named it`);
4308
+ }
4309
+ return path;
4310
+ }
4311
+ function findInstructionFiles(restArgs, excludes, agentDir = "") {
4218
4312
  const patterns = ["**/CLAUDE.md", "**/AGENTS.md", "**/SKILL.md"];
4219
4313
  // Include the active harness's subagent dir so a COMPILED `agents/<name>.md`
4220
4314
  // (a vigiles-hashed file) is integrity-checked (dogfood E2). Empty for a
@@ -4222,12 +4316,17 @@ function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4222
4316
  if (agentDir !== "")
4223
4317
  patterns.push(`**/${agentDir}/*.md`);
4224
4318
  // `exclude` (from .vigilesrc.json) drops vendored/benchmark fixtures the repo's
4225
- // own lint shouldn't police — a third-party CLAUDE.md isn't held to require-instructions-spec.
4226
- // node_modules/dist/.git stay always-excluded.
4227
- const ignore = [...IGNORE_NODE_MODULES, "dist/**", ".git/**", ...exclude];
4319
+ // own lint shouldn't police — a third-party CLAUDE.md isn't held to
4320
+ // require-instructions-spec. The FUNCTION face of the ExcludeSet, not the
4321
+ // string list: `discoverIn` may glob from a subdirectory (`vigiles lint
4322
+ // some/dir`), where a root-relative string pattern would match nothing.
4228
4323
  // Discover instruction files under one directory, as paths relative to cwd.
4229
4324
  const discoverIn = (dirAbs) => patterns
4230
- .flatMap((p) => (0, glob_1.globSync)(p, { ignore, cwd: dirAbs, absolute: true }))
4325
+ .flatMap((p) => (0, glob_1.globSync)(p, {
4326
+ ignore: excludes.globIgnore,
4327
+ cwd: dirAbs,
4328
+ absolute: true,
4329
+ }))
4231
4330
  .map((abs) => (0, node_path_1.relative)(process.cwd(), abs));
4232
4331
  if (restArgs.length === 0)
4233
4332
  return discoverIn(process.cwd());
@@ -4237,6 +4336,7 @@ function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4237
4336
  const out = [];
4238
4337
  for (const arg of restArgs) {
4239
4338
  const abs = (0, node_path_1.resolve)(process.cwd(), arg);
4339
+ noteExplicitOverride(excludes, arg, "linting");
4240
4340
  if ((0, node_fs_1.existsSync)(abs) && (0, node_fs_1.lstatSync)(abs).isDirectory()) {
4241
4341
  out.push(...discoverIn(abs));
4242
4342
  }
@@ -4674,9 +4774,14 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
4674
4774
  }
4675
4775
  return [...names];
4676
4776
  }
4777
+ const recordsConfig = (0, validate_js_1.loadConfig)();
4677
4778
  const scan = (0, test_coverage_js_1.findUntestedSurfaces)({
4678
4779
  basePath: cwd,
4679
- layout: harnessLayoutFor(cwd, (0, validate_js_1.loadConfig)(), harnessFlag),
4780
+ layout: harnessLayoutFor(cwd, recordsConfig, harnessFlag),
4781
+ // The repo-wide `exclude` only — the per-rule severities/testGlobs stay out
4782
+ // of record resolution on purpose (a run's probes must map to a surface
4783
+ // whether or not the untested-* rule for its kind is on).
4784
+ exclude: (0, exclude_js_1.excludeSet)(cwd, recordsConfig.exclude).ignore,
4680
4785
  });
4681
4786
  return (0, coverage_artifact_js_1.recordsFrom)({
4682
4787
  runs,
@@ -4754,7 +4859,7 @@ function gitHead(cwd) {
4754
4859
  return "";
4755
4860
  }
4756
4861
  }
4757
- async function handleRunScripts(kind, args, restArgs) {
4862
+ async function handleRunScripts(kind, args, restArgs, excludes) {
4758
4863
  const cwd = process.cwd();
4759
4864
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
4760
4865
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
@@ -4768,7 +4873,9 @@ async function handleRunScripts(kind, args, restArgs) {
4768
4873
  return;
4769
4874
  lockEnv = r;
4770
4875
  }
4771
- const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
4876
+ // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4877
+ // harness must not run as ours), but a script you NAME still runs (#192).
4878
+ const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.ignore);
4772
4879
  // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
4773
4880
  // a renamed file, or a glob that matched nothing fails LOUD instead of passing
4774
4881
  // green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
@@ -5481,8 +5588,18 @@ function evalLockNudgeHookCommand() {
5481
5588
  // heard nothing at all, while a repo that already tests got reminded. That is
5482
5589
  // backwards, and `untested-skill` already stated the missing half correctly;
5483
5590
  // it just lived in `vigiles lint`, which someone has to run by hand.
5484
- const msg = (0, test_coverage_js_1.skillTestNudge)(target, { ...options, basePath: cwd, layout }) ??
5485
- (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
5591
+ const msg = (0, test_coverage_js_1.skillTestNudge)(target, {
5592
+ ...options,
5593
+ basePath: cwd,
5594
+ layout,
5595
+ // Same union `vigiles lint` applies: the rule's exclude narrows, the
5596
+ // repo-wide exclude is the floor (#192) — the nudge must not report a
5597
+ // vendored skill the linter would never list.
5598
+ exclude: [
5599
+ ...(options.exclude ?? []),
5600
+ ...(0, exclude_js_1.excludeSet)(cwd, config.exclude).ignore,
5601
+ ],
5602
+ }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
5486
5603
  if (!msg)
5487
5604
  return;
5488
5605
  process.stdout.write(JSON.stringify({
@@ -6654,6 +6771,10 @@ async function main() {
6654
6771
  // Shared flags (--max-rules, --catalog-only) override the loaded config so
6655
6772
  // every GitHub Action input maps to a real CLI flag. See src/cli-flags.ts.
6656
6773
  const config = (0, cli_flags_js_1.applyConfigFlags)((0, validate_js_1.loadConfig)(), args);
6774
+ // `.vigilesrc.json#exclude`, parsed ONCE here and threaded to every walk that
6775
+ // polices the repo (src/exclude.ts). Built where the config is loaded so a
6776
+ // command cannot re-derive it differently — the drift #192 measured.
6777
+ const excludes = (0, exclude_js_1.excludeSet)(process.cwd(), config.exclude);
6657
6778
  switch (command) {
6658
6779
  // --- Primary commands ---
6659
6780
  case "init": {
@@ -6676,8 +6797,10 @@ async function main() {
6676
6797
  // a hook program → its harness config + stamp (cohesive-cli-surface). With
6677
6798
  // explicit args, partition by extension; bare, discover both.
6678
6799
  const specs = restArgs.length > 0
6679
- ? restArgs.filter((f) => f.endsWith(".spec.ts"))
6680
- : findSpecs();
6800
+ ? restArgs
6801
+ .filter((f) => f.endsWith(".spec.ts"))
6802
+ .map((f) => noteExplicitOverride(excludes, f, "compiling"))
6803
+ : findSpecs(excludes);
6681
6804
  const hooks = restArgs.length > 0
6682
6805
  ? restArgs.filter((f) => !f.endsWith(".spec.ts"))
6683
6806
  : (0, hook_install_js_1.discoverHookFiles)(process.cwd());
@@ -6688,7 +6811,8 @@ async function main() {
6688
6811
  }
6689
6812
  let valid = true;
6690
6813
  if (specs.length > 0)
6691
- valid = (await compile(specs, config, { harnessFlag })) && valid;
6814
+ valid =
6815
+ (await compile(specs, config, excludes, { harnessFlag })) && valid;
6692
6816
  valid = (await installHooks(hooks, harnessFlag, config.harness)) && valid;
6693
6817
  // Keep an existing whole-harness registry in sync (cheap, opt-in) so the
6694
6818
  // user never hand-runs `generate-harness`. Skipped when no harness.gen.ts.
@@ -6712,7 +6836,7 @@ async function main() {
6712
6836
  case "lint": {
6713
6837
  // lint = verify references + discover + guidance count
6714
6838
  const flags = args.slice(1).filter((a) => a.startsWith("--"));
6715
- const report = await runLint(restArgs, flags, config);
6839
+ const report = await runLint(restArgs, flags, excludes, config);
6716
6840
  annotateLintForGitHub(report, flags);
6717
6841
  const exitCode = lintExitCode(report);
6718
6842
  if (exitCode !== 0) {
@@ -6721,10 +6845,10 @@ async function main() {
6721
6845
  break;
6722
6846
  }
6723
6847
  case "test":
6724
- await handleRunScripts("test", args, restArgs);
6848
+ await handleRunScripts("test", args, restArgs, excludes);
6725
6849
  break;
6726
6850
  case "eval":
6727
- await handleRunScripts("eval", args, restArgs);
6851
+ await handleRunScripts("eval", args, restArgs, excludes);
6728
6852
  break;
6729
6853
  case "audit": {
6730
6854
  // The Lighthouse run: a plain `audit` is a deterministic READ — rings, each
@@ -6735,7 +6859,9 @@ async function main() {
6735
6859
  // in `.vigilesrc.json`), headless it stays a read + a one-line nudge. There
6736
6860
  // is NO execution flag — automation tests the harness via the vigiles testing
6737
6861
  // API + skills, not the report verb. See the `audit-side-effect-free` rule.
6738
- const dirs = restArgs.length > 0 ? restArgs : ["."];
6862
+ const dirs = restArgs.length > 0
6863
+ ? restArgs.map((d) => noteExplicitOverride(excludes, d, "auditing"))
6864
+ : ["."];
6739
6865
  const json = args.includes("--json");
6740
6866
  // A single dir that's a marketplace (e.g. wshobson/agents' 80+ plugins
6741
6867
  // under one marketplace.json) expands into its members and ranks them.
@@ -6905,7 +7031,7 @@ async function main() {
6905
7031
  vigilesVersion: getVersion(),
6906
7032
  adoptableSurfaces,
6907
7033
  observations: (0, observe_js_1.summarizeObservations)(ledgerRecords),
6908
- rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile),
7034
+ rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile, excludes),
6909
7035
  firingMeasured,
6910
7036
  });
6911
7037
  const sc = auditReportBase.score;
@@ -6994,7 +7120,7 @@ async function main() {
6994
7120
  // computeRuleRouting re-reads) — route the prose rules, enumerating the
6995
7121
  // live catalog when consented + own-repo. Feeds the JSON report, the
6996
7122
  // written artifacts, and the terminal rule-map summary.
6997
- const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile);
7123
+ const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile, excludes);
6998
7124
  const auditReport = ruleRouting
6999
7125
  ? { ...auditReportBase, ruleRouting }
7000
7126
  : auditReportBase;