vigiles 38.2.0 → 39.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.
Files changed (102) hide show
  1. package/dist/adapters/claude-code/event-capability.js +24 -8
  2. package/dist/adapters/claude-code/hook-protocol.js +2 -1
  3. package/dist/adapters/codex/hook-protocol.js +2 -1
  4. package/dist/cli-flag-check.d.ts +36 -0
  5. package/dist/cli-flag-check.js +72 -1
  6. package/dist/cli-main.d.ts +2 -2
  7. package/dist/cli-main.js +548 -379
  8. package/dist/core/bash-effects.d.ts +0 -57
  9. package/dist/core/bash-effects.js +0 -147
  10. package/dist/core/compile-generator.d.ts +6 -6
  11. package/dist/core/compile-generator.js +4 -4
  12. package/dist/core/compile.d.ts +72 -8
  13. package/dist/core/compile.js +276 -133
  14. package/dist/core/config-schema.d.ts +40 -10
  15. package/dist/core/config-schema.js +139 -16
  16. package/dist/core/coverage.d.ts +16 -0
  17. package/dist/core/coverage.js +22 -0
  18. package/dist/core/doc-refs.d.ts +3 -2
  19. package/dist/core/doc-refs.js +2 -2
  20. package/dist/core/enforce-marker.d.ts +28 -0
  21. package/dist/core/enforce-marker.js +34 -0
  22. package/dist/core/eslint-inventory.d.ts +96 -0
  23. package/dist/core/eslint-inventory.js +251 -0
  24. package/dist/core/event-capability.d.ts +5 -1
  25. package/dist/core/frame.d.ts +43 -2
  26. package/dist/core/frame.js +85 -2
  27. package/dist/core/generate-schema.d.ts +3 -2
  28. package/dist/core/generate-schema.js +2 -2
  29. package/dist/core/generate-types.d.ts +11 -2
  30. package/dist/core/generate-types.js +12 -5
  31. package/dist/core/hook-command.d.ts +172 -0
  32. package/dist/core/hook-command.js +277 -0
  33. package/dist/core/hook-delivery.d.ts +133 -0
  34. package/dist/core/hook-delivery.js +150 -0
  35. package/dist/core/hook-paths.d.ts +14 -0
  36. package/dist/core/hook-paths.js +22 -0
  37. package/dist/core/hook-program.d.ts +4 -4
  38. package/dist/core/hook-program.js +13 -25
  39. package/dist/core/hook-state.d.ts +1 -1
  40. package/dist/core/hook-state.js +1 -1
  41. package/dist/core/instruction-remedies.d.ts +35 -0
  42. package/dist/core/instruction-remedies.js +70 -1
  43. package/dist/core/integrity-header.d.ts +54 -0
  44. package/dist/core/integrity-header.js +90 -0
  45. package/dist/core/integrity.d.ts +1 -25
  46. package/dist/core/integrity.js +16 -74
  47. package/dist/core/linter-adapter.d.ts +27 -3
  48. package/dist/core/linters.d.ts +8 -1
  49. package/dist/core/linters.js +116 -81
  50. package/dist/core/orphans.d.ts +3 -5
  51. package/dist/core/orphans.js +9 -10
  52. package/dist/core/presets.d.ts +19 -7
  53. package/dist/core/presets.js +19 -6
  54. package/dist/core/rule-catalog.d.ts +11 -12
  55. package/dist/core/rule-catalog.js +25 -94
  56. package/dist/core/rule-meta.d.ts +12 -0
  57. package/dist/core/rule-meta.js +47 -10
  58. package/dist/core/source-refs.d.ts +3 -23
  59. package/dist/core/source-refs.js +3 -27
  60. package/dist/core/test-utils.d.ts +13 -0
  61. package/dist/core/test-utils.js +40 -1
  62. package/dist/core/types.d.ts +18 -26
  63. package/dist/core/validate.d.ts +1 -2
  64. package/dist/core/validate.js +60 -47
  65. package/dist/coverage-evidence.js +2 -3
  66. package/dist/deprecated-key-files.d.ts +6 -0
  67. package/dist/deprecated-key-files.js +7 -6
  68. package/dist/exclude.d.ts +3 -1
  69. package/dist/exclude.js +9 -33
  70. package/dist/harness-assert.d.ts +18 -0
  71. package/dist/harness-assert.js +38 -0
  72. package/dist/hook-check-install.js +15 -6
  73. package/dist/hook-check.d.ts +1 -3
  74. package/dist/hook-check.js +3 -5
  75. package/dist/hook-install.d.ts +7 -41
  76. package/dist/hook-install.js +29 -96
  77. package/dist/hook-runtime.js +2 -1
  78. package/dist/load-hook.d.ts +3 -2
  79. package/dist/load-hook.js +28 -6
  80. package/dist/overrides.d.ts +25 -0
  81. package/dist/overrides.js +51 -0
  82. package/dist/plugin-loader.d.ts +23 -0
  83. package/dist/plugin-loader.js +42 -12
  84. package/dist/root-globs.d.ts +9 -0
  85. package/dist/root-globs.js +48 -0
  86. package/dist/rule-routing.js +6 -3
  87. package/dist/run-hook.d.ts +26 -2
  88. package/dist/run-hook.js +48 -12
  89. package/dist/scan-core.d.ts +96 -20
  90. package/dist/scan-core.js +171 -176
  91. package/dist/scan-files.d.ts +0 -8
  92. package/dist/scan-files.js +78 -20
  93. package/dist/scan.d.ts +12 -12
  94. package/dist/scan.js +33 -10
  95. package/dist/setup-plan.d.ts +15 -9
  96. package/dist/setup-plan.js +26 -18
  97. package/dist/test-coverage.d.ts +3 -8
  98. package/dist/test-coverage.js +6 -6
  99. package/dist/test.d.ts +1 -1
  100. package/dist/vigilesrc.schema.json +1329 -118
  101. package/package.json +1 -1
  102. package/skills/adopt-spec/SKILL.md +2 -1
package/dist/cli-main.js CHANGED
@@ -32,6 +32,7 @@ const generate_types_js_1 = require("./core/generate-types.js");
32
32
  const generate_harness_js_1 = require("./core/generate-harness.js");
33
33
  const capability_diff_js_1 = require("./core/capability-diff.js");
34
34
  const validate_js_1 = require("./core/validate.js");
35
+ const overrides_js_1 = require("./overrides.js");
35
36
  const eval_lock_js_1 = require("./eval-lock.js");
36
37
  const eval_entry_js_1 = require("./eval-entry.js");
37
38
  const zod_1 = require("zod");
@@ -69,6 +70,7 @@ const instruction_sources_js_1 = require("./instruction-sources.js");
69
70
  const rule_catalog_js_1 = require("./core/rule-catalog.js");
70
71
  const adoptability_js_1 = require("./adoptability.js");
71
72
  const compile_js_1 = require("./core/compile.js");
73
+ const enforce_marker_js_1 = require("./core/enforce-marker.js");
72
74
  const proofs_js_1 = require("./core/proofs.js");
73
75
  const inline_js_1 = require("./core/inline.js");
74
76
  const frontmatter_js_1 = require("./core/frontmatter.js");
@@ -79,6 +81,7 @@ const action_gate_js_1 = require("./action-gate.js");
79
81
  const guards_js_1 = require("./core/guards.js");
80
82
  const hook_program_js_1 = require("./core/hook-program.js");
81
83
  const hook_install_js_1 = require("./hook-install.js");
84
+ const hook_command_js_1 = require("./core/hook-command.js");
82
85
  const hook_check_install_js_1 = require("./hook-check-install.js");
83
86
  const hook_providers_js_1 = require("./core/hook-providers.js");
84
87
  const toml_1 = require("@iarna/toml");
@@ -98,6 +101,7 @@ const harness_test_js_1 = require("./harness-test.js");
98
101
  const run_scripts_js_1 = require("./adapters/claude-code/run-scripts.js");
99
102
  const coverage_artifact_js_1 = require("./coverage-artifact.js");
100
103
  const integrity_js_1 = require("./core/integrity.js");
104
+ const instruction_remedies_js_1 = require("./core/instruction-remedies.js");
101
105
  const adopt_js_1 = require("./core/adopt.js");
102
106
  const coverage_js_1 = require("./core/coverage.js");
103
107
  const orphans_js_1 = require("./core/orphans.js");
@@ -343,6 +347,27 @@ function printErrors(specFile, errors) {
343
347
  console.log(`::error file=${specFile}::${err.message}`);
344
348
  }
345
349
  }
350
+ /**
351
+ * The rule count line, split by what compile VERIFIED (#351): "linter-verified"
352
+ * counts only references rendered "**Enforced by:**"; every other `enforce()` was
353
+ * rendered "**Claimed (unverified):**" and gets one line saying why and what
354
+ * turns it into "Enforced by". Never changes the exit code.
355
+ */
356
+ function printRuleSummary(specFile, ruleCount, verdicts) {
357
+ const unverified = verdicts.flatMap((v) => v.verdict.kind === "unverified"
358
+ ? [{ ref: v.ref, reason: v.verdict.reason }]
359
+ : []);
360
+ const verified = verdicts.length - unverified.length;
361
+ const claimed = unverified.length > 0
362
+ ? `, ${String(unverified.length)} claimed unverified`
363
+ : "";
364
+ console.log(` ${String(ruleCount)} rules (${String(verified)} linter-verified${claimed})`);
365
+ unverified.forEach((u) => {
366
+ const advice = (0, compile_js_1.unverifiedAdvice)(u.ref, u.reason);
367
+ console.log(` ⚠ Claimed (unverified) — ${advice}`);
368
+ console.log(`::warning file=${specFile}::${advice}`);
369
+ });
370
+ }
346
371
  /** Non-blocking advisories — printed, but never fail the compile. */
347
372
  function printWarnings(specFile, warnings) {
348
373
  for (const w of warnings) {
@@ -355,10 +380,10 @@ function printWarnings(specFile, warnings) {
355
380
  // Commands
356
381
  // ---------------------------------------------------------------------------
357
382
  /** Compile a generator-skill spec from source → SKILL.md. Returns validity. */
358
- function compileGeneratorSkillToFile(specPath, source) {
383
+ function compileGeneratorSkillToFile(root, specPath, source) {
359
384
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
360
385
  const { artifact, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
361
- basePath: process.cwd(),
386
+ basePath: root,
362
387
  specFile: specPath,
363
388
  });
364
389
  // Written only when the compile is clean: `artifact` is null otherwise, and
@@ -375,9 +400,9 @@ function compileGeneratorSkillToFile(specPath, source) {
375
400
  return false;
376
401
  }
377
402
  /** Compile a ClaudeSpec → its primary + any additional targets. */
378
- async function compileClaudeToFile(spec, specPath, config, dialect) {
379
- const basePath = process.cwd();
380
- const { markdown, errors, warnings, linterResults, targets } = await (0, compile_js_1.compileClaude)(spec, {
403
+ async function compileClaudeToFile(root, spec, specPath, config, dialect) {
404
+ const basePath = root;
405
+ const { markdown, errors, warnings, enforceVerdicts, outputs } = await (0, compile_js_1.compileClaude)(spec, {
381
406
  basePath,
382
407
  specFile: specPath,
383
408
  dialect,
@@ -386,7 +411,7 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
386
411
  maxSectionLines: config.maxSectionLines,
387
412
  catalogOnly: config.catalogOnly,
388
413
  linters: config.linters,
389
- ...compileBudgetOptions(config),
414
+ ...compileBudgetOptions(config, specPath.replace(/\.spec\.ts$/, "")),
390
415
  });
391
416
  const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
392
417
  // Budget findings print BEFORE the pass/fail line and never change the exit
@@ -418,20 +443,12 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
418
443
  console.log(` → ${primaryOutput} was NOT written; the previous version is left in place.`);
419
444
  return false;
420
445
  }
421
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, primaryOutput), markdown);
422
- const outputNames = [primaryOutput];
423
- for (const t of targets.slice(1)) {
424
- const body = markdown
425
- .replace(/^<!-- vigiles:[^\n]+\n\n?/, "")
426
- .replace(/^# [^\n]+/, `# ${t}`);
427
- const dir = primaryOutput.substring(0, primaryOutput.lastIndexOf("/") + 1);
428
- const targetPath = dir + t;
429
- (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, targetPath), (0, compile_js_1.addHash)(body, specPath));
430
- outputNames.push(targetPath);
431
- }
432
- const linterCount = linterResults.filter((r) => r.exists).length;
433
- console.log(`\n✓ ${specPath} → ${outputNames.join(", ")} (${formatArtifactSize(markdown)})`);
434
- console.log(` ${String(Object.keys(spec.rules).length)} rules (${String(linterCount)} linter-verified)`);
446
+ // Each target as compile rendered it for its own path: a secondary in
447
+ // another directory has its own links, so it is never a re-headed copy.
448
+ for (const o of outputs)
449
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, o.path), o.markdown);
450
+ console.log(`\n✓ ${specPath} → ${outputs.map((o) => o.path).join(", ")} (${formatArtifactSize(markdown)})`);
451
+ printRuleSummary(specPath, Object.keys(spec.rules).length, enforceVerdicts);
435
452
  return true;
436
453
  }
437
454
  /**
@@ -512,10 +529,10 @@ function formatArtifactSize(markdown) {
512
529
  return `${size} · ~${String(kTokens)}k tokens est.`;
513
530
  }
514
531
  /** Compile a declarative SkillSpec → SKILL.md. */
515
- async function compileSkillToFile(spec, specPath, dialect) {
532
+ async function compileSkillToFile(root, spec, specPath, dialect) {
516
533
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
517
534
  const { artifact, errors, warnings } = await (0, compile_js_1.compileSkill)(spec, {
518
- basePath: process.cwd(),
535
+ basePath: root,
519
536
  specFile: specPath,
520
537
  // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
521
538
  // repo gets a minimal (name + description) SKILL.md; CC gets the full set.
@@ -536,12 +553,15 @@ async function compileSkillToFile(spec, specPath, dialect) {
536
553
  return false;
537
554
  }
538
555
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
539
- async function compileAgentToFile(spec, specPath, dialect) {
556
+ async function compileAgentToFile(root, spec, specPath, config, dialect) {
540
557
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
558
+ // The same verify options an instruction file gets (`compileClaudeToFile`).
541
559
  const { artifact, errors, warnings } = await (0, compile_js_1.compileAgent)(spec, {
542
- basePath: process.cwd(),
560
+ basePath: root,
543
561
  specFile: specPath,
544
562
  dialect,
563
+ catalogOnly: config.catalogOnly,
564
+ linters: config.linters,
545
565
  });
546
566
  // Written only when the compile is clean: `artifact` is null otherwise, and
547
567
  // `writeArtifact` takes nothing else.
@@ -591,6 +611,7 @@ async function collectAgentNames(excludes) {
591
611
  return names;
592
612
  }
593
613
  async function compile(specPaths, config, excludes, opts = {}) {
614
+ const root = excludes.root;
594
615
  let allValid = true;
595
616
  // Parse the declared harness set ONCE (alias-normalized) and feed both the
596
617
  // dialect pick and the mirror from it — no re-parsing, no cwd-sniffing in the
@@ -598,7 +619,7 @@ async function compile(specPaths, config, excludes, opts = {}) {
598
619
  // ambiguous-detection pick.
599
620
  const declaredHarnesses = (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses);
600
621
  const selection = (0, adapter_registry_js_1.resolveHarnessSelection)({
601
- root: process.cwd(),
622
+ root,
602
623
  flag: opts.harnessFlag,
603
624
  configHarness: declaredHarnesses,
604
625
  });
@@ -610,9 +631,9 @@ async function compile(specPaths, config, excludes, opts = {}) {
610
631
  let knownAgents = null;
611
632
  for (const specPath of specPaths) {
612
633
  // Generator skills can't be executed to markdown — compile from source.
613
- const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), specPath), "utf-8");
634
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, specPath), "utf-8");
614
635
  if (/\bgenSkill\s*\(/.test(source)) {
615
- if (!compileGeneratorSkillToFile(specPath, source))
636
+ if (!compileGeneratorSkillToFile(root, specPath, source))
616
637
  allValid = false;
617
638
  continue;
618
639
  }
@@ -633,7 +654,7 @@ async function compile(specPaths, config, excludes, opts = {}) {
633
654
  const specDialect = opts.harnessFlag === undefined
634
655
  ? ((0, adapter_registry_js_1.adapterForInstructionFile)(targetFile)?.dialect ?? dialect)
635
656
  : dialect;
636
- if (await compileClaudeToFile(spec, specPath, config, specDialect)) {
657
+ if (await compileClaudeToFile(root, spec, specPath, config, specDialect)) {
637
658
  writeInstructionMirrors(specPath.replace(/\.spec\.ts$/, ""), declaredHarnesses);
638
659
  }
639
660
  else {
@@ -649,11 +670,11 @@ async function compile(specPaths, config, excludes, opts = {}) {
649
670
  for (const w of (0, skill_harness_js_1.skillFrontmatterDropWarnings)(spec, forHarnesses)) {
650
671
  console.log(`⚠ ${w}`);
651
672
  }
652
- if (!(await compileSkillToFile(spec, specPath, dialect)))
673
+ if (!(await compileSkillToFile(root, spec, specPath, dialect)))
653
674
  allValid = false;
654
675
  }
655
676
  else if (spec._specType === "agent") {
656
- if (!(await compileAgentToFile(spec, specPath, dialect)))
677
+ if (!(await compileAgentToFile(root, spec, specPath, config, dialect)))
657
678
  allValid = false;
658
679
  }
659
680
  else if (spec._specType === "railway") {
@@ -690,14 +711,14 @@ function ghAnnotate(level, message, file, line) {
690
711
  const loc = locParts.length > 0 ? " " + locParts.join(",") : "";
691
712
  console.log(`::${level}${loc}::${message}`);
692
713
  }
693
- function verifyHashes(frame, filePaths, silent = false) {
714
+ function verifyHashes(root, frame, filePaths, silent = false) {
694
715
  let errorCount = 0;
695
716
  const log = (msg) => {
696
717
  if (!silent)
697
718
  console.log(msg);
698
719
  };
699
720
  for (const filePath of filePaths) {
700
- const fullPath = (0, node_path_1.resolve)(process.cwd(), filePath);
721
+ const fullPath = (0, node_path_1.resolve)(root, filePath);
701
722
  // If the file doesn't exist at all (typo, deleted), that's an error —
702
723
  // not a "no hash" informational message. Without this check, a scoped
703
724
  // lint like `vigiles lint typo.md` would silently exit clean.
@@ -727,14 +748,14 @@ function verifyHashes(frame, filePaths, silent = false) {
727
748
  }
728
749
  return { valid: errorCount === 0, errorCount };
729
750
  }
730
- function validateSpecs(filePaths, rulesConfig, silent = false) {
751
+ function validateSpecs(root, filePaths, rulesConfig, rules, silent = false) {
731
752
  let allValid = true;
732
753
  const log = (msg) => {
733
754
  if (!silent)
734
755
  console.log(msg);
735
756
  };
736
757
  for (const filePath of filePaths) {
737
- const fullPath = (0, node_path_1.resolve)(process.cwd(), filePath);
758
+ const fullPath = (0, node_path_1.resolve)(root, filePath);
738
759
  let content;
739
760
  try {
740
761
  content = (0, node_fs_1.readFileSync)(fullPath, "utf-8");
@@ -747,16 +768,24 @@ function validateSpecs(filePaths, rulesConfig, silent = false) {
747
768
  const hashMatch = content.match(/<!-- vigiles:sha256:[a-f0-9]+ compiled from (.+) -->/);
748
769
  if (hashMatch) {
749
770
  // Verify the referenced spec still exists
750
- const specRef = (0, node_path_1.resolve)(process.cwd(), hashMatch[1]);
771
+ const specRef = (0, node_path_1.resolve)(root, hashMatch[1]);
751
772
  if (!(0, node_fs_1.existsSync)(specRef)) {
752
773
  log(` ✗ [require-instructions-spec] ${filePath} references "${hashMatch[1]}" but that spec no longer exists.`);
753
774
  allValid = false;
754
775
  }
755
776
  continue;
756
777
  }
778
+ // The path as discovered (cwd-relative), not `fullPath`: the finding prints
779
+ // it, and its `exclude` snippet must be a root-relative glob.
757
780
  const result = (0, validate_js_1.validate)(content, {
758
- filePath: fullPath,
759
- rules: rulesConfig,
781
+ filePath,
782
+ // The two spec-presence rules resolved at THIS file (`overrides`), the
783
+ // rest as configured — validate reads only these two per file.
784
+ rules: {
785
+ ...rulesConfig,
786
+ "require-instructions-spec": rules.severityAt("require-instructions-spec", filePath),
787
+ "require-skill-spec": rules.severityAt("require-skill-spec", filePath),
788
+ },
760
789
  });
761
790
  for (const err of result.errors) {
762
791
  log(` ✗ [${err.rule}] ${err.message}`);
@@ -768,10 +797,10 @@ function validateSpecs(filePaths, rulesConfig, silent = false) {
768
797
  }
769
798
  return allValid;
770
799
  }
771
- function check(frame, filePaths, silent = false) {
772
- const hashes = verifyHashes(frame, filePaths, silent);
773
- const vConfig = (0, validate_js_1.loadConfig)();
774
- const specsValid = validateSpecs(filePaths, vConfig.rules, silent);
800
+ function check(root, frame, filePaths, silent = false) {
801
+ const hashes = verifyHashes(root, frame, filePaths, silent);
802
+ const vConfig = (0, validate_js_1.loadConfig)(root);
803
+ const specsValid = validateSpecs(root, filePaths, vConfig.rules, (0, overrides_js_1.ruleResolver)(vConfig.rules, vConfig.overrides), silent);
775
804
  return {
776
805
  valid: hashes.valid && specsValid,
777
806
  hashErrors: hashes.errorCount,
@@ -787,7 +816,8 @@ function check(frame, filePaths, silent = false) {
787
816
  * Catches spec bloat — rules that likely say the same thing in different words.
788
817
  * Uses information-theoretic distance (gzip-based) — no LLM, fully deterministic.
789
818
  */
790
- async function findDuplicateRules(excludes, threshold = 0.3, silent = false, scopeFiles) {
819
+ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, scopeFiles, severityAt = () => "warn") {
820
+ const root = excludes.root;
791
821
  const log = (msg) => {
792
822
  if (!silent)
793
823
  console.log(msg);
@@ -810,28 +840,33 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
810
840
  for (const f of scopeFiles) {
811
841
  let resolved;
812
842
  try {
813
- const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), f), "utf-8");
843
+ const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, f), "utf-8");
814
844
  const m = compiledFromRe.exec(content);
815
845
  if (m) {
816
- resolved = (0, node_path_1.resolve)(process.cwd(), m[1].trim());
846
+ resolved = (0, node_path_1.resolve)(root, m[1].trim());
817
847
  }
818
848
  }
819
849
  catch {
820
850
  // File unreadable — fall through to the naming convention
821
851
  }
822
852
  if (!resolved) {
823
- resolved = (0, node_path_1.resolve)(process.cwd(), `${f}.spec.ts`);
853
+ resolved = (0, node_path_1.resolve)(root, `${f}.spec.ts`);
824
854
  }
825
855
  wanted.add(resolved);
826
856
  }
827
- return allSpecs.filter((specPath) => wanted.has((0, node_path_1.resolve)(process.cwd(), specPath)));
857
+ return allSpecs.filter((specPath) => wanted.has((0, node_path_1.resolve)(root, specPath)));
828
858
  })()
829
859
  : allSpecs;
830
860
  if (specs.length === 0)
831
- return { valid: true, pairCount: 0 };
861
+ return { valid: true, pairCount: 0, errorPairs: 0 };
832
862
  let totalPairs = 0;
863
+ let errorPairs = 0;
833
864
  let specsWithDuplicates = 0;
834
865
  for (const specPath of specs) {
866
+ // Per spec: an `overrides` entry may turn `duplicate-rules` off for it.
867
+ const sev = severityAt(specPath);
868
+ if (sev === false)
869
+ continue;
835
870
  const spec = await loadSpec(specPath);
836
871
  if (!spec || spec._specType !== "claude")
837
872
  continue;
@@ -847,6 +882,8 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
847
882
  }
848
883
  specsWithDuplicates++;
849
884
  totalPairs += pairs.length;
885
+ if (sev === "error")
886
+ errorPairs += pairs.length;
850
887
  log(` ${specPath}`);
851
888
  for (const pair of pairs.slice(0, 5)) {
852
889
  log(` ${pair.idA} ↔ ${pair.idB} (distance: ${pair.distance.toFixed(3)})`);
@@ -857,10 +894,10 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
857
894
  }
858
895
  if (totalPairs === 0) {
859
896
  log("No near-duplicate rules detected.");
860
- return { valid: true, pairCount: 0 };
897
+ return { valid: true, pairCount: 0, errorPairs: 0 };
861
898
  }
862
899
  log(`\n ${String(totalPairs)} duplicate pair(s) in ${String(specsWithDuplicates)} spec(s). Consider merging or rewording.`);
863
- return { valid: false, pairCount: totalPairs };
900
+ return { valid: false, pairCount: totalPairs, errorPairs };
864
901
  }
865
902
  /**
866
903
  * Verify the file-qualified symbol references (`path.ext#symbol`) in instruction
@@ -1005,12 +1042,12 @@ function lintExitCode(report) {
1005
1042
  report.skillFrontmatterErrors > 0 ||
1006
1043
  report.mcpToolErrors > 0 ||
1007
1044
  report.hookScriptErrors > 0 ||
1045
+ report.handWrittenHookErrors > 0 ||
1008
1046
  report.disallowedToolErrors > 0 ||
1009
1047
  report.descriptionOverlapErrors > 0 ||
1010
1048
  report.descriptionBudgetErrors > 0 ||
1011
1049
  report.frontmatterValidErrors > 0 ||
1012
1050
  report.mcpHookErrors > 0 ||
1013
- report.preferCompiledHookErrors > 0 ||
1014
1051
  report.lethalTrifectaErrors > 0 ||
1015
1052
  report.skillResourceErrors > 0 ||
1016
1053
  report.skillFenceErrors > 0 ||
@@ -1338,6 +1375,31 @@ function discoverNestedBundles(root, excludes) {
1338
1375
  }
1339
1376
  return out.sort();
1340
1377
  }
1378
+ /**
1379
+ * Each finding with the severity `rule` has at its path (`overrides`
1380
+ * applied), the ones an override turns off removed. `pathOf` returns
1381
+ * `undefined` for a finding about the repository as a whole, which keeps the
1382
+ * severity `rules` gives it.
1383
+ */
1384
+ function graded(rules, rule, found, pathOf) {
1385
+ return found.flatMap((f) => {
1386
+ const sev = rules.severityAt(rule, pathOf(f));
1387
+ return sev === false ? [] : [{ ...f, sev }];
1388
+ });
1389
+ }
1390
+ /** The `{ issues, errors }` a per-surface check returns, from its graded findings. */
1391
+ function tally(found) {
1392
+ return {
1393
+ issues: found.length,
1394
+ errors: found.filter((f) => f.sev === "error").length,
1395
+ };
1396
+ }
1397
+ /** The terminal mark and annotation level for a severity. */
1398
+ function markOf(sev) {
1399
+ return sev === "error"
1400
+ ? { mark: "✗", level: "error" }
1401
+ : { mark: "⚠", level: "warning" };
1402
+ }
1341
1403
  /**
1342
1404
  * Run one per-surface check over EVERY bundle and sum its counters.
1343
1405
  *
@@ -1397,13 +1459,13 @@ function overBundles(fn, run, bundles) {
1397
1459
  * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1398
1460
  * so a repo with no specs does no extra work at all.
1399
1461
  */
1400
- async function checkSpecRefs(excludes, config, silent, dialect) {
1401
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1402
- // `compile-budget` is reported here only at "error": at its default it is a
1403
- // compile-time warning, and `lint` printed nothing about size before the rule
1404
- // had a name — the default output does not change.
1405
- const budgetGate = (0, types_js_1.ruleSeverity)(config?.rules?.["compile-budget"]) === "error";
1406
- if (!sev && !budgetGate)
1462
+ async function checkSpecRefs(excludes, config, rules, silent, dialect) {
1463
+ // Both rules are resolved per compiled target below (`overrides`). The scan
1464
+ // is skipped only when neither can fire anywhere. `compile-budget` is
1465
+ // reported here only at "error": at its default it is a compile-time
1466
+ // warning, and `lint` printed nothing about size before the rule had a name
1467
+ // — the default output does not change.
1468
+ if (!rules.mayFire("spec-refs") && !rules.mayFire("compile-budget"))
1407
1469
  return { issues: 0, errors: 0, budgetErrors: 0 };
1408
1470
  const derived = [];
1409
1471
  let knownAgents;
@@ -1422,7 +1484,12 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1422
1484
  derived.push({
1423
1485
  target,
1424
1486
  specPath,
1425
- findings: await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []),
1487
+ findings: await specCompileErrors(spec, specPath, {
1488
+ root: excludes.root,
1489
+ dialect,
1490
+ config,
1491
+ knownAgents: knownAgents ?? [],
1492
+ }),
1426
1493
  });
1427
1494
  }
1428
1495
  catch {
@@ -1431,50 +1498,32 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1431
1498
  continue;
1432
1499
  }
1433
1500
  }
1434
- const messages = (pick) => derived.flatMap((d) => pick(d.findings).map((e) => `${d.target}: ${e.message} (from ${d.specPath})`));
1435
- const found = sev ? messages((f) => f.errors) : [];
1436
- const overBudget = messages((f) => f.budget);
1501
+ const messages = (pick) => derived.flatMap((d) => pick(d.findings).map((e) => ({
1502
+ target: d.target,
1503
+ msg: `${d.target}: ${e.message} (from ${d.specPath})`,
1504
+ })));
1505
+ const found = graded(rules, "spec-refs", messages((f) => f.errors), (m) => m.target);
1506
+ const overBudget = graded(rules, "compile-budget", messages((f) => f.budget), (m) => m.target).filter((m) => m.sev === "error");
1437
1507
  if (found.length > 0 && !silent) {
1438
1508
  console.log("\nSpec reference check:\n");
1439
- for (const msg of found) {
1440
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
1441
- ghAnnotate(sev === "error" ? "error" : "warning", msg);
1509
+ for (const { msg, sev } of found) {
1510
+ console.log(` ${markOf(sev).mark} ${msg}`);
1511
+ ghAnnotate(markOf(sev).level, msg);
1442
1512
  }
1443
1513
  }
1444
1514
  if (overBudget.length > 0 && !silent) {
1445
1515
  console.log("\nCompile budget check (compile-budget):\n");
1446
- overBudget.forEach((msg) => {
1516
+ overBudget.forEach(({ msg }) => {
1447
1517
  console.log(` ✗ ${msg}`);
1448
1518
  ghAnnotate("error", `compile-budget: ${msg}`);
1449
1519
  });
1450
1520
  }
1451
- return {
1452
- issues: found.length,
1453
- errors: sev === "error" ? found.length : 0,
1454
- budgetErrors: overBudget.length,
1455
- };
1521
+ return { ...tally(found), budgetErrors: overBudget.length };
1456
1522
  }
1457
- /**
1458
- * The ONE spec-type dispatcher, in the only shape both directions can share:
1459
- * errors, no writing. `compile` reaches its compilers through the
1460
- * `compile*ToFile` wrappers (which write and print); `checkSpecRefs` reaches the
1461
- * SAME compilers through this, because a read must never write.
1462
- *
1463
- * Why it exists (#190): `checkSpecRefs` used to call `compileClaude` directly and
1464
- * skip every other spec type, so a SKILL.md / subagent / railway artifact
1465
- * committed with a since-deleted reference hashed cleanly against its own header
1466
- * forever — `lint` green, `compile` red. Measured on this repo:
1467
- * `examples/SKILL.md.spec.ts` names `skills/enforce-rules-format/SKILL.md`, which
1468
- * does not exist (the skill lives under `.claude/skills/`), and `lint` reported
1469
- * `hash valid`.
1470
- *
1471
- * A switch rather than a per-type "ref accessor": all four compilers already
1472
- * return the same `errors: CompileError[]`, so there is nothing to normalize —
1473
- * only the options differ. Exhaustive over `_specType`, so a FIFTH spec type is
1474
- * a tsc error here instead of a silent skip, which is the failure this closes.
1475
- */
1476
- async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1477
- const basePath = process.cwd();
1523
+ async function specCompileErrors(spec, specPath, { root, dialect, config, knownAgents }) {
1524
+ const basePath = root;
1525
+ // The compiled file this spec writes — `overrides` resolve at its path.
1526
+ const target = specPath.replace(/\.spec\.ts$/, "");
1478
1527
  switch (spec._specType) {
1479
1528
  case "claude": {
1480
1529
  const r = await (0, compile_js_1.compileClaude)(spec, {
@@ -1486,7 +1535,7 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1486
1535
  maxSectionLines: config?.maxSectionLines,
1487
1536
  catalogOnly: config?.catalogOnly,
1488
1537
  linters: config?.linters,
1489
- ...compileBudgetOptions(config),
1538
+ ...compileBudgetOptions(config, target),
1490
1539
  });
1491
1540
  // A size finding is `compile-budget`'s, not a dead reference: split by
1492
1541
  // IDENTITY against the compiler's own list, never by the type's name.
@@ -1502,7 +1551,13 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1502
1551
  };
1503
1552
  case "agent":
1504
1553
  return {
1505
- errors: (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors,
1554
+ errors: (await (0, compile_js_1.compileAgent)(spec, {
1555
+ basePath,
1556
+ specFile: specPath,
1557
+ dialect,
1558
+ catalogOnly: config?.catalogOnly,
1559
+ linters: config?.linters,
1560
+ })).errors,
1506
1561
  budget: [],
1507
1562
  };
1508
1563
  case "railway":
@@ -1518,17 +1573,18 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1518
1573
  }
1519
1574
  }
1520
1575
  /**
1521
- * The `compile-budget` rule, as `compileClaude` options: its severity and any
1522
- * thresholds the config set. The ONE translation, so `compile` (which writes)
1523
- * and `lint` (which re-derives) can never measure against different numbers.
1576
+ * The `compile-budget` rule, as `compileClaude` options: its severity at the
1577
+ * compiled `target` (`overrides` applied) and any thresholds `rules` set. The
1578
+ * ONE translation, so `compile` (which writes) and `lint` (which re-derives)
1579
+ * can never measure against different numbers.
1524
1580
  */
1525
- function compileBudgetOptions(config) {
1581
+ function compileBudgetOptions(config, target) {
1526
1582
  const rule = config?.rules?.["compile-budget"];
1527
1583
  // No config at all (a programmatic caller, a missing file) is the default.
1528
1584
  if (rule === undefined)
1529
1585
  return { budgetSeverity: "warn" };
1530
1586
  return {
1531
- budgetSeverity: (0, types_js_1.ruleSeverity)(rule),
1587
+ budgetSeverity: (0, overrides_js_1.ruleResolver)(config?.rules ?? validate_js_1.DEFAULT_RULES, config?.overrides).severityAt("compile-budget", target),
1532
1588
  ...(0, types_js_1.ruleOptions)(rule),
1533
1589
  };
1534
1590
  }
@@ -1545,6 +1601,8 @@ function writeArtifact(outputPath, artifact) {
1545
1601
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1546
1602
  }
1547
1603
  async function runLint(restArgs, flags, excludes, config) {
1604
+ // The project root the CLI resolved once; the excludes are rooted at it.
1605
+ const root = excludes.root;
1548
1606
  const summary = flags.includes("--summary");
1549
1607
  const json = flags.includes("--json");
1550
1608
  const silent = summary || json;
@@ -1561,7 +1619,7 @@ async function runLint(restArgs, flags, excludes, config) {
1561
1619
  (0, node_fs_1.existsSync)(positional[0]) &&
1562
1620
  (0, node_fs_1.lstatSync)(positional[0]).isDirectory()
1563
1621
  ? (0, node_path_1.resolve)(positional[0])
1564
- : process.cwd();
1622
+ : root;
1565
1623
  // Resolve the active harness ONCE so the harness-specific checks below run
1566
1624
  // against the right adapter's dialect (tool/event catalogs) and surfaces —
1567
1625
  // not a hard-coded Claude Code default. A subagent-surface rule reports n/a
@@ -1595,8 +1653,9 @@ async function runLint(restArgs, flags, excludes, config) {
1595
1653
  // ONE frame for the whole run (#281): every path a check prints or annotates is
1596
1654
  // relative to it, and config globs resolve from it — whichever bundle is being
1597
1655
  // scored. Built here, once, instead of each option re-deriving its own root.
1598
- const frame = (0, frame_js_1.frameFor)(process.cwd(), scanRoot);
1599
- const run = { config, silent, adapter, excludes, frame };
1656
+ const frame = (0, frame_js_1.frameFor)(root, scanRoot);
1657
+ const rules = (0, overrides_js_1.ruleResolver)(config?.rules ?? validate_js_1.DEFAULT_RULES, config?.overrides);
1658
+ const run = { config, rules, silent, adapter, excludes, frame };
1600
1659
  if (!silent && nestedBundles.length > 0) {
1601
1660
  const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1602
1661
  console.log(scoreAll
@@ -1622,7 +1681,7 @@ async function runLint(restArgs, flags, excludes, config) {
1622
1681
  }
1623
1682
  }
1624
1683
  const hashResult = files.length > 0
1625
- ? check(frame, files, silent)
1684
+ ? check(root, frame, files, silent)
1626
1685
  : { valid: true, hashErrors: 0, validationErrors: 0 };
1627
1686
  // 1b. Verify inline + frontmatter rules in instruction files not managed
1628
1687
  // by a spec. See verifyMarkdownModeRules / docs/markdown-mode.md.
@@ -1637,16 +1696,15 @@ async function runLint(restArgs, flags, excludes, config) {
1637
1696
  // fail on unrelated duplicates elsewhere in the repo.
1638
1697
  if (!silent)
1639
1698
  console.log("\nDuplicate rule detection:\n");
1640
- const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined);
1699
+ const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined, (specPath) => rules.severityAt("duplicate-rules", specPath));
1641
1700
  // 4. Guidance rule count (strengthen suggestions moved to /strengthen skill)
1642
1701
  const guidanceCount = await countGuidanceRules(excludes, silent);
1643
1702
  // 5. Integrity check (hand-edit detection via SHA-256 hash)
1644
- const integritySeverity = config?.rules.integrity ?? "warn";
1645
1703
  let integrityErrors = 0;
1646
- if (integritySeverity) {
1704
+ if (rules.mayFire("integrity")) {
1647
1705
  if (!silent)
1648
1706
  console.log("\nIntegrity check:\n");
1649
- integrityErrors = checkIntegrityForFiles(files, integritySeverity, silent);
1707
+ integrityErrors = checkIntegrityForFiles(root, files, rules, silent);
1650
1708
  }
1651
1709
  // 6. Coverage thresholds (gates CI when severity is "error")
1652
1710
  const coverageErrors = await checkCoverageThresholds(excludes, coverage, config, silent);
@@ -1664,8 +1722,7 @@ async function runLint(restArgs, flags, excludes, config) {
1664
1722
  // always-blocking check and deleting the `orphans` block. `warn` now reports
1665
1723
  // without touching the exit code and `false`/`"off"` skips the scan, exactly
1666
1724
  // like every other rule.
1667
- const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
1668
- const orphansCfg = orphanSeverity ? config?.orphans : undefined;
1725
+ const orphansCfg = rules.mayFire("orphan-docs") ? config?.orphans : undefined;
1669
1726
  if (!silent)
1670
1727
  console.log("\nOrphan docs check:\n");
1671
1728
  let orphanReport = {
@@ -1675,12 +1732,10 @@ async function runLint(restArgs, flags, excludes, config) {
1675
1732
  orphans: [],
1676
1733
  };
1677
1734
  if (orphansCfg) {
1678
- orphanReport = (0, orphans_js_1.findOrphanDocs)({
1679
- basePath: process.cwd(),
1735
+ const found = (0, orphans_js_1.findOrphanDocs)({
1736
+ basePath: root,
1680
1737
  include: orphansCfg.include,
1681
- exclude: orphansCfg.exclude,
1682
- // The repo-wide `exclude` is the FLOOR under the rule's own `exclude`
1683
- // (union, never override): an excluded corpus is neither an orphan
1738
+ // The repo-wide `exclude`: an excluded corpus is neither an orphan
1684
1739
  // candidate nor a source of references that keep a doc alive (#192).
1685
1740
  repoExclude: excludes.globIgnore,
1686
1741
  // Exempt every registered harness's surface files (instruction file,
@@ -1688,6 +1743,10 @@ async function runLint(restArgs, flags, excludes, config) {
1688
1743
  // core carries no harness literal (see src/core/orphans.ts).
1689
1744
  layouts: adapter_registry_js_1.ADAPTERS.map((a) => a.layout),
1690
1745
  });
1746
+ // Per doc: an `overrides` entry with `orphan-docs` off drops it from the
1747
+ // report (it still counts as a doc that references others).
1748
+ const orphanGraded = graded(rules, "orphan-docs", found.orphans.map((doc) => ({ doc })), (o) => o.doc);
1749
+ orphanReport = { ...found, orphans: orphanGraded.map((o) => o.doc) };
1691
1750
  if (!silent) {
1692
1751
  for (const line of (0, orphans_js_1.formatOrphanReport)(orphanReport).split("\n")) {
1693
1752
  console.log(` ${line}`);
@@ -1702,7 +1761,7 @@ async function runLint(restArgs, flags, excludes, config) {
1702
1761
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1703
1762
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1704
1763
  // is unchanged, not that what it names still exists (#173).
1705
- const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1764
+ const specRefs = await checkSpecRefs(excludes, config, rules, silent, adapter.dialect);
1706
1765
  const untested = overBundles(checkUntestedSurfaces, run, lintRoots);
1707
1766
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1708
1767
  // rail against the harness catalog (the moat). n/a on a harness with no
@@ -1728,6 +1787,8 @@ async function runLint(restArgs, flags, excludes, config) {
1728
1787
  // validate`"; measured false on 2026-09-08 (Claude Code 2.1.263) — see
1729
1788
  // docs/rules/hook-script-exists.md and tools/measure-validate-overlap.mjs.
1730
1789
  const hookScripts = overBundles(checkHookScriptExists, run, lintRoots);
1790
+ // 7i². Hand-written hooks — every hook line vigiles did not write, by name.
1791
+ const handWrittenHooks = overBundles(checkHandWrittenHooks, run, lintRoots);
1731
1792
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1732
1793
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1733
1794
  const disallowedTools = overBundles(checkDisallowedTools, run, lintRoots);
@@ -1744,9 +1805,6 @@ async function runLint(restArgs, flags, excludes, config) {
1744
1805
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1745
1806
  // targets an undeclared server (the moat applied to the hook surface).
1746
1807
  const mcpHookTargets = overBundles(checkMcpHookTargets, run, lintRoots);
1747
- // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1748
- // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1749
- const preferCompiledHooks = overBundles(checkPreferCompiledHooks, run, lintRoots);
1750
1808
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1751
1809
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1752
1810
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
@@ -1792,11 +1850,12 @@ async function runLint(restArgs, flags, excludes, config) {
1792
1850
  // over the repository, like the ratchet: a nested bundle's spec would
1793
1851
  // otherwise be reported again by every bundle that encloses it.
1794
1852
  const deprecatedKeyFiles = (0, deprecated_key_files_js_1.checkDeprecatedKeyFiles)({
1795
- severity: (0, types_js_1.ruleSeverity)(config?.rules?.["deprecated-key-files"]),
1796
- find: () => specsUsingKeyFiles(process.cwd(), excludes.globIgnore),
1853
+ severity: rules.mayFire("deprecated-key-files") ? "warn" : false,
1854
+ severityAt: (path) => rules.severityAt("deprecated-key-files", path),
1855
+ find: () => specsUsingKeyFiles(root, excludes.globIgnore),
1797
1856
  silent,
1798
1857
  annotate: (level, message, path) => {
1799
- ghAnnotate(level, message, frame.repo((0, node_path_1.resolve)(process.cwd(), path)));
1858
+ ghAnnotate(level, message, frame.repo((0, node_path_1.resolve)(root, path)));
1800
1859
  },
1801
1860
  });
1802
1861
  // 8. Validate vigiles builder calls inside markdown code blocks — the
@@ -1819,7 +1878,7 @@ async function runLint(restArgs, flags, excludes, config) {
1819
1878
  // globs without `dot`, so `.claude/**` — where real skills and agents live —
1820
1879
  // has never been scanned. The rule has never once looked at a deployed
1821
1880
  // instruction file; every ref it has ever judged was in prose.
1822
- const docRefSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["doc-refs"]);
1881
+ const docRefSeverity = rules.mayFire("doc-refs");
1823
1882
  if (!silent && docRefSeverity)
1824
1883
  console.log("\nMarkdown code block refs:\n");
1825
1884
  // 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
@@ -1835,8 +1894,8 @@ async function runLint(restArgs, flags, excludes, config) {
1835
1894
  // With no way to reach 0 the step was made `continue-on-error: true`, and a lint
1836
1895
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1837
1896
  // grew to do the gating instead. One unpassed argument, that whole chain.
1838
- const docRefReport = docRefSeverity
1839
- ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.globIgnore })
1897
+ const docRefFound = docRefSeverity
1898
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: root, ignore: excludes.globIgnore })
1840
1899
  : {
1841
1900
  filesScanned: 0,
1842
1901
  filesIgnored: 0,
@@ -1846,10 +1905,24 @@ async function runLint(restArgs, flags, excludes, config) {
1846
1905
  unverified: 0,
1847
1906
  placeholders: 0,
1848
1907
  };
1908
+ // Per file: an `overrides` entry may turn `doc-refs` off, or set its
1909
+ // severity, for a folder. Printed once per severity present, so a `✗` never
1910
+ // stands next to a finding that does not gate.
1911
+ const docRefErrors = graded(rules, "doc-refs", docRefFound.errors, (e) => e.file);
1912
+ const docRefReport = { ...docRefFound, errors: docRefErrors };
1849
1913
  if (!silent && docRefSeverity) {
1850
- const rendered = (0, doc_refs_js_1.formatDocRefReport)(docRefReport, docRefSeverity === "error" ? "error" : "warn");
1851
- for (const line of rendered.split("\n")) {
1852
- console.log(` ${line}`);
1914
+ const groups = ["error", "warn"]
1915
+ .map((sev) => ({
1916
+ sev,
1917
+ errors: docRefErrors.filter((e) => e.sev === sev),
1918
+ }))
1919
+ .filter((g, i, all) => g.errors.length > 0 ||
1920
+ (i === 0 && all.every((x) => x.errors.length === 0)));
1921
+ for (const g of groups) {
1922
+ const rendered = (0, doc_refs_js_1.formatDocRefReport)({ ...docRefFound, errors: g.errors }, g.sev);
1923
+ for (const line of rendered.split("\n")) {
1924
+ console.log(` ${line}`);
1925
+ }
1853
1926
  }
1854
1927
  }
1855
1928
  // Per-line GitHub annotations for each broken doc ref — each carries file+line,
@@ -1859,7 +1932,7 @@ async function runLint(restArgs, flags, excludes, config) {
1859
1932
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1860
1933
  if (isGitHubActions() && !silent && docRefSeverity) {
1861
1934
  for (const e of docRefReport.errors) {
1862
- ghAnnotate(docRefSeverity === "error" ? "error" : "warning", `${e.kind}("${e.value}") — ${e.message}`, frame.repo((0, node_path_1.resolve)(process.cwd(), e.file)), e.line);
1935
+ ghAnnotate(markOf(e.sev).level, `${e.kind}("${e.value}") — ${e.message}`, frame.repo((0, node_path_1.resolve)(root, e.file)), e.line);
1863
1936
  }
1864
1937
  }
1865
1938
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
@@ -1878,14 +1951,16 @@ async function runLint(restArgs, flags, excludes, config) {
1878
1951
  specRefErrors: specRefs.errors,
1879
1952
  compileBudgetErrors: specRefs.budgetErrors,
1880
1953
  duplicatePairs: dups.pairCount,
1881
- duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1954
+ duplicateSeverity: dups.errorPairs > 0 ? "error" : "warn",
1882
1955
  coverageEnabled: coverage.enabled,
1883
1956
  coverageDocumented: coverage.documented,
1884
1957
  strengthenSuggestions: guidanceCount,
1885
1958
  integrityErrors,
1886
1959
  coverageErrors,
1887
1960
  orphanCount: orphanReport.orphans.length,
1888
- orphanSeverity: orphanSeverity,
1961
+ orphanSeverity: orphanReport.orphans.some((doc) => rules.severityAt("orphan-docs", doc) === "error")
1962
+ ? "error"
1963
+ : "warn",
1889
1964
  untestedSurfaces: untested.untested,
1890
1965
  untestedErrors: untested.errors,
1891
1966
  toolContractIssues: toolContract.issues,
@@ -1902,6 +1977,8 @@ async function runLint(restArgs, flags, excludes, config) {
1902
1977
  mcpToolErrors: mcpToolResolves.errors,
1903
1978
  hookScriptIssues: hookScripts.issues,
1904
1979
  hookScriptErrors: hookScripts.errors,
1980
+ handWrittenHookIssues: handWrittenHooks.issues,
1981
+ handWrittenHookErrors: handWrittenHooks.errors,
1905
1982
  disallowedToolIssues: disallowedTools.issues,
1906
1983
  disallowedToolErrors: disallowedTools.errors,
1907
1984
  descriptionOverlapIssues: descriptionOverlap.issues,
@@ -1912,8 +1989,6 @@ async function runLint(restArgs, flags, excludes, config) {
1912
1989
  frontmatterValidErrors: frontmatterValid.errors,
1913
1990
  mcpHookIssues: mcpHookTargets.issues,
1914
1991
  mcpHookErrors: mcpHookTargets.errors,
1915
- preferCompiledHookIssues: preferCompiledHooks.issues,
1916
- preferCompiledHookErrors: preferCompiledHooks.errors,
1917
1992
  lethalTrifectaIssues: lethalTrifecta.issues,
1918
1993
  lethalTrifectaErrors: lethalTrifecta.errors,
1919
1994
  skillResourceIssues: skillResources.issues,
@@ -1935,7 +2010,7 @@ async function runLint(restArgs, flags, excludes, config) {
1935
2010
  // Only the "error" tier gates. At "warn" the findings are printed and
1936
2011
  // annotated, and the exit code is untouched — same contract as every other
1937
2012
  // opt-in rule here.
1938
- docRefErrors: docRefSeverity === "error" ? docRefReport.errors.length : 0,
2013
+ docRefErrors: docRefErrors.filter((e) => e.sev === "error").length,
1939
2014
  symbolRefErrors,
1940
2015
  mcpRefErrors,
1941
2016
  files,
@@ -2021,10 +2096,9 @@ function collectDocumentedRules(excludes) {
2021
2096
  });
2022
2097
  for (const mdFile of mdFiles) {
2023
2098
  const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), mdFile), "utf-8");
2024
- const enforcedRe = /\*\*Enforced by:\*\*\s*`([^`]+)`/g;
2025
- let m;
2026
- while ((m = enforcedRe.exec(content)) !== null) {
2027
- documented.add(m[1]);
2099
+ // Both compiled spellings (#351): an unverified claim is still documented.
2100
+ for (const m of content.matchAll(new RegExp(enforce_marker_js_1.RULE_MARKER_SOURCE, "g"))) {
2101
+ documented.add(m[2]);
2028
2102
  }
2029
2103
  }
2030
2104
  return documented;
@@ -2079,10 +2153,12 @@ function discover(excludes, silent = false) {
2079
2153
  }
2080
2154
  const totalPct = totalEnabled > 0 ? Math.round((totalDocumented / totalEnabled) * 100) : 0;
2081
2155
  log(`Coverage: ${String(totalDocumented)}/${String(totalEnabled)} rules documented (${String(totalPct)}%)`);
2082
- if (totalDocumented < totalEnabled) {
2083
- log(`\nConsider adding enforce() rules for frequently-triggered undocumented rules.`);
2084
- log(`The agent encounters these rules but has no context about WHY.`);
2085
- }
2156
+ const advice = (0, coverage_js_1.undocumentedRulesAdvice)({
2157
+ enabled: totalEnabled,
2158
+ documented: totalDocumented,
2159
+ });
2160
+ if (advice.length > 0)
2161
+ log(["", ...advice].join("\n"));
2086
2162
  return { enabled: totalEnabled, documented: totalDocumented };
2087
2163
  }
2088
2164
  /** This package's own version (from the installed package.json). */
@@ -2178,21 +2254,40 @@ function discoverAdoptableSurfaces(cwd) {
2178
2254
  }
2179
2255
  return out;
2180
2256
  }
2181
- /** The full adoptable-surface list `audit` reports: the instruction file (when it
2182
- * exists hand-written, no spec) PLUS every skill/subagent surface without a spec.
2183
- * Same notion `init` adopts; surfaced in the AuditReport + the terminal nudge so
2184
- * the report's "Create spec" / "Create all specs" affordances have their paths.
2185
- * Composition-root only — CC paths are intentional here (like discoverAdoptableSurfaces). */
2186
- function discoverAdoptableForAudit(root, instructionFile) {
2187
- const out = [];
2188
- const instrAbs = (0, node_path_1.resolve)(root, instructionFile);
2189
- if ((0, node_fs_1.existsSync)(instrAbs) &&
2190
- !targetHasHash(instrAbs) &&
2191
- !(0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, `${instructionFile}.spec.ts`))) {
2192
- out.push(instructionFile);
2193
- }
2194
- out.push(...discoverAdoptableSurfaces(root));
2195
- return out;
2257
+ /** Every hand-written instruction file under `root` — root AND nested
2258
+ * `CLAUDE.md` / `AGENTS.md`, the same files `gatherInstructionFiles` reads —
2259
+ * by the one predicate `lint`'s `require-instructions-spec` asks
2260
+ * (`instructionSpecState`). Measured on a repo with six hand-written files
2261
+ * (root + five nested): `audit` counted 1 (root only) while `lint` warned about
2262
+ * all six. A spec-managed or opted-out file is not listed. */
2263
+ function handWrittenInstructionFiles(root, instructionFile, excludes, rules) {
2264
+ return (gatherInstructionFiles(root, instructionFile, excludes)
2265
+ .filter((f) => (0, integrity_js_1.instructionSpecState)(f.text, (0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, `${f.path}.spec.ts`))) === "hand-written")
2266
+ // A folder an `overrides` entry turned the rule off for keeps its files
2267
+ // hand-written on purpose: that decline is remembered, not re-offered.
2268
+ .filter((f) => rules.severityAt("require-instructions-spec", f.path) !== false)
2269
+ .map((f) => f.path));
2270
+ }
2271
+ /** The hand-written instruction files BELOW the root — the ones `init`'s
2272
+ * root-level `determineTargets` does not see. Bare `init` adopts them too, so a
2273
+ * repo it sets up passes `require-instructions-spec` (in `vigiles:recommended`)
2274
+ * on its first `lint`. Read with the repo's own `exclude`. */
2275
+ function nestedHandWrittenInstructionFiles(cwd) {
2276
+ const config = (0, validate_js_1.loadConfig)();
2277
+ return handWrittenInstructionFiles(cwd, adapter_registry_js_1.defaultAdapter.layout.instructionFile, (0, exclude_js_1.excludeSet)(cwd, config.exclude), (0, overrides_js_1.ruleResolver)(config.rules, config.overrides)).filter((p) => p.includes("/"));
2278
+ }
2279
+ /** The full adoptable-surface list `audit` reports: every hand-written
2280
+ * instruction file (nested included) PLUS every skill/subagent surface without
2281
+ * a spec. Same notion `init` adopts; surfaced in the AuditReport + the terminal
2282
+ * nudge so the report's "Create spec" / "Create all specs" affordances have
2283
+ * their paths. Composition-root only — CC paths are intentional here (like
2284
+ * discoverAdoptableSurfaces). */
2285
+ function discoverAdoptableForAudit(root, { instructionFile, excludes, config, }) {
2286
+ const rules = (0, overrides_js_1.ruleResolver)(config.rules, config.overrides);
2287
+ return [
2288
+ ...handWrittenInstructionFiles(root, instructionFile, excludes, rules),
2289
+ ...discoverAdoptableSurfaces(root),
2290
+ ].filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)));
2196
2291
  }
2197
2292
  /** Lint-config file BASENAMES whose CONTENTS (textual, NEVER executed) reveal
2198
2293
  * which rules a repo has configured — read best-effort for the rule-inventory
@@ -2425,32 +2520,47 @@ function computeRuleRouting(root, instructionFile, excludes) {
2425
2520
  return undefined;
2426
2521
  }
2427
2522
  }
2428
- /** The terminal "adoptable surfaces" nudge — N un-spec'd surfaces + the create-all
2429
- * command and up to ~5 per-surface commands (then "+K more"). "" when nothing to
2523
+ /** The terminal "adoptable surfaces" nudge. Instruction files are listed in
2524
+ * full (up to {@link NUDGE_LIST_CAP}) with the shared adoption remedy — they
2525
+ * have two adopters, `init --target` and the `adopt-spec` skill; skills and
2526
+ * subagents keep the per-surface `init --target` command. "" when nothing to
2430
2527
  * adopt (a fully spec-managed repo says nothing). */
2431
2528
  function formatAdoptableNudge(surfaces) {
2432
2529
  if (surfaces.length === 0)
2433
2530
  return "";
2531
+ const instructions = surfaces.filter((s) => surfaceKind(s) === "instruction");
2532
+ const others = surfaces.filter((s) => surfaceKind(s) !== "instruction");
2434
2533
  const n = surfaces.length;
2534
+ const capped = (items, line) => {
2535
+ const shown = items.slice(0, NUDGE_LIST_CAP).map(line);
2536
+ const more = items.length - shown.length;
2537
+ return more > 0
2538
+ ? [...shown, ` • +${String(more)} more (all in --json)`]
2539
+ : shown;
2540
+ };
2435
2541
  // Lead with the VALUE, not the chore: a spec is the pull up from the gate to
2436
2542
  // the richer layer, so say what it BUYS (drift-proof references + testable),
2437
2543
  // not just "create one". The invitation is where richer-feature adoption is
2438
2544
  // won — keep it compelling, never a bare to-do (research/adoption-design.md §1).
2439
2545
  const lines = [
2440
2546
  `ℹ ${String(n)} surface${n === 1 ? "" : "s"} could be spec-managed — a spec re-verifies its references on every change (a hand-edit can't silently break them) and makes it testable.`,
2441
- ` Adopt now, or one at a time with \`npx vigiles init --target=<path>\`:`,
2442
2547
  ];
2443
- const shown = surfaces.slice(0, 5);
2444
- for (const s of shown)
2445
- lines.push(` • npx vigiles init --target=${s}`);
2446
- const more = n - shown.length;
2447
- if (more > 0)
2448
- lines.push(` • +${String(more)} more`);
2548
+ if (instructions.length > 0) {
2549
+ const k = instructions.length;
2550
+ lines.push(` ${String(k)} hand-written instruction file${k === 1 ? "" : "s"}:`, ...capped(instructions, (s) => ` • ${s}`), ...(0, instruction_remedies_js_1.adoptInstructionFileRemedy)("<path>")
2551
+ .split("\n")
2552
+ .map((l) => ` ${l}`));
2553
+ }
2554
+ if (others.length > 0) {
2555
+ lines.push(` Skills and subagents, one at a time with \`npx vigiles init --target=<path>\`:`, ...capped(others, (s) => ` • npx vigiles init --target=${s}`));
2556
+ }
2449
2557
  // The remembered-decline affordance (non-evil contract): one keystroke to
2450
2558
  // silence, stated where it's shown so it's never a surprise nag.
2451
2559
  lines.push(` (set "nudge": "dismissed" in .vigilesrc.json to hide this)`);
2452
2560
  return lines.join("\n");
2453
2561
  }
2562
+ /** How many paths one group of the adoption nudge prints before "+N more". */
2563
+ const NUDGE_LIST_CAP = 10;
2454
2564
  /** A small, terse behavioral nudge — the deterministic read can't tell whether a
2455
2565
  * skill actually FIRES. "" when there are no model-invocable skills. */
2456
2566
  function formatTriggerNudge(triggerableSkills) {
@@ -3168,7 +3278,11 @@ async function setupPillar1(detected, targetValue, harnesses) {
3168
3278
  // file. An explicit --target stays scoped to that one surface.
3169
3279
  const targets = targetValue
3170
3280
  ? instructionTargets
3171
- : [...instructionTargets, ...discoverAdoptableSurfaces(cwd)];
3281
+ : [
3282
+ ...instructionTargets,
3283
+ ...nestedHandWrittenInstructionFiles(cwd).filter((p) => !instructionTargets.includes(p)),
3284
+ ...discoverAdoptableSurfaces(cwd),
3285
+ ];
3172
3286
  // Create specs. An existing hand-written target is faithfully ADOPTED into a
3173
3287
  // spec (scaffoldSpec() does the convert), not clobbered with a blank one — so the
3174
3288
  // compile below reproduces it (the user reviews the diff). A greenfield target
@@ -3199,6 +3313,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3199
3313
  for (const l of typesResult.linters) {
3200
3314
  console.log(` ${l.linter}: ${String(l.rules.length)} rules`);
3201
3315
  }
3316
+ printUnreadableLinters(typesResult.unreadable);
3202
3317
  if (typesResult.scripts.length > 0) {
3203
3318
  console.log(` npm scripts: ${String(typesResult.scripts.length)}`);
3204
3319
  }
@@ -3811,6 +3926,7 @@ async function setup(args) {
3811
3926
  strict,
3812
3927
  reportOnly: parsed.reportOnly,
3813
3928
  lint: plan.lint,
3929
+ specs: plan.scaffoldSpecs,
3814
3930
  written,
3815
3931
  });
3816
3932
  printSetupSummary({
@@ -3930,6 +4046,7 @@ function fullSetupGap(plan, detected, harnesses) {
3930
4046
  const unspecced = pillar1
3931
4047
  ? [
3932
4048
  ...determineTargets(detected, undefined, [...harnesses]),
4049
+ ...nestedHandWrittenInstructionFiles(cwd),
3933
4050
  ...discoverAdoptableSurfaces(cwd),
3934
4051
  ].filter((t, i, all) => all.indexOf(t) === i && !has(`${t}.spec.ts`))
3935
4052
  : [];
@@ -4010,6 +4127,7 @@ function writeProjectConfig(opts) {
4010
4127
  strict: opts.strict,
4011
4128
  reportOnly: opts.reportOnly,
4012
4129
  lint: opts.lint,
4130
+ specs: opts.specs,
4013
4131
  });
4014
4132
  if (!merged)
4015
4133
  return;
@@ -4022,31 +4140,34 @@ function writeProjectConfig(opts) {
4022
4140
  // ---------------------------------------------------------------------------
4023
4141
  // Strengthen: guidance() → enforce() suggestions
4024
4142
  // ---------------------------------------------------------------------------
4025
- function checkIntegrityForFiles(files, severity, silent) {
4143
+ function checkIntegrityForFiles(root, files, rules, silent) {
4026
4144
  const log = (msg) => {
4027
4145
  if (!silent)
4028
4146
  console.log(msg);
4029
4147
  };
4030
- let errorCount = 0;
4031
- const basePath = process.cwd();
4032
- for (const filePath of files) {
4148
+ const basePath = root;
4149
+ // Per file: an `overrides` entry may turn `integrity` off for a folder.
4150
+ const checked = files.flatMap((filePath) => {
4151
+ const severity = rules.severityAt("integrity", filePath);
4033
4152
  const abs = (0, node_path_1.resolve)(basePath, filePath);
4034
- if (!(0, node_fs_1.existsSync)(abs))
4035
- continue;
4153
+ if (severity === false || !(0, node_fs_1.existsSync)(abs))
4154
+ return [];
4036
4155
  const result = (0, integrity_js_1.checkIntegrity)((0, node_fs_1.readFileSync)(abs, "utf-8"));
4156
+ return [{ filePath, severity, result }];
4157
+ });
4158
+ for (const { filePath, severity, result } of checked) {
4037
4159
  if (!result.intact) {
4038
- errorCount++;
4039
- const marker = severity === "error" ? "✗" : "⚠";
4040
- log(` ${marker} ${filePath} — ${result.reason ?? "tampered"}`);
4160
+ log(` ${markOf(severity).mark} ${filePath} — ${result.reason ?? "tampered"}`);
4041
4161
  }
4042
4162
  else if (!silent) {
4043
4163
  log(` ✓ ${filePath}`);
4044
4164
  }
4045
4165
  }
4046
- if (errorCount === 0) {
4166
+ const broken = checked.filter((c) => !c.result.intact);
4167
+ if (broken.length === 0) {
4047
4168
  log(" All compiled files intact.");
4048
4169
  }
4049
- return severity === "error" ? errorCount : 0;
4170
+ return broken.filter((c) => c.severity === "error").length;
4050
4171
  }
4051
4172
  /**
4052
4173
  * The layout of the harness this repo actually targets — `--harness=`/config/
@@ -4093,7 +4214,7 @@ const UNTESTED_RULE = {
4093
4214
  /**
4094
4215
  * The `untested-*` rules AS THE DETECTOR TAKES THEM: which kinds this repo
4095
4216
  * enabled, and the discovery options merged from whichever of the three rules
4096
- * carries them (`include` / `exclude` / `testExtension` are shared).
4217
+ * carries them (`include` / `testExtension` are shared).
4097
4218
  *
4098
4219
  * 🔴 ONE READER, BECAUSE THE SECOND ONE DRIFTED. `vigiles lint` read the config
4099
4220
  * here; the PostToolUse nudge (`evalLockNudgeHookCommand`) called the same
@@ -4104,25 +4225,24 @@ const UNTESTED_RULE = {
4104
4225
  * while the nudge said "no test or eval covers it". A nudge contradicting the
4105
4226
  * linter of the same repo, in the same second, teaches people to ignore both.
4106
4227
  */
4107
- function untestedRules(config) {
4108
- const rules = config?.rules;
4109
- const severity = (kind) => (0, types_js_1.ruleSeverity)(rules?.[UNTESTED_RULE[kind]]);
4228
+ function untestedRules(config, rules) {
4110
4229
  const names = Object.values(UNTESTED_RULE);
4111
4230
  const opts = names.reduce((acc, rule) => ({
4112
4231
  ...acc,
4113
- ...(0, types_js_1.ruleOptions)(rules?.[rule]),
4232
+ ...(0, types_js_1.ruleOptions)(config?.rules?.[rule]),
4114
4233
  }), {});
4115
- const on = (kind) => severity(kind) !== false;
4234
+ // A kind is SCANNED when its rule can fire anywhere — an override may turn
4235
+ // it on for one folder while `rules` has it off.
4236
+ const on = (kind) => rules.mayFire(UNTESTED_RULE[kind]);
4116
4237
  return {
4117
- severity,
4118
- anyEnabled: names.some((rule) => (0, types_js_1.ruleSeverity)(rules?.[rule]) !== false),
4238
+ severity: (kind, path) => rules.severityAt(UNTESTED_RULE[kind], path),
4239
+ anyEnabled: names.some((rule) => rules.mayFire(rule)),
4119
4240
  options: {
4120
4241
  skills: on("skill"),
4121
4242
  agents: on("agent"),
4122
4243
  hooks: on("hook"),
4123
4244
  outputStyles: on("output-style"),
4124
4245
  include: opts.include,
4125
- exclude: opts.exclude,
4126
4246
  // Without this the `testExtension` documented on TestCoverageOptions was a
4127
4247
  // config key nothing read: a TypeScript-shaped repo got `.ts` suggestions
4128
4248
  // however the author configured it. Prose isn't policy, in our own CLI.
@@ -4139,33 +4259,37 @@ function untestedRules(config) {
4139
4259
  */
4140
4260
  function checkUntestedSurfaces(ctx) {
4141
4261
  const { config, silent, adapter, frame, bundle } = ctx;
4142
- const { severity: sevFor, anyEnabled, options } = untestedRules(config);
4262
+ const { severity: sevFor, anyEnabled, options, } = untestedRules(config, ctx.rules);
4143
4263
  if (!anyEnabled)
4144
4264
  return { untested: 0, errors: 0 };
4145
- const report = (0, test_coverage_js_1.findUntestedSurfaces)({
4265
+ const scanned = (0, test_coverage_js_1.findUntestedSurfaces)({
4146
4266
  ...options,
4147
4267
  basePath: bundle.abs,
4148
- // #281: `include`/`exclude` and every reported path are relative to the
4268
+ // #281: `include` and every reported path are relative to the
4149
4269
  // frame root (where `.vigilesrc.json` lives), not to the bundle being scored.
4150
4270
  root: frame.root,
4151
4271
  layout: adapter.layout,
4152
- // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
4153
- // it. Union, never override — a rule option must not re-admit a vendored
4154
- // corpus the repo excluded (#192). Passed WHOLE so it carries its own root.
4272
+ // The repo-wide `exclude` — passed WHOLE so it carries its own root (#192).
4155
4273
  excludes: ctx.excludes,
4156
4274
  });
4275
+ // Per surface: an `overrides` entry may turn its kind's rule off for a folder.
4276
+ const untested = scanned.untested.flatMap((s) => {
4277
+ const sev = sevFor(s.kind, s.path);
4278
+ return sev === false ? [] : [{ ...s, sev }];
4279
+ });
4280
+ const report = { ...scanned, untested };
4157
4281
  if (!silent) {
4158
4282
  console.log("\nUntested surfaces:\n");
4159
4283
  for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
4160
4284
  console.log(` ${line}`);
4161
4285
  }
4162
4286
  for (const s of report.untested) {
4163
- 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)));
4287
+ ctx.annotate(markOf(s.sev).level, `${s.kind} ${s.path} ships without a test or eval`, frame.repo((0, node_path_1.join)(frame.root, s.path)));
4164
4288
  }
4165
4289
  }
4166
4290
  return {
4167
4291
  untested: report.untested.length,
4168
- errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
4292
+ errors: untested.filter((s) => s.sev === "error").length,
4169
4293
  };
4170
4294
  }
4171
4295
  /**
@@ -4190,9 +4314,8 @@ function reportNotApplicable(check, surface, adapter, silent) {
4190
4314
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
4191
4315
  */
4192
4316
  function checkSubagentToolContracts(ctx) {
4193
- const { config, silent, adapter } = ctx;
4194
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
4195
- if (!sev)
4317
+ const { silent, adapter } = ctx;
4318
+ if (!ctx.rules.mayFire("subagent-tool-contract"))
4196
4319
  return { issues: 0, errors: 0 };
4197
4320
  if (!adapter.subagents) {
4198
4321
  reportNotApplicable("Subagent tool-contract check", "subagents", adapter, silent);
@@ -4208,24 +4331,16 @@ function checkSubagentToolContracts(ctx) {
4208
4331
  catch {
4209
4332
  return { issues: 0, errors: 0 };
4210
4333
  }
4211
- let issues = 0;
4212
- let printedHeader = false;
4213
- for (const agent of agents) {
4214
- if (agent.toolIssues.length === 0)
4215
- continue;
4216
- issues += agent.toolIssues.length;
4217
- if (!silent) {
4218
- if (!printedHeader) {
4219
- console.log("\nSubagent tool-contract check:\n");
4220
- printedHeader = true;
4221
- }
4222
- for (const issue of agent.toolIssues) {
4223
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(agent.path)}: ${issue.message}`);
4224
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(agent.path));
4225
- }
4334
+ const found = graded(ctx.rules, "subagent-tool-contract", agents.flatMap((a) => a.toolIssues.map((i) => ({ path: a.path, message: i.message }))), (f) => ctx.bundle.scanned(f.path));
4335
+ if (found.length > 0 && !silent) {
4336
+ console.log("\nSubagent tool-contract check:\n");
4337
+ for (const issue of found) {
4338
+ const at = ctx.bundle.scanned(issue.path);
4339
+ console.log(` ${markOf(issue.sev).mark} ${at}: ${issue.message}`);
4340
+ ctx.annotate(markOf(issue.sev).level, issue.message, at);
4226
4341
  }
4227
4342
  }
4228
- return { issues, errors: sev === "error" ? issues : 0 };
4343
+ return tally(found);
4229
4344
  }
4230
4345
  /**
4231
4346
  * Apply the `hook-events` rule: flag a hook registered under an event name the
@@ -4266,30 +4381,30 @@ function checkHookEvents(ctx) {
4266
4381
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
4267
4382
  */
4268
4383
  function checkFrontmatterSchema(ctx) {
4269
- const { config, silent, adapter } = ctx;
4270
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
4271
- if (!sev)
4384
+ const { silent, adapter } = ctx;
4385
+ if (!ctx.rules.mayFire("subagent-frontmatter"))
4272
4386
  return { issues: 0, errors: 0 };
4273
4387
  if (!adapter.subagents) {
4274
4388
  reportNotApplicable("Subagent-frontmatter check", "subagents", adapter, silent);
4275
4389
  return { issues: 0, errors: 0 };
4276
4390
  }
4277
- let found;
4391
+ let raw;
4278
4392
  try {
4279
4393
  const r = ctx.scan();
4280
- found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4394
+ raw = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4281
4395
  }
4282
4396
  catch {
4283
4397
  return { issues: 0, errors: 0 };
4284
4398
  }
4399
+ const found = graded(ctx.rules, "subagent-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4285
4400
  if (found.length > 0 && !silent) {
4286
4401
  console.log("\nFrontmatter-schema check:\n");
4287
4402
  for (const issue of found) {
4288
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4289
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4403
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4404
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4290
4405
  }
4291
4406
  }
4292
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4407
+ return tally(found);
4293
4408
  }
4294
4409
  /**
4295
4410
  * Apply the `skill-frontmatter` rule: RECOMMEND (not require) that a SKILL.md
@@ -4300,54 +4415,25 @@ function checkFrontmatterSchema(ctx) {
4300
4415
  * `skillMetaIssues`.
4301
4416
  */
4302
4417
  function checkSkillFrontmatter(ctx) {
4303
- const { config, silent } = ctx;
4304
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
4305
- if (!sev)
4418
+ const { silent } = ctx;
4419
+ if (!ctx.rules.mayFire("skill-frontmatter"))
4306
4420
  return { issues: 0, errors: 0 };
4307
- let found;
4421
+ let raw;
4308
4422
  try {
4309
- found = ctx.scan().skillMetaIssues;
4423
+ raw = ctx.scan().skillMetaIssues;
4310
4424
  }
4311
4425
  catch {
4312
4426
  return { issues: 0, errors: 0 };
4313
4427
  }
4428
+ const found = graded(ctx.rules, "skill-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4314
4429
  if (found.length > 0 && !silent) {
4315
4430
  console.log("\nSkill-frontmatter check:\n");
4316
4431
  for (const issue of found) {
4317
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4318
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4432
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4433
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4319
4434
  }
4320
4435
  }
4321
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4322
- }
4323
- /**
4324
- * Apply the `prefer-compiled-hooks` rule: a SINGLE repo-level recommendation
4325
- * (one finding regardless of hook count) nudging hand-written hooks toward
4326
- * compiled `vigiles/hook` artifacts. A discovery nudge, not a defect — the shell
4327
- * lane stays first-class — so it fires once and the message links the guide.
4328
- * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
4329
- */
4330
- function checkPreferCompiledHooks(ctx) {
4331
- const { config, silent } = ctx;
4332
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
4333
- if (!sev)
4334
- return { issues: 0, errors: 0 };
4335
- let count;
4336
- try {
4337
- count = ctx.scan().manualHookCount;
4338
- }
4339
- catch {
4340
- return { issues: 0, errors: 0 };
4341
- }
4342
- if (count === 0)
4343
- return { issues: 0, errors: 0 };
4344
- const message = (0, scan_js_1.preferCompiledHooksMessage)(count);
4345
- if (!silent) {
4346
- console.log("\nCompiled-hooks check:\n");
4347
- console.log(` ${sev === "error" ? "✗" : "ℹ"} ${ctx.label(message)}`);
4348
- ctx.annotate(sev === "error" ? "error" : "warning", message);
4349
- }
4350
- return { issues: 1, errors: sev === "error" ? 1 : 0 };
4436
+ return tally(found);
4351
4437
  }
4352
4438
  /**
4353
4439
  * Apply the `mcp-config` rule: a declared MCP server with neither a `command`
@@ -4383,17 +4469,16 @@ function checkMcpConfig(ctx) {
4383
4469
  * "error" gates CI.
4384
4470
  */
4385
4471
  function checkDisallowedTools(ctx) {
4386
- const { config, silent, adapter } = ctx;
4387
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
4388
- if (!sev)
4472
+ const { silent, adapter } = ctx;
4473
+ if (!ctx.rules.mayFire("disallowed-tools-contract"))
4389
4474
  return { issues: 0, errors: 0 };
4390
4475
  if (!adapter.subagents) {
4391
4476
  reportNotApplicable("Disallowed-tools check", "subagents", adapter, silent);
4392
4477
  return { issues: 0, errors: 0 };
4393
4478
  }
4394
- let found;
4479
+ let raw;
4395
4480
  try {
4396
- found = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4481
+ raw = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4397
4482
  message: i.message,
4398
4483
  path: a.path,
4399
4484
  })));
@@ -4401,14 +4486,15 @@ function checkDisallowedTools(ctx) {
4401
4486
  catch {
4402
4487
  return { issues: 0, errors: 0 };
4403
4488
  }
4489
+ const found = graded(ctx.rules, "disallowed-tools-contract", raw, (f) => ctx.bundle.scanned(f.path));
4404
4490
  if (found.length > 0 && !silent) {
4405
4491
  console.log("\nDisallowed-tools check:\n");
4406
4492
  for (const issue of found) {
4407
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4408
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4493
+ console.log(` ${markOf(issue.sev).mark} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4494
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4409
4495
  }
4410
4496
  }
4411
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4497
+ return tally(found);
4412
4498
  }
4413
4499
  /**
4414
4500
  * Apply the `frontmatter-valid` rule: a skill/agent `---` block that EXISTS but
@@ -4419,25 +4505,25 @@ function checkDisallowedTools(ctx) {
4419
4505
  * (verify before setting "error").
4420
4506
  */
4421
4507
  function checkFrontmatterValid(ctx) {
4422
- const { config, silent } = ctx;
4423
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
4424
- if (!sev)
4508
+ const { silent } = ctx;
4509
+ if (!ctx.rules.mayFire("frontmatter-valid"))
4425
4510
  return { issues: 0, errors: 0 };
4426
- let found;
4511
+ let raw;
4427
4512
  try {
4428
- found = ctx.scan().malformedFrontmatter;
4513
+ raw = ctx.scan().malformedFrontmatter;
4429
4514
  }
4430
4515
  catch {
4431
4516
  return { issues: 0, errors: 0 };
4432
4517
  }
4518
+ const found = graded(ctx.rules, "frontmatter-valid", raw, (f) => ctx.bundle.scanned(f.path));
4433
4519
  if (found.length > 0 && !silent) {
4434
4520
  console.log("\nFrontmatter-validity check:\n");
4435
4521
  for (const issue of found) {
4436
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4437
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4522
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4523
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4438
4524
  }
4439
4525
  }
4440
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4526
+ return tally(found);
4441
4527
  }
4442
4528
  /**
4443
4529
  * Apply the `description-overlap` rule: two model-invocable skills with
@@ -4512,38 +4598,40 @@ function checkDescriptionBudget(ctx) {
4512
4598
  * unaffected by the collapse.
4513
4599
  */
4514
4600
  function checkLethalTrifecta(ctx) {
4515
- const { config, silent } = ctx;
4516
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
4517
- if (!sev)
4601
+ const { silent } = ctx;
4602
+ if (!ctx.rules.mayFire("lethal-trifecta"))
4518
4603
  return { issues: 0, errors: 0 };
4519
- let found;
4604
+ let raw;
4520
4605
  try {
4521
- found = ctx.scan().trifectaFindings;
4606
+ raw = ctx.scan().trifectaFindings;
4522
4607
  }
4523
4608
  catch {
4524
4609
  return { issues: 0, errors: 0 };
4525
4610
  }
4611
+ const found = graded(ctx.rules, "lethal-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4526
4612
  if (found.length > 0 && !silent) {
4527
- const mark = sev === "error" ? "✗" : "⚠";
4528
- const level = sev === "error" ? "error" : "warning";
4529
4613
  console.log("\nLethal-trifecta check:\n");
4530
4614
  const unfenced = found.filter((t) => t.kind === "skill" && t.finding.fence === "none");
4531
4615
  for (const t of found) {
4532
4616
  if (unfenced.includes(t))
4533
4617
  continue;
4534
4618
  const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
4535
- console.log(` ${mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4536
- ctx.annotate(level, msg, ctx.bundle.scanned(t.path));
4619
+ console.log(` ${markOf(t.sev).mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4620
+ ctx.annotate(markOf(t.sev).level, msg, ctx.bundle.scanned(t.path));
4537
4621
  }
4538
4622
  if (unfenced.length > 0) {
4539
- console.log(` ${mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
4623
+ // One line for the group, at the worst severity any of its skills has.
4624
+ const groupSev = unfenced.some((t) => t.sev === "error")
4625
+ ? "error"
4626
+ : "warn";
4627
+ console.log(` ${markOf(groupSev).mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
4540
4628
  `inherits every tool the session grants and holds all three legs. ` +
4541
4629
  `\`allowed-tools:\` pre-approves, it does not restrict. ` +
4542
4630
  `Add one \`disallowed-tools:\` line per skill to drop a leg: ` +
4543
4631
  unfenced.map((t) => t.name).join(", "));
4544
4632
  }
4545
4633
  }
4546
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4634
+ return tally(found);
4547
4635
  }
4548
4636
  /**
4549
4637
  * Apply the `skill-resource-resolves` rule: a SKILL.md body referencing a bundled
@@ -4554,12 +4642,11 @@ function checkLethalTrifecta(ctx) {
4554
4642
  */
4555
4643
  function checkSkillResourceResolves(ctx) {
4556
4644
  const { config, silent } = ctx;
4557
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
4558
- if (!sev)
4645
+ if (!ctx.rules.mayFire("skill-resource-resolves"))
4559
4646
  return { issues: 0, errors: 0 };
4560
- let found;
4647
+ let raw;
4561
4648
  try {
4562
- found = ctx.scan({
4649
+ raw = ctx.scan({
4563
4650
  sharedDirs: config?.sharedDirs,
4564
4651
  // sharedDirs live at the frame root — the repo that OWNS the scan target:
4565
4652
  // cwd for a scoped subdir or a nested bundle, the target for a foreign lint.
@@ -4569,6 +4656,7 @@ function checkSkillResourceResolves(ctx) {
4569
4656
  catch {
4570
4657
  return { issues: 0, errors: 0 };
4571
4658
  }
4659
+ const found = graded(ctx.rules, "skill-resource-resolves", raw, (f) => ctx.bundle.scanned(f.path));
4572
4660
  if (found.length > 0 && !silent) {
4573
4661
  console.log("\nSkill-resource check:\n");
4574
4662
  for (const s of found) {
@@ -4578,11 +4666,11 @@ function checkSkillResourceResolves(ctx) {
4578
4666
  // it was documented only in docs/skills-monorepo.md, so a CI log gave no
4579
4667
  // hint and the rule read as broken rather than misconfigured.
4580
4668
  ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
4581
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4582
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4669
+ console.log(` ${markOf(s.sev).mark} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4670
+ ctx.annotate(markOf(s.sev).level, msg, ctx.bundle.scanned(s.path));
4583
4671
  }
4584
4672
  }
4585
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4673
+ return tally(found);
4586
4674
  }
4587
4675
  /**
4588
4676
  * Apply the `skill-missing-fence` rule: a SKILL.md that opens with
@@ -4592,26 +4680,26 @@ function checkSkillResourceResolves(ctx) {
4592
4680
  * default; "error" gates CI.
4593
4681
  */
4594
4682
  function checkSkillMissingFence(ctx) {
4595
- const { config, silent } = ctx;
4596
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
4597
- if (!sev)
4683
+ const { silent } = ctx;
4684
+ if (!ctx.rules.mayFire("skill-missing-fence"))
4598
4685
  return { issues: 0, errors: 0 };
4599
- let found;
4686
+ let raw;
4600
4687
  try {
4601
- found = ctx.scan().skillFenceIssues;
4688
+ raw = ctx.scan().skillFenceIssues;
4602
4689
  }
4603
4690
  catch {
4604
4691
  return { issues: 0, errors: 0 };
4605
4692
  }
4693
+ const found = graded(ctx.rules, "skill-missing-fence", raw, (f) => ctx.bundle.scanned(f.path));
4606
4694
  if (found.length > 0 && !silent) {
4607
4695
  console.log("\nSkill-missing-fence check:\n");
4608
4696
  for (const s of found) {
4609
4697
  const msg = `${s.name}: ${s.finding.message}`;
4610
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4611
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4698
+ console.log(` ${markOf(s.sev).mark} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4699
+ ctx.annotate(markOf(s.sev).level, msg, ctx.bundle.scanned(s.path));
4612
4700
  }
4613
4701
  }
4614
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4702
+ return tally(found);
4615
4703
  }
4616
4704
  /**
4617
4705
  * Apply the `plugin-dir-layout` rule: functional surface dirs (skills/agents/
@@ -4621,25 +4709,25 @@ function checkSkillMissingFence(ctx) {
4621
4709
  * gates CI.
4622
4710
  */
4623
4711
  function checkPluginDirLayout(ctx) {
4624
- const { config, silent } = ctx;
4625
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
4626
- if (!sev)
4712
+ const { silent } = ctx;
4713
+ if (!ctx.rules.mayFire("plugin-dir-layout"))
4627
4714
  return { issues: 0, errors: 0 };
4628
- let found;
4715
+ let raw;
4629
4716
  try {
4630
- found = ctx.scan().pluginLayoutIssues;
4717
+ raw = ctx.scan().pluginLayoutIssues;
4631
4718
  }
4632
4719
  catch {
4633
4720
  return { issues: 0, errors: 0 };
4634
4721
  }
4722
+ const found = graded(ctx.rules, "plugin-dir-layout", raw, (f) => ctx.bundle.scanned(f.dir));
4635
4723
  if (found.length > 0 && !silent) {
4636
4724
  console.log("\nPlugin-dir-layout check:\n");
4637
4725
  for (const p of found) {
4638
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(p.message)}`);
4639
- ctx.annotate(sev === "error" ? "error" : "warning", p.message);
4726
+ console.log(` ${markOf(p.sev).mark} ${ctx.label(p.message)}`);
4727
+ ctx.annotate(markOf(p.sev).level, p.message);
4640
4728
  }
4641
4729
  }
4642
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4730
+ return tally(found);
4643
4731
  }
4644
4732
  /**
4645
4733
  * Apply the `delegation-trifecta` rule: a lethal trifecta that EMERGES across a
@@ -4650,26 +4738,26 @@ function checkPluginDirLayout(ctx) {
4650
4738
  * capability the way a surface-specific rule is.
4651
4739
  */
4652
4740
  function checkDelegationTrifecta(ctx) {
4653
- const { config, silent } = ctx;
4654
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
4655
- if (!sev)
4741
+ const { silent } = ctx;
4742
+ if (!ctx.rules.mayFire("delegation-trifecta"))
4656
4743
  return { issues: 0, errors: 0 };
4657
- let found;
4744
+ let raw;
4658
4745
  try {
4659
- found = ctx.scan().delegationTrifecta;
4746
+ raw = ctx.scan().delegationTrifecta;
4660
4747
  }
4661
4748
  catch {
4662
4749
  return { issues: 0, errors: 0 };
4663
4750
  }
4751
+ const found = graded(ctx.rules, "delegation-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4664
4752
  if (found.length > 0 && !silent) {
4665
4753
  console.log("\nDelegation-trifecta check:\n");
4666
4754
  for (const d of found) {
4667
4755
  const msg = `${d.finding.name}: ${d.finding.message}`;
4668
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4669
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(d.path));
4756
+ console.log(` ${markOf(d.sev).mark} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4757
+ ctx.annotate(markOf(d.sev).level, msg, ctx.bundle.scanned(d.path));
4670
4758
  }
4671
4759
  }
4672
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4760
+ return tally(found);
4673
4761
  }
4674
4762
  /**
4675
4763
  * Apply the `hook-block-ineffective` rule: a hook that LOOKS like it blocks but
@@ -4787,43 +4875,57 @@ function checkMcpHookTargets(ctx) {
4787
4875
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4788
4876
  }
4789
4877
  /**
4790
- * Apply the `hook-script-exists` rule: a hook command references a script file
4791
- * that doesn't exist on disk (with `${CLAUDE_PLUGIN_ROOT}` resolved) → the hook
4792
- * silently never runs. Reuses `scanPlugin`'s `hooks` (status "missing"); the
4793
- * shared resolver already excludes the FP-prone cases (unresolved vars,
4794
- * existence-guarded one-liners, inline commands). Matches Anthropic's own
4795
- * `claude plugin validate`. Warning by default; "error" gates CI.
4878
+ * Apply the `hook-script-exists` rule: a file a COMPILED hook line needs is
4879
+ * missing — the runtime, the program or its stamp — so the hook cannot run.
4880
+ * The files come from the hook-line plan (`hookLinePlan`), never from reading a
4881
+ * line: a line is compiled only by identity with what `compile` writes.
4796
4882
  */
4797
4883
  function checkHookScriptExists(ctx) {
4884
+ return reportHookLines(ctx, "hook-script-exists", "Hook-script existence check", (scan) => [
4885
+ ...scan.hooks
4886
+ .filter((h) => h.status === "missing")
4887
+ // `script` is absolute (the root variable resolved) — printed as-is it
4888
+ // was a machine path (#281); `scanned` makes it the bundle's own.
4889
+ .map((h) => (0, scan_js_1.hookScriptMissingMessage)(ctx.bundle.scanned(h.script))),
4890
+ // A line vigiles wrote that is now wrong: its own wiring, so here.
4891
+ ...scan.staleHooks.map(scan_js_1.staleHookMessage),
4892
+ ]);
4893
+ }
4894
+ /**
4895
+ * Apply the `hand-written-hook` rule: one finding per hook line vigiles did not
4896
+ * write (or wrote and someone edited). vigiles never parses such a line, so it
4897
+ * cannot say what it runs; the finding names it and points at compiled hooks.
4898
+ */
4899
+ function checkHandWrittenHooks(ctx) {
4900
+ return reportHookLines(ctx, "hand-written-hook", "Hand-written hooks", (scan) => scan.handWrittenHooks.map(scan_js_1.handWrittenHookMessage));
4901
+ }
4902
+ /** Print and count one hook-line rule's messages at its configured severity. */
4903
+ function reportHookLines(ctx, rule, heading, messagesOf) {
4798
4904
  const { config, silent, adapter } = ctx;
4799
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
4905
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.[rule]);
4800
4906
  if (!sev)
4801
4907
  return { issues: 0, errors: 0 };
4802
4908
  if (!adapter.shellHooks) {
4803
- reportNotApplicable("Hook-script existence check", "shell hooks", adapter, silent);
4909
+ reportNotApplicable(heading, "shell hooks", adapter, silent);
4804
4910
  return { issues: 0, errors: 0 };
4805
4911
  }
4806
- let missing;
4912
+ let messages;
4807
4913
  try {
4808
- missing = ctx.scan().hooks.filter((h) => h.status === "missing");
4914
+ messages = messagesOf(ctx.scan());
4809
4915
  }
4810
4916
  catch {
4811
4917
  return { issues: 0, errors: 0 };
4812
4918
  }
4813
- if (missing.length > 0 && !silent) {
4814
- console.log("\nHook-script existence check:\n");
4815
- for (const h of missing) {
4816
- // `script` arrives with the plugin-root token EXPANDED, i.e. absolute —
4817
- // printed as-is it was a machine path (#281). A relative one is the
4818
- // bundle's own, which is what `scanned` assumes.
4819
- const msg = `hook script "${ctx.bundle.scanned(h.script)}" is referenced but missing — the hook never runs.`;
4919
+ if (messages.length > 0 && !silent) {
4920
+ console.log(`\n${heading}:\n`);
4921
+ for (const msg of messages) {
4820
4922
  console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(msg)}`);
4821
4923
  ctx.annotate(sev === "error" ? "error" : "warning", msg);
4822
4924
  }
4823
4925
  }
4824
4926
  return {
4825
- issues: missing.length,
4826
- errors: sev === "error" ? missing.length : 0,
4927
+ issues: messages.length,
4928
+ errors: sev === "error" ? messages.length : 0,
4827
4929
  };
4828
4930
  }
4829
4931
  /**
@@ -4834,31 +4936,31 @@ function checkHookScriptExists(ctx) {
4834
4936
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
4835
4937
  */
4836
4938
  function checkMcpToolResolves(ctx) {
4837
- const { config, silent, adapter } = ctx;
4838
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
4839
- if (!sev)
4939
+ const { silent, adapter } = ctx;
4940
+ if (!ctx.rules.mayFire("mcp-tool-resolves"))
4840
4941
  return { issues: 0, errors: 0 };
4841
4942
  if (!adapter.subagents) {
4842
4943
  reportNotApplicable("MCP tool-resolution check", "subagents", adapter, silent);
4843
4944
  return { issues: 0, errors: 0 };
4844
4945
  }
4845
- let found;
4946
+ let raw;
4846
4947
  try {
4847
- found = ctx
4948
+ raw = ctx
4848
4949
  .scan()
4849
4950
  .agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
4850
4951
  }
4851
4952
  catch {
4852
4953
  return { issues: 0, errors: 0 };
4853
4954
  }
4955
+ const found = graded(ctx.rules, "mcp-tool-resolves", raw, (f) => ctx.bundle.scanned(f.path));
4854
4956
  if (found.length > 0 && !silent) {
4855
4957
  console.log("\nMCP tool-resolution check:\n");
4856
4958
  for (const issue of found) {
4857
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4858
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4959
+ console.log(` ${markOf(issue.sev).mark} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4960
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4859
4961
  }
4860
4962
  }
4861
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4963
+ return tally(found);
4862
4964
  }
4863
4965
  /**
4864
4966
  * Apply the configured coverage thresholds. Returns the number of failing
@@ -5082,6 +5184,17 @@ async function handleGenerate(restArgs, args) {
5082
5184
  process.exit(2);
5083
5185
  }
5084
5186
  }
5187
+ /**
5188
+ * A linter whose config could not be read is missing from the generated types;
5189
+ * say so on stderr (and as a CI annotation) instead of a silent short list.
5190
+ */
5191
+ function printUnreadableLinters(unreadable) {
5192
+ unreadable.forEach(({ linter, reason }) => {
5193
+ const msg = `could not read the ${linter} config: ${reason} (its rules are not listed)`;
5194
+ console.error(` ⚠ ${msg}`);
5195
+ console.error(`::warning::${msg}`);
5196
+ });
5197
+ }
5085
5198
  function handleGenerateTypes(args, restArgs) {
5086
5199
  const checkOnly = args.includes("--check");
5087
5200
  const outPath = restArgs[0] ?? ".vigiles/generated.d.ts";
@@ -5097,6 +5210,7 @@ function handleGenerateTypes(args, restArgs) {
5097
5210
  for (const l of result.linters) {
5098
5211
  console.log(` ${l.linter}: ${String(l.rules.length)} enabled rules (via ${l.via})`);
5099
5212
  }
5213
+ printUnreadableLinters(result.unreadable);
5100
5214
  if (result.scripts.length > 0) {
5101
5215
  console.log(` npm scripts: ${String(result.scripts.length)}`);
5102
5216
  }
@@ -6382,7 +6496,8 @@ function evalLockNudgeHookCommand() {
6382
6496
  // into a failed edit: the nudge not firing is the cheaper failure. The verbs
6383
6497
  // throw on the same config; what is CHECKED is identical.
6384
6498
  const config = (0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" });
6385
- const { options } = untestedRules(config);
6499
+ const nudgeRules = (0, overrides_js_1.ruleResolver)(config.rules, config.overrides);
6500
+ const { options } = untestedRules(config, nudgeRules);
6386
6501
  // 🔴 THE SAME LAYOUT `vigiles lint` RESOLVES, for the same reason as the config
6387
6502
  // above. This used to pass `basePath` alone, so the detector fell back to the
6388
6503
  // Claude Code layout: in a Codex repo whose surfaces live at
@@ -6397,9 +6512,8 @@ function evalLockNudgeHookCommand() {
6397
6512
  // heard nothing at all, while a repo that already tests got reminded. That is
6398
6513
  // backwards, and `untested-skill` already stated the missing half correctly;
6399
6514
  // it just lived in `vigiles lint`, which someone has to run by hand.
6400
- // Same union `vigiles lint` applies: the rule's exclude narrows, the
6401
- // repo-wide exclude is the floor (#192) — the nudge must not report a
6402
- // vendored skill the linter would never list.
6515
+ // The repo-wide exclude `vigiles lint` applies (#192) — the nudge must not
6516
+ // report a vendored skill the linter would never list.
6403
6517
  const excludes = (0, exclude_js_1.excludeSet)(cwd, config.exclude);
6404
6518
  // 🔴 THE BUNDLE THAT HOLDS THE EDIT, found the way `lint` finds it (#281, D6).
6405
6519
  // The nudge scanned the root bundle only and matched by SUFFIX, so an edit to
@@ -6416,13 +6530,18 @@ function evalLockNudgeHookCommand() {
6416
6530
  return r !== "" && !r.startsWith("..") && !(0, node_path_1.isAbsolute)(r);
6417
6531
  })
6418
6532
  : undefined) ?? cwd;
6419
- const msg = (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
6420
- ...options,
6421
- basePath: home,
6422
- root: cwd,
6423
- layout,
6424
- excludes,
6425
- }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
6533
+ // The same per-path answer `lint` gives: no untested nudge for a skill whose
6534
+ // folder an `overrides` entry turned `untested-skill` off for.
6535
+ const untestedHere = nudgeRules.severityAt("untested-skill", frame.repo(edited)) !== false;
6536
+ const msg = (untestedHere
6537
+ ? (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
6538
+ ...options,
6539
+ basePath: home,
6540
+ root: cwd,
6541
+ layout,
6542
+ excludes,
6543
+ })
6544
+ : undefined) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
6426
6545
  if (!msg)
6427
6546
  return;
6428
6547
  process.stdout.write(JSON.stringify({
@@ -6462,7 +6581,10 @@ async function refsHookCommand() {
6462
6581
  // from the process's directory is how a disabled rule comes back to life.
6463
6582
  const cwd = runtimeRoot(eventRoot(raw));
6464
6583
  // A hook rail — see `evalLockNudgeHookCommand` for why it warns, not throws.
6465
- const severity = (0, types_js_1.ruleSeverity)((0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" }).rules["unmarked-refs"]);
6584
+ // Resolved at the edited file's own path, so an `overrides` entry that turns
6585
+ // `unmarked-refs` off for a folder is honoured at edit time too.
6586
+ const refsConfig = (0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" });
6587
+ const severity = (0, overrides_js_1.ruleResolver)(refsConfig.rules, refsConfig.overrides).severityAt("unmarked-refs", (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)));
6466
6588
  if (severity === false)
6467
6589
  return;
6468
6590
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
@@ -6542,7 +6664,7 @@ async function compileProviders() {
6542
6664
  * the string the user typed — `x.hook.ts` and `./x.hook.ts` are the same wiring),
6543
6665
  * so recompiling updates in place and never clobbers the user's own hooks.
6544
6666
  */
6545
- async function installHookFile(file, adapter, registeredProviders = []) {
6667
+ async function installHookFile(file, adapter, registeredProviders, runtime) {
6546
6668
  const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
6547
6669
  const program = await (0, hook_runtime_js_1.loadHookProgram)(file);
6548
6670
  // Emit the CANONICAL path so two spellings of the same file produce the same
@@ -6561,16 +6683,17 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6561
6683
  // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6562
6684
  // the shell does when that file cannot start at all. `compileHookProgram`
6563
6685
  // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6564
- gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
6565
- (0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
6686
+ gateCommand: (0, hook_command_js_1.runProgramCommand)((0, hook_command_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens, runtime), (0, hook_command_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)),
6566
6687
  dialect: adapter.dialect,
6567
6688
  hookProtocol: adapter.hookProtocol,
6568
6689
  settings: adapter.layout.settings,
6569
6690
  registeredProviders,
6570
6691
  });
6571
- // Tamper-evident stamp beside the source (one dir → basename is unique).
6692
+ // Tamper-evident stamp beside the source (one dir → basename is unique). The
6693
+ // role is recorded with it: `lint` rebuilds the exact line compile writes
6694
+ // from it (`hookLinePlan`), so a wired line is read by identity, not parsed.
6572
6695
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)((0, hook_runtime_js_1.hookStampPath)(file)), { recursive: true });
6573
- (0, node_fs_1.writeFileSync)((0, hook_runtime_js_1.hookStampPath)(file), JSON.stringify({ file, stamp: compiled.stamp }, null, 2) + "\n");
6696
+ (0, node_fs_1.writeFileSync)((0, hook_runtime_js_1.hookStampPath)(file), JSON.stringify({ file, stamp: compiled.stamp, role: (0, hook_program_js_1.dispatchKind)(program) }, null, 2) + "\n");
6574
6697
  // Merge into the harness's native config, idempotently.
6575
6698
  //
6576
6699
  // 🔴 THREE FORMAT BRANCHES BECAME ZERO, and they were three because the ONE
@@ -6646,7 +6769,14 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6646
6769
  `yet automatic).`;
6647
6770
  }
6648
6771
  }
6649
- return { role, settingsPath: adapter.layout.settingsPath, warning };
6772
+ // 🔴 `compiled.warnings` WAS COMPUTED AND NEVER PRINTED until 2026-10-11
6773
+ // (#356): a degraded role/event fit reached the author's terminal by no
6774
+ // path at all — a nudge with no delivery, inside the check for exactly that.
6775
+ return {
6776
+ role,
6777
+ settingsPath: adapter.layout.settingsPath,
6778
+ warnings: [...(compiled.warnings ?? []), ...(warning ? [warning] : [])],
6779
+ };
6650
6780
  }
6651
6781
  /**
6652
6782
  * Compile + install every hook (explicit paths, else discovered under
@@ -6680,15 +6810,29 @@ async function installHooks(hookFiles, harnessFlag, configHarness, validateProvi
6680
6810
  }
6681
6811
  if (hookFiles.length === 0)
6682
6812
  return true; // providers validated; nothing to wire
6813
+ // WHICH runtime the wiring starts — the installed package for a consumer, the
6814
+ // package's own built bin when this IS vigiles (see `hookRuntimeFor`).
6815
+ const runtime = (0, hook_install_js_1.readHookRuntime)(process.cwd());
6816
+ // 🔴 SAY IT NOW, because nothing will later. A nudge whose runtime cannot
6817
+ // start ends in `|| exit 0` by design, so from then on it is indistinguishable
6818
+ // from a nudge with nothing to say. Measured 2026-10-11 on this repository:
6819
+ // dead for three weeks, every gate green. Not an error — the file may arrive
6820
+ // with the next install or build — but never silent.
6821
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), runtime.path))) {
6822
+ console.warn(`⚠ the hook runtime ${runtime.path} is not on disk — every hook compiled ` +
6823
+ `here fails to load until it is: a reminder is skipped without a word, a ` +
6824
+ `gate refuses every command. Run ${runtime.cure}.`);
6825
+ }
6683
6826
  let ok = true;
6684
6827
  for (const file of hookFiles) {
6685
6828
  try {
6686
6829
  // Fan out: the same compiled hook lands in each enabled harness's config.
6687
6830
  for (const adapter of adapters) {
6688
- const r = await installHookFile(file, adapter, registeredProviders);
6831
+ const r = await installHookFile(file, adapter, registeredProviders, runtime);
6689
6832
  console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
6690
- if (r.warning)
6691
- console.warn(`⚠ ${r.warning}`);
6833
+ r.warnings.forEach((w) => {
6834
+ console.warn(`⚠ ${w}`);
6835
+ });
6692
6836
  }
6693
6837
  }
6694
6838
  catch (e) {
@@ -7091,6 +7235,30 @@ async function askOnce(q) {
7091
7235
  rl.close();
7092
7236
  }
7093
7237
  }
7238
+ /**
7239
+ * 🔴 THE PROJECT ROOT, resolved ONCE (#371) — the only place the CLI asks where
7240
+ * it was started. Every command then runs from the root, so `vigiles lint` in
7241
+ * `src/` reads the root's `.vigilesrc.json`, its overrides and its compiled
7242
+ * files exactly as it does at the root, and passes the root on explicitly. A
7243
+ * path the user typed means "from where I typed it", so path arguments are
7244
+ * re-expressed from the root first (`PATH_ARGUMENTS` says which arguments are
7245
+ * paths). `hook-runtime` is exempt: its cwd and argv come from the harness.
7246
+ */
7247
+ function enterProjectRoot() {
7248
+ const typed = process.argv.slice(2);
7249
+ const [command = "", ...rest] = typed;
7250
+ const invocationDir = process.cwd();
7251
+ if (!(0, cli_flag_check_js_1.runsFromProjectRoot)(command))
7252
+ return { command, projectRoot: invocationDir, args: typed };
7253
+ const projectRoot = (0, frame_js_1.findProjectRoot)(invocationDir, node_fs_1.existsSync);
7254
+ const args = [
7255
+ command,
7256
+ ...(0, cli_flag_check_js_1.rebasePathArgs)(command, rest, (p) => (0, frame_js_1.rebaseArg)(p, invocationDir, projectRoot)),
7257
+ ];
7258
+ if (projectRoot !== invocationDir)
7259
+ process.chdir(projectRoot);
7260
+ return { command, projectRoot, args };
7261
+ }
7094
7262
  /**
7095
7263
  * Persist the audit consent (`audit.measure`) into `.vigilesrc.json` without
7096
7264
  * clobbering existing keys — the "ask once, remember" sticky choice. Best-effort:
@@ -7120,8 +7288,7 @@ function rememberAuditMeasure(value) {
7120
7288
  }
7121
7289
  }
7122
7290
  async function main() {
7123
- const args = process.argv.slice(2);
7124
- const command = args[0];
7291
+ const { command, projectRoot, args } = enterProjectRoot();
7125
7292
  // `--version` / `-v` / `version` prints the version number, not the help
7126
7293
  // banner — so `npx vigiles --version` reports e.g. `3.0.0`.
7127
7294
  if (command === "--version" || command === "-v" || command === "version") {
@@ -7156,11 +7323,11 @@ async function main() {
7156
7323
  const restArgs = args.slice(1).filter((a) => !a.startsWith("--"));
7157
7324
  // Shared flags (--max-rules, --catalog-only) override the loaded config so
7158
7325
  // every GitHub Action input maps to a real CLI flag. See src/cli-flags.ts.
7159
- const config = (0, cli_flags_js_1.applyConfigFlags)((0, validate_js_1.loadConfig)(), args);
7326
+ const config = (0, cli_flags_js_1.applyConfigFlags)((0, validate_js_1.loadConfig)(projectRoot), args);
7160
7327
  // `.vigilesrc.json#exclude`, parsed ONCE here and threaded to every walk that
7161
7328
  // polices the repo (src/exclude.ts). Built where the config is loaded so a
7162
7329
  // command cannot re-derive it differently — the drift #192 measured.
7163
- const excludes = (0, exclude_js_1.excludeSet)(process.cwd(), config.exclude);
7330
+ const excludes = (0, exclude_js_1.excludeSet)(projectRoot, config.exclude);
7164
7331
  switch (command) {
7165
7332
  // --- Primary commands ---
7166
7333
  case "init": {
@@ -7193,7 +7360,7 @@ async function main() {
7193
7360
  // is refused below, out loud, with the command that fixes it.
7194
7361
  const found = restArgs.length > 0
7195
7362
  ? (0, hook_install_js_1.partitionHookArgs)(restArgs.filter((f) => !f.endsWith(".spec.ts")))
7196
- : (0, hook_install_js_1.discoveredHookArgs)(process.cwd());
7363
+ : (0, hook_install_js_1.discoveredHookArgs)(projectRoot);
7197
7364
  const hooks = [...found.hooks];
7198
7365
  const unclaimed = found.unclaimed;
7199
7366
  if (specs.length === 0 &&
@@ -7228,8 +7395,8 @@ async function main() {
7228
7395
  // committed baseline — shown at the moment of change. Report-only:
7229
7396
  // `lint` is the gate (rule `instruction-weight`).
7230
7397
  if (specs.length > 0) {
7231
- const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
7232
- root: process.cwd(),
7398
+ const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(projectRoot, (0, scan_js_1.measureInstructionWeight)(projectRoot, (0, adapter_registry_js_1.resolveHarnessSelection)({
7399
+ root: projectRoot,
7233
7400
  flag: harnessFlag,
7234
7401
  configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
7235
7402
  }).adapter));
@@ -7432,7 +7599,7 @@ async function main() {
7432
7599
  }
7433
7600
  // The same single owner of "relative to which directory" `lint` uses
7434
7601
  // (#281): cwd, unless the audited dir is somebody else's repository.
7435
- const frame = (0, frame_js_1.frameFor)(process.cwd(), root);
7602
+ const frame = (0, frame_js_1.frameFor)(projectRoot, root);
7436
7603
  const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
7437
7604
  deprecatedKeyFiles: specsUsingKeyFiles(root, excludes.globIgnore),
7438
7605
  sharedDirs: config.sharedDirs,
@@ -7478,9 +7645,11 @@ async function main() {
7478
7645
  // --target=skills/x/SKILL.md` named a file that does not exist from where
7479
7646
  // you run it, and offered to adopt a skill `exclude` had removed.
7480
7647
  const auditedBundle = frame.bundle(root);
7481
- const adoptableSurfaces = discoverAdoptableForAudit(root, adapter.layout.instructionFile)
7482
- .filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)))
7483
- .map((p) => auditedBundle.scanned(p));
7648
+ const adoptableSurfaces = discoverAdoptableForAudit(root, {
7649
+ instructionFile: adapter.layout.instructionFile,
7650
+ excludes,
7651
+ config,
7652
+ }).map((p) => auditedBundle.scanned(p));
7484
7653
  // Read the local flight recorder ONCE — feeds both the JSON report
7485
7654
  // (structured summary, the product boundary) and the terminal render.
7486
7655
  const ledgerRecords = (0, observe_js_1.readObservations)(root);
@@ -7488,7 +7657,7 @@ async function main() {
7488
7657
  // report is built (it's a pure read off the scan) because the report's
7489
7658
  // `Evaluated` ring needs to know whether this run will ever ask the
7490
7659
  // skill-firing question — see `firingMeasured` below.
7491
- const isForeign = root !== process.cwd();
7660
+ const isForeign = root !== projectRoot;
7492
7661
  // ONE read of "can we reach a model, and on whose bill", from the driving
7493
7662
  // adapter. It replaces a name check OR-ed with one harness's env vars at
7494
7663
  // four sites below, and the two hard-coded "authenticate the `claude` CLI"
@@ -7758,8 +7927,8 @@ async function main() {
7758
7927
  // CI upload / a custom location). The dir is created if missing.
7759
7928
  const outFlag = args.find((a) => a.startsWith("--out="));
7760
7929
  const outDir = outFlag
7761
- ? (0, node_path_1.resolve)(process.cwd(), outFlag.slice("--out=".length))
7762
- : process.cwd();
7930
+ ? (0, node_path_1.resolve)(projectRoot, outFlag.slice("--out=".length))
7931
+ : projectRoot;
7763
7932
  const openReport = !args.includes("--no-open");
7764
7933
  const wroteReports = [];
7765
7934
  // The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
@@ -7805,14 +7974,14 @@ async function main() {
7805
7974
  // `--out` pointing elsewhere yields nothing to write rather than a
7806
7975
  // dead `../../..` entry.
7807
7976
  const rel = wroteReports
7808
- .map((f) => (0, repo_path_js_1.repoRelative)(process.cwd(), (0, node_path_1.resolve)(outDir, f), {
7977
+ .map((f) => (0, repo_path_js_1.repoRelative)(projectRoot, (0, node_path_1.resolve)(outDir, f), {
7809
7978
  relative: node_path_1.relative,
7810
7979
  resolve: node_path_1.resolve,
7811
7980
  isAbsolute: node_path_1.isAbsolute,
7812
7981
  sep: node_path_1.sep,
7813
7982
  }))
7814
7983
  .filter((p) => p !== null);
7815
- ensureReportGitignored(process.cwd(), rel);
7984
+ ensureReportGitignored(projectRoot, rel);
7816
7985
  }
7817
7986
  // A shareable deep-link for a public GitHub repo: the in-browser demo
7818
7987
  // re-runs the audit LIVE for whoever opens it, so a local result is