vigiles 25.0.0 → 26.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
@@ -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,12 +1278,12 @@ 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
+ for (const specPath of findSpecs(excludes)) {
1275
1287
  const target = specPath.replace(/\.spec\.ts$/, "");
1276
1288
  if (!(0, node_fs_1.existsSync)(target))
1277
1289
  continue; // never compiled — `compile` reports it
@@ -1319,7 +1331,7 @@ async function checkSpecRefs(config, silent, dialect) {
1319
1331
  function writeArtifact(outputPath, artifact) {
1320
1332
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1321
1333
  }
1322
- async function runLint(restArgs, flags, config) {
1334
+ async function runLint(restArgs, flags, excludes, config) {
1323
1335
  const summary = flags.includes("--summary");
1324
1336
  const json = flags.includes("--json");
1325
1337
  const silent = summary || json;
@@ -1364,7 +1376,7 @@ async function runLint(restArgs, flags, config) {
1364
1376
  // is the false positive that gets a gate turned off. So the DEFAULT fixes the
1365
1377
  // SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
1366
1378
  // whole repo, which is what a CI gate needs.
1367
- const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
1379
+ const nestedBundles = discoverNestedBundles(scanRoot, excludes);
1368
1380
  const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1369
1381
  const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1370
1382
  if (!silent && nestedBundles.length > 0) {
@@ -1381,7 +1393,7 @@ async function runLint(restArgs, flags, config) {
1381
1393
  // hand-edit of a compiled subagent slipped past `lint`. A hand-written agent
1382
1394
  // (no hash header) stays a no-op, and require-instructions-spec is filename-
1383
1395
  // scoped to CLAUDE/AGENTS so agents are never falsely flagged as spec-less.
1384
- const files = findInstructionFiles(restArgs, config?.exclude, adapter.layout.agentDir);
1396
+ const files = findInstructionFiles(restArgs, excludes, adapter.layout.agentDir);
1385
1397
  // 1. Verify hashes and structure
1386
1398
  if (!silent) {
1387
1399
  if (files.length > 0) {
@@ -1401,15 +1413,15 @@ async function runLint(restArgs, flags, config) {
1401
1413
  // 2. Coverage gaps (discover)
1402
1414
  if (!silent)
1403
1415
  console.log("\nLinter rule coverage:\n");
1404
- const coverage = discover(silent);
1416
+ const coverage = discover(excludes, silent);
1405
1417
  // 3. Duplicate rule detection (NCD). Scope to the requested files when
1406
1418
  // lint was invoked with explicit paths, so targeted CI checks don't
1407
1419
  // fail on unrelated duplicates elsewhere in the repo.
1408
1420
  if (!silent)
1409
1421
  console.log("\nDuplicate rule detection:\n");
1410
- const dups = await findDuplicateRules(0.3, silent, restArgs.length > 0 ? files : undefined);
1422
+ const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined);
1411
1423
  // 4. Guidance rule count (strengthen suggestions moved to /strengthen skill)
1412
- const guidanceCount = await countGuidanceRules(silent);
1424
+ const guidanceCount = await countGuidanceRules(excludes, silent);
1413
1425
  // 5. Integrity check (hand-edit detection via SHA-256 hash)
1414
1426
  const integritySeverity = config?.rules.integrity ?? "warn";
1415
1427
  let integrityErrors = 0;
@@ -1419,7 +1431,7 @@ async function runLint(restArgs, flags, config) {
1419
1431
  integrityErrors = checkIntegrityForFiles(files, integritySeverity, silent);
1420
1432
  }
1421
1433
  // 6. Coverage thresholds (gates CI when severity is "error")
1422
- const coverageErrors = await checkCoverageThresholds(coverage, config, silent);
1434
+ const coverageErrors = await checkCoverageThresholds(excludes, coverage, config, silent);
1423
1435
  // 7. Orphan docs check — OPT-IN (the `orphans` block in .vigilesrc.json is
1424
1436
  // the on-switch). "Unreferenced" only means "rot" for a hand-cross-linked
1425
1437
  // corpus; on a nav-managed doc site (Docusaurus/MkDocs) the page graph lives
@@ -1449,6 +1461,10 @@ async function runLint(restArgs, flags, config) {
1449
1461
  basePath: process.cwd(),
1450
1462
  include: orphansCfg.include,
1451
1463
  exclude: orphansCfg.exclude,
1464
+ // The repo-wide `exclude` is the FLOOR under the rule's own `exclude`
1465
+ // (union, never override): an excluded corpus is neither an orphan
1466
+ // candidate nor a source of references that keep a doc alive (#192).
1467
+ repoExclude: excludes.ignore,
1452
1468
  // Exempt every registered harness's surface files (instruction file,
1453
1469
  // SKILL.md, subagents, commands) as orphan candidates — layout-driven so
1454
1470
  // core carries no harness literal (see src/core/orphans.ts).
@@ -1468,8 +1484,8 @@ async function runLint(restArgs, flags, config) {
1468
1484
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1469
1485
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1470
1486
  // 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);
1487
+ const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1488
+ const untested = overBundles((c, s, a, r) => checkUntestedSurfaces(excludes, c, s, a, r), config, silent, adapter, lintRoots);
1473
1489
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1474
1490
  // rail against the harness catalog (the moat). n/a on a harness with no
1475
1491
  // subagents. Off by default unless a severity is configured; warning surfaces
@@ -1572,7 +1588,7 @@ async function runLint(restArgs, flags, config) {
1572
1588
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1573
1589
  // grew to do the gating instead. One unpassed argument, that whole chain.
1574
1590
  const docRefReport = docRefSeverity
1575
- ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: config?.exclude })
1591
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.ignore })
1576
1592
  : {
1577
1593
  filesScanned: 0,
1578
1594
  filesIgnored: 0,
@@ -1744,10 +1760,10 @@ function printLintSummary(report) {
1744
1760
  console.log(`vigiles: ${parts.join(" / ")}`);
1745
1761
  }
1746
1762
  }
1747
- function collectDocumentedRules() {
1763
+ function collectDocumentedRules(excludes) {
1748
1764
  const documented = new Set();
1749
1765
  const mdFiles = (0, glob_1.globSync)("**/CLAUDE.md", {
1750
- ignore: IGNORE_NODE_MODULES,
1766
+ ignore: excludes.globIgnore,
1751
1767
  cwd: process.cwd(),
1752
1768
  });
1753
1769
  for (const mdFile of mdFiles) {
@@ -1789,14 +1805,14 @@ function printLinterCoverage(linter, documentedRules, silent = false) {
1789
1805
  log("");
1790
1806
  return { enabled: linter.rules.length, documented: documented.length };
1791
1807
  }
1792
- function discover(silent = false) {
1808
+ function discover(excludes, silent = false) {
1793
1809
  const log = (msg) => {
1794
1810
  if (!silent)
1795
1811
  console.log(msg);
1796
1812
  };
1797
1813
  log("Scanning project for linter rules...\n");
1798
1814
  const result = (0, generate_types_js_1.generateTypes)({ basePath: process.cwd() });
1799
- const documentedRules = collectDocumentedRules();
1815
+ const documentedRules = collectDocumentedRules(excludes);
1800
1816
  log("Detected linters:\n");
1801
1817
  let totalEnabled = 0;
1802
1818
  let totalDocumented = 0;
@@ -1889,6 +1905,7 @@ function discoverAdoptableSurfaces(cwd) {
1889
1905
  const abs = (0, node_path_1.resolve)(cwd, root);
1890
1906
  if (!(0, node_fs_1.existsSync)(abs))
1891
1907
  continue;
1908
+ // eslint-disable-next-line no-restricted-syntax -- init's shallow adoptable-surface sweep, top level only (exclude.ts exceptions)
1892
1909
  for (const e of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1893
1910
  const rel = `${root}/${e.name}/SKILL.md`;
1894
1911
  if (e.isDirectory() && unspecced(rel))
@@ -1899,6 +1916,7 @@ function discoverAdoptableSurfaces(cwd) {
1899
1916
  const abs = (0, node_path_1.resolve)(cwd, root);
1900
1917
  if (!(0, node_fs_1.existsSync)(abs))
1901
1918
  continue;
1919
+ // eslint-disable-next-line no-restricted-syntax -- init's shallow adoptable-surface sweep, top level only (exclude.ts exceptions)
1902
1920
  for (const e of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1903
1921
  const rel = `${root}/${e.name}`;
1904
1922
  if (e.isFile() && e.name.endsWith(".md") && unspecced(rel))
@@ -1970,6 +1988,7 @@ const RULE_INVENTORY_SKIP_DIRS = new Set([
1970
1988
  /** readdir that returns [] instead of throwing (perms, races). */
1971
1989
  function safeReaddir(dir) {
1972
1990
  try {
1991
+ // eslint-disable-next-line no-restricted-syntax -- lint-config collection for the linter catalog, not repo policing (exclude.ts exceptions)
1973
1992
  return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
1974
1993
  }
1975
1994
  catch {
@@ -2011,11 +2030,11 @@ function collectLintConfigText(root) {
2011
2030
  * file(s) + lint-config TEXT (never executed) and map documented intents to
2012
2031
  * off-the-shelf rules + whether they're already configured. Best-effort, fs-only;
2013
2032
  * NO model, NO config execution — safe on any repo. Composition-root. */
2014
- function computeRuleInventory(root, instructionFile) {
2033
+ function computeRuleInventory(root, instructionFile, excludes) {
2015
2034
  try {
2016
2035
  // The inventory maps intents → rules; it has no per-file line provenance, so
2017
2036
  // the concatenated text is fine here (unlike the routing preview below).
2018
- const instructionText = gatherInstructionFiles(root, instructionFile)
2037
+ const instructionText = gatherInstructionFiles(root, instructionFile, excludes)
2019
2038
  .map((f) => f.text)
2020
2039
  .join("\n");
2021
2040
  if (!instructionText.trim())
@@ -2034,7 +2053,7 @@ function computeRuleInventory(root, instructionFile) {
2034
2053
  * `research/CLAUDE.md`, …), skipping fixture/demo/build/test dirs (`isFixturePath`)
2035
2054
  * so a repo's real memory is read without the test-fixture noise. `.claude/` rule
2036
2055
  * sources remain a future source. research/rule-enforcer-multilang-design.md §0. */
2037
- function gatherInstructionFiles(root, instructionFile) {
2056
+ function gatherInstructionFiles(root, instructionFile, excludes) {
2038
2057
  const raw = [];
2039
2058
  const collect = (rel) => {
2040
2059
  const p = (0, node_path_1.resolve)(root, rel);
@@ -2049,11 +2068,14 @@ function gatherInstructionFiles(root, instructionFile) {
2049
2068
  // Root instruction files first (stable, deterministic order).
2050
2069
  for (const name of new Set([instructionFile, "CLAUDE.md", "AGENTS.md"]))
2051
2070
  collect(name);
2052
- // Nested subdirectory memory, minus fixture/demo/build/test noise.
2071
+ // Nested subdirectory memory, minus fixture/demo/build/test noise. Two
2072
+ // filters, deliberately: `isFixturePath` is the zero-config floor (audit
2073
+ // reads any repo with no .vigilesrc.json), and the configured `exclude` is
2074
+ // unioned ON TOP for the dirs the heuristic cannot guess (#192).
2053
2075
  try {
2054
2076
  const nested = (0, glob_1.globSync)(["**/CLAUDE.md", "**/AGENTS.md"], {
2055
2077
  cwd: root,
2056
- ignore: [...IGNORE_NODE_MODULES, "dist/**", ".git/**"],
2078
+ ignore: excludes.globIgnore,
2057
2079
  })
2058
2080
  .filter((rel) => !(0, instruction_sources_js_1.isFixturePath)(rel))
2059
2081
  .sort();
@@ -2111,9 +2133,9 @@ function hasPythonSurface(root) {
2111
2133
  }
2112
2134
  return false;
2113
2135
  }
2114
- function computeRuleRouting(root, instructionFile) {
2136
+ function computeRuleRouting(root, instructionFile, excludes) {
2115
2137
  try {
2116
- const files = gatherInstructionFiles(root, instructionFile);
2138
+ const files = gatherInstructionFiles(root, instructionFile, excludes);
2117
2139
  if (files.every((f) => !f.text.trim()))
2118
2140
  return undefined;
2119
2141
  // Own-repo + consented → enumerate the live rule catalog of whichever
@@ -2246,16 +2268,23 @@ function scaffoldSpec(args) {
2246
2268
  logAdoptedSurface(target, specPath, "subagent", unmappedKeys);
2247
2269
  }
2248
2270
  else {
2249
- const { source, tier, sectionCount } = (0, adopt_js_1.adoptMarkdown)(md, (0, node_path_1.basename)(target));
2271
+ const { source, tier, sectionCount, adoptedRefs } = (0, adopt_js_1.adoptMarkdown)(md, (0, node_path_1.basename)(target),
2272
+ // RESOLVE-NOW, not a heuristic. The undecidable question is "is this OUR
2273
+ // path or one inside the repo this document DESCRIBES" — the wall that
2274
+ // disabled `doc-refs`. Asking "does it resolve here, right now?" sidesteps
2275
+ // it: a described repo's path does not exist locally, so only already-green
2276
+ // refs are emitted and adoption can never turn a passing file red.
2277
+ { exists: (ref) => (0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), ref)) });
2250
2278
  (0, node_fs_1.writeFileSync)(specAbs, source);
2251
2279
  console.log(`Adopted ${target} → ${specPath} (${tier}, ${String(sectionCount)} section${sectionCount === 1 ? "" : "s"}). ` +
2280
+ (adoptedRefs && adoptedRefs.length > 0
2281
+ ? `${String(adoptedRefs.length)} reference${adoptedRefs.length === 1 ? "" : "s"} resolved and became verified \`file()\` calls — they now fail the build if the file moves. `
2282
+ : "") +
2252
2283
  `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.
2284
+ // Adoption infers NO RULES (that stays `strengthen`'s job) but it DOES now
2285
+ // extract refs that resolve — measured 2026-08-28 on a 51-skill monorepo,
2286
+ // where extracting nothing meant the cost landed at once (build artifact,
2287
+ // edits into TS) while the payoff waited on a manual pass nobody ran.
2259
2288
  if (tier === "raw")
2260
2289
  console.log(` ℹ 0 refs extracted — this spec verifies nothing yet. Wrap paths in \`file()\` ` +
2261
2290
  `and commands in \`cmd()\` to make \`compile\` check them.`);
@@ -2401,6 +2430,7 @@ function specReferencedElsewhere(specFile, ejectedFile) {
2401
2430
  const dir = stack.pop();
2402
2431
  let entries;
2403
2432
  try {
2433
+ // eslint-disable-next-line no-restricted-syntax -- eject's is-this-spec-compiled-elsewhere safety check; wider is safer (exclude.ts exceptions)
2404
2434
  entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
2405
2435
  }
2406
2436
  catch {
@@ -2904,15 +2934,17 @@ async function setupPillar1(detected, targetValue, harnesses) {
2904
2934
  // run `npm install` yet, so compiling would just error; defer it with a clear
2905
2935
  // next step instead of a scary stack-traceless "failed to load".
2906
2936
  const canCompile = canResolveVigiles(cwd);
2937
+ const initConfig = (0, validate_js_1.loadConfig)();
2938
+ const excludes = (0, exclude_js_1.excludeSet)(cwd, initConfig.exclude);
2907
2939
  const specs = canCompile
2908
- ? findSpecs().filter((s) => {
2940
+ ? findSpecs(excludes).filter((s) => {
2909
2941
  const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
2910
2942
  return !(0, node_fs_1.existsSync)(tf) || targetHasHash(tf);
2911
2943
  })
2912
2944
  : [];
2913
2945
  if (specs.length > 0) {
2914
2946
  console.log("\nCompiling specs...");
2915
- await compile(specs, (0, validate_js_1.loadConfig)());
2947
+ await compile(specs, initConfig, excludes);
2916
2948
  }
2917
2949
  else if (!canCompile) {
2918
2950
  // Honest, project-type-aware guidance. A JS repo just needs `npm install`
@@ -3476,7 +3508,7 @@ function untestedRules(config) {
3476
3508
  * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
3477
3509
  * untested count plus the severity-gated error count.
3478
3510
  */
3479
- function checkUntestedSurfaces(config, silent, adapter, scanRoot) {
3511
+ function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3480
3512
  const { severity: sevFor, anyEnabled, options } = untestedRules(config);
3481
3513
  if (!anyEnabled)
3482
3514
  return { untested: 0, errors: 0 };
@@ -3484,6 +3516,10 @@ function checkUntestedSurfaces(config, silent, adapter, scanRoot) {
3484
3516
  ...options,
3485
3517
  basePath: scanRoot,
3486
3518
  layout: adapter.layout,
3519
+ // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
3520
+ // it. Union, never override — a rule option must not re-admit a vendored
3521
+ // corpus the repo excluded (#192).
3522
+ exclude: [...(options.exclude ?? []), ...excludes.ignore],
3487
3523
  });
3488
3524
  if (!silent) {
3489
3525
  console.log("\nUntested surfaces:\n");
@@ -4155,7 +4191,7 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4155
4191
  * avoids depending on a pre-built `dist/` tree, which the setup-generated
4156
4192
  * CI step doesn't guarantee.
4157
4193
  */
4158
- async function checkCoverageThresholds(coverage, config, silent) {
4194
+ async function checkCoverageThresholds(excludes, coverage, config, silent) {
4159
4195
  const severity = (0, types_js_1.ruleSeverity)(config?.rules.coverage);
4160
4196
  if (!severity)
4161
4197
  return 0;
@@ -4181,9 +4217,9 @@ async function checkCoverageThresholds(coverage, config, silent) {
4181
4217
  }
4182
4218
  if (opts.scripts !== undefined) {
4183
4219
  // Load all claude specs so coverage doesn't depend on a built dist/.
4184
- const loaded = await Promise.all(findSpecs().map(loadSpec));
4220
+ const loaded = await Promise.all(findSpecs(excludes).map(loadSpec));
4185
4221
  const claudeSpecs = loaded.filter((s) => s?._specType === "claude");
4186
- const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs);
4222
+ const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs, excludes.ignore);
4187
4223
  const ok = metric.passing;
4188
4224
  if (!ok)
4189
4225
  failing++;
@@ -4192,8 +4228,8 @@ async function checkCoverageThresholds(coverage, config, silent) {
4192
4228
  }
4193
4229
  return severity === "error" ? failing : 0;
4194
4230
  }
4195
- async function countGuidanceRules(silent = false) {
4196
- const specs = findSpecs();
4231
+ async function countGuidanceRules(excludes, silent = false) {
4232
+ const specs = findSpecs(excludes);
4197
4233
  if (specs.length === 0)
4198
4234
  return 0;
4199
4235
  let count = 0;
@@ -4214,7 +4250,22 @@ async function countGuidanceRules(silent = false) {
4214
4250
  // ---------------------------------------------------------------------------
4215
4251
  // Command handlers for main()
4216
4252
  // ---------------------------------------------------------------------------
4217
- function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4253
+ /**
4254
+ * An explicitly named path is processed even when `exclude` matches it, and ONE
4255
+ * line says so. `exclude` filters DISCOVERY; an argument is an instruction.
4256
+ * Measured 2026-09-03: ripgrep and tsc do this silently, ESLint skips the file
4257
+ * with a warning, prettier skips it and reports "all files use Prettier code
4258
+ * style" — the silent no-op. We take rg/tsc's semantics with ESLint's loudness.
4259
+ * Returns the path unchanged so it composes inside a `.map`.
4260
+ */
4261
+ function noteExplicitOverride(excludes, path, verb) {
4262
+ const pattern = excludes.explain((0, node_path_1.relative)(excludes.root, (0, node_path_1.resolve)(path)));
4263
+ if (pattern !== null) {
4264
+ console.log(`note: ${path} matches exclude "${pattern}" — ${verb} because you named it`);
4265
+ }
4266
+ return path;
4267
+ }
4268
+ function findInstructionFiles(restArgs, excludes, agentDir = "") {
4218
4269
  const patterns = ["**/CLAUDE.md", "**/AGENTS.md", "**/SKILL.md"];
4219
4270
  // Include the active harness's subagent dir so a COMPILED `agents/<name>.md`
4220
4271
  // (a vigiles-hashed file) is integrity-checked (dogfood E2). Empty for a
@@ -4222,12 +4273,17 @@ function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4222
4273
  if (agentDir !== "")
4223
4274
  patterns.push(`**/${agentDir}/*.md`);
4224
4275
  // `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];
4276
+ // own lint shouldn't police — a third-party CLAUDE.md isn't held to
4277
+ // require-instructions-spec. The FUNCTION face of the ExcludeSet, not the
4278
+ // string list: `discoverIn` may glob from a subdirectory (`vigiles lint
4279
+ // some/dir`), where a root-relative string pattern would match nothing.
4228
4280
  // Discover instruction files under one directory, as paths relative to cwd.
4229
4281
  const discoverIn = (dirAbs) => patterns
4230
- .flatMap((p) => (0, glob_1.globSync)(p, { ignore, cwd: dirAbs, absolute: true }))
4282
+ .flatMap((p) => (0, glob_1.globSync)(p, {
4283
+ ignore: excludes.globIgnore,
4284
+ cwd: dirAbs,
4285
+ absolute: true,
4286
+ }))
4231
4287
  .map((abs) => (0, node_path_1.relative)(process.cwd(), abs));
4232
4288
  if (restArgs.length === 0)
4233
4289
  return discoverIn(process.cwd());
@@ -4237,6 +4293,7 @@ function findInstructionFiles(restArgs, exclude = [], agentDir = "") {
4237
4293
  const out = [];
4238
4294
  for (const arg of restArgs) {
4239
4295
  const abs = (0, node_path_1.resolve)(process.cwd(), arg);
4296
+ noteExplicitOverride(excludes, arg, "linting");
4240
4297
  if ((0, node_fs_1.existsSync)(abs) && (0, node_fs_1.lstatSync)(abs).isDirectory()) {
4241
4298
  out.push(...discoverIn(abs));
4242
4299
  }
@@ -4674,9 +4731,14 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
4674
4731
  }
4675
4732
  return [...names];
4676
4733
  }
4734
+ const recordsConfig = (0, validate_js_1.loadConfig)();
4677
4735
  const scan = (0, test_coverage_js_1.findUntestedSurfaces)({
4678
4736
  basePath: cwd,
4679
- layout: harnessLayoutFor(cwd, (0, validate_js_1.loadConfig)(), harnessFlag),
4737
+ layout: harnessLayoutFor(cwd, recordsConfig, harnessFlag),
4738
+ // The repo-wide `exclude` only — the per-rule severities/testGlobs stay out
4739
+ // of record resolution on purpose (a run's probes must map to a surface
4740
+ // whether or not the untested-* rule for its kind is on).
4741
+ exclude: (0, exclude_js_1.excludeSet)(cwd, recordsConfig.exclude).ignore,
4680
4742
  });
4681
4743
  return (0, coverage_artifact_js_1.recordsFrom)({
4682
4744
  runs,
@@ -4754,7 +4816,7 @@ function gitHead(cwd) {
4754
4816
  return "";
4755
4817
  }
4756
4818
  }
4757
- async function handleRunScripts(kind, args, restArgs) {
4819
+ async function handleRunScripts(kind, args, restArgs, excludes) {
4758
4820
  const cwd = process.cwd();
4759
4821
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
4760
4822
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
@@ -4768,7 +4830,9 @@ async function handleRunScripts(kind, args, restArgs) {
4768
4830
  return;
4769
4831
  lockEnv = r;
4770
4832
  }
4771
- const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
4833
+ // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4834
+ // harness must not run as ours), but a script you NAME still runs (#192).
4835
+ const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.ignore);
4772
4836
  // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
4773
4837
  // a renamed file, or a glob that matched nothing fails LOUD instead of passing
4774
4838
  // green with zero evals executed. Default 0 (off) keeps local runs ergonomic.
@@ -5481,8 +5545,18 @@ function evalLockNudgeHookCommand() {
5481
5545
  // heard nothing at all, while a repo that already tests got reminded. That is
5482
5546
  // backwards, and `untested-skill` already stated the missing half correctly;
5483
5547
  // 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));
5548
+ const msg = (0, test_coverage_js_1.skillTestNudge)(target, {
5549
+ ...options,
5550
+ basePath: cwd,
5551
+ layout,
5552
+ // Same union `vigiles lint` applies: the rule's exclude narrows, the
5553
+ // repo-wide exclude is the floor (#192) — the nudge must not report a
5554
+ // vendored skill the linter would never list.
5555
+ exclude: [
5556
+ ...(options.exclude ?? []),
5557
+ ...(0, exclude_js_1.excludeSet)(cwd, config.exclude).ignore,
5558
+ ],
5559
+ }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
5486
5560
  if (!msg)
5487
5561
  return;
5488
5562
  process.stdout.write(JSON.stringify({
@@ -6654,6 +6728,10 @@ async function main() {
6654
6728
  // Shared flags (--max-rules, --catalog-only) override the loaded config so
6655
6729
  // every GitHub Action input maps to a real CLI flag. See src/cli-flags.ts.
6656
6730
  const config = (0, cli_flags_js_1.applyConfigFlags)((0, validate_js_1.loadConfig)(), args);
6731
+ // `.vigilesrc.json#exclude`, parsed ONCE here and threaded to every walk that
6732
+ // polices the repo (src/exclude.ts). Built where the config is loaded so a
6733
+ // command cannot re-derive it differently — the drift #192 measured.
6734
+ const excludes = (0, exclude_js_1.excludeSet)(process.cwd(), config.exclude);
6657
6735
  switch (command) {
6658
6736
  // --- Primary commands ---
6659
6737
  case "init": {
@@ -6676,8 +6754,10 @@ async function main() {
6676
6754
  // a hook program → its harness config + stamp (cohesive-cli-surface). With
6677
6755
  // explicit args, partition by extension; bare, discover both.
6678
6756
  const specs = restArgs.length > 0
6679
- ? restArgs.filter((f) => f.endsWith(".spec.ts"))
6680
- : findSpecs();
6757
+ ? restArgs
6758
+ .filter((f) => f.endsWith(".spec.ts"))
6759
+ .map((f) => noteExplicitOverride(excludes, f, "compiling"))
6760
+ : findSpecs(excludes);
6681
6761
  const hooks = restArgs.length > 0
6682
6762
  ? restArgs.filter((f) => !f.endsWith(".spec.ts"))
6683
6763
  : (0, hook_install_js_1.discoverHookFiles)(process.cwd());
@@ -6688,7 +6768,8 @@ async function main() {
6688
6768
  }
6689
6769
  let valid = true;
6690
6770
  if (specs.length > 0)
6691
- valid = (await compile(specs, config, { harnessFlag })) && valid;
6771
+ valid =
6772
+ (await compile(specs, config, excludes, { harnessFlag })) && valid;
6692
6773
  valid = (await installHooks(hooks, harnessFlag, config.harness)) && valid;
6693
6774
  // Keep an existing whole-harness registry in sync (cheap, opt-in) so the
6694
6775
  // user never hand-runs `generate-harness`. Skipped when no harness.gen.ts.
@@ -6712,7 +6793,7 @@ async function main() {
6712
6793
  case "lint": {
6713
6794
  // lint = verify references + discover + guidance count
6714
6795
  const flags = args.slice(1).filter((a) => a.startsWith("--"));
6715
- const report = await runLint(restArgs, flags, config);
6796
+ const report = await runLint(restArgs, flags, excludes, config);
6716
6797
  annotateLintForGitHub(report, flags);
6717
6798
  const exitCode = lintExitCode(report);
6718
6799
  if (exitCode !== 0) {
@@ -6721,10 +6802,10 @@ async function main() {
6721
6802
  break;
6722
6803
  }
6723
6804
  case "test":
6724
- await handleRunScripts("test", args, restArgs);
6805
+ await handleRunScripts("test", args, restArgs, excludes);
6725
6806
  break;
6726
6807
  case "eval":
6727
- await handleRunScripts("eval", args, restArgs);
6808
+ await handleRunScripts("eval", args, restArgs, excludes);
6728
6809
  break;
6729
6810
  case "audit": {
6730
6811
  // The Lighthouse run: a plain `audit` is a deterministic READ — rings, each
@@ -6735,7 +6816,9 @@ async function main() {
6735
6816
  // in `.vigilesrc.json`), headless it stays a read + a one-line nudge. There
6736
6817
  // is NO execution flag — automation tests the harness via the vigiles testing
6737
6818
  // API + skills, not the report verb. See the `audit-side-effect-free` rule.
6738
- const dirs = restArgs.length > 0 ? restArgs : ["."];
6819
+ const dirs = restArgs.length > 0
6820
+ ? restArgs.map((d) => noteExplicitOverride(excludes, d, "auditing"))
6821
+ : ["."];
6739
6822
  const json = args.includes("--json");
6740
6823
  // A single dir that's a marketplace (e.g. wshobson/agents' 80+ plugins
6741
6824
  // under one marketplace.json) expands into its members and ranks them.
@@ -6905,7 +6988,7 @@ async function main() {
6905
6988
  vigilesVersion: getVersion(),
6906
6989
  adoptableSurfaces,
6907
6990
  observations: (0, observe_js_1.summarizeObservations)(ledgerRecords),
6908
- rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile),
6991
+ rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile, excludes),
6909
6992
  firingMeasured,
6910
6993
  });
6911
6994
  const sc = auditReportBase.score;
@@ -6994,7 +7077,7 @@ async function main() {
6994
7077
  // computeRuleRouting re-reads) — route the prose rules, enumerating the
6995
7078
  // live catalog when consented + own-repo. Feeds the JSON report, the
6996
7079
  // written artifacts, and the terminal rule-map summary.
6997
- const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile);
7080
+ const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile, excludes);
6998
7081
  const auditReport = ruleRouting
6999
7082
  ? { ...auditReportBase, ruleRouting }
7000
7083
  : auditReportBase;