vigiles 30.0.2 → 31.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/adapter-conformance.js +9 -15
  2. package/dist/adapters/claude-code/run-scripts.d.ts +5 -3
  3. package/dist/adapters/claude-code/run-scripts.js +5 -3
  4. package/dist/claude-code.js +5 -0
  5. package/dist/cli-main.d.ts +8 -0
  6. package/dist/cli-main.js +315 -212
  7. package/dist/codex.js +5 -0
  8. package/dist/core/compile.d.ts +5 -5
  9. package/dist/core/compile.js +28 -25
  10. package/dist/core/config-schema.d.ts +32 -32
  11. package/dist/core/coverage.d.ts +4 -3
  12. package/dist/core/coverage.js +4 -3
  13. package/dist/core/doc-refs.d.ts +3 -1
  14. package/dist/core/doc-refs.js +2 -1
  15. package/dist/core/frame.d.ts +90 -0
  16. package/dist/core/frame.js +58 -0
  17. package/dist/core/glob-ignore.d.ts +28 -0
  18. package/dist/core/glob-ignore.js +42 -0
  19. package/dist/core/orphans.d.ts +5 -3
  20. package/dist/core/orphans.js +5 -4
  21. package/dist/core/refs.d.ts +2 -2
  22. package/dist/core/refs.js +9 -12
  23. package/dist/core/spec.js +5 -0
  24. package/dist/core/symbols.d.ts +25 -19
  25. package/dist/core/symbols.js +51 -74
  26. package/dist/core/tree-sitter-wasm.d.ts +24 -0
  27. package/dist/core/tree-sitter-wasm.js +128 -0
  28. package/dist/eval-surface.js +5 -0
  29. package/dist/exclude.d.ts +3 -5
  30. package/dist/exclude.js +3 -3
  31. package/dist/hook-install.js +23 -10
  32. package/dist/hook.js +5 -0
  33. package/dist/jest.js +5 -0
  34. package/dist/linting.d.ts +11 -30
  35. package/dist/linting.js +22 -35
  36. package/dist/scan.d.ts +3 -3
  37. package/dist/scan.js +8 -7
  38. package/dist/test-coverage.d.ts +20 -3
  39. package/dist/test-coverage.js +40 -14
  40. package/dist/test.js +5 -0
  41. package/dist/vitest.mjs +5 -0
  42. package/package.json +4 -7
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");
@@ -335,9 +336,9 @@ function compileGeneratorSkillToFile(specPath, source) {
335
336
  return false;
336
337
  }
337
338
  /** Compile a ClaudeSpec → its primary + any additional targets. */
338
- function compileClaudeToFile(spec, specPath, config, dialect) {
339
+ async function compileClaudeToFile(spec, specPath, config, dialect) {
339
340
  const basePath = process.cwd();
340
- const { markdown, errors, warnings, linterResults, targets } = (0, compile_js_1.compileClaude)(spec, {
341
+ const { markdown, errors, warnings, linterResults, targets } = await (0, compile_js_1.compileClaude)(spec, {
341
342
  basePath,
342
343
  specFile: specPath,
343
344
  dialect,
@@ -471,9 +472,9 @@ function formatArtifactSize(markdown) {
471
472
  return `${size} · ~${String(kTokens)}k tokens est.`;
472
473
  }
473
474
  /** Compile a declarative SkillSpec → SKILL.md. */
474
- function compileSkillToFile(spec, specPath, dialect) {
475
+ async function compileSkillToFile(spec, specPath, dialect) {
475
476
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
476
- const { artifact, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
477
+ const { artifact, errors, warnings } = await (0, compile_js_1.compileSkill)(spec, {
477
478
  basePath: process.cwd(),
478
479
  specFile: specPath,
479
480
  // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
@@ -495,9 +496,9 @@ function compileSkillToFile(spec, specPath, dialect) {
495
496
  return false;
496
497
  }
497
498
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
498
- function compileAgentToFile(spec, specPath, dialect) {
499
+ async function compileAgentToFile(spec, specPath, dialect) {
499
500
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
500
- const { artifact, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
501
+ const { artifact, errors, warnings } = await (0, compile_js_1.compileAgent)(spec, {
501
502
  basePath: process.cwd(),
502
503
  specFile: specPath,
503
504
  dialect,
@@ -592,7 +593,7 @@ async function compile(specPaths, config, excludes, opts = {}) {
592
593
  const specDialect = opts.harnessFlag === undefined
593
594
  ? ((0, adapter_registry_js_1.adapterForInstructionFile)(targetFile)?.dialect ?? dialect)
594
595
  : dialect;
595
- if (compileClaudeToFile(spec, specPath, config, specDialect)) {
596
+ if (await compileClaudeToFile(spec, specPath, config, specDialect)) {
596
597
  writeInstructionMirrors(specPath.replace(/\.spec\.ts$/, ""), declaredHarnesses);
597
598
  }
598
599
  else {
@@ -608,11 +609,11 @@ async function compile(specPaths, config, excludes, opts = {}) {
608
609
  for (const w of (0, skill_harness_js_1.skillFrontmatterDropWarnings)(spec, forHarnesses)) {
609
610
  console.log(`⚠ ${w}`);
610
611
  }
611
- if (!compileSkillToFile(spec, specPath, dialect))
612
+ if (!(await compileSkillToFile(spec, specPath, dialect)))
612
613
  allValid = false;
613
614
  }
614
615
  else if (spec._specType === "agent") {
615
- if (!compileAgentToFile(spec, specPath, dialect))
616
+ if (!(await compileAgentToFile(spec, specPath, dialect)))
616
617
  allValid = false;
617
618
  }
618
619
  else if (spec._specType === "railway") {
@@ -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
- 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();
@@ -835,7 +843,7 @@ function verifyMarkdownSymbols(files, silent) {
835
843
  catch {
836
844
  continue;
837
845
  }
838
- const broken = (0, refs_js_1.verifySymbolRefs)(markdown, (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, f)));
846
+ const broken = await (0, refs_js_1.verifySymbolRefs)(markdown, (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, f)));
839
847
  if (broken.length === 0)
840
848
  continue;
841
849
  if (!silent) {
@@ -845,7 +853,7 @@ 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 @@ 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
  /**
@@ -1361,7 +1372,7 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1361
1372
  // collected lazily so a repo with no railway spec never pays for the walk.
1362
1373
  if (spec._specType === "railway")
1363
1374
  knownAgents ??= await collectAgentNames(excludes);
1364
- const errors = specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []);
1375
+ const errors = await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []);
1365
1376
  for (const e of errors)
1366
1377
  found.push(`${target}: ${e.message} (from ${specPath})`);
1367
1378
  }
@@ -1399,11 +1410,11 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1399
1410
  * only the options differ. Exhaustive over `_specType`, so a FIFTH spec type is
1400
1411
  * a tsc error here instead of a silent skip, which is the failure this closes.
1401
1412
  */
1402
- function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1413
+ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1403
1414
  const basePath = process.cwd();
1404
1415
  switch (spec._specType) {
1405
1416
  case "claude":
1406
- return (0, compile_js_1.compileClaude)(spec, {
1417
+ return (await (0, compile_js_1.compileClaude)(spec, {
1407
1418
  basePath,
1408
1419
  specFile: specPath,
1409
1420
  dialect,
@@ -1412,13 +1423,11 @@ function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1412
1423
  maxSectionLines: config?.maxSectionLines,
1413
1424
  catalogOnly: config?.catalogOnly,
1414
1425
  linters: config?.linters,
1415
- }).errors;
1426
+ })).errors;
1416
1427
  case "skill":
1417
- return (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })
1418
- .errors;
1428
+ return (await (0, compile_js_1.compileSkill)(spec, { basePath, specFile: specPath, dialect })).errors;
1419
1429
  case "agent":
1420
- return (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })
1421
- .errors;
1430
+ return (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors;
1422
1431
  case "railway":
1423
1432
  return (0, compile_js_1.compileRailway)(spec, { specFile: specPath, knownAgents }).errors;
1424
1433
  default:
@@ -1487,6 +1496,11 @@ async function runLint(restArgs, flags, excludes, config) {
1487
1496
  const nestedBundles = discoverNestedBundles(scanRoot, excludes);
1488
1497
  const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1489
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 };
1490
1504
  if (!silent && nestedBundles.length > 0) {
1491
1505
  const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1492
1506
  console.log(scoreAll
@@ -1512,11 +1526,11 @@ async function runLint(restArgs, flags, excludes, config) {
1512
1526
  }
1513
1527
  }
1514
1528
  const hashResult = files.length > 0
1515
- ? check(files, silent)
1529
+ ? check(frame, files, silent)
1516
1530
  : { valid: true, hashErrors: 0, validationErrors: 0 };
1517
1531
  // 1b. Verify inline + frontmatter rules in instruction files not managed
1518
1532
  // by a spec. See verifyMarkdownModeRules / docs/markdown-mode.md.
1519
- const md = verifyMarkdownModeRules(files, silent, config);
1533
+ const md = verifyMarkdownModeRules(frame, files, silent, config);
1520
1534
  const { inlineErrors, inlineRules, frontmatterErrors, frontmatterRules } = md;
1521
1535
  // 2. Coverage gaps (discover)
1522
1536
  if (!silent)
@@ -1572,7 +1586,7 @@ async function runLint(restArgs, flags, excludes, config) {
1572
1586
  // The repo-wide `exclude` is the FLOOR under the rule's own `exclude`
1573
1587
  // (union, never override): an excluded corpus is neither an orphan
1574
1588
  // candidate nor a source of references that keep a doc alive (#192).
1575
- repoExclude: excludes.ignore,
1589
+ repoExclude: excludes.globIgnore,
1576
1590
  // Exempt every registered harness's surface files (instruction file,
1577
1591
  // SKILL.md, subagents, commands) as orphan candidates — layout-driven so
1578
1592
  // core carries no harness literal (see src/core/orphans.ts).
@@ -1593,74 +1607,74 @@ async function runLint(restArgs, flags, excludes, config) {
1593
1607
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1594
1608
  // is unchanged, not that what it names still exists (#173).
1595
1609
  const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1596
- const untested = overBundles((c, s, a, r) => checkUntestedSurfaces(excludes, c, s, a, r), config, silent, adapter, lintRoots);
1610
+ const untested = overBundles(checkUntestedSurfaces, run, lintRoots);
1597
1611
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1598
1612
  // rail against the harness catalog (the moat). n/a on a harness with no
1599
1613
  // subagents. Off by default unless a severity is configured; warning surfaces
1600
1614
  // a typo/never-available tool, error gates CI.
1601
- const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
1615
+ const toolContract = overBundles(checkSubagentToolContracts, run, lintRoots);
1602
1616
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1603
1617
  // define never fires. High-precision (close typos only). Off unless configured.
1604
- const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
1618
+ const hookEvents = overBundles(checkHookEvents, run, lintRoots);
1605
1619
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1606
1620
  // (name + description) won't register. n/a on a harness with no subagents.
1607
- const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
1621
+ const frontmatter = overBundles(checkFrontmatterSchema, run, lintRoots);
1608
1622
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1609
- const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
1623
+ const mcpConfig = overBundles(checkMcpConfig, run, lintRoots);
1610
1624
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1611
1625
  // reliable trigger surface). Best-practice nudge; skills load without it.
1612
- const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
1626
+ const skillFm = overBundles(checkSkillFrontmatter, run, lintRoots);
1613
1627
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1614
1628
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1615
- const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
1629
+ const mcpToolResolves = overBundles(checkMcpToolResolves, run, lintRoots);
1616
1630
  // 7i. Hook-script existence — a hook command referencing a missing script file
1617
1631
  // never runs. This comment used to add "matches Anthropic's own `claude plugin
1618
1632
  // validate`"; measured false on 2026-09-08 (Claude Code 2.1.263) — see
1619
1633
  // docs/rules/hook-script-exists.md and tools/measure-validate-overlap.mjs.
1620
- const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
1634
+ const hookScripts = overBundles(checkHookScriptExists, run, lintRoots);
1621
1635
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1622
1636
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1623
- const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
1637
+ const disallowedTools = overBundles(checkDisallowedTools, run, lintRoots);
1624
1638
  // 7k. Description-overlap — two model-invocable skills with near-identical
1625
1639
  // descriptions collide in the selector (deterministic NCD precision proxy).
1626
- const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
1640
+ const descriptionOverlap = overBundles(checkDescriptionOverlap, run, lintRoots);
1627
1641
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1628
1642
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1629
1643
  // precision). Generous 500-char budget; warn-tier, never gates.
1630
- const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
1644
+ const descriptionBudget = overBundles(checkDescriptionBudget, run, lintRoots);
1631
1645
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1632
1646
  // stricter than some loaders, so verify before enforcing).
1633
- const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
1647
+ const frontmatterValid = overBundles(checkFrontmatterValid, run, lintRoots);
1634
1648
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1635
1649
  // targets an undeclared server (the moat applied to the hook surface).
1636
- const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
1650
+ const mcpHookTargets = overBundles(checkMcpHookTargets, run, lintRoots);
1637
1651
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1638
1652
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1639
- const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
1653
+ const preferCompiledHooks = overBundles(checkPreferCompiledHooks, run, lintRoots);
1640
1654
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1641
1655
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1642
1656
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1643
- const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
1657
+ const lethalTrifecta = overBundles(checkLethalTrifecta, run, lintRoots);
1644
1658
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1645
1659
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1646
- const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
1660
+ const skillResources = overBundles(checkSkillResourceResolves, run, lintRoots);
1647
1661
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1648
1662
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1649
- const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
1663
+ const skillFence = overBundles(checkSkillMissingFence, run, lintRoots);
1650
1664
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1651
1665
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1652
- const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
1666
+ const pluginLayout = overBundles(checkPluginDirLayout, run, lintRoots);
1653
1667
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1654
1668
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1655
- const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
1669
+ const delegationTrifecta = overBundles(checkDelegationTrifecta, run, lintRoots);
1656
1670
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1657
1671
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1658
1672
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1659
- const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
1673
+ const hookBlock = overBundles(checkHookBlockIneffective, run, lintRoots);
1660
1674
  // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1661
1675
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1662
1676
  // server naming, or an undeclared MCP server).
1663
- const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
1677
+ const hookMatcher = overBundles(checkHookMatcher, run, lintRoots);
1664
1678
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1665
1679
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1666
1680
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1698,7 +1712,7 @@ async function runLint(restArgs, flags, excludes, config) {
1698
1712
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1699
1713
  // grew to do the gating instead. One unpassed argument, that whole chain.
1700
1714
  const docRefReport = docRefSeverity
1701
- ? (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 })
1702
1716
  : {
1703
1717
  filesScanned: 0,
1704
1718
  filesIgnored: 0,
@@ -1721,14 +1735,14 @@ async function runLint(restArgs, flags, excludes, config) {
1721
1735
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1722
1736
  if (isGitHubActions() && !silent && docRefSeverity) {
1723
1737
  for (const e of docRefReport.errors) {
1724
- 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);
1725
1739
  }
1726
1740
  }
1727
1741
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
1728
- const symbolRefErrors = verifyMarkdownSymbols(files, silent);
1742
+ const symbolRefErrors = await verifyMarkdownSymbols(frame, files, silent);
1729
1743
  // 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
1730
1744
  // (only when a .mcp.json declares them). See src/mcp.ts.
1731
- const mcpRefErrors = await verifyMarkdownMcpRefs(files, silent);
1745
+ const mcpRefErrors = await verifyMarkdownMcpRefs(frame, files, silent);
1732
1746
  const report = {
1733
1747
  hashErrors: hashResult.hashErrors,
1734
1748
  validationErrors: hashResult.validationErrors,
@@ -3607,18 +3621,22 @@ function untestedRules(config) {
3607
3621
  * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
3608
3622
  * untested count plus the severity-gated error count.
3609
3623
  */
3610
- function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3624
+ function checkUntestedSurfaces(ctx) {
3625
+ const { config, silent, adapter, frame, bundle } = ctx;
3611
3626
  const { severity: sevFor, anyEnabled, options } = untestedRules(config);
3612
3627
  if (!anyEnabled)
3613
3628
  return { untested: 0, errors: 0 };
3614
3629
  const report = (0, test_coverage_js_1.findUntestedSurfaces)({
3615
3630
  ...options,
3616
- 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,
3617
3635
  layout: adapter.layout,
3618
3636
  // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
3619
3637
  // it. Union, never override — a rule option must not re-admit a vendored
3620
- // corpus the repo excluded (#192).
3621
- exclude: [...(options.exclude ?? []), ...excludes.ignore],
3638
+ // corpus the repo excluded (#192). Passed WHOLE so it carries its own root.
3639
+ excludes: ctx.excludes,
3622
3640
  });
3623
3641
  if (!silent) {
3624
3642
  console.log("\nUntested surfaces:\n");
@@ -3626,7 +3644,7 @@ function checkUntestedSurfaces(excludes, config, silent, adapter, scanRoot) {
3626
3644
  console.log(` ${line}`);
3627
3645
  }
3628
3646
  for (const s of report.untested) {
3629
- 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)));
3630
3648
  }
3631
3649
  }
3632
3650
  return {
@@ -3655,7 +3673,8 @@ function reportNotApplicable(check, surface, adapter, silent) {
3655
3673
  * (plugin/MCP-provided) is never a false alarm. Warning by default; set
3656
3674
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
3657
3675
  */
3658
- function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3676
+ function checkSubagentToolContracts(ctx) {
3677
+ const { config, silent, adapter } = ctx;
3659
3678
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
3660
3679
  if (!sev)
3661
3680
  return { issues: 0, errors: 0 };
@@ -3668,7 +3687,7 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3668
3687
  // `agents/` path, so a harness with a different subagent dir Just Works.
3669
3688
  let agents;
3670
3689
  try {
3671
- agents = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents;
3690
+ agents = ctx.scan().agents;
3672
3691
  }
3673
3692
  catch {
3674
3693
  return { issues: 0, errors: 0 };
@@ -3685,8 +3704,8 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3685
3704
  printedHeader = true;
3686
3705
  }
3687
3706
  for (const issue of agent.toolIssues) {
3688
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${agent.path}: ${issue.message}`);
3689
- 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));
3690
3709
  }
3691
3710
  }
3692
3711
  }
@@ -3698,7 +3717,8 @@ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
3698
3717
  * `hookEventIssues` (the shared detector, high-precision: close typos only, never
3699
3718
  * a framework/custom event). Warning by default; "error" gates CI.
3700
3719
  */
3701
- function checkHookEvents(config, silent, adapter, scanRoot) {
3720
+ function checkHookEvents(ctx) {
3721
+ const { config, silent, adapter } = ctx;
3702
3722
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-events"]);
3703
3723
  if (!sev)
3704
3724
  return { issues: 0, errors: 0 };
@@ -3708,7 +3728,7 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3708
3728
  }
3709
3729
  let found;
3710
3730
  try {
3711
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookEventIssues;
3731
+ found = ctx.scan().hookEventIssues;
3712
3732
  }
3713
3733
  catch {
3714
3734
  return { issues: 0, errors: 0 };
@@ -3716,8 +3736,8 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3716
3736
  if (found.length > 0 && !silent) {
3717
3737
  console.log("\nHook-event check:\n");
3718
3738
  for (const issue of found) {
3719
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3720
- 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);
3721
3741
  }
3722
3742
  }
3723
3743
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3729,7 +3749,8 @@ function checkHookEvents(config, silent, adapter, scanRoot) {
3729
3749
  * of a real one) — it silently falls back / is ignored. Reuses `scanPlugin`'s
3730
3750
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
3731
3751
  */
3732
- function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3752
+ function checkFrontmatterSchema(ctx) {
3753
+ const { config, silent, adapter } = ctx;
3733
3754
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
3734
3755
  if (!sev)
3735
3756
  return { issues: 0, errors: 0 };
@@ -3739,7 +3760,7 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3739
3760
  }
3740
3761
  let found;
3741
3762
  try {
3742
- const r = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect);
3763
+ const r = ctx.scan();
3743
3764
  found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
3744
3765
  }
3745
3766
  catch {
@@ -3748,8 +3769,8 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3748
3769
  if (found.length > 0 && !silent) {
3749
3770
  console.log("\nFrontmatter-schema check:\n");
3750
3771
  for (const issue of found) {
3751
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3752
- 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));
3753
3774
  }
3754
3775
  }
3755
3776
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3762,13 +3783,14 @@ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
3762
3783
  * default; set "error" to enforce it on your own skills. Reuses `scanPlugin`'s
3763
3784
  * `skillMetaIssues`.
3764
3785
  */
3765
- function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3786
+ function checkSkillFrontmatter(ctx) {
3787
+ const { config, silent } = ctx;
3766
3788
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
3767
3789
  if (!sev)
3768
3790
  return { issues: 0, errors: 0 };
3769
3791
  let found;
3770
3792
  try {
3771
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillMetaIssues;
3793
+ found = ctx.scan().skillMetaIssues;
3772
3794
  }
3773
3795
  catch {
3774
3796
  return { issues: 0, errors: 0 };
@@ -3776,8 +3798,8 @@ function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3776
3798
  if (found.length > 0 && !silent) {
3777
3799
  console.log("\nSkill-frontmatter check:\n");
3778
3800
  for (const issue of found) {
3779
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3780
- 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));
3781
3803
  }
3782
3804
  }
3783
3805
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3789,13 +3811,14 @@ function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
3789
3811
  * lane stays first-class — so it fires once and the message links the guide.
3790
3812
  * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
3791
3813
  */
3792
- function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3814
+ function checkPreferCompiledHooks(ctx) {
3815
+ const { config, silent } = ctx;
3793
3816
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
3794
3817
  if (!sev)
3795
3818
  return { issues: 0, errors: 0 };
3796
3819
  let count;
3797
3820
  try {
3798
- count = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).manualHookCount;
3821
+ count = ctx.scan().manualHookCount;
3799
3822
  }
3800
3823
  catch {
3801
3824
  return { issues: 0, errors: 0 };
@@ -3805,8 +3828,8 @@ function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3805
3828
  const message = (0, scan_js_1.preferCompiledHooksMessage)(count);
3806
3829
  if (!silent) {
3807
3830
  console.log("\nCompiled-hooks check:\n");
3808
- console.log(` ${sev === "error" ? "✗" : "ℹ"} ${message}`);
3809
- ghAnnotate(sev === "error" ? "error" : "warning", message);
3831
+ console.log(` ${sev === "error" ? "✗" : "ℹ"} ${ctx.label(message)}`);
3832
+ ctx.annotate(sev === "error" ? "error" : "warning", message);
3810
3833
  }
3811
3834
  return { issues: 1, errors: sev === "error" ? 1 : 0 };
3812
3835
  }
@@ -3815,13 +3838,14 @@ function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
3815
3838
  * (stdio) nor a `url` (http/sse) can't start. Reuses `scanPlugin`'s `mcpIssues`.
3816
3839
  * Warning by default; "error" gates CI.
3817
3840
  */
3818
- function checkMcpConfig(config, silent, adapter, scanRoot) {
3841
+ function checkMcpConfig(ctx) {
3842
+ const { config, silent } = ctx;
3819
3843
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-config"]);
3820
3844
  if (!sev)
3821
3845
  return { issues: 0, errors: 0 };
3822
3846
  let found;
3823
3847
  try {
3824
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpIssues;
3848
+ found = ctx.scan().mcpIssues;
3825
3849
  }
3826
3850
  catch {
3827
3851
  return { issues: 0, errors: 0 };
@@ -3829,8 +3853,8 @@ function checkMcpConfig(config, silent, adapter, scanRoot) {
3829
3853
  if (found.length > 0 && !silent) {
3830
3854
  console.log("\nMCP-config check:\n");
3831
3855
  for (const issue of found) {
3832
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3833
- 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);
3834
3858
  }
3835
3859
  }
3836
3860
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3842,7 +3866,8 @@ function checkMcpConfig(config, silent, adapter, scanRoot) {
3842
3866
  * `disallowedToolIssues` (close-typo only — high-precision). Warning by default;
3843
3867
  * "error" gates CI.
3844
3868
  */
3845
- function checkDisallowedTools(config, silent, adapter, scanRoot) {
3869
+ function checkDisallowedTools(ctx) {
3870
+ const { config, silent, adapter } = ctx;
3846
3871
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
3847
3872
  if (!sev)
3848
3873
  return { issues: 0, errors: 0 };
@@ -3852,7 +3877,10 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3852
3877
  }
3853
3878
  let found;
3854
3879
  try {
3855
- 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
+ })));
3856
3884
  }
3857
3885
  catch {
3858
3886
  return { issues: 0, errors: 0 };
@@ -3860,8 +3888,8 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3860
3888
  if (found.length > 0 && !silent) {
3861
3889
  console.log("\nDisallowed-tools check:\n");
3862
3890
  for (const issue of found) {
3863
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
3864
- 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));
3865
3893
  }
3866
3894
  }
3867
3895
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3874,13 +3902,14 @@ function checkDisallowedTools(config, silent, adapter, scanRoot) {
3874
3902
  * colon / `<example>` is flagged though it may still load — hence WARN by default
3875
3903
  * (verify before setting "error").
3876
3904
  */
3877
- function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3905
+ function checkFrontmatterValid(ctx) {
3906
+ const { config, silent } = ctx;
3878
3907
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
3879
3908
  if (!sev)
3880
3909
  return { issues: 0, errors: 0 };
3881
3910
  let found;
3882
3911
  try {
3883
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).malformedFrontmatter;
3912
+ found = ctx.scan().malformedFrontmatter;
3884
3913
  }
3885
3914
  catch {
3886
3915
  return { issues: 0, errors: 0 };
@@ -3888,8 +3917,8 @@ function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3888
3917
  if (found.length > 0 && !silent) {
3889
3918
  console.log("\nFrontmatter-validity check:\n");
3890
3919
  for (const issue of found) {
3891
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3892
- 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));
3893
3922
  }
3894
3923
  }
3895
3924
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3901,13 +3930,14 @@ function checkFrontmatterValid(config, silent, adapter, scanRoot) {
3901
3930
  * `scanPlugin`'s `descriptionOverlaps` (calibrated FP-safe: only basically
3902
3931
  * identical text). Warning by default; "error" gates CI.
3903
3932
  */
3904
- function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3933
+ function checkDescriptionOverlap(ctx) {
3934
+ const { config, silent } = ctx;
3905
3935
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["description-overlap"]);
3906
3936
  if (!sev)
3907
3937
  return { issues: 0, errors: 0 };
3908
3938
  let found;
3909
3939
  try {
3910
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionOverlaps;
3940
+ found = ctx.scan().descriptionOverlaps;
3911
3941
  }
3912
3942
  catch {
3913
3943
  return { issues: 0, errors: 0 };
@@ -3915,8 +3945,8 @@ function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3915
3945
  if (found.length > 0 && !silent) {
3916
3946
  console.log("\nDescription-overlap check:\n");
3917
3947
  for (const issue of found) {
3918
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3919
- 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);
3920
3950
  }
3921
3951
  }
3922
3952
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3928,13 +3958,14 @@ function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
3928
3958
  * deterministic heuristic proxy (generous 500-char budget). Reuses `scanPlugin`'s
3929
3959
  * `descriptionBudgetIssues`. Warning by default; "error" gates CI.
3930
3960
  */
3931
- function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3961
+ function checkDescriptionBudget(ctx) {
3962
+ const { config, silent } = ctx;
3932
3963
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-description-budget"]);
3933
3964
  if (!sev)
3934
3965
  return { issues: 0, errors: 0 };
3935
3966
  let found;
3936
3967
  try {
3937
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionBudgetIssues;
3968
+ found = ctx.scan().descriptionBudgetIssues;
3938
3969
  }
3939
3970
  catch {
3940
3971
  return { issues: 0, errors: 0 };
@@ -3942,8 +3973,8 @@ function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3942
3973
  if (found.length > 0 && !silent) {
3943
3974
  console.log("\nSkill-description-budget check:\n");
3944
3975
  for (const issue of found) {
3945
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
3946
- 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);
3947
3978
  }
3948
3979
  }
3949
3980
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -3964,13 +3995,14 @@ function checkDescriptionBudget(config, silent, adapter, scanRoot) {
3964
3995
  * COUNT is unchanged (units, not lines), so exit codes and the CI gate are
3965
3996
  * unaffected by the collapse.
3966
3997
  */
3967
- function checkLethalTrifecta(config, silent, adapter, scanRoot) {
3998
+ function checkLethalTrifecta(ctx) {
3999
+ const { config, silent } = ctx;
3968
4000
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
3969
4001
  if (!sev)
3970
4002
  return { issues: 0, errors: 0 };
3971
4003
  let found;
3972
4004
  try {
3973
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).trifectaFindings;
4005
+ found = ctx.scan().trifectaFindings;
3974
4006
  }
3975
4007
  catch {
3976
4008
  return { issues: 0, errors: 0 };
@@ -3984,8 +4016,8 @@ function checkLethalTrifecta(config, silent, adapter, scanRoot) {
3984
4016
  if (unfenced.includes(t))
3985
4017
  continue;
3986
4018
  const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
3987
- console.log(` ${mark} ${t.path}: ${msg}`);
3988
- 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));
3989
4021
  }
3990
4022
  if (unfenced.length > 0) {
3991
4023
  console.log(` ${mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
@@ -4004,17 +4036,18 @@ function checkLethalTrifecta(config, silent, adapter, scanRoot) {
4004
4036
  * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
4005
4037
  * one detector, no drift). Warning by default; "error" gates CI.
4006
4038
  */
4007
- function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4039
+ function checkSkillResourceResolves(ctx) {
4040
+ const { config, silent } = ctx;
4008
4041
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
4009
4042
  if (!sev)
4010
4043
  return { issues: 0, errors: 0 };
4011
4044
  let found;
4012
4045
  try {
4013
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect, {
4046
+ found = ctx.scan({
4014
4047
  sharedDirs: config?.sharedDirs,
4015
- // sharedDirs live at the repo root that OWNS the scan target — cwd for a
4016
- // scoped subdir of this repo, the target itself for a foreign-repo lint.
4017
- 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,
4018
4051
  }).skillResourceIssues;
4019
4052
  }
4020
4053
  catch {
@@ -4029,8 +4062,8 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4029
4062
  // it was documented only in docs/skills-monorepo.md, so a CI log gave no
4030
4063
  // hint and the rule read as broken rather than misconfigured.
4031
4064
  ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
4032
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
4033
- 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));
4034
4067
  }
4035
4068
  }
4036
4069
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4042,13 +4075,14 @@ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
4042
4075
  * Reuses `scanPlugin`'s `skillFenceIssues` (one detector, no drift). Warning by
4043
4076
  * default; "error" gates CI.
4044
4077
  */
4045
- function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4078
+ function checkSkillMissingFence(ctx) {
4079
+ const { config, silent } = ctx;
4046
4080
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
4047
4081
  if (!sev)
4048
4082
  return { issues: 0, errors: 0 };
4049
4083
  let found;
4050
4084
  try {
4051
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillFenceIssues;
4085
+ found = ctx.scan().skillFenceIssues;
4052
4086
  }
4053
4087
  catch {
4054
4088
  return { issues: 0, errors: 0 };
@@ -4057,8 +4091,8 @@ function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4057
4091
  console.log("\nSkill-missing-fence check:\n");
4058
4092
  for (const s of found) {
4059
4093
  const msg = `${s.name}: ${s.finding.message}`;
4060
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
4061
- 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));
4062
4096
  }
4063
4097
  }
4064
4098
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4070,13 +4104,14 @@ function checkSkillMissingFence(config, silent, adapter, scanRoot) {
4070
4104
  * `pluginLayoutIssues` (one detector, no drift). Warning by default; "error"
4071
4105
  * gates CI.
4072
4106
  */
4073
- function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4107
+ function checkPluginDirLayout(ctx) {
4108
+ const { config, silent } = ctx;
4074
4109
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
4075
4110
  if (!sev)
4076
4111
  return { issues: 0, errors: 0 };
4077
4112
  let found;
4078
4113
  try {
4079
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).pluginLayoutIssues;
4114
+ found = ctx.scan().pluginLayoutIssues;
4080
4115
  }
4081
4116
  catch {
4082
4117
  return { issues: 0, errors: 0 };
@@ -4084,8 +4119,8 @@ function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4084
4119
  if (found.length > 0 && !silent) {
4085
4120
  console.log("\nPlugin-dir-layout check:\n");
4086
4121
  for (const p of found) {
4087
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${p.message}`);
4088
- 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);
4089
4124
  }
4090
4125
  }
4091
4126
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4098,13 +4133,14 @@ function checkPluginDirLayout(config, silent, adapter, scanRoot) {
4098
4133
  * gates CI. Surfaces across the subagent graph, so it is NOT gated on a
4099
4134
  * capability the way a surface-specific rule is.
4100
4135
  */
4101
- function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4136
+ function checkDelegationTrifecta(ctx) {
4137
+ const { config, silent } = ctx;
4102
4138
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
4103
4139
  if (!sev)
4104
4140
  return { issues: 0, errors: 0 };
4105
4141
  let found;
4106
4142
  try {
4107
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).delegationTrifecta;
4143
+ found = ctx.scan().delegationTrifecta;
4108
4144
  }
4109
4145
  catch {
4110
4146
  return { issues: 0, errors: 0 };
@@ -4113,8 +4149,8 @@ function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4113
4149
  console.log("\nDelegation-trifecta check:\n");
4114
4150
  for (const d of found) {
4115
4151
  const msg = `${d.finding.name}: ${d.finding.message}`;
4116
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${d.path}: ${msg}`);
4117
- 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));
4118
4154
  }
4119
4155
  }
4120
4156
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4126,7 +4162,8 @@ function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
4126
4162
  * permission-gated event (#19009, the #1 verified hook pain). Reuses `scanPlugin`'s
4127
4163
  * `hookBlockFindings` (one detector, no drift). Warning by default; "error" gates CI.
4128
4164
  */
4129
- function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4165
+ function checkHookBlockIneffective(ctx) {
4166
+ const { config, silent, adapter } = ctx;
4130
4167
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-block-ineffective"]);
4131
4168
  if (!sev)
4132
4169
  return { issues: 0, errors: 0 };
@@ -4136,22 +4173,38 @@ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4136
4173
  }
4137
4174
  let found;
4138
4175
  try {
4139
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookBlockFindings;
4176
+ found = ctx.scan().hookBlockFindings;
4140
4177
  }
4141
4178
  catch {
4142
4179
  return { issues: 0, errors: 0 };
4143
4180
  }
4144
4181
  if (found.length > 0 && !silent) {
4182
+ const mark = sev === "error" ? "✗" : "⚠";
4183
+ const level = sev === "error" ? "error" : "warning";
4145
4184
  console.log("\nHook-block check:\n");
4146
4185
  for (const h of found) {
4147
- const where = h.scriptPath ?? "(inline)";
4148
- const msg = `[${h.event}] ${where}: ${h.message}`;
4149
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
4150
- 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);
4151
4189
  }
4152
4190
  }
4153
4191
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4154
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
+ }
4155
4208
  /**
4156
4209
  * Apply the `hook-matcher` rule: a hook `matcher` string that doesn't fire as
4157
4210
  * written — a tool-name typo (`bash`→`Bash`), a matcher that doesn't compile, an
@@ -4160,7 +4213,8 @@ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
4160
4213
  * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
4161
4214
  * by default; "error" gates CI.
4162
4215
  */
4163
- function checkHookMatcher(config, silent, adapter, scanRoot) {
4216
+ function checkHookMatcher(ctx) {
4217
+ const { config, silent, adapter } = ctx;
4164
4218
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-matcher"]);
4165
4219
  if (!sev)
4166
4220
  return { issues: 0, errors: 0 };
@@ -4170,7 +4224,7 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4170
4224
  }
4171
4225
  let found;
4172
4226
  try {
4173
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookMatcherFindings;
4227
+ found = ctx.scan().hookMatcherFindings;
4174
4228
  }
4175
4229
  catch {
4176
4230
  return { issues: 0, errors: 0 };
@@ -4178,8 +4232,8 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4178
4232
  if (found.length > 0 && !silent) {
4179
4233
  console.log("\nHook-matcher check:\n");
4180
4234
  for (const m of found) {
4181
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${m.message}`);
4182
- 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);
4183
4237
  }
4184
4238
  }
4185
4239
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4191,7 +4245,8 @@ function checkHookMatcher(config, silent, adapter, scanRoot) {
4191
4245
  * `mcpHookIssues` (high-precision: declared-set gated, built-ins allowlisted).
4192
4246
  * Warning by default; "error" gates CI.
4193
4247
  */
4194
- function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4248
+ function checkMcpHookTargets(ctx) {
4249
+ const { config, silent, adapter } = ctx;
4195
4250
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-hook-target-resolves"]);
4196
4251
  if (!sev)
4197
4252
  return { issues: 0, errors: 0 };
@@ -4201,7 +4256,7 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4201
4256
  }
4202
4257
  let found;
4203
4258
  try {
4204
- found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpHookIssues;
4259
+ found = ctx.scan().mcpHookIssues;
4205
4260
  }
4206
4261
  catch {
4207
4262
  return { issues: 0, errors: 0 };
@@ -4209,8 +4264,8 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4209
4264
  if (found.length > 0 && !silent) {
4210
4265
  console.log("\nMCP hook-target check:\n");
4211
4266
  for (const issue of found) {
4212
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.message}`);
4213
- 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);
4214
4269
  }
4215
4270
  }
4216
4271
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4223,7 +4278,8 @@ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
4223
4278
  * existence-guarded one-liners, inline commands). Matches Anthropic's own
4224
4279
  * `claude plugin validate`. Warning by default; "error" gates CI.
4225
4280
  */
4226
- function checkHookScriptExists(config, silent, adapter, scanRoot) {
4281
+ function checkHookScriptExists(ctx) {
4282
+ const { config, silent, adapter } = ctx;
4227
4283
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
4228
4284
  if (!sev)
4229
4285
  return { issues: 0, errors: 0 };
@@ -4233,7 +4289,7 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4233
4289
  }
4234
4290
  let missing;
4235
4291
  try {
4236
- 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");
4237
4293
  }
4238
4294
  catch {
4239
4295
  return { issues: 0, errors: 0 };
@@ -4241,9 +4297,12 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4241
4297
  if (missing.length > 0 && !silent) {
4242
4298
  console.log("\nHook-script existence check:\n");
4243
4299
  for (const h of missing) {
4244
- const msg = `hook script "${h.script}" is referenced but missing — the hook never runs.`;
4245
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
4246
- 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);
4247
4306
  }
4248
4307
  }
4249
4308
  return {
@@ -4258,7 +4317,8 @@ function checkHookScriptExists(config, silent, adapter, scanRoot) {
4258
4317
  * — high-precision (gated on a declared set, built-ins allowlisted, the
4259
4318
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
4260
4319
  */
4261
- function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4320
+ function checkMcpToolResolves(ctx) {
4321
+ const { config, silent, adapter } = ctx;
4262
4322
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
4263
4323
  if (!sev)
4264
4324
  return { issues: 0, errors: 0 };
@@ -4268,7 +4328,9 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4268
4328
  }
4269
4329
  let found;
4270
4330
  try {
4271
- 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 })));
4272
4334
  }
4273
4335
  catch {
4274
4336
  return { issues: 0, errors: 0 };
@@ -4276,8 +4338,8 @@ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
4276
4338
  if (found.length > 0 && !silent) {
4277
4339
  console.log("\nMCP tool-resolution check:\n");
4278
4340
  for (const issue of found) {
4279
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
4280
- 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));
4281
4343
  }
4282
4344
  }
4283
4345
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
@@ -4318,7 +4380,7 @@ async function checkCoverageThresholds(excludes, coverage, config, silent) {
4318
4380
  // Load all claude specs so coverage doesn't depend on a built dist/.
4319
4381
  const loaded = await Promise.all(findSpecs(excludes).map(loadSpec));
4320
4382
  const claudeSpecs = loaded.filter((s) => s?._specType === "claude");
4321
- 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);
4322
4384
  const ok = metric.passing;
4323
4385
  if (!ok)
4324
4386
  failing++;
@@ -4830,20 +4892,35 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
4830
4892
  return [...names];
4831
4893
  }
4832
4894
  const recordsConfig = (0, validate_js_1.loadConfig)();
4833
- const scan = (0, test_coverage_js_1.findUntestedSurfaces)({
4834
- basePath: cwd,
4835
- layout: harnessLayoutFor(cwd, recordsConfig, harnessFlag),
4836
- // The repo-wide `exclude` only — the per-rule severities/include stay out
4837
- // of record resolution on purpose (a run's probes must map to a surface
4838
- // whether or not the untested-* rule for its kind is on).
4839
- 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];
4840
4917
  });
4841
4918
  return (0, coverage_artifact_js_1.recordsFrom)({
4842
4919
  runs,
4843
- surfaces: [...scan.covered, ...scan.untested],
4920
+ surfaces,
4844
4921
  tier,
4845
4922
  at: new Date().toISOString(),
4846
- selfNamespaces: selfNamespaces(cwd),
4923
+ selfNamespaces: [...new Set(bundles.flatMap(selfNamespaces))],
4847
4924
  // The root an ABSOLUTE command ref must lie beneath to be OURS. Without it a
4848
4925
  // harness that executed `/tmp/fixture/hooks/pre.sh` credited this repo's own
4849
4926
  // `hooks/pre.sh`, because the tail matched. See the ladder note on
@@ -4945,7 +5022,7 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
4945
5022
  }
4946
5023
  // A script under an excluded path is not DISCOVERED (a vendored corpus's own
4947
5024
  // harness must not run as ours), but a script you NAME still runs (#192).
4948
- 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);
4949
5026
  // `--min=N`: a CI gate asserts at least N scripts actually LOADED — so a bad
4950
5027
  // path, a renamed file, a glob that matched nothing, or a file that matched and
4951
5028
  // never linked fails LOUD instead of passing green with nothing executed.
@@ -5648,7 +5725,7 @@ async function handleHookRuntime(kind, restArgs) {
5648
5725
  actionHookCommand();
5649
5726
  return;
5650
5727
  case "refs":
5651
- refsHookCommand();
5728
+ await refsHookCommand();
5652
5729
  return;
5653
5730
  case "eval-lock-nudge":
5654
5731
  evalLockNudgeHookCommand();
@@ -5768,17 +5845,31 @@ function evalLockNudgeHookCommand() {
5768
5845
  // heard nothing at all, while a repo that already tests got reminded. That is
5769
5846
  // backwards, and `untested-skill` already stated the missing half correctly;
5770
5847
  // it just lived in `vigiles lint`, which someone has to run by hand.
5771
- 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), {
5772
5868
  ...options,
5773
- basePath: cwd,
5869
+ basePath: home,
5870
+ root: cwd,
5774
5871
  layout,
5775
- // Same union `vigiles lint` applies: the rule's exclude narrows, the
5776
- // repo-wide exclude is the floor (#192) — the nudge must not report a
5777
- // vendored skill the linter would never list.
5778
- exclude: [
5779
- ...(options.exclude ?? []),
5780
- ...(0, exclude_js_1.excludeSet)(cwd, config.exclude).ignore,
5781
- ],
5872
+ excludes,
5782
5873
  }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
5783
5874
  if (!msg)
5784
5875
  return;
@@ -5797,7 +5888,7 @@ function evalLockNudgeHookCommand() {
5797
5888
  * agent mark its references, at write time, with full context. `vigiles:ignore`
5798
5889
  * opts a prose span out.
5799
5890
  */
5800
- function refsHookCommand() {
5891
+ async function refsHookCommand() {
5801
5892
  let raw = "";
5802
5893
  try {
5803
5894
  raw = (0, node_fs_1.readFileSync)(0, "utf-8");
@@ -5830,7 +5921,7 @@ function refsHookCommand() {
5830
5921
  catch {
5831
5922
  return;
5832
5923
  }
5833
- const issues = (0, refs_js_1.collectRefIssues)(markdown, (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, file)));
5924
+ const issues = await (0, refs_js_1.collectRefIssues)(markdown, (0, node_path_1.dirname)((0, node_path_1.resolve)(cwd, file)));
5834
5925
  const action = (0, refs_js_1.refsHookAction)(issues.length, severity);
5835
5926
  if (action === "ok")
5836
5927
  return;
@@ -6735,9 +6826,12 @@ async function main() {
6735
6826
  process.exitCode = 2;
6736
6827
  return;
6737
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);
6738
6832
  const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
6739
6833
  sharedDirs: config.sharedDirs,
6740
- sharedDirsRoot: sharedDirsRootFor(targets[0]),
6834
+ sharedDirsRoot: frame.root,
6741
6835
  // `.vigilesrc.json#exclude` reaches surface DISCOVERY, not just the
6742
6836
  // instruction file. Measured 2026-09-21 before this line existed: a repo
6743
6837
  // with `{"exclude": [".claude"]}` and one skill at `.claude/skills/demo`
@@ -6772,7 +6866,16 @@ async function main() {
6772
6866
  // `init` adopts (layout-driven instruction file + skill/subagent sweep).
6773
6867
  // Surfaced in the AuditReport (the report's "Create spec" command-emit
6774
6868
  // buttons read it) and the terminal nudge below.
6775
- 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));
6776
6879
  // Read the local flight recorder ONCE — feeds both the JSON report
6777
6880
  // (structured summary, the product boundary) and the terminal render.
6778
6881
  const ledgerRecords = (0, observe_js_1.readObservations)(root);