vigiles 31.0.0 → 32.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-main.js CHANGED
@@ -34,6 +34,7 @@ const setup_plan_js_1 = require("./setup-plan.js");
34
34
  const types_js_1 = require("./core/types.js");
35
35
  const test_coverage_js_1 = require("./test-coverage.js");
36
36
  const scan_js_1 = require("./scan.js");
37
+ const frame_js_1 = require("./core/frame.js");
37
38
  const surface_discovery_js_1 = require("./core/surface-discovery.js");
38
39
  const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
39
40
  const install_reader_js_1 = require("./core/install-reader.js");
@@ -630,6 +631,13 @@ function isGitHubActions() {
630
631
  /**
631
632
  * Emit a GitHub Actions annotation for the inline PR experience.
632
633
  * No-op outside GitHub Actions.
634
+ *
635
+ * `file` is a {@link RepoPath}, not a `string`, and that is the #281 fix made
636
+ * structural: GitHub resolves `file=` against the checked-out repository, so a
637
+ * path relative to a nested bundle (`skills/x/SKILL.md` for
638
+ * `plugins/p/skills/x/SKILL.md`) or an absolute machine path lands on a missing
639
+ * file — or, worse, on the ROOT bundle's file of the same name. Such a path no
640
+ * longer compiles here; it has to go through `core/frame.ts` first.
633
641
  */
634
642
  function ghAnnotate(level, message, file, line) {
635
643
  if (!isGitHubActions())
@@ -642,7 +650,7 @@ function ghAnnotate(level, message, file, line) {
642
650
  const loc = locParts.length > 0 ? " " + locParts.join(",") : "";
643
651
  console.log(`::${level}${loc}::${message}`);
644
652
  }
645
- function verifyHashes(filePaths, silent = false) {
653
+ function verifyHashes(frame, filePaths, silent = false) {
646
654
  let errorCount = 0;
647
655
  const log = (msg) => {
648
656
  if (!silent)
@@ -656,7 +664,7 @@ function verifyHashes(filePaths, silent = false) {
656
664
  if (!(0, node_fs_1.existsSync)(fullPath)) {
657
665
  log(`\n✗ ${filePath} — file not found`);
658
666
  if (!silent) {
659
- ghAnnotate("error", `File not found: ${filePath}`, filePath);
667
+ ghAnnotate("error", `File not found: ${filePath}`, frame.repo(fullPath));
660
668
  }
661
669
  errorCount++;
662
670
  continue;
@@ -673,7 +681,7 @@ function verifyHashes(filePaths, silent = false) {
673
681
  log(`\n✗ ${filePath} — hash mismatch (manually edited after compilation)`);
674
682
  log(` Re-run \`vigiles compile\` to regenerate from ${result.specFile ?? "spec"}.`);
675
683
  if (!silent) {
676
- ghAnnotate("error", "Hash mismatch — file was manually edited after compilation", filePath);
684
+ ghAnnotate("error", "Hash mismatch — file was manually edited after compilation", frame.repo(fullPath));
677
685
  }
678
686
  errorCount++;
679
687
  }
@@ -720,8 +728,8 @@ function validateSpecs(filePaths, rulesConfig, silent = false) {
720
728
  }
721
729
  return allValid;
722
730
  }
723
- function check(filePaths, silent = false) {
724
- const hashes = verifyHashes(filePaths, silent);
731
+ function check(frame, filePaths, silent = false) {
732
+ const hashes = verifyHashes(frame, filePaths, silent);
725
733
  const vConfig = (0, validate_js_1.loadConfig)();
726
734
  const specsValid = validateSpecs(filePaths, vConfig.rules, silent);
727
735
  return {
@@ -821,7 +829,7 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
821
829
  * Each named file is parsed on demand; there is no project-wide index. Returns
822
830
  * the count of broken references.
823
831
  */
824
- async function verifyMarkdownSymbols(files, silent) {
832
+ async function verifyMarkdownSymbols(frame, files, silent) {
825
833
  if (files.length === 0)
826
834
  return 0;
827
835
  const cwd = process.cwd();
@@ -845,7 +853,7 @@ async function verifyMarkdownSymbols(files, silent) {
845
853
  }
846
854
  for (const b of broken) {
847
855
  console.log(` ✗ ${f}:${String(b.line)} ${b.reason}`);
848
- ghAnnotate("error", b.reason, f, b.line);
856
+ ghAnnotate("error", b.reason, frame.repo((0, node_path_1.resolve)(cwd, f)), b.line);
849
857
  }
850
858
  }
851
859
  errors += broken.length;
@@ -859,7 +867,7 @@ async function verifyMarkdownSymbols(files, silent) {
859
867
  * only started if a mark actually references it. Returns the count of broken
860
868
  * references. Async because it speaks to real servers.
861
869
  */
862
- async function verifyMarkdownMcpRefs(files, silent) {
870
+ async function verifyMarkdownMcpRefs(frame, files, silent) {
863
871
  const cwd = process.cwd();
864
872
  const servers = (0, mcp_js_1.loadMcpServers)(cwd);
865
873
  if (files.length === 0 || Object.keys(servers).length === 0)
@@ -885,7 +893,7 @@ async function verifyMarkdownMcpRefs(files, silent) {
885
893
  for (const b of broken) {
886
894
  const msg = (0, mcp_js_1.mcpRefMessage)(b);
887
895
  console.log(` ✗ ${f}:${String(b.line)} ${msg}`);
888
- ghAnnotate("error", msg, f, b.line);
896
+ ghAnnotate("error", msg, frame.repo((0, node_path_1.resolve)(cwd, f)), b.line);
889
897
  }
890
898
  }
891
899
  errors += broken.length;
@@ -993,7 +1001,7 @@ function lintExitCode(report) {
993
1001
  * Verify one parsed enforce rule against the linter catalog/config, logging
994
1002
  * and annotating on failure. Returns true when the rule is valid+enabled.
995
1003
  */
996
- function verifyOneRule(rule, filePath, silent, linterOptions) {
1004
+ function verifyOneRule(frame, rule, filePath, silent, linterOptions) {
997
1005
  const log = (msg) => {
998
1006
  if (!silent)
999
1007
  console.log(msg);
@@ -1003,14 +1011,14 @@ function verifyOneRule(rule, filePath, silent, linterOptions) {
1003
1011
  const message = result.error ?? `Rule "${rule.linterRule}" not found`;
1004
1012
  log(` ✗ line ${String(rule.line)}: ${message}`);
1005
1013
  if (!silent)
1006
- ghAnnotate("error", message, filePath, rule.line);
1014
+ ghAnnotate("error", message, frame.repo((0, node_path_1.resolve)(process.cwd(), filePath)), rule.line);
1007
1015
  return false;
1008
1016
  }
1009
1017
  if (result.enabled === "disabled") {
1010
1018
  const message = `Rule "${rule.linterRule}" exists but is disabled in ${result.linter} config`;
1011
1019
  log(` ✗ line ${String(rule.line)}: ${message}`);
1012
1020
  if (!silent)
1013
- ghAnnotate("error", message, filePath, rule.line);
1021
+ ghAnnotate("error", message, frame.repo((0, node_path_1.resolve)(process.cwd(), filePath)), rule.line);
1014
1022
  return false;
1015
1023
  }
1016
1024
  log(` ✓ line ${String(rule.line)}: ${rule.linterRule}`);
@@ -1023,13 +1031,13 @@ function verifyOneRule(rule, filePath, silent, linterOptions) {
1023
1031
  * package.json / the filesystem. References resolve relative to the markdown
1024
1032
  * file's own directory. Returns the number of stale references found.
1025
1033
  */
1026
- function verifyMarkdownRefs(files, commands, filePath, silent) {
1034
+ function verifyMarkdownRefs(frame, files, commands, filePath, silent) {
1027
1035
  const basePath = (0, node_path_1.dirname)((0, node_path_1.resolve)(process.cwd(), filePath));
1028
1036
  let errorCount = 0;
1029
1037
  const report = (err, line) => {
1030
1038
  if (!silent) {
1031
1039
  console.log(` ✗ line ${String(line)}: ${err.message}`);
1032
- ghAnnotate("error", err.message, filePath, line);
1040
+ ghAnnotate("error", err.message, frame.repo((0, node_path_1.resolve)(process.cwd(), filePath)), line);
1033
1041
  }
1034
1042
  errorCount++;
1035
1043
  };
@@ -1045,7 +1053,7 @@ function verifyMarkdownRefs(files, commands, filePath, silent) {
1045
1053
  }
1046
1054
  return errorCount;
1047
1055
  }
1048
- function verifyInlineRules(filePath, silent, linterOptions) {
1056
+ function verifyInlineRules(frame, filePath, silent, linterOptions) {
1049
1057
  const log = (msg) => {
1050
1058
  if (!silent)
1051
1059
  console.log(msg);
@@ -1070,14 +1078,14 @@ function verifyInlineRules(filePath, silent, linterOptions) {
1070
1078
  log(` ✗ line ${String(err.line)}: ${err.message}`);
1071
1079
  errorCount++;
1072
1080
  if (!silent) {
1073
- ghAnnotate("error", err.message, filePath, err.line);
1081
+ ghAnnotate("error", err.message, frame.repo((0, node_path_1.resolve)(process.cwd(), filePath)), err.line);
1074
1082
  }
1075
1083
  }
1076
1084
  for (const rule of rules) {
1077
- if (!verifyOneRule(rule, filePath, silent, linterOptions))
1085
+ if (!verifyOneRule(frame, rule, filePath, silent, linterOptions))
1078
1086
  errorCount++;
1079
1087
  }
1080
- errorCount += verifyMarkdownRefs(files, commands, filePath, silent);
1088
+ errorCount += verifyMarkdownRefs(frame, files, commands, filePath, silent);
1081
1089
  return {
1082
1090
  ok: errorCount === 0,
1083
1091
  errorCount,
@@ -1091,7 +1099,7 @@ function verifyInlineRules(filePath, silent, linterOptions) {
1091
1099
  * in `exclude` (e.g. declared inline in the same file) are skipped so a
1092
1100
  * rule present in both sources is reported once, not twice.
1093
1101
  */
1094
- function verifyFrontmatterRules(filePath, silent, exclude, linterOptions) {
1102
+ function verifyFrontmatterRules(frame, filePath, silent, exclude, linterOptions) {
1095
1103
  const log = (msg) => {
1096
1104
  if (!silent)
1097
1105
  console.log(msg);
@@ -1117,14 +1125,14 @@ function verifyFrontmatterRules(filePath, silent, exclude, linterOptions) {
1117
1125
  log(` ✗ line ${String(err.line)}: ${err.message}`);
1118
1126
  errorCount++;
1119
1127
  if (!silent) {
1120
- ghAnnotate("error", err.message, filePath, err.line);
1128
+ ghAnnotate("error", err.message, frame.repo((0, node_path_1.resolve)(process.cwd(), filePath)), err.line);
1121
1129
  }
1122
1130
  }
1123
1131
  for (const rule of rules) {
1124
- if (!verifyOneRule(rule, filePath, silent, linterOptions))
1132
+ if (!verifyOneRule(frame, rule, filePath, silent, linterOptions))
1125
1133
  errorCount++;
1126
1134
  }
1127
- errorCount += verifyMarkdownRefs(files, commands, filePath, silent);
1135
+ errorCount += verifyMarkdownRefs(frame, files, commands, filePath, silent);
1128
1136
  return {
1129
1137
  ok: errorCount === 0,
1130
1138
  errorCount,
@@ -1162,7 +1170,7 @@ const FRONTMATTER_MODE_ENABLED = false;
1162
1170
  * declared both inline and in frontmatter is verified once (inline wins as
1163
1171
  * the first source). See docs/markdown-mode.md.
1164
1172
  */
1165
- function verifyMarkdownModeRules(files, silent, config) {
1173
+ function verifyMarkdownModeRules(frame, files, silent, config) {
1166
1174
  const totals = {
1167
1175
  inlineErrors: 0,
1168
1176
  inlineRules: 0,
@@ -1190,13 +1198,13 @@ function verifyMarkdownModeRules(files, silent, config) {
1190
1198
  }
1191
1199
  if (compiledFromRe.test(content))
1192
1200
  continue; // managed via hash header
1193
- const inline = verifyInlineRules(filePath, silent, linterOptions);
1201
+ const inline = verifyInlineRules(frame, filePath, silent, linterOptions);
1194
1202
  totals.inlineErrors += inline.errorCount;
1195
1203
  totals.inlineRules += inline.ruleCount;
1196
1204
  // Frontmatter mode is DISABLED (kept in code, inert in lint) — a `vigiles:`
1197
1205
  // block is ignored, never verified. See FRONTMATTER_MODE_ENABLED.
1198
1206
  if (FRONTMATTER_MODE_ENABLED) {
1199
- const fm = verifyFrontmatterRules(filePath, silent, new Set(inline.ruleNames), linterOptions);
1207
+ const fm = verifyFrontmatterRules(frame, filePath, silent, new Set(inline.ruleNames), linterOptions);
1200
1208
  totals.frontmatterErrors += fm.errorCount;
1201
1209
  totals.frontmatterRules += fm.ruleCount;
1202
1210
  }
@@ -1217,21 +1225,6 @@ function verifyMarkdownModeRules(files, silent, config) {
1217
1225
  * --summary Print a single-line summary (for SessionStart hooks)
1218
1226
  * --json Print structured JSON report (for CI integration)
1219
1227
  */
1220
- /**
1221
- * The repo root that `sharedDirs` resolve against. The caller's cwd (where
1222
- * `.vigilesrc.json` lives) is used ONLY when the scan target is INSIDE it — e.g.
1223
- * `lint packages/foo` from the repo root, where the shared tree is an ancestor of
1224
- * the scoped subdir. When the target is NOT under cwd (`lint path/to/other-repo`),
1225
- * we resolve against the TARGET itself, so a foreign-repo lint never lets the
1226
- * caller's own files satisfy the target's bundled resources (scoped-lint integrity).
1227
- */
1228
- function sharedDirsRootFor(scanTarget) {
1229
- const cwd = process.cwd();
1230
- const target = (0, node_path_1.resolve)(scanTarget);
1231
- const rel = (0, node_path_1.relative)(cwd, target);
1232
- const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
1233
- return underCwd ? cwd : target;
1234
- }
1235
1228
  /**
1236
1229
  * Nested plugin bundles under a lint root — a directory that is itself a harness
1237
1230
  * (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
@@ -1303,21 +1296,39 @@ function discoverNestedBundles(root, excludes) {
1303
1296
  return out.sort();
1304
1297
  }
1305
1298
  /**
1306
- * Run one per-surface check over EVERY root and sum its counters.
1299
+ * Run one per-surface check over EVERY bundle and sum its counters.
1307
1300
  *
1308
- * The checks all share `(config, silent, adapter, root)` and return a small
1309
- * record of numbers, so one wrapper covers all twenty rather than twenty edits —
1310
- * and a check added later is swept in by using it, not by remembering to.
1301
+ * The checks all take a {@link BundleCheck} and return a small record of
1302
+ * numbers, so one wrapper covers all of them rather than twenty edits — and a
1303
+ * check added later is swept in by using it, not by remembering to. The first
1304
+ * bundle is the lint target; the rest are nested bundles.
1311
1305
  */
1312
- function overBundles(fn, config, silent, adapter, roots) {
1313
- const [first, ...rest] = roots;
1314
- const total = { ...fn(config, silent, adapter, first) };
1315
- for (const root of rest) {
1316
- const next = fn(config, silent, adapter, root);
1306
+ function overBundles(fn, run, bundles) {
1307
+ const many = bundles.length > 1;
1308
+ const ctxFor = (abs, i) => {
1309
+ const bundle = run.frame.bundle(abs);
1310
+ const label = (text) => many && i > 0 ? `[${bundle.at}] ${text}` : text;
1311
+ return {
1312
+ ...run,
1313
+ bundle,
1314
+ label,
1315
+ annotate: (level, message, file) => {
1316
+ ghAnnotate(level, file === undefined ? label(message) : message, file);
1317
+ },
1318
+ scan: (extra = {}) => (0, scan_js_1.scanPlugin)(bundle.abs, run.adapter.layout, run.adapter.dialect, {
1319
+ ...extra,
1320
+ excludes: run.excludes,
1321
+ }),
1322
+ };
1323
+ };
1324
+ const [first, ...rest] = bundles;
1325
+ const total = { ...fn(ctxFor(first, 0)) };
1326
+ rest.forEach((abs, i) => {
1327
+ const next = fn(ctxFor(abs, i + 1));
1317
1328
  for (const key of Object.keys(next))
1318
1329
  total[key] =
1319
1330
  (total[key] ?? 0) + (next[key] ?? 0);
1320
- }
1331
+ });
1321
1332
  return total;
1322
1333
  }
1323
1334
  /**
@@ -1485,6 +1496,11 @@ async function runLint(restArgs, flags, excludes, config) {
1485
1496
  const nestedBundles = discoverNestedBundles(scanRoot, excludes);
1486
1497
  const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1487
1498
  const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1499
+ // ONE frame for the whole run (#281): every path a check prints or annotates is
1500
+ // relative to it, and config globs resolve from it — whichever bundle is being
1501
+ // scored. Built here, once, instead of each option re-deriving its own root.
1502
+ const frame = (0, frame_js_1.frameFor)(process.cwd(), scanRoot);
1503
+ const run = { config, silent, adapter, excludes, frame };
1488
1504
  if (!silent && nestedBundles.length > 0) {
1489
1505
  const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1490
1506
  console.log(scoreAll
@@ -1510,11 +1526,11 @@ async function runLint(restArgs, flags, excludes, config) {
1510
1526
  }
1511
1527
  }
1512
1528
  const hashResult = files.length > 0
1513
- ? check(files, silent)
1529
+ ? check(frame, files, silent)
1514
1530
  : { valid: true, hashErrors: 0, validationErrors: 0 };
1515
1531
  // 1b. Verify inline + frontmatter rules in instruction files not managed
1516
1532
  // by a spec. See verifyMarkdownModeRules / docs/markdown-mode.md.
1517
- const md = verifyMarkdownModeRules(files, silent, config);
1533
+ const md = verifyMarkdownModeRules(frame, files, silent, config);
1518
1534
  const { inlineErrors, inlineRules, frontmatterErrors, frontmatterRules } = md;
1519
1535
  // 2. Coverage gaps (discover)
1520
1536
  if (!silent)
@@ -1570,7 +1586,7 @@ async function runLint(restArgs, flags, excludes, config) {
1570
1586
  // The repo-wide `exclude` is the FLOOR under the rule's own `exclude`
1571
1587
  // (union, never override): an excluded corpus is neither an orphan
1572
1588
  // candidate nor a source of references that keep a doc alive (#192).
1573
- repoExclude: excludes.ignore,
1589
+ repoExclude: excludes.globIgnore,
1574
1590
  // Exempt every registered harness's surface files (instruction file,
1575
1591
  // SKILL.md, subagents, commands) as orphan candidates — layout-driven so
1576
1592
  // core carries no harness literal (see src/core/orphans.ts).
@@ -1591,74 +1607,74 @@ async function runLint(restArgs, flags, excludes, config) {
1591
1607
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1592
1608
  // is unchanged, not that what it names still exists (#173).
1593
1609
  const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1594
- const untested = overBundles((c, s, a, r) => checkUntestedSurfaces(excludes, c, s, a, r), config, silent, adapter, lintRoots);
1610
+ const untested = overBundles(checkUntestedSurfaces, run, lintRoots);
1595
1611
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1596
1612
  // rail against the harness catalog (the moat). n/a on a harness with no
1597
1613
  // subagents. Off by default unless a severity is configured; warning surfaces
1598
1614
  // a typo/never-available tool, error gates CI.
1599
- const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
1615
+ const toolContract = overBundles(checkSubagentToolContracts, run, lintRoots);
1600
1616
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1601
1617
  // define never fires. High-precision (close typos only). Off unless configured.
1602
- const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
1618
+ const hookEvents = overBundles(checkHookEvents, run, lintRoots);
1603
1619
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1604
1620
  // (name + description) won't register. n/a on a harness with no subagents.
1605
- const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
1621
+ const frontmatter = overBundles(checkFrontmatterSchema, run, lintRoots);
1606
1622
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1607
- const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
1623
+ const mcpConfig = overBundles(checkMcpConfig, run, lintRoots);
1608
1624
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1609
1625
  // reliable trigger surface). Best-practice nudge; skills load without it.
1610
- const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
1626
+ const skillFm = overBundles(checkSkillFrontmatter, run, lintRoots);
1611
1627
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1612
1628
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1613
- const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
1629
+ const mcpToolResolves = overBundles(checkMcpToolResolves, run, lintRoots);
1614
1630
  // 7i. Hook-script existence — a hook command referencing a missing script file
1615
1631
  // never runs. This comment used to add "matches Anthropic's own `claude plugin
1616
1632
  // validate`"; measured false on 2026-09-08 (Claude Code 2.1.263) — see
1617
1633
  // docs/rules/hook-script-exists.md and tools/measure-validate-overlap.mjs.
1618
- const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
1634
+ const hookScripts = overBundles(checkHookScriptExists, run, lintRoots);
1619
1635
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1620
1636
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1621
- const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
1637
+ const disallowedTools = overBundles(checkDisallowedTools, run, lintRoots);
1622
1638
  // 7k. Description-overlap — two model-invocable skills with near-identical
1623
1639
  // descriptions collide in the selector (deterministic NCD precision proxy).
1624
- const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
1640
+ const descriptionOverlap = overBundles(checkDescriptionOverlap, run, lintRoots);
1625
1641
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1626
1642
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1627
1643
  // precision). Generous 500-char budget; warn-tier, never gates.
1628
- const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
1644
+ const descriptionBudget = overBundles(checkDescriptionBudget, run, lintRoots);
1629
1645
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1630
1646
  // stricter than some loaders, so verify before enforcing).
1631
- const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
1647
+ const frontmatterValid = overBundles(checkFrontmatterValid, run, lintRoots);
1632
1648
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1633
1649
  // targets an undeclared server (the moat applied to the hook surface).
1634
- const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
1650
+ const mcpHookTargets = overBundles(checkMcpHookTargets, run, lintRoots);
1635
1651
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1636
1652
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1637
- const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
1653
+ const preferCompiledHooks = overBundles(checkPreferCompiledHooks, run, lintRoots);
1638
1654
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1639
1655
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1640
1656
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1641
- const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
1657
+ const lethalTrifecta = overBundles(checkLethalTrifecta, run, lintRoots);
1642
1658
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1643
1659
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1644
- const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
1660
+ const skillResources = overBundles(checkSkillResourceResolves, run, lintRoots);
1645
1661
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1646
1662
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1647
- const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
1663
+ const skillFence = overBundles(checkSkillMissingFence, run, lintRoots);
1648
1664
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1649
1665
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1650
- const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
1666
+ const pluginLayout = overBundles(checkPluginDirLayout, run, lintRoots);
1651
1667
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1652
1668
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1653
- const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
1669
+ const delegationTrifecta = overBundles(checkDelegationTrifecta, run, lintRoots);
1654
1670
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1655
1671
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1656
1672
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1657
- const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
1673
+ const hookBlock = overBundles(checkHookBlockIneffective, run, lintRoots);
1658
1674
  // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1659
1675
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1660
1676
  // server naming, or an undeclared MCP server).
1661
- const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
1677
+ const hookMatcher = overBundles(checkHookMatcher, run, lintRoots);
1662
1678
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1663
1679
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1664
1680
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1696,7 +1712,7 @@ async function runLint(restArgs, flags, excludes, config) {
1696
1712
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1697
1713
  // grew to do the gating instead. One unpassed argument, that whole chain.
1698
1714
  const docRefReport = docRefSeverity
1699
- ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.ignore })
1715
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.globIgnore })
1700
1716
  : {
1701
1717
  filesScanned: 0,
1702
1718
  filesIgnored: 0,
@@ -1719,14 +1735,14 @@ async function runLint(restArgs, flags, excludes, config) {
1719
1735
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1720
1736
  if (isGitHubActions() && !silent && docRefSeverity) {
1721
1737
  for (const e of docRefReport.errors) {
1722
- ghAnnotate(docRefSeverity === "error" ? "error" : "warning", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
1738
+ ghAnnotate(docRefSeverity === "error" ? "error" : "warning", `${e.kind}("${e.value}") — ${e.message}`, frame.repo((0, node_path_1.resolve)(process.cwd(), e.file)), e.line);
1723
1739
  }
1724
1740
  }
1725
1741
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
1726
- const symbolRefErrors = await verifyMarkdownSymbols(files, silent);
1742
+ const symbolRefErrors = await verifyMarkdownSymbols(frame, files, silent);
1727
1743
  // 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
1728
1744
  // (only when a .mcp.json declares them). See src/mcp.ts.
1729
- const mcpRefErrors = await verifyMarkdownMcpRefs(files, silent);
1745
+ const mcpRefErrors = await verifyMarkdownMcpRefs(frame, files, silent);
1730
1746
  const report = {
1731
1747
  hashErrors: hashResult.hashErrors,
1732
1748
  validationErrors: hashResult.validationErrors,
@@ -3605,18 +3621,22 @@ function untestedRules(config) {
3605
3621
  * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
3606
3622
  * untested count plus the severity-gated error count.
3607
3623
  */
3608
- function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3624
+ function checkUntestedSurfaces(ctx) {
3625
+ const { config, silent, adapter, frame, bundle } = ctx;
3609
3626
  const { severity: sevFor, anyEnabled, options } = untestedRules(config);
3610
3627
  if (!anyEnabled)
3611
3628
  return { untested: 0, errors: 0 };
3612
3629
  const report = (0, test_coverage_js_1.findUntestedSurfaces)({
3613
3630
  ...options,
3614
- basePath: scanRoot,
3631
+ basePath: bundle.abs,
3632
+ // #281: `include`/`exclude` and every reported path are relative to the
3633
+ // frame root (where `.vigilesrc.json` lives), not to the bundle being scored.
3634
+ root: frame.root,
3615
3635
  layout: adapter.layout,
3616
3636
  // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
3617
3637
  // it. Union, never override — a rule option must not re-admit a vendored
3618
- // corpus the repo excluded (#192).
3619
- exclude: [...(options.exclude ?? []), ...excludes.ignore],
3638
+ // corpus the repo excluded (#192). Passed WHOLE so it carries its own root.
3639
+ excludes: ctx.excludes,
3620
3640
  });
3621
3641
  if (!silent) {
3622
3642
  console.log("\nUntested surfaces:\n");
@@ -3624,7 +3644,7 @@ function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3624
3644
  console.log(` ${line}`);
3625
3645
  }
3626
3646
  for (const s of report.untested) {
3627
- ghAnnotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, s.path);
3647
+ ctx.annotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, frame.repo((0, node_path_1.join)(frame.root, s.path)));
3628
3648
  }
3629
3649
  }
3630
3650
  return {
@@ -3653,7 +3673,8 @@ function reportNotApplicable(check, surface, adapter, silent) {
3653
3673
  * (plugin/MCP-provided) is never a false alarm. Warning by default; set
3654
3674
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
3655
3675
  */
3656
- function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3676
+ function checkSubagentToolContracts(ctx) {
3677
+ const { config, silent, adapter } = ctx;
3657
3678
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
3658
3679
  if (!sev)
3659
3680
  return { issues: 0, errors: 0 };
@@ -3666,7 +3687,7 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3666
3687
  // `agents/` path, so a harness with a different subagent dir Just Works.
3667
3688
  let agents;
3668
3689
  try {
3669
- agents = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents;
3690
+ agents = ctx.scan().agents;
3670
3691
  }
3671
3692
  catch {
3672
3693
  return { issues: 0, errors: 0 };
@@ -3683,8 +3704,8 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3683
3704
  printedHeader = true;
3684
3705
  }
3685
3706
  for (const issue of agent.toolIssues) {
3686
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${agent.path}: ${issue.message}`);
3687
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, agent.path);
3707
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(agent.path)}: ${issue.message}`);
3708
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(agent.path));
3688
3709
  }
3689
3710
  }
3690
3711
  }
@@ -3696,7 +3717,8 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3696
3717
  * `hookEventIssues` (the shared detector, high-precision: close typos only, never
3697
3718
  * a framework/custom event). Warning by default; "error" gates CI.
3698
3719
  */
3699
- function checkHookEvents(config, silent, adapter, scanRoot) {
3720
+ function checkHookEvents(ctx) {
3721
+ const { config, silent, adapter } = ctx;
3700
3722
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-events"]);
3701
3723
  if (!sev)
3702
3724
  return { issues: 0, errors: 0 };
@@ -3706,7 +3728,7 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3706
3728
  }
3707
3729
  let found;
3708
3730
  try {
3709
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookEventIssues;
3731
+ found = ctx.scan().hookEventIssues;
3710
3732
  }
3711
3733
  catch {
3712
3734
  return { issues: 0, errors: 0 };
@@ -3714,8 +3736,8 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3714
3736
  if (found.length > 0 && !silent) {
3715
3737
  console.log("\nHook-event check:\n");
3716
3738
  for (const issue of found) {
3717
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3718
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message);
3739
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3740
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message);
3719
3741
  }
3720
3742
  }
3721
3743
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3727,7 +3749,8 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3727
3749
  * of a real one) — it silently falls back / is ignored. Reuses `scanPlugin`'s
3728
3750
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
3729
3751
  */
3730
- function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3752
+ function checkFrontmatterSchema(ctx) {
3753
+ const { config, silent, adapter } = ctx;
3731
3754
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
3732
3755
  if (!sev)
3733
3756
  return { issues: 0, errors: 0 };
@@ -3737,7 +3760,7 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3737
3760
  }
3738
3761
  let found;
3739
3762
  try {
3740
- const r = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect);
3763
+ const r = ctx.scan();
3741
3764
  found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
3742
3765
  }
3743
3766
  catch {
@@ -3746,8 +3769,8 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3746
3769
  if (found.length > 0 && !silent) {
3747
3770
  console.log("\nFrontmatter-schema check:\n");
3748
3771
  for (const issue of found) {
3749
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3750
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, issue.path);
3772
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3773
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
3751
3774
  }
3752
3775
  }
3753
3776
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3760,13 +3783,14 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3760
3783
  * default; set "error" to enforce it on your own skills. Reuses `scanPlugin`'s
3761
3784
  * `skillMetaIssues`.
3762
3785
  */
3763
- function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3786
+ function checkSkillFrontmatter(ctx) {
3787
+ const { config, silent } = ctx;
3764
3788
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
3765
3789
  if (!sev)
3766
3790
  return { issues: 0, errors: 0 };
3767
3791
  let found;
3768
3792
  try {
3769
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillMetaIssues;
3793
+ found = ctx.scan().skillMetaIssues;
3770
3794
  }
3771
3795
  catch {
3772
3796
  return { issues: 0, errors: 0 };
@@ -3774,8 +3798,8 @@ function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3774
3798
  if (found.length > 0 && !silent) {
3775
3799
  console.log("\nSkill-frontmatter check:\n");
3776
3800
  for (const issue of found) {
3777
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3778
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, issue.path);
3801
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3802
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
3779
3803
  }
3780
3804
  }
3781
3805
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3787,13 +3811,14 @@ function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3787
3811
  * lane stays first-class — so it fires once and the message links the guide.
3788
3812
  * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
3789
3813
  */
3790
- function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3814
+ function checkPreferCompiledHooks(ctx) {
3815
+ const { config, silent } = ctx;
3791
3816
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
3792
3817
  if (!sev)
3793
3818
  return { issues: 0, errors: 0 };
3794
3819
  let count;
3795
3820
  try {
3796
- count = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).manualHookCount;
3821
+ count = ctx.scan().manualHookCount;
3797
3822
  }
3798
3823
  catch {
3799
3824
  return { issues: 0, errors: 0 };
@@ -3803,8 +3828,8 @@ function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3803
3828
  const message = (0, scan_js_1.preferCompiledHooksMessage)(count);
3804
3829
  if (!silent) {
3805
3830
  console.log("\nCompiled-hooks check:\n");
3806
- console.log(` ${sev === "error" ? "✗" : "ℹ"} ${message}`);
3807
- ghAnnotate(sev === "error" ? "error" : "warning", message);
3831
+ console.log(` ${sev === "error" ? "✗" : "ℹ"} ${ctx.label(message)}`);
3832
+ ctx.annotate(sev === "error" ? "error" : "warning", message);
3808
3833
  }
3809
3834
  return { issues: 1, errors: sev === "error" ? 1 : 0 };
3810
3835
  }
@@ -3813,13 +3838,14 @@ function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3813
3838
  * (stdio) nor a `url` (http/sse) can't start. Reuses `scanPlugin`'s `mcpIssues`.
3814
3839
  * Warning by default; "error" gates CI.
3815
3840
  */
3816
- function checkMcpConfig(config, silent, adapter, scanRoot) {
3841
+ function checkMcpConfig(ctx) {
3842
+ const { config, silent } = ctx;
3817
3843
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-config"]);
3818
3844
  if (!sev)
3819
3845
  return { issues: 0, errors: 0 };
3820
3846
  let found;
3821
3847
  try {
3822
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpIssues;
3848
+ found = ctx.scan().mcpIssues;
3823
3849
  }
3824
3850
  catch {
3825
3851
  return { issues: 0, errors: 0 };
@@ -3827,8 +3853,8 @@ function checkMcpConfig(config, silent, adapter, scanRoot) {
3827
3853
  if (found.length > 0 && !silent) {
3828
3854
  console.log("\nMCP-config check:\n");
3829
3855
  for (const issue of found) {
3830
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3831
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message);
3856
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3857
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message);
3832
3858
  }
3833
3859
  }
3834
3860
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3840,7 +3866,8 @@ function checkMcpConfig(config, silent, adapter, scanRoot) {
3840
3866
  * `disallowedToolIssues` (close-typo only — high-precision). Warning by default;
3841
3867
  * "error" gates CI.
3842
3868
  */
3843
- function checkDisallowedTools(config, silent, adapter, scanRoot) {
3869
+ function checkDisallowedTools(ctx) {
3870
+ const { config, silent, adapter } = ctx;
3844
3871
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
3845
3872
  if (!sev)
3846
3873
  return { issues: 0, errors: 0 };
@@ -3850,7 +3877,10 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3850
3877
  }
3851
3878
  let found;
3852
3879
  try {
3853
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({ message: i.message, path: a.path })));
3880
+ found = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
3881
+ message: i.message,
3882
+ path: a.path,
3883
+ })));
3854
3884
  }
3855
3885
  catch {
3856
3886
  return { issues: 0, errors: 0 };
@@ -3858,8 +3888,8 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3858
3888
  if (found.length > 0 && !silent) {
3859
3889
  console.log("\nDisallowed-tools check:\n");
3860
3890
  for (const issue of found) {
3861
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
3862
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, issue.path);
3891
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
3892
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
3863
3893
  }
3864
3894
  }
3865
3895
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3872,13 +3902,14 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3872
3902
  * colon / `<example>` is flagged though it may still load — hence WARN by default
3873
3903
  * (verify before setting "error").
3874
3904
  */
3875
- function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3905
+ function checkFrontmatterValid(ctx) {
3906
+ const { config, silent } = ctx;
3876
3907
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
3877
3908
  if (!sev)
3878
3909
  return { issues: 0, errors: 0 };
3879
3910
  let found;
3880
3911
  try {
3881
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).malformedFrontmatter;
3912
+ found = ctx.scan().malformedFrontmatter;
3882
3913
  }
3883
3914
  catch {
3884
3915
  return { issues: 0, errors: 0 };
@@ -3886,8 +3917,8 @@ function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3886
3917
  if (found.length > 0 && !silent) {
3887
3918
  console.log("\nFrontmatter-validity check:\n");
3888
3919
  for (const issue of found) {
3889
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3890
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, issue.path);
3920
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3921
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
3891
3922
  }
3892
3923
  }
3893
3924
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3899,13 +3930,14 @@ function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3899
3930
  * `scanPlugin`'s `descriptionOverlaps` (calibrated FP-safe: only basically
3900
3931
  * identical text). Warning by default; "error" gates CI.
3901
3932
  */
3902
- function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3933
+ function checkDescriptionOverlap(ctx) {
3934
+ const { config, silent } = ctx;
3903
3935
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["description-overlap"]);
3904
3936
  if (!sev)
3905
3937
  return { issues: 0, errors: 0 };
3906
3938
  let found;
3907
3939
  try {
3908
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionOverlaps;
3940
+ found = ctx.scan().descriptionOverlaps;
3909
3941
  }
3910
3942
  catch {
3911
3943
  return { issues: 0, errors: 0 };
@@ -3913,8 +3945,8 @@ function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3913
3945
  if (found.length > 0 && !silent) {
3914
3946
  console.log("\nDescription-overlap check:\n");
3915
3947
  for (const issue of found) {
3916
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3917
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message);
3948
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3949
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message);
3918
3950
  }
3919
3951
  }
3920
3952
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3926,13 +3958,14 @@ function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3926
3958
  * deterministic heuristic proxy (generous 500-char budget). Reuses `scanPlugin`'s
3927
3959
  * `descriptionBudgetIssues`. Warning by default; "error" gates CI.
3928
3960
  */
3929
- function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3961
+ function checkDescriptionBudget(ctx) {
3962
+ const { config, silent } = ctx;
3930
3963
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-description-budget"]);
3931
3964
  if (!sev)
3932
3965
  return { issues: 0, errors: 0 };
3933
3966
  let found;
3934
3967
  try {
3935
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionBudgetIssues;
3968
+ found = ctx.scan().descriptionBudgetIssues;
3936
3969
  }
3937
3970
  catch {
3938
3971
  return { issues: 0, errors: 0 };
@@ -3940,8 +3973,8 @@ function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3940
3973
  if (found.length > 0 && !silent) {
3941
3974
  console.log("\nSkill-description-budget check:\n");
3942
3975
  for (const issue of found) {
3943
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3944
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message);
3976
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
3977
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message);
3945
3978
  }
3946
3979
  }
3947
3980
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3962,13 +3995,14 @@ function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3962
3995
  * COUNT is unchanged (units, not lines), so exit codes and the CI gate are
3963
3996
  * unaffected by the collapse.
3964
3997
  */
3965
- function checkLethalTrifecta(config, silent, adapter, scanRoot) {
3998
+ function checkLethalTrifecta(ctx) {
3999
+ const { config, silent } = ctx;
3966
4000
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
3967
4001
  if (!sev)
3968
4002
  return { issues: 0, errors: 0 };
3969
4003
  let found;
3970
4004
  try {
3971
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).trifectaFindings;
4005
+ found = ctx.scan().trifectaFindings;
3972
4006
  }
3973
4007
  catch {
3974
4008
  return { issues: 0, errors: 0 };
@@ -3982,8 +4016,8 @@ function checkLethalTrifecta(config, silent, adapter, scanRoot) {
3982
4016
  if (unfenced.includes(t))
3983
4017
  continue;
3984
4018
  const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
3985
- console.log(` ${mark} ${t.path}: ${msg}`);
3986
- ghAnnotate(level, msg, t.path);
4019
+ console.log(` ${mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4020
+ ctx.annotate(level, msg, ctx.bundle.scanned(t.path));
3987
4021
  }
3988
4022
  if (unfenced.length > 0) {
3989
4023
  console.log(` ${mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
@@ -4002,17 +4036,18 @@ function checkLethalTrifecta(config, silent, adapter, scanRoot) {
4002
4036
  * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
4003
4037
  * one detector, no drift). Warning by default; "error" gates CI.
4004
4038
  */
4005
- function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4039
+ function checkSkillResourceResolves(ctx) {
4040
+ const { config, silent } = ctx;
4006
4041
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
4007
4042
  if (!sev)
4008
4043
  return { issues: 0, errors: 0 };
4009
4044
  let found;
4010
4045
  try {
4011
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect, {
4046
+ found = ctx.scan({
4012
4047
  sharedDirs: config?.sharedDirs,
4013
- // sharedDirs live at the repo root that OWNS the scan target — cwd for a
4014
- // scoped subdir of this repo, the target itself for a foreign-repo lint.
4015
- sharedDirsRoot: sharedDirsRootFor(scanRoot),
4048
+ // sharedDirs live at the frame root — the repo that OWNS the scan target:
4049
+ // cwd for a scoped subdir or a nested bundle, the target for a foreign lint.
4050
+ sharedDirsRoot: ctx.frame.root,
4016
4051
  }).skillResourceIssues;
4017
4052
  }
4018
4053
  catch {
@@ -4027,8 +4062,8 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4027
4062
  // it was documented only in docs/skills-monorepo.md, so a CI log gave no
4028
4063
  // hint and the rule read as broken rather than misconfigured.
4029
4064
  ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
4030
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
4031
- ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
4065
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4066
+ ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4032
4067
  }
4033
4068
  }
4034
4069
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4040,13 +4075,14 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4040
4075
  * Reuses `scanPlugin`'s `skillFenceIssues` (one detector, no drift). Warning by
4041
4076
  * default; "error" gates CI.
4042
4077
  */
4043
- function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4078
+ function checkSkillMissingFence(ctx) {
4079
+ const { config, silent } = ctx;
4044
4080
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
4045
4081
  if (!sev)
4046
4082
  return { issues: 0, errors: 0 };
4047
4083
  let found;
4048
4084
  try {
4049
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillFenceIssues;
4085
+ found = ctx.scan().skillFenceIssues;
4050
4086
  }
4051
4087
  catch {
4052
4088
  return { issues: 0, errors: 0 };
@@ -4055,8 +4091,8 @@ function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4055
4091
  console.log("\nSkill-missing-fence check:\n");
4056
4092
  for (const s of found) {
4057
4093
  const msg = `${s.name}: ${s.finding.message}`;
4058
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
4059
- ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
4094
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4095
+ ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4060
4096
  }
4061
4097
  }
4062
4098
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4068,13 +4104,14 @@ function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4068
4104
  * `pluginLayoutIssues` (one detector, no drift). Warning by default; "error"
4069
4105
  * gates CI.
4070
4106
  */
4071
- function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4107
+ function checkPluginDirLayout(ctx) {
4108
+ const { config, silent } = ctx;
4072
4109
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
4073
4110
  if (!sev)
4074
4111
  return { issues: 0, errors: 0 };
4075
4112
  let found;
4076
4113
  try {
4077
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).pluginLayoutIssues;
4114
+ found = ctx.scan().pluginLayoutIssues;
4078
4115
  }
4079
4116
  catch {
4080
4117
  return { issues: 0, errors: 0 };
@@ -4082,8 +4119,8 @@ function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4082
4119
  if (found.length > 0 && !silent) {
4083
4120
  console.log("\nPlugin-dir-layout check:\n");
4084
4121
  for (const p of found) {
4085
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${p.message}`);
4086
- ghAnnotate(sev === "error" ? "error" : "warning", p.message);
4122
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(p.message)}`);
4123
+ ctx.annotate(sev === "error" ? "error" : "warning", p.message);
4087
4124
  }
4088
4125
  }
4089
4126
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4096,13 +4133,14 @@ function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4096
4133
  * gates CI. Surfaces across the subagent graph, so it is NOT gated on a
4097
4134
  * capability the way a surface-specific rule is.
4098
4135
  */
4099
- function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4136
+ function checkDelegationTrifecta(ctx) {
4137
+ const { config, silent } = ctx;
4100
4138
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
4101
4139
  if (!sev)
4102
4140
  return { issues: 0, errors: 0 };
4103
4141
  let found;
4104
4142
  try {
4105
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).delegationTrifecta;
4143
+ found = ctx.scan().delegationTrifecta;
4106
4144
  }
4107
4145
  catch {
4108
4146
  return { issues: 0, errors: 0 };
@@ -4111,8 +4149,8 @@ function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4111
4149
  console.log("\nDelegation-trifecta check:\n");
4112
4150
  for (const d of found) {
4113
4151
  const msg = `${d.finding.name}: ${d.finding.message}`;
4114
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${d.path}: ${msg}`);
4115
- ghAnnotate(sev === "error" ? "error" : "warning", msg, d.path);
4152
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4153
+ ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(d.path));
4116
4154
  }
4117
4155
  }
4118
4156
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4124,7 +4162,8 @@ function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4124
4162
  * permission-gated event (#19009, the #1 verified hook pain). Reuses `scanPlugin`'s
4125
4163
  * `hookBlockFindings` (one detector, no drift). Warning by default; "error" gates CI.
4126
4164
  */
4127
- function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4165
+ function checkHookBlockIneffective(ctx) {
4166
+ const { config, silent, adapter } = ctx;
4128
4167
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-block-ineffective"]);
4129
4168
  if (!sev)
4130
4169
  return { issues: 0, errors: 0 };
@@ -4134,22 +4173,38 @@ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4134
4173
  }
4135
4174
  let found;
4136
4175
  try {
4137
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookBlockFindings;
4176
+ found = ctx.scan().hookBlockFindings;
4138
4177
  }
4139
4178
  catch {
4140
4179
  return { issues: 0, errors: 0 };
4141
4180
  }
4142
4181
  if (found.length > 0 && !silent) {
4182
+ const mark = sev === "error" ? "✗" : "⚠";
4183
+ const level = sev === "error" ? "error" : "warning";
4143
4184
  console.log("\nHook-block check:\n");
4144
4185
  for (const h of found) {
4145
- const where = h.scriptPath ?? "(inline)";
4146
- const msg = `[${h.event}] ${where}: ${h.message}`;
4147
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
4148
- ghAnnotate(sev === "error" ? "error" : "warning", msg, h.scriptPath ?? undefined);
4186
+ const { msg, file, shown } = hookBlockLine(ctx, h);
4187
+ console.log(` ${mark} ${shown}`);
4188
+ ctx.annotate(level, msg, file);
4149
4189
  }
4150
4190
  }
4151
4191
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4152
4192
  }
4193
+ /**
4194
+ * One hook-block finding in the repo frame. scan-core reports the script
4195
+ * ABSOLUTE (it resolves the command against the bundle), which used to print a
4196
+ * machine path and annotate `file=/home/…` (#281); an inline hook names no file
4197
+ * and so carries the bundle label instead.
4198
+ */
4199
+ function hookBlockLine(ctx, h) {
4200
+ if (h.scriptPath === null) {
4201
+ const msg = `[${h.event}] (inline): ${h.message}`;
4202
+ return { msg, shown: ctx.label(msg) };
4203
+ }
4204
+ const file = ctx.bundle.scanned(h.scriptPath);
4205
+ const msg = `[${h.event}] ${file}: ${h.message}`;
4206
+ return { msg, file, shown: msg };
4207
+ }
4153
4208
  /**
4154
4209
  * Apply the `hook-matcher` rule: a hook `matcher` string that doesn't fire as
4155
4210
  * written — a tool-name typo (`bash`→`Bash`), a matcher that doesn't compile, an
@@ -4158,7 +4213,8 @@ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4158
4213
  * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
4159
4214
  * by default; "error" gates CI.
4160
4215
  */
4161
- function checkHookMatcher(config, silent, adapter, scanRoot) {
4216
+ function checkHookMatcher(ctx) {
4217
+ const { config, silent, adapter } = ctx;
4162
4218
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-matcher"]);
4163
4219
  if (!sev)
4164
4220
  return { issues: 0, errors: 0 };
@@ -4168,7 +4224,7 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4168
4224
  }
4169
4225
  let found;
4170
4226
  try {
4171
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookMatcherFindings;
4227
+ found = ctx.scan().hookMatcherFindings;
4172
4228
  }
4173
4229
  catch {
4174
4230
  return { issues: 0, errors: 0 };
@@ -4176,8 +4232,8 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4176
4232
  if (found.length > 0 && !silent) {
4177
4233
  console.log("\nHook-matcher check:\n");
4178
4234
  for (const m of found) {
4179
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${m.message}`);
4180
- ghAnnotate(sev === "error" ? "error" : "warning", m.message);
4235
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(m.message)}`);
4236
+ ctx.annotate(sev === "error" ? "error" : "warning", m.message);
4181
4237
  }
4182
4238
  }
4183
4239
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4189,7 +4245,8 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4189
4245
  * `mcpHookIssues` (high-precision: declared-set gated, built-ins allowlisted).
4190
4246
  * Warning by default; "error" gates CI.
4191
4247
  */
4192
- function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4248
+ function checkMcpHookTargets(ctx) {
4249
+ const { config, silent, adapter } = ctx;
4193
4250
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-hook-target-resolves"]);
4194
4251
  if (!sev)
4195
4252
  return { issues: 0, errors: 0 };
@@ -4199,7 +4256,7 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4199
4256
  }
4200
4257
  let found;
4201
4258
  try {
4202
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpHookIssues;
4259
+ found = ctx.scan().mcpHookIssues;
4203
4260
  }
4204
4261
  catch {
4205
4262
  return { issues: 0, errors: 0 };
@@ -4207,8 +4264,8 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4207
4264
  if (found.length > 0 && !silent) {
4208
4265
  console.log("\nMCP hook-target check:\n");
4209
4266
  for (const issue of found) {
4210
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
4211
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message);
4267
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4268
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message);
4212
4269
  }
4213
4270
  }
4214
4271
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4221,7 +4278,8 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4221
4278
  * existence-guarded one-liners, inline commands). Matches Anthropic's own
4222
4279
  * `claude plugin validate`. Warning by default; "error" gates CI.
4223
4280
  */
4224
- function checkHookScriptExists(config, silent, adapter, scanRoot) {
4281
+ function checkHookScriptExists(ctx) {
4282
+ const { config, silent, adapter } = ctx;
4225
4283
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
4226
4284
  if (!sev)
4227
4285
  return { issues: 0, errors: 0 };
@@ -4231,7 +4289,7 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4231
4289
  }
4232
4290
  let missing;
4233
4291
  try {
4234
- missing = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hooks.filter((h) => h.status === "missing");
4292
+ missing = ctx.scan().hooks.filter((h) => h.status === "missing");
4235
4293
  }
4236
4294
  catch {
4237
4295
  return { issues: 0, errors: 0 };
@@ -4239,9 +4297,12 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4239
4297
  if (missing.length > 0 && !silent) {
4240
4298
  console.log("\nHook-script existence check:\n");
4241
4299
  for (const h of missing) {
4242
- const msg = `hook script "${h.script}" is referenced but missing — the hook never runs.`;
4243
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
4244
- ghAnnotate(sev === "error" ? "error" : "warning", msg);
4300
+ // `script` arrives with the plugin-root token EXPANDED, i.e. absolute —
4301
+ // printed as-is it was a machine path (#281). A relative one is the
4302
+ // bundle's own, which is what `scanned` assumes.
4303
+ const msg = `hook script "${ctx.bundle.scanned(h.script)}" is referenced but missing — the hook never runs.`;
4304
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(msg)}`);
4305
+ ctx.annotate(sev === "error" ? "error" : "warning", msg);
4245
4306
  }
4246
4307
  }
4247
4308
  return {
@@ -4256,7 +4317,8 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4256
4317
  * — high-precision (gated on a declared set, built-ins allowlisted, the
4257
4318
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
4258
4319
  */
4259
- function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4320
+ function checkMcpToolResolves(ctx) {
4321
+ const { config, silent, adapter } = ctx;
4260
4322
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
4261
4323
  if (!sev)
4262
4324
  return { issues: 0, errors: 0 };
@@ -4266,7 +4328,9 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4266
4328
  }
4267
4329
  let found;
4268
4330
  try {
4269
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
4331
+ found = ctx
4332
+ .scan()
4333
+ .agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
4270
4334
  }
4271
4335
  catch {
4272
4336
  return { issues: 0, errors: 0 };
@@ -4274,8 +4338,8 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4274
4338
  if (found.length > 0 && !silent) {
4275
4339
  console.log("\nMCP tool-resolution check:\n");
4276
4340
  for (const issue of found) {
4277
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
4278
- ghAnnotate(sev === "error" ? "error" : "warning", issue.message, issue.path);
4341
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4342
+ ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4279
4343
  }
4280
4344
  }
4281
4345
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4316,7 +4380,7 @@ async function checkCoverageThresholds(excludes, coverage, config, silent) {
4316
4380
  // Load all claude specs so coverage doesn't depend on a built dist/.
4317
4381
  const loaded = await Promise.all(findSpecs(excludes).map(loadSpec));
4318
4382
  const claudeSpecs = loaded.filter((s) => s?._specType === "claude");
4319
- const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs, excludes.ignore);
4383
+ const metric = (0, coverage_js_1.computeScriptCoverage)(process.cwd(), opts.scripts, claudeSpecs, excludes.globIgnore);
4320
4384
  const ok = metric.passing;
4321
4385
  if (!ok)
4322
4386
  failing++;
@@ -4807,7 +4871,7 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
4807
4871
  * 1. `.claude-plugin/plugin.json#name` — the repo's own declared name, used
4808
4872
  * when the run installed the repo AS a plugin (`pluginDir`).
4809
4873
  * 2. `vigiles-loose-skills` — the synthetic name OUR OWN packaging gives a
4810
- * loose `.claude/skills` dir (`packageSkillsDir`, and `underTestSource`'s
4874
+ * loose `.claude/skills` dir (`packageSkillsDir`, and `installNamespace`'s
4811
4875
  * fallback when a plugin manifest has no name). A repo that is not a plugin
4812
4876
  * still reports namespaced ids under it, so omitting it would drop every
4813
4877
  * trigger-rate record for the documented one-liner.
@@ -4828,20 +4892,35 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
4828
4892
  return [...names];
4829
4893
  }
4830
4894
  const recordsConfig = (0, validate_js_1.loadConfig)();
4831
- const scan = (0, test_coverage_js_1.findUntestedSurfaces)({
4832
- basePath: cwd,
4833
- layout: harnessLayoutFor(cwd, recordsConfig, harnessFlag),
4834
- // The repo-wide `exclude` only — the per-rule severities/include stay out
4835
- // of record resolution on purpose (a run's probes must map to a surface
4836
- // whether or not the untested-* rule for its kind is on).
4837
- exclude: (0, exclude_js_1.excludeSet)(cwd, recordsConfig.exclude).ignore,
4895
+ const layout = harnessLayoutFor(cwd, recordsConfig, harnessFlag);
4896
+ // The repo-wide `exclude` only — the per-rule severities/include stay out
4897
+ // of record resolution on purpose (a run's probes must map to a surface
4898
+ // whether or not the untested-* rule for its kind is on).
4899
+ const excludes = (0, exclude_js_1.excludeSet)(cwd, recordsConfig.exclude);
4900
+ // 🔴 THE SAME BUNDLES `lint` SCORES (#281, D5). This discovered the root
4901
+ // bundle only, so under `bundles: "all"` a run that exercised a nested skill
4902
+ // recorded nothing, and `lint` then called that skill untested. Every bundle's
4903
+ // surfaces are expressed from `cwd`, the one frame the artifact is keyed in; a
4904
+ // name two bundles share identifies neither (`resolveProbe` takes only a
4905
+ // unique match), which is the honest answer to an ambiguous probe.
4906
+ const bundles = recordsConfig.bundles === "all"
4907
+ ? [cwd, ...discoverNestedBundles(cwd, excludes)]
4908
+ : [cwd];
4909
+ const surfaces = bundles.flatMap((basePath) => {
4910
+ const scan = (0, test_coverage_js_1.findUntestedSurfaces)({
4911
+ basePath,
4912
+ root: cwd,
4913
+ layout,
4914
+ excludes,
4915
+ });
4916
+ return [...scan.covered, ...scan.untested];
4838
4917
  });
4839
4918
  return (0, coverage_artifact_js_1.recordsFrom)({
4840
4919
  runs,
4841
- surfaces: [...scan.covered, ...scan.untested],
4920
+ surfaces,
4842
4921
  tier,
4843
4922
  at: new Date().toISOString(),
4844
- selfNamespaces: selfNamespaces(cwd),
4923
+ selfNamespaces: [...new Set(bundles.flatMap(selfNamespaces))],
4845
4924
  // The root an ABSOLUTE command ref must lie beneath to be OURS. Without it a
4846
4925
  // harness that executed `/tmp/fixture/hooks/pre.sh` credited this repo's own
4847
4926
  // `hooks/pre.sh`, because the tail matched. See the ladder note on
@@ -4943,7 +5022,7 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
4943
5022
  }
4944
5023
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4945
5024
  // harness must not run as ours), but a script you NAME still runs (#192).
4946
- const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.ignore);
5025
+ const files = (0, run_scripts_js_1.discoverScripts)(restArgs.map((p) => noteExplicitOverride(excludes, p, "running")), defaultGlob, cwd, excludes.globIgnore);
4947
5026
  // `--min=N`: a CI gate asserts at least N scripts actually LOADED — so a bad
4948
5027
  // path, a renamed file, a glob that matched nothing, or a file that matched and
4949
5028
  // never linked fails LOUD instead of passing green with nothing executed.
@@ -5766,17 +5845,31 @@ function evalLockNudgeHookCommand() {
5766
5845
  // heard nothing at all, while a repo that already tests got reminded. That is
5767
5846
  // backwards, and `untested-skill` already stated the missing half correctly;
5768
5847
  // it just lived in `vigiles lint`, which someone has to run by hand.
5769
- const msg = (0, test_coverage_js_1.skillTestNudge)(target, {
5848
+ // Same union `vigiles lint` applies: the rule's exclude narrows, the
5849
+ // repo-wide exclude is the floor (#192) — the nudge must not report a
5850
+ // vendored skill the linter would never list.
5851
+ const excludes = (0, exclude_js_1.excludeSet)(cwd, config.exclude);
5852
+ // 🔴 THE BUNDLE THAT HOLDS THE EDIT, found the way `lint` finds it (#281, D6).
5853
+ // The nudge scanned the root bundle only and matched by SUFFIX, so an edit to
5854
+ // `plugins/p/skills/x/SKILL.md` was answered with the coverage of the ROOT's
5855
+ // `skills/x`. Now the edited path is a `RepoPath`, the scan runs over the
5856
+ // bundle it lives in, and the two are compared for equality in one frame.
5857
+ // A nested bundle `lint` would not score (no `bundles: "all"`) gets no nudge,
5858
+ // for the same reason it gets no finding.
5859
+ const frame = (0, frame_js_1.frameAt)(cwd);
5860
+ const edited = (0, node_path_1.resolve)(cwd, file);
5861
+ const home = (config.bundles === "all"
5862
+ ? discoverNestedBundles(cwd, excludes).find((b) => {
5863
+ const r = (0, node_path_1.relative)(b, edited);
5864
+ return r !== "" && !r.startsWith("..") && !(0, node_path_1.isAbsolute)(r);
5865
+ })
5866
+ : undefined) ?? cwd;
5867
+ const msg = (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
5770
5868
  ...options,
5771
- basePath: cwd,
5869
+ basePath: home,
5870
+ root: cwd,
5772
5871
  layout,
5773
- // Same union `vigiles lint` applies: the rule's exclude narrows, the
5774
- // repo-wide exclude is the floor (#192) — the nudge must not report a
5775
- // vendored skill the linter would never list.
5776
- exclude: [
5777
- ...(options.exclude ?? []),
5778
- ...(0, exclude_js_1.excludeSet)(cwd, config.exclude).ignore,
5779
- ],
5872
+ excludes,
5780
5873
  }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
5781
5874
  if (!msg)
5782
5875
  return;
@@ -6733,9 +6826,12 @@ async function main() {
6733
6826
  process.exitCode = 2;
6734
6827
  return;
6735
6828
  }
6829
+ // The same single owner of "relative to which directory" `lint` uses
6830
+ // (#281): cwd, unless the audited dir is somebody else's repository.
6831
+ const frame = (0, frame_js_1.frameFor)(process.cwd(), root);
6736
6832
  const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
6737
6833
  sharedDirs: config.sharedDirs,
6738
- sharedDirsRoot: sharedDirsRootFor(targets[0]),
6834
+ sharedDirsRoot: frame.root,
6739
6835
  // `.vigilesrc.json#exclude` reaches surface DISCOVERY, not just the
6740
6836
  // instruction file. Measured 2026-09-21 before this line existed: a repo
6741
6837
  // with `{"exclude": [".claude"]}` and one skill at `.claude/skills/demo`
@@ -6770,7 +6866,16 @@ async function main() {
6770
6866
  // `init` adopts (layout-driven instruction file + skill/subagent sweep).
6771
6867
  // Surfaced in the AuditReport (the report's "Create spec" command-emit
6772
6868
  // buttons read it) and the terminal nudge below.
6773
- const adoptableSurfaces = discoverAdoptableForAudit(root, adapter.layout.instructionFile);
6869
+ //
6870
+ // 🔴 IN THE FRAME `init` RUNS IN, AND WITHOUT WHAT THE REPO EXCLUDES (#281,
6871
+ // D7). The sweep reads the audited dir, so its paths are relative to it —
6872
+ // and for `audit plugins/p` the printed `npx vigiles init
6873
+ // --target=skills/x/SKILL.md` named a file that does not exist from where
6874
+ // you run it, and offered to adopt a skill `exclude` had removed.
6875
+ const auditedBundle = frame.bundle(root);
6876
+ const adoptableSurfaces = discoverAdoptableForAudit(root, adapter.layout.instructionFile)
6877
+ .filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)))
6878
+ .map((p) => auditedBundle.scanned(p));
6774
6879
  // Read the local flight recorder ONCE — feeds both the JSON report
6775
6880
  // (structured summary, the product boundary) and the terminal render.
6776
6881
  const ledgerRecords = (0, observe_js_1.readObservations)(root);