vigiles 38.1.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 (108) hide show
  1. package/dist/adapters/claude-code/dialect.js +5 -1
  2. package/dist/adapters/claude-code/event-capability.js +24 -8
  3. package/dist/adapters/claude-code/hook-protocol.js +2 -1
  4. package/dist/adapters/codex/hook-protocol.js +2 -1
  5. package/dist/cli-flag-check.d.ts +36 -0
  6. package/dist/cli-flag-check.js +72 -1
  7. package/dist/cli-main.d.ts +2 -2
  8. package/dist/cli-main.js +549 -379
  9. package/dist/core/bash-effects.d.ts +0 -57
  10. package/dist/core/bash-effects.js +0 -147
  11. package/dist/core/compile-generator.d.ts +6 -6
  12. package/dist/core/compile-generator.js +4 -4
  13. package/dist/core/compile.d.ts +72 -8
  14. package/dist/core/compile.js +283 -137
  15. package/dist/core/config-schema.d.ts +45 -11
  16. package/dist/core/config-schema.js +174 -18
  17. package/dist/core/coverage.d.ts +16 -0
  18. package/dist/core/coverage.js +22 -0
  19. package/dist/core/doc-refs.d.ts +3 -2
  20. package/dist/core/doc-refs.js +2 -2
  21. package/dist/core/enforce-marker.d.ts +28 -0
  22. package/dist/core/enforce-marker.js +34 -0
  23. package/dist/core/eslint-inventory.d.ts +96 -0
  24. package/dist/core/eslint-inventory.js +251 -0
  25. package/dist/core/event-capability.d.ts +5 -1
  26. package/dist/core/frame.d.ts +43 -2
  27. package/dist/core/frame.js +85 -2
  28. package/dist/core/generate-schema.d.ts +3 -2
  29. package/dist/core/generate-schema.js +2 -2
  30. package/dist/core/generate-types.d.ts +11 -2
  31. package/dist/core/generate-types.js +12 -5
  32. package/dist/core/hook-command.d.ts +172 -0
  33. package/dist/core/hook-command.js +277 -0
  34. package/dist/core/hook-delivery.d.ts +133 -0
  35. package/dist/core/hook-delivery.js +150 -0
  36. package/dist/core/hook-paths.d.ts +14 -0
  37. package/dist/core/hook-paths.js +22 -0
  38. package/dist/core/hook-program.d.ts +4 -4
  39. package/dist/core/hook-program.js +13 -25
  40. package/dist/core/hook-state.d.ts +1 -1
  41. package/dist/core/hook-state.js +1 -1
  42. package/dist/core/instruction-baseline.d.ts +26 -0
  43. package/dist/core/instruction-baseline.js +45 -1
  44. package/dist/core/instruction-remedies.d.ts +48 -0
  45. package/dist/core/instruction-remedies.js +93 -0
  46. package/dist/core/instruction-weight.d.ts +6 -1
  47. package/dist/core/integrity-header.d.ts +54 -0
  48. package/dist/core/integrity-header.js +90 -0
  49. package/dist/core/integrity.d.ts +1 -25
  50. package/dist/core/integrity.js +16 -74
  51. package/dist/core/linter-adapter.d.ts +27 -3
  52. package/dist/core/linters.d.ts +8 -1
  53. package/dist/core/linters.js +116 -81
  54. package/dist/core/orphans.d.ts +3 -5
  55. package/dist/core/orphans.js +9 -10
  56. package/dist/core/presets.d.ts +19 -7
  57. package/dist/core/presets.js +19 -6
  58. package/dist/core/rule-catalog.d.ts +11 -12
  59. package/dist/core/rule-catalog.js +25 -94
  60. package/dist/core/rule-meta.d.ts +12 -0
  61. package/dist/core/rule-meta.js +47 -10
  62. package/dist/core/source-refs.d.ts +3 -23
  63. package/dist/core/source-refs.js +3 -27
  64. package/dist/core/test-utils.d.ts +13 -0
  65. package/dist/core/test-utils.js +40 -1
  66. package/dist/core/types.d.ts +36 -27
  67. package/dist/core/validate.d.ts +1 -2
  68. package/dist/core/validate.js +60 -47
  69. package/dist/coverage-evidence.js +2 -3
  70. package/dist/deprecated-key-files.d.ts +6 -0
  71. package/dist/deprecated-key-files.js +7 -6
  72. package/dist/exclude.d.ts +3 -1
  73. package/dist/exclude.js +9 -33
  74. package/dist/harness-assert.d.ts +18 -0
  75. package/dist/harness-assert.js +38 -0
  76. package/dist/hook-check-install.js +15 -6
  77. package/dist/hook-check.d.ts +1 -3
  78. package/dist/hook-check.js +3 -5
  79. package/dist/hook-install.d.ts +7 -41
  80. package/dist/hook-install.js +29 -96
  81. package/dist/hook-runtime.js +2 -1
  82. package/dist/instruction-weight-ratchet.d.ts +17 -4
  83. package/dist/instruction-weight-ratchet.js +71 -22
  84. package/dist/load-hook.d.ts +3 -2
  85. package/dist/load-hook.js +28 -6
  86. package/dist/overrides.d.ts +25 -0
  87. package/dist/overrides.js +51 -0
  88. package/dist/plugin-loader.d.ts +23 -0
  89. package/dist/plugin-loader.js +42 -12
  90. package/dist/root-globs.d.ts +9 -0
  91. package/dist/root-globs.js +48 -0
  92. package/dist/rule-routing.js +6 -3
  93. package/dist/run-hook.d.ts +26 -2
  94. package/dist/run-hook.js +48 -12
  95. package/dist/scan-core.d.ts +96 -20
  96. package/dist/scan-core.js +171 -176
  97. package/dist/scan-files.d.ts +0 -8
  98. package/dist/scan-files.js +78 -20
  99. package/dist/scan.d.ts +12 -12
  100. package/dist/scan.js +33 -10
  101. package/dist/setup-plan.d.ts +15 -9
  102. package/dist/setup-plan.js +26 -18
  103. package/dist/test-coverage.d.ts +3 -8
  104. package/dist/test-coverage.js +6 -6
  105. package/dist/test.d.ts +1 -1
  106. package/dist/vigilesrc.schema.json +1405 -144
  107. package/package.json +1 -1
  108. 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.
@@ -1777,6 +1835,7 @@ async function runLint(restArgs, flags, excludes, config) {
1777
1835
  const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
1778
1836
  root: frame.root,
1779
1837
  severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
1838
+ max: (0, types_js_1.ruleOptions)(config?.rules?.["instruction-weight"])?.max,
1780
1839
  update: flags.includes("--update-baseline"),
1781
1840
  silent,
1782
1841
  measure: () => lintRoots.map((abs) => [
@@ -1791,11 +1850,12 @@ async function runLint(restArgs, flags, excludes, config) {
1791
1850
  // over the repository, like the ratchet: a nested bundle's spec would
1792
1851
  // otherwise be reported again by every bundle that encloses it.
1793
1852
  const deprecatedKeyFiles = (0, deprecated_key_files_js_1.checkDeprecatedKeyFiles)({
1794
- severity: (0, types_js_1.ruleSeverity)(config?.rules?.["deprecated-key-files"]),
1795
- 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),
1796
1856
  silent,
1797
1857
  annotate: (level, message, path) => {
1798
- 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)));
1799
1859
  },
1800
1860
  });
1801
1861
  // 8. Validate vigiles builder calls inside markdown code blocks — the
@@ -1818,7 +1878,7 @@ async function runLint(restArgs, flags, excludes, config) {
1818
1878
  // globs without `dot`, so `.claude/**` — where real skills and agents live —
1819
1879
  // has never been scanned. The rule has never once looked at a deployed
1820
1880
  // instruction file; every ref it has ever judged was in prose.
1821
- const docRefSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["doc-refs"]);
1881
+ const docRefSeverity = rules.mayFire("doc-refs");
1822
1882
  if (!silent && docRefSeverity)
1823
1883
  console.log("\nMarkdown code block refs:\n");
1824
1884
  // 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
@@ -1834,8 +1894,8 @@ async function runLint(restArgs, flags, excludes, config) {
1834
1894
  // With no way to reach 0 the step was made `continue-on-error: true`, and a lint
1835
1895
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1836
1896
  // grew to do the gating instead. One unpassed argument, that whole chain.
1837
- const docRefReport = docRefSeverity
1838
- ? (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 })
1839
1899
  : {
1840
1900
  filesScanned: 0,
1841
1901
  filesIgnored: 0,
@@ -1845,10 +1905,24 @@ async function runLint(restArgs, flags, excludes, config) {
1845
1905
  unverified: 0,
1846
1906
  placeholders: 0,
1847
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 };
1848
1913
  if (!silent && docRefSeverity) {
1849
- const rendered = (0, doc_refs_js_1.formatDocRefReport)(docRefReport, docRefSeverity === "error" ? "error" : "warn");
1850
- for (const line of rendered.split("\n")) {
1851
- 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
+ }
1852
1926
  }
1853
1927
  }
1854
1928
  // Per-line GitHub annotations for each broken doc ref — each carries file+line,
@@ -1858,7 +1932,7 @@ async function runLint(restArgs, flags, excludes, config) {
1858
1932
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1859
1933
  if (isGitHubActions() && !silent && docRefSeverity) {
1860
1934
  for (const e of docRefReport.errors) {
1861
- 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);
1862
1936
  }
1863
1937
  }
1864
1938
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
@@ -1877,14 +1951,16 @@ async function runLint(restArgs, flags, excludes, config) {
1877
1951
  specRefErrors: specRefs.errors,
1878
1952
  compileBudgetErrors: specRefs.budgetErrors,
1879
1953
  duplicatePairs: dups.pairCount,
1880
- duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1954
+ duplicateSeverity: dups.errorPairs > 0 ? "error" : "warn",
1881
1955
  coverageEnabled: coverage.enabled,
1882
1956
  coverageDocumented: coverage.documented,
1883
1957
  strengthenSuggestions: guidanceCount,
1884
1958
  integrityErrors,
1885
1959
  coverageErrors,
1886
1960
  orphanCount: orphanReport.orphans.length,
1887
- orphanSeverity: orphanSeverity,
1961
+ orphanSeverity: orphanReport.orphans.some((doc) => rules.severityAt("orphan-docs", doc) === "error")
1962
+ ? "error"
1963
+ : "warn",
1888
1964
  untestedSurfaces: untested.untested,
1889
1965
  untestedErrors: untested.errors,
1890
1966
  toolContractIssues: toolContract.issues,
@@ -1901,6 +1977,8 @@ async function runLint(restArgs, flags, excludes, config) {
1901
1977
  mcpToolErrors: mcpToolResolves.errors,
1902
1978
  hookScriptIssues: hookScripts.issues,
1903
1979
  hookScriptErrors: hookScripts.errors,
1980
+ handWrittenHookIssues: handWrittenHooks.issues,
1981
+ handWrittenHookErrors: handWrittenHooks.errors,
1904
1982
  disallowedToolIssues: disallowedTools.issues,
1905
1983
  disallowedToolErrors: disallowedTools.errors,
1906
1984
  descriptionOverlapIssues: descriptionOverlap.issues,
@@ -1911,8 +1989,6 @@ async function runLint(restArgs, flags, excludes, config) {
1911
1989
  frontmatterValidErrors: frontmatterValid.errors,
1912
1990
  mcpHookIssues: mcpHookTargets.issues,
1913
1991
  mcpHookErrors: mcpHookTargets.errors,
1914
- preferCompiledHookIssues: preferCompiledHooks.issues,
1915
- preferCompiledHookErrors: preferCompiledHooks.errors,
1916
1992
  lethalTrifectaIssues: lethalTrifecta.issues,
1917
1993
  lethalTrifectaErrors: lethalTrifecta.errors,
1918
1994
  skillResourceIssues: skillResources.issues,
@@ -1934,7 +2010,7 @@ async function runLint(restArgs, flags, excludes, config) {
1934
2010
  // Only the "error" tier gates. At "warn" the findings are printed and
1935
2011
  // annotated, and the exit code is untouched — same contract as every other
1936
2012
  // opt-in rule here.
1937
- docRefErrors: docRefSeverity === "error" ? docRefReport.errors.length : 0,
2013
+ docRefErrors: docRefErrors.filter((e) => e.sev === "error").length,
1938
2014
  symbolRefErrors,
1939
2015
  mcpRefErrors,
1940
2016
  files,
@@ -2020,10 +2096,9 @@ function collectDocumentedRules(excludes) {
2020
2096
  });
2021
2097
  for (const mdFile of mdFiles) {
2022
2098
  const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), mdFile), "utf-8");
2023
- const enforcedRe = /\*\*Enforced by:\*\*\s*`([^`]+)`/g;
2024
- let m;
2025
- while ((m = enforcedRe.exec(content)) !== null) {
2026
- 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]);
2027
2102
  }
2028
2103
  }
2029
2104
  return documented;
@@ -2078,10 +2153,12 @@ function discover(excludes, silent = false) {
2078
2153
  }
2079
2154
  const totalPct = totalEnabled > 0 ? Math.round((totalDocumented / totalEnabled) * 100) : 0;
2080
2155
  log(`Coverage: ${String(totalDocumented)}/${String(totalEnabled)} rules documented (${String(totalPct)}%)`);
2081
- if (totalDocumented < totalEnabled) {
2082
- log(`\nConsider adding enforce() rules for frequently-triggered undocumented rules.`);
2083
- log(`The agent encounters these rules but has no context about WHY.`);
2084
- }
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"));
2085
2162
  return { enabled: totalEnabled, documented: totalDocumented };
2086
2163
  }
2087
2164
  /** This package's own version (from the installed package.json). */
@@ -2177,21 +2254,40 @@ function discoverAdoptableSurfaces(cwd) {
2177
2254
  }
2178
2255
  return out;
2179
2256
  }
2180
- /** The full adoptable-surface list `audit` reports: the instruction file (when it
2181
- * exists hand-written, no spec) PLUS every skill/subagent surface without a spec.
2182
- * Same notion `init` adopts; surfaced in the AuditReport + the terminal nudge so
2183
- * the report's "Create spec" / "Create all specs" affordances have their paths.
2184
- * Composition-root only — CC paths are intentional here (like discoverAdoptableSurfaces). */
2185
- function discoverAdoptableForAudit(root, instructionFile) {
2186
- const out = [];
2187
- const instrAbs = (0, node_path_1.resolve)(root, instructionFile);
2188
- if ((0, node_fs_1.existsSync)(instrAbs) &&
2189
- !targetHasHash(instrAbs) &&
2190
- !(0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, `${instructionFile}.spec.ts`))) {
2191
- out.push(instructionFile);
2192
- }
2193
- out.push(...discoverAdoptableSurfaces(root));
2194
- 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)));
2195
2291
  }
2196
2292
  /** Lint-config file BASENAMES whose CONTENTS (textual, NEVER executed) reveal
2197
2293
  * which rules a repo has configured — read best-effort for the rule-inventory
@@ -2424,32 +2520,47 @@ function computeRuleRouting(root, instructionFile, excludes) {
2424
2520
  return undefined;
2425
2521
  }
2426
2522
  }
2427
- /** The terminal "adoptable surfaces" nudge — N un-spec'd surfaces + the create-all
2428
- * 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
2429
2527
  * adopt (a fully spec-managed repo says nothing). */
2430
2528
  function formatAdoptableNudge(surfaces) {
2431
2529
  if (surfaces.length === 0)
2432
2530
  return "";
2531
+ const instructions = surfaces.filter((s) => surfaceKind(s) === "instruction");
2532
+ const others = surfaces.filter((s) => surfaceKind(s) !== "instruction");
2433
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
+ };
2434
2541
  // Lead with the VALUE, not the chore: a spec is the pull up from the gate to
2435
2542
  // the richer layer, so say what it BUYS (drift-proof references + testable),
2436
2543
  // not just "create one". The invitation is where richer-feature adoption is
2437
2544
  // won — keep it compelling, never a bare to-do (research/adoption-design.md §1).
2438
2545
  const lines = [
2439
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.`,
2440
- ` Adopt now, or one at a time with \`npx vigiles init --target=<path>\`:`,
2441
2547
  ];
2442
- const shown = surfaces.slice(0, 5);
2443
- for (const s of shown)
2444
- lines.push(` • npx vigiles init --target=${s}`);
2445
- const more = n - shown.length;
2446
- if (more > 0)
2447
- 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
+ }
2448
2557
  // The remembered-decline affordance (non-evil contract): one keystroke to
2449
2558
  // silence, stated where it's shown so it's never a surprise nag.
2450
2559
  lines.push(` (set "nudge": "dismissed" in .vigilesrc.json to hide this)`);
2451
2560
  return lines.join("\n");
2452
2561
  }
2562
+ /** How many paths one group of the adoption nudge prints before "+N more". */
2563
+ const NUDGE_LIST_CAP = 10;
2453
2564
  /** A small, terse behavioral nudge — the deterministic read can't tell whether a
2454
2565
  * skill actually FIRES. "" when there are no model-invocable skills. */
2455
2566
  function formatTriggerNudge(triggerableSkills) {
@@ -3167,7 +3278,11 @@ async function setupPillar1(detected, targetValue, harnesses) {
3167
3278
  // file. An explicit --target stays scoped to that one surface.
3168
3279
  const targets = targetValue
3169
3280
  ? instructionTargets
3170
- : [...instructionTargets, ...discoverAdoptableSurfaces(cwd)];
3281
+ : [
3282
+ ...instructionTargets,
3283
+ ...nestedHandWrittenInstructionFiles(cwd).filter((p) => !instructionTargets.includes(p)),
3284
+ ...discoverAdoptableSurfaces(cwd),
3285
+ ];
3171
3286
  // Create specs. An existing hand-written target is faithfully ADOPTED into a
3172
3287
  // spec (scaffoldSpec() does the convert), not clobbered with a blank one — so the
3173
3288
  // compile below reproduces it (the user reviews the diff). A greenfield target
@@ -3198,6 +3313,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3198
3313
  for (const l of typesResult.linters) {
3199
3314
  console.log(` ${l.linter}: ${String(l.rules.length)} rules`);
3200
3315
  }
3316
+ printUnreadableLinters(typesResult.unreadable);
3201
3317
  if (typesResult.scripts.length > 0) {
3202
3318
  console.log(` npm scripts: ${String(typesResult.scripts.length)}`);
3203
3319
  }
@@ -3810,6 +3926,7 @@ async function setup(args) {
3810
3926
  strict,
3811
3927
  reportOnly: parsed.reportOnly,
3812
3928
  lint: plan.lint,
3929
+ specs: plan.scaffoldSpecs,
3813
3930
  written,
3814
3931
  });
3815
3932
  printSetupSummary({
@@ -3929,6 +4046,7 @@ function fullSetupGap(plan, detected, harnesses) {
3929
4046
  const unspecced = pillar1
3930
4047
  ? [
3931
4048
  ...determineTargets(detected, undefined, [...harnesses]),
4049
+ ...nestedHandWrittenInstructionFiles(cwd),
3932
4050
  ...discoverAdoptableSurfaces(cwd),
3933
4051
  ].filter((t, i, all) => all.indexOf(t) === i && !has(`${t}.spec.ts`))
3934
4052
  : [];
@@ -4009,6 +4127,7 @@ function writeProjectConfig(opts) {
4009
4127
  strict: opts.strict,
4010
4128
  reportOnly: opts.reportOnly,
4011
4129
  lint: opts.lint,
4130
+ specs: opts.specs,
4012
4131
  });
4013
4132
  if (!merged)
4014
4133
  return;
@@ -4021,31 +4140,34 @@ function writeProjectConfig(opts) {
4021
4140
  // ---------------------------------------------------------------------------
4022
4141
  // Strengthen: guidance() → enforce() suggestions
4023
4142
  // ---------------------------------------------------------------------------
4024
- function checkIntegrityForFiles(files, severity, silent) {
4143
+ function checkIntegrityForFiles(root, files, rules, silent) {
4025
4144
  const log = (msg) => {
4026
4145
  if (!silent)
4027
4146
  console.log(msg);
4028
4147
  };
4029
- let errorCount = 0;
4030
- const basePath = process.cwd();
4031
- 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);
4032
4152
  const abs = (0, node_path_1.resolve)(basePath, filePath);
4033
- if (!(0, node_fs_1.existsSync)(abs))
4034
- continue;
4153
+ if (severity === false || !(0, node_fs_1.existsSync)(abs))
4154
+ return [];
4035
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) {
4036
4159
  if (!result.intact) {
4037
- errorCount++;
4038
- const marker = severity === "error" ? "✗" : "⚠";
4039
- log(` ${marker} ${filePath} — ${result.reason ?? "tampered"}`);
4160
+ log(` ${markOf(severity).mark} ${filePath} — ${result.reason ?? "tampered"}`);
4040
4161
  }
4041
4162
  else if (!silent) {
4042
4163
  log(` ✓ ${filePath}`);
4043
4164
  }
4044
4165
  }
4045
- if (errorCount === 0) {
4166
+ const broken = checked.filter((c) => !c.result.intact);
4167
+ if (broken.length === 0) {
4046
4168
  log(" All compiled files intact.");
4047
4169
  }
4048
- return severity === "error" ? errorCount : 0;
4170
+ return broken.filter((c) => c.severity === "error").length;
4049
4171
  }
4050
4172
  /**
4051
4173
  * The layout of the harness this repo actually targets — `--harness=`/config/
@@ -4092,7 +4214,7 @@ const UNTESTED_RULE = {
4092
4214
  /**
4093
4215
  * The `untested-*` rules AS THE DETECTOR TAKES THEM: which kinds this repo
4094
4216
  * enabled, and the discovery options merged from whichever of the three rules
4095
- * carries them (`include` / `exclude` / `testExtension` are shared).
4217
+ * carries them (`include` / `testExtension` are shared).
4096
4218
  *
4097
4219
  * 🔴 ONE READER, BECAUSE THE SECOND ONE DRIFTED. `vigiles lint` read the config
4098
4220
  * here; the PostToolUse nudge (`evalLockNudgeHookCommand`) called the same
@@ -4103,25 +4225,24 @@ const UNTESTED_RULE = {
4103
4225
  * while the nudge said "no test or eval covers it". A nudge contradicting the
4104
4226
  * linter of the same repo, in the same second, teaches people to ignore both.
4105
4227
  */
4106
- function untestedRules(config) {
4107
- const rules = config?.rules;
4108
- const severity = (kind) => (0, types_js_1.ruleSeverity)(rules?.[UNTESTED_RULE[kind]]);
4228
+ function untestedRules(config, rules) {
4109
4229
  const names = Object.values(UNTESTED_RULE);
4110
4230
  const opts = names.reduce((acc, rule) => ({
4111
4231
  ...acc,
4112
- ...(0, types_js_1.ruleOptions)(rules?.[rule]),
4232
+ ...(0, types_js_1.ruleOptions)(config?.rules?.[rule]),
4113
4233
  }), {});
4114
- 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]);
4115
4237
  return {
4116
- severity,
4117
- 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)),
4118
4240
  options: {
4119
4241
  skills: on("skill"),
4120
4242
  agents: on("agent"),
4121
4243
  hooks: on("hook"),
4122
4244
  outputStyles: on("output-style"),
4123
4245
  include: opts.include,
4124
- exclude: opts.exclude,
4125
4246
  // Without this the `testExtension` documented on TestCoverageOptions was a
4126
4247
  // config key nothing read: a TypeScript-shaped repo got `.ts` suggestions
4127
4248
  // however the author configured it. Prose isn't policy, in our own CLI.
@@ -4138,33 +4259,37 @@ function untestedRules(config) {
4138
4259
  */
4139
4260
  function checkUntestedSurfaces(ctx) {
4140
4261
  const { config, silent, adapter, frame, bundle } = ctx;
4141
- const { severity: sevFor, anyEnabled, options } = untestedRules(config);
4262
+ const { severity: sevFor, anyEnabled, options, } = untestedRules(config, ctx.rules);
4142
4263
  if (!anyEnabled)
4143
4264
  return { untested: 0, errors: 0 };
4144
- const report = (0, test_coverage_js_1.findUntestedSurfaces)({
4265
+ const scanned = (0, test_coverage_js_1.findUntestedSurfaces)({
4145
4266
  ...options,
4146
4267
  basePath: bundle.abs,
4147
- // #281: `include`/`exclude` and every reported path are relative to the
4268
+ // #281: `include` and every reported path are relative to the
4148
4269
  // frame root (where `.vigilesrc.json` lives), not to the bundle being scored.
4149
4270
  root: frame.root,
4150
4271
  layout: adapter.layout,
4151
- // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
4152
- // it. Union, never override — a rule option must not re-admit a vendored
4153
- // 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).
4154
4273
  excludes: ctx.excludes,
4155
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 };
4156
4281
  if (!silent) {
4157
4282
  console.log("\nUntested surfaces:\n");
4158
4283
  for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
4159
4284
  console.log(` ${line}`);
4160
4285
  }
4161
4286
  for (const s of report.untested) {
4162
- 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)));
4163
4288
  }
4164
4289
  }
4165
4290
  return {
4166
4291
  untested: report.untested.length,
4167
- errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
4292
+ errors: untested.filter((s) => s.sev === "error").length,
4168
4293
  };
4169
4294
  }
4170
4295
  /**
@@ -4189,9 +4314,8 @@ function reportNotApplicable(check, surface, adapter, silent) {
4189
4314
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
4190
4315
  */
4191
4316
  function checkSubagentToolContracts(ctx) {
4192
- const { config, silent, adapter } = ctx;
4193
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
4194
- if (!sev)
4317
+ const { silent, adapter } = ctx;
4318
+ if (!ctx.rules.mayFire("subagent-tool-contract"))
4195
4319
  return { issues: 0, errors: 0 };
4196
4320
  if (!adapter.subagents) {
4197
4321
  reportNotApplicable("Subagent tool-contract check", "subagents", adapter, silent);
@@ -4207,24 +4331,16 @@ function checkSubagentToolContracts(ctx) {
4207
4331
  catch {
4208
4332
  return { issues: 0, errors: 0 };
4209
4333
  }
4210
- let issues = 0;
4211
- let printedHeader = false;
4212
- for (const agent of agents) {
4213
- if (agent.toolIssues.length === 0)
4214
- continue;
4215
- issues += agent.toolIssues.length;
4216
- if (!silent) {
4217
- if (!printedHeader) {
4218
- console.log("\nSubagent tool-contract check:\n");
4219
- printedHeader = true;
4220
- }
4221
- for (const issue of agent.toolIssues) {
4222
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(agent.path)}: ${issue.message}`);
4223
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(agent.path));
4224
- }
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);
4225
4341
  }
4226
4342
  }
4227
- return { issues, errors: sev === "error" ? issues : 0 };
4343
+ return tally(found);
4228
4344
  }
4229
4345
  /**
4230
4346
  * Apply the `hook-events` rule: flag a hook registered under an event name the
@@ -4265,30 +4381,30 @@ function checkHookEvents(ctx) {
4265
4381
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
4266
4382
  */
4267
4383
  function checkFrontmatterSchema(ctx) {
4268
- const { config, silent, adapter } = ctx;
4269
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
4270
- if (!sev)
4384
+ const { silent, adapter } = ctx;
4385
+ if (!ctx.rules.mayFire("subagent-frontmatter"))
4271
4386
  return { issues: 0, errors: 0 };
4272
4387
  if (!adapter.subagents) {
4273
4388
  reportNotApplicable("Subagent-frontmatter check", "subagents", adapter, silent);
4274
4389
  return { issues: 0, errors: 0 };
4275
4390
  }
4276
- let found;
4391
+ let raw;
4277
4392
  try {
4278
4393
  const r = ctx.scan();
4279
- found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4394
+ raw = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4280
4395
  }
4281
4396
  catch {
4282
4397
  return { issues: 0, errors: 0 };
4283
4398
  }
4399
+ const found = graded(ctx.rules, "subagent-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4284
4400
  if (found.length > 0 && !silent) {
4285
4401
  console.log("\nFrontmatter-schema check:\n");
4286
4402
  for (const issue of found) {
4287
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4288
- 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));
4289
4405
  }
4290
4406
  }
4291
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4407
+ return tally(found);
4292
4408
  }
4293
4409
  /**
4294
4410
  * Apply the `skill-frontmatter` rule: RECOMMEND (not require) that a SKILL.md
@@ -4299,54 +4415,25 @@ function checkFrontmatterSchema(ctx) {
4299
4415
  * `skillMetaIssues`.
4300
4416
  */
4301
4417
  function checkSkillFrontmatter(ctx) {
4302
- const { config, silent } = ctx;
4303
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
4304
- if (!sev)
4418
+ const { silent } = ctx;
4419
+ if (!ctx.rules.mayFire("skill-frontmatter"))
4305
4420
  return { issues: 0, errors: 0 };
4306
- let found;
4421
+ let raw;
4307
4422
  try {
4308
- found = ctx.scan().skillMetaIssues;
4423
+ raw = ctx.scan().skillMetaIssues;
4309
4424
  }
4310
4425
  catch {
4311
4426
  return { issues: 0, errors: 0 };
4312
4427
  }
4428
+ const found = graded(ctx.rules, "skill-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4313
4429
  if (found.length > 0 && !silent) {
4314
4430
  console.log("\nSkill-frontmatter check:\n");
4315
4431
  for (const issue of found) {
4316
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4317
- 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));
4318
4434
  }
4319
4435
  }
4320
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4321
- }
4322
- /**
4323
- * Apply the `prefer-compiled-hooks` rule: a SINGLE repo-level recommendation
4324
- * (one finding regardless of hook count) nudging hand-written hooks toward
4325
- * compiled `vigiles/hook` artifacts. A discovery nudge, not a defect — the shell
4326
- * lane stays first-class — so it fires once and the message links the guide.
4327
- * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
4328
- */
4329
- function checkPreferCompiledHooks(ctx) {
4330
- const { config, silent } = ctx;
4331
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
4332
- if (!sev)
4333
- return { issues: 0, errors: 0 };
4334
- let count;
4335
- try {
4336
- count = ctx.scan().manualHookCount;
4337
- }
4338
- catch {
4339
- return { issues: 0, errors: 0 };
4340
- }
4341
- if (count === 0)
4342
- return { issues: 0, errors: 0 };
4343
- const message = (0, scan_js_1.preferCompiledHooksMessage)(count);
4344
- if (!silent) {
4345
- console.log("\nCompiled-hooks check:\n");
4346
- console.log(` ${sev === "error" ? "✗" : "ℹ"} ${ctx.label(message)}`);
4347
- ctx.annotate(sev === "error" ? "error" : "warning", message);
4348
- }
4349
- return { issues: 1, errors: sev === "error" ? 1 : 0 };
4436
+ return tally(found);
4350
4437
  }
4351
4438
  /**
4352
4439
  * Apply the `mcp-config` rule: a declared MCP server with neither a `command`
@@ -4382,17 +4469,16 @@ function checkMcpConfig(ctx) {
4382
4469
  * "error" gates CI.
4383
4470
  */
4384
4471
  function checkDisallowedTools(ctx) {
4385
- const { config, silent, adapter } = ctx;
4386
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
4387
- if (!sev)
4472
+ const { silent, adapter } = ctx;
4473
+ if (!ctx.rules.mayFire("disallowed-tools-contract"))
4388
4474
  return { issues: 0, errors: 0 };
4389
4475
  if (!adapter.subagents) {
4390
4476
  reportNotApplicable("Disallowed-tools check", "subagents", adapter, silent);
4391
4477
  return { issues: 0, errors: 0 };
4392
4478
  }
4393
- let found;
4479
+ let raw;
4394
4480
  try {
4395
- found = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4481
+ raw = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4396
4482
  message: i.message,
4397
4483
  path: a.path,
4398
4484
  })));
@@ -4400,14 +4486,15 @@ function checkDisallowedTools(ctx) {
4400
4486
  catch {
4401
4487
  return { issues: 0, errors: 0 };
4402
4488
  }
4489
+ const found = graded(ctx.rules, "disallowed-tools-contract", raw, (f) => ctx.bundle.scanned(f.path));
4403
4490
  if (found.length > 0 && !silent) {
4404
4491
  console.log("\nDisallowed-tools check:\n");
4405
4492
  for (const issue of found) {
4406
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4407
- 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));
4408
4495
  }
4409
4496
  }
4410
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4497
+ return tally(found);
4411
4498
  }
4412
4499
  /**
4413
4500
  * Apply the `frontmatter-valid` rule: a skill/agent `---` block that EXISTS but
@@ -4418,25 +4505,25 @@ function checkDisallowedTools(ctx) {
4418
4505
  * (verify before setting "error").
4419
4506
  */
4420
4507
  function checkFrontmatterValid(ctx) {
4421
- const { config, silent } = ctx;
4422
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
4423
- if (!sev)
4508
+ const { silent } = ctx;
4509
+ if (!ctx.rules.mayFire("frontmatter-valid"))
4424
4510
  return { issues: 0, errors: 0 };
4425
- let found;
4511
+ let raw;
4426
4512
  try {
4427
- found = ctx.scan().malformedFrontmatter;
4513
+ raw = ctx.scan().malformedFrontmatter;
4428
4514
  }
4429
4515
  catch {
4430
4516
  return { issues: 0, errors: 0 };
4431
4517
  }
4518
+ const found = graded(ctx.rules, "frontmatter-valid", raw, (f) => ctx.bundle.scanned(f.path));
4432
4519
  if (found.length > 0 && !silent) {
4433
4520
  console.log("\nFrontmatter-validity check:\n");
4434
4521
  for (const issue of found) {
4435
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4436
- 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));
4437
4524
  }
4438
4525
  }
4439
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4526
+ return tally(found);
4440
4527
  }
4441
4528
  /**
4442
4529
  * Apply the `description-overlap` rule: two model-invocable skills with
@@ -4511,38 +4598,40 @@ function checkDescriptionBudget(ctx) {
4511
4598
  * unaffected by the collapse.
4512
4599
  */
4513
4600
  function checkLethalTrifecta(ctx) {
4514
- const { config, silent } = ctx;
4515
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
4516
- if (!sev)
4601
+ const { silent } = ctx;
4602
+ if (!ctx.rules.mayFire("lethal-trifecta"))
4517
4603
  return { issues: 0, errors: 0 };
4518
- let found;
4604
+ let raw;
4519
4605
  try {
4520
- found = ctx.scan().trifectaFindings;
4606
+ raw = ctx.scan().trifectaFindings;
4521
4607
  }
4522
4608
  catch {
4523
4609
  return { issues: 0, errors: 0 };
4524
4610
  }
4611
+ const found = graded(ctx.rules, "lethal-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4525
4612
  if (found.length > 0 && !silent) {
4526
- const mark = sev === "error" ? "✗" : "⚠";
4527
- const level = sev === "error" ? "error" : "warning";
4528
4613
  console.log("\nLethal-trifecta check:\n");
4529
4614
  const unfenced = found.filter((t) => t.kind === "skill" && t.finding.fence === "none");
4530
4615
  for (const t of found) {
4531
4616
  if (unfenced.includes(t))
4532
4617
  continue;
4533
4618
  const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
4534
- console.log(` ${mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4535
- 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));
4536
4621
  }
4537
4622
  if (unfenced.length > 0) {
4538
- 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 ` +
4539
4628
  `inherits every tool the session grants and holds all three legs. ` +
4540
4629
  `\`allowed-tools:\` pre-approves, it does not restrict. ` +
4541
4630
  `Add one \`disallowed-tools:\` line per skill to drop a leg: ` +
4542
4631
  unfenced.map((t) => t.name).join(", "));
4543
4632
  }
4544
4633
  }
4545
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4634
+ return tally(found);
4546
4635
  }
4547
4636
  /**
4548
4637
  * Apply the `skill-resource-resolves` rule: a SKILL.md body referencing a bundled
@@ -4553,12 +4642,11 @@ function checkLethalTrifecta(ctx) {
4553
4642
  */
4554
4643
  function checkSkillResourceResolves(ctx) {
4555
4644
  const { config, silent } = ctx;
4556
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
4557
- if (!sev)
4645
+ if (!ctx.rules.mayFire("skill-resource-resolves"))
4558
4646
  return { issues: 0, errors: 0 };
4559
- let found;
4647
+ let raw;
4560
4648
  try {
4561
- found = ctx.scan({
4649
+ raw = ctx.scan({
4562
4650
  sharedDirs: config?.sharedDirs,
4563
4651
  // sharedDirs live at the frame root — the repo that OWNS the scan target:
4564
4652
  // cwd for a scoped subdir or a nested bundle, the target for a foreign lint.
@@ -4568,6 +4656,7 @@ function checkSkillResourceResolves(ctx) {
4568
4656
  catch {
4569
4657
  return { issues: 0, errors: 0 };
4570
4658
  }
4659
+ const found = graded(ctx.rules, "skill-resource-resolves", raw, (f) => ctx.bundle.scanned(f.path));
4571
4660
  if (found.length > 0 && !silent) {
4572
4661
  console.log("\nSkill-resource check:\n");
4573
4662
  for (const s of found) {
@@ -4577,11 +4666,11 @@ function checkSkillResourceResolves(ctx) {
4577
4666
  // it was documented only in docs/skills-monorepo.md, so a CI log gave no
4578
4667
  // hint and the rule read as broken rather than misconfigured.
4579
4668
  ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
4580
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4581
- 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));
4582
4671
  }
4583
4672
  }
4584
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4673
+ return tally(found);
4585
4674
  }
4586
4675
  /**
4587
4676
  * Apply the `skill-missing-fence` rule: a SKILL.md that opens with
@@ -4591,26 +4680,26 @@ function checkSkillResourceResolves(ctx) {
4591
4680
  * default; "error" gates CI.
4592
4681
  */
4593
4682
  function checkSkillMissingFence(ctx) {
4594
- const { config, silent } = ctx;
4595
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
4596
- if (!sev)
4683
+ const { silent } = ctx;
4684
+ if (!ctx.rules.mayFire("skill-missing-fence"))
4597
4685
  return { issues: 0, errors: 0 };
4598
- let found;
4686
+ let raw;
4599
4687
  try {
4600
- found = ctx.scan().skillFenceIssues;
4688
+ raw = ctx.scan().skillFenceIssues;
4601
4689
  }
4602
4690
  catch {
4603
4691
  return { issues: 0, errors: 0 };
4604
4692
  }
4693
+ const found = graded(ctx.rules, "skill-missing-fence", raw, (f) => ctx.bundle.scanned(f.path));
4605
4694
  if (found.length > 0 && !silent) {
4606
4695
  console.log("\nSkill-missing-fence check:\n");
4607
4696
  for (const s of found) {
4608
4697
  const msg = `${s.name}: ${s.finding.message}`;
4609
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4610
- 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));
4611
4700
  }
4612
4701
  }
4613
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4702
+ return tally(found);
4614
4703
  }
4615
4704
  /**
4616
4705
  * Apply the `plugin-dir-layout` rule: functional surface dirs (skills/agents/
@@ -4620,25 +4709,25 @@ function checkSkillMissingFence(ctx) {
4620
4709
  * gates CI.
4621
4710
  */
4622
4711
  function checkPluginDirLayout(ctx) {
4623
- const { config, silent } = ctx;
4624
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
4625
- if (!sev)
4712
+ const { silent } = ctx;
4713
+ if (!ctx.rules.mayFire("plugin-dir-layout"))
4626
4714
  return { issues: 0, errors: 0 };
4627
- let found;
4715
+ let raw;
4628
4716
  try {
4629
- found = ctx.scan().pluginLayoutIssues;
4717
+ raw = ctx.scan().pluginLayoutIssues;
4630
4718
  }
4631
4719
  catch {
4632
4720
  return { issues: 0, errors: 0 };
4633
4721
  }
4722
+ const found = graded(ctx.rules, "plugin-dir-layout", raw, (f) => ctx.bundle.scanned(f.dir));
4634
4723
  if (found.length > 0 && !silent) {
4635
4724
  console.log("\nPlugin-dir-layout check:\n");
4636
4725
  for (const p of found) {
4637
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(p.message)}`);
4638
- 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);
4639
4728
  }
4640
4729
  }
4641
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4730
+ return tally(found);
4642
4731
  }
4643
4732
  /**
4644
4733
  * Apply the `delegation-trifecta` rule: a lethal trifecta that EMERGES across a
@@ -4649,26 +4738,26 @@ function checkPluginDirLayout(ctx) {
4649
4738
  * capability the way a surface-specific rule is.
4650
4739
  */
4651
4740
  function checkDelegationTrifecta(ctx) {
4652
- const { config, silent } = ctx;
4653
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
4654
- if (!sev)
4741
+ const { silent } = ctx;
4742
+ if (!ctx.rules.mayFire("delegation-trifecta"))
4655
4743
  return { issues: 0, errors: 0 };
4656
- let found;
4744
+ let raw;
4657
4745
  try {
4658
- found = ctx.scan().delegationTrifecta;
4746
+ raw = ctx.scan().delegationTrifecta;
4659
4747
  }
4660
4748
  catch {
4661
4749
  return { issues: 0, errors: 0 };
4662
4750
  }
4751
+ const found = graded(ctx.rules, "delegation-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4663
4752
  if (found.length > 0 && !silent) {
4664
4753
  console.log("\nDelegation-trifecta check:\n");
4665
4754
  for (const d of found) {
4666
4755
  const msg = `${d.finding.name}: ${d.finding.message}`;
4667
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4668
- 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));
4669
4758
  }
4670
4759
  }
4671
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4760
+ return tally(found);
4672
4761
  }
4673
4762
  /**
4674
4763
  * Apply the `hook-block-ineffective` rule: a hook that LOOKS like it blocks but
@@ -4786,43 +4875,57 @@ function checkMcpHookTargets(ctx) {
4786
4875
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4787
4876
  }
4788
4877
  /**
4789
- * Apply the `hook-script-exists` rule: a hook command references a script file
4790
- * that doesn't exist on disk (with `${CLAUDE_PLUGIN_ROOT}` resolved) → the hook
4791
- * silently never runs. Reuses `scanPlugin`'s `hooks` (status "missing"); the
4792
- * shared resolver already excludes the FP-prone cases (unresolved vars,
4793
- * existence-guarded one-liners, inline commands). Matches Anthropic's own
4794
- * `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.
4795
4882
  */
4796
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) {
4797
4904
  const { config, silent, adapter } = ctx;
4798
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
4905
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.[rule]);
4799
4906
  if (!sev)
4800
4907
  return { issues: 0, errors: 0 };
4801
4908
  if (!adapter.shellHooks) {
4802
- reportNotApplicable("Hook-script existence check", "shell hooks", adapter, silent);
4909
+ reportNotApplicable(heading, "shell hooks", adapter, silent);
4803
4910
  return { issues: 0, errors: 0 };
4804
4911
  }
4805
- let missing;
4912
+ let messages;
4806
4913
  try {
4807
- missing = ctx.scan().hooks.filter((h) => h.status === "missing");
4914
+ messages = messagesOf(ctx.scan());
4808
4915
  }
4809
4916
  catch {
4810
4917
  return { issues: 0, errors: 0 };
4811
4918
  }
4812
- if (missing.length > 0 && !silent) {
4813
- console.log("\nHook-script existence check:\n");
4814
- for (const h of missing) {
4815
- // `script` arrives with the plugin-root token EXPANDED, i.e. absolute —
4816
- // printed as-is it was a machine path (#281). A relative one is the
4817
- // bundle's own, which is what `scanned` assumes.
4818
- 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) {
4819
4922
  console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(msg)}`);
4820
4923
  ctx.annotate(sev === "error" ? "error" : "warning", msg);
4821
4924
  }
4822
4925
  }
4823
4926
  return {
4824
- issues: missing.length,
4825
- errors: sev === "error" ? missing.length : 0,
4927
+ issues: messages.length,
4928
+ errors: sev === "error" ? messages.length : 0,
4826
4929
  };
4827
4930
  }
4828
4931
  /**
@@ -4833,31 +4936,31 @@ function checkHookScriptExists(ctx) {
4833
4936
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
4834
4937
  */
4835
4938
  function checkMcpToolResolves(ctx) {
4836
- const { config, silent, adapter } = ctx;
4837
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
4838
- if (!sev)
4939
+ const { silent, adapter } = ctx;
4940
+ if (!ctx.rules.mayFire("mcp-tool-resolves"))
4839
4941
  return { issues: 0, errors: 0 };
4840
4942
  if (!adapter.subagents) {
4841
4943
  reportNotApplicable("MCP tool-resolution check", "subagents", adapter, silent);
4842
4944
  return { issues: 0, errors: 0 };
4843
4945
  }
4844
- let found;
4946
+ let raw;
4845
4947
  try {
4846
- found = ctx
4948
+ raw = ctx
4847
4949
  .scan()
4848
4950
  .agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
4849
4951
  }
4850
4952
  catch {
4851
4953
  return { issues: 0, errors: 0 };
4852
4954
  }
4955
+ const found = graded(ctx.rules, "mcp-tool-resolves", raw, (f) => ctx.bundle.scanned(f.path));
4853
4956
  if (found.length > 0 && !silent) {
4854
4957
  console.log("\nMCP tool-resolution check:\n");
4855
4958
  for (const issue of found) {
4856
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4857
- 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));
4858
4961
  }
4859
4962
  }
4860
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4963
+ return tally(found);
4861
4964
  }
4862
4965
  /**
4863
4966
  * Apply the configured coverage thresholds. Returns the number of failing
@@ -5081,6 +5184,17 @@ async function handleGenerate(restArgs, args) {
5081
5184
  process.exit(2);
5082
5185
  }
5083
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
+ }
5084
5198
  function handleGenerateTypes(args, restArgs) {
5085
5199
  const checkOnly = args.includes("--check");
5086
5200
  const outPath = restArgs[0] ?? ".vigiles/generated.d.ts";
@@ -5096,6 +5210,7 @@ function handleGenerateTypes(args, restArgs) {
5096
5210
  for (const l of result.linters) {
5097
5211
  console.log(` ${l.linter}: ${String(l.rules.length)} enabled rules (via ${l.via})`);
5098
5212
  }
5213
+ printUnreadableLinters(result.unreadable);
5099
5214
  if (result.scripts.length > 0) {
5100
5215
  console.log(` npm scripts: ${String(result.scripts.length)}`);
5101
5216
  }
@@ -6381,7 +6496,8 @@ function evalLockNudgeHookCommand() {
6381
6496
  // into a failed edit: the nudge not firing is the cheaper failure. The verbs
6382
6497
  // throw on the same config; what is CHECKED is identical.
6383
6498
  const config = (0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" });
6384
- const { options } = untestedRules(config);
6499
+ const nudgeRules = (0, overrides_js_1.ruleResolver)(config.rules, config.overrides);
6500
+ const { options } = untestedRules(config, nudgeRules);
6385
6501
  // 🔴 THE SAME LAYOUT `vigiles lint` RESOLVES, for the same reason as the config
6386
6502
  // above. This used to pass `basePath` alone, so the detector fell back to the
6387
6503
  // Claude Code layout: in a Codex repo whose surfaces live at
@@ -6396,9 +6512,8 @@ function evalLockNudgeHookCommand() {
6396
6512
  // heard nothing at all, while a repo that already tests got reminded. That is
6397
6513
  // backwards, and `untested-skill` already stated the missing half correctly;
6398
6514
  // it just lived in `vigiles lint`, which someone has to run by hand.
6399
- // Same union `vigiles lint` applies: the rule's exclude narrows, the
6400
- // repo-wide exclude is the floor (#192) — the nudge must not report a
6401
- // 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.
6402
6517
  const excludes = (0, exclude_js_1.excludeSet)(cwd, config.exclude);
6403
6518
  // 🔴 THE BUNDLE THAT HOLDS THE EDIT, found the way `lint` finds it (#281, D6).
6404
6519
  // The nudge scanned the root bundle only and matched by SUFFIX, so an edit to
@@ -6415,13 +6530,18 @@ function evalLockNudgeHookCommand() {
6415
6530
  return r !== "" && !r.startsWith("..") && !(0, node_path_1.isAbsolute)(r);
6416
6531
  })
6417
6532
  : undefined) ?? cwd;
6418
- const msg = (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
6419
- ...options,
6420
- basePath: home,
6421
- root: cwd,
6422
- layout,
6423
- excludes,
6424
- }) ?? (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));
6425
6545
  if (!msg)
6426
6546
  return;
6427
6547
  process.stdout.write(JSON.stringify({
@@ -6461,7 +6581,10 @@ async function refsHookCommand() {
6461
6581
  // from the process's directory is how a disabled rule comes back to life.
6462
6582
  const cwd = runtimeRoot(eventRoot(raw));
6463
6583
  // A hook rail — see `evalLockNudgeHookCommand` for why it warns, not throws.
6464
- 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)));
6465
6588
  if (severity === false)
6466
6589
  return;
6467
6590
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
@@ -6541,7 +6664,7 @@ async function compileProviders() {
6541
6664
  * the string the user typed — `x.hook.ts` and `./x.hook.ts` are the same wiring),
6542
6665
  * so recompiling updates in place and never clobbers the user's own hooks.
6543
6666
  */
6544
- async function installHookFile(file, adapter, registeredProviders = []) {
6667
+ async function installHookFile(file, adapter, registeredProviders, runtime) {
6545
6668
  const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
6546
6669
  const program = await (0, hook_runtime_js_1.loadHookProgram)(file);
6547
6670
  // Emit the CANONICAL path so two spellings of the same file produce the same
@@ -6560,16 +6683,17 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6560
6683
  // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6561
6684
  // the shell does when that file cannot start at all. `compileHookProgram`
6562
6685
  // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6563
- gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
6564
- (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)),
6565
6687
  dialect: adapter.dialect,
6566
6688
  hookProtocol: adapter.hookProtocol,
6567
6689
  settings: adapter.layout.settings,
6568
6690
  registeredProviders,
6569
6691
  });
6570
- // 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.
6571
6695
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)((0, hook_runtime_js_1.hookStampPath)(file)), { recursive: true });
6572
- (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");
6573
6697
  // Merge into the harness's native config, idempotently.
6574
6698
  //
6575
6699
  // 🔴 THREE FORMAT BRANCHES BECAME ZERO, and they were three because the ONE
@@ -6645,7 +6769,14 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6645
6769
  `yet automatic).`;
6646
6770
  }
6647
6771
  }
6648
- 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
+ };
6649
6780
  }
6650
6781
  /**
6651
6782
  * Compile + install every hook (explicit paths, else discovered under
@@ -6679,15 +6810,29 @@ async function installHooks(hookFiles, harnessFlag, configHarness, validateProvi
6679
6810
  }
6680
6811
  if (hookFiles.length === 0)
6681
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
+ }
6682
6826
  let ok = true;
6683
6827
  for (const file of hookFiles) {
6684
6828
  try {
6685
6829
  // Fan out: the same compiled hook lands in each enabled harness's config.
6686
6830
  for (const adapter of adapters) {
6687
- const r = await installHookFile(file, adapter, registeredProviders);
6831
+ const r = await installHookFile(file, adapter, registeredProviders, runtime);
6688
6832
  console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
6689
- if (r.warning)
6690
- console.warn(`⚠ ${r.warning}`);
6833
+ r.warnings.forEach((w) => {
6834
+ console.warn(`⚠ ${w}`);
6835
+ });
6691
6836
  }
6692
6837
  }
6693
6838
  catch (e) {
@@ -7090,6 +7235,30 @@ async function askOnce(q) {
7090
7235
  rl.close();
7091
7236
  }
7092
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
+ }
7093
7262
  /**
7094
7263
  * Persist the audit consent (`audit.measure`) into `.vigilesrc.json` without
7095
7264
  * clobbering existing keys — the "ask once, remember" sticky choice. Best-effort:
@@ -7119,8 +7288,7 @@ function rememberAuditMeasure(value) {
7119
7288
  }
7120
7289
  }
7121
7290
  async function main() {
7122
- const args = process.argv.slice(2);
7123
- const command = args[0];
7291
+ const { command, projectRoot, args } = enterProjectRoot();
7124
7292
  // `--version` / `-v` / `version` prints the version number, not the help
7125
7293
  // banner — so `npx vigiles --version` reports e.g. `3.0.0`.
7126
7294
  if (command === "--version" || command === "-v" || command === "version") {
@@ -7155,11 +7323,11 @@ async function main() {
7155
7323
  const restArgs = args.slice(1).filter((a) => !a.startsWith("--"));
7156
7324
  // Shared flags (--max-rules, --catalog-only) override the loaded config so
7157
7325
  // every GitHub Action input maps to a real CLI flag. See src/cli-flags.ts.
7158
- 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);
7159
7327
  // `.vigilesrc.json#exclude`, parsed ONCE here and threaded to every walk that
7160
7328
  // polices the repo (src/exclude.ts). Built where the config is loaded so a
7161
7329
  // command cannot re-derive it differently — the drift #192 measured.
7162
- const excludes = (0, exclude_js_1.excludeSet)(process.cwd(), config.exclude);
7330
+ const excludes = (0, exclude_js_1.excludeSet)(projectRoot, config.exclude);
7163
7331
  switch (command) {
7164
7332
  // --- Primary commands ---
7165
7333
  case "init": {
@@ -7192,7 +7360,7 @@ async function main() {
7192
7360
  // is refused below, out loud, with the command that fixes it.
7193
7361
  const found = restArgs.length > 0
7194
7362
  ? (0, hook_install_js_1.partitionHookArgs)(restArgs.filter((f) => !f.endsWith(".spec.ts")))
7195
- : (0, hook_install_js_1.discoveredHookArgs)(process.cwd());
7363
+ : (0, hook_install_js_1.discoveredHookArgs)(projectRoot);
7196
7364
  const hooks = [...found.hooks];
7197
7365
  const unclaimed = found.unclaimed;
7198
7366
  if (specs.length === 0 &&
@@ -7227,8 +7395,8 @@ async function main() {
7227
7395
  // committed baseline — shown at the moment of change. Report-only:
7228
7396
  // `lint` is the gate (rule `instruction-weight`).
7229
7397
  if (specs.length > 0) {
7230
- const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
7231
- 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,
7232
7400
  flag: harnessFlag,
7233
7401
  configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
7234
7402
  }).adapter));
@@ -7431,7 +7599,7 @@ async function main() {
7431
7599
  }
7432
7600
  // The same single owner of "relative to which directory" `lint` uses
7433
7601
  // (#281): cwd, unless the audited dir is somebody else's repository.
7434
- const frame = (0, frame_js_1.frameFor)(process.cwd(), root);
7602
+ const frame = (0, frame_js_1.frameFor)(projectRoot, root);
7435
7603
  const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
7436
7604
  deprecatedKeyFiles: specsUsingKeyFiles(root, excludes.globIgnore),
7437
7605
  sharedDirs: config.sharedDirs,
@@ -7477,9 +7645,11 @@ async function main() {
7477
7645
  // --target=skills/x/SKILL.md` named a file that does not exist from where
7478
7646
  // you run it, and offered to adopt a skill `exclude` had removed.
7479
7647
  const auditedBundle = frame.bundle(root);
7480
- const adoptableSurfaces = discoverAdoptableForAudit(root, adapter.layout.instructionFile)
7481
- .filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)))
7482
- .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));
7483
7653
  // Read the local flight recorder ONCE — feeds both the JSON report
7484
7654
  // (structured summary, the product boundary) and the terminal render.
7485
7655
  const ledgerRecords = (0, observe_js_1.readObservations)(root);
@@ -7487,7 +7657,7 @@ async function main() {
7487
7657
  // report is built (it's a pure read off the scan) because the report's
7488
7658
  // `Evaluated` ring needs to know whether this run will ever ask the
7489
7659
  // skill-firing question — see `firingMeasured` below.
7490
- const isForeign = root !== process.cwd();
7660
+ const isForeign = root !== projectRoot;
7491
7661
  // ONE read of "can we reach a model, and on whose bill", from the driving
7492
7662
  // adapter. It replaces a name check OR-ed with one harness's env vars at
7493
7663
  // four sites below, and the two hard-coded "authenticate the `claude` CLI"
@@ -7757,8 +7927,8 @@ async function main() {
7757
7927
  // CI upload / a custom location). The dir is created if missing.
7758
7928
  const outFlag = args.find((a) => a.startsWith("--out="));
7759
7929
  const outDir = outFlag
7760
- ? (0, node_path_1.resolve)(process.cwd(), outFlag.slice("--out=".length))
7761
- : process.cwd();
7930
+ ? (0, node_path_1.resolve)(projectRoot, outFlag.slice("--out=".length))
7931
+ : projectRoot;
7762
7932
  const openReport = !args.includes("--no-open");
7763
7933
  const wroteReports = [];
7764
7934
  // The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
@@ -7804,14 +7974,14 @@ async function main() {
7804
7974
  // `--out` pointing elsewhere yields nothing to write rather than a
7805
7975
  // dead `../../..` entry.
7806
7976
  const rel = wroteReports
7807
- .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), {
7808
7978
  relative: node_path_1.relative,
7809
7979
  resolve: node_path_1.resolve,
7810
7980
  isAbsolute: node_path_1.isAbsolute,
7811
7981
  sep: node_path_1.sep,
7812
7982
  }))
7813
7983
  .filter((p) => p !== null);
7814
- ensureReportGitignored(process.cwd(), rel);
7984
+ ensureReportGitignored(projectRoot, rel);
7815
7985
  }
7816
7986
  // A shareable deep-link for a public GitHub repo: the in-browser demo
7817
7987
  // re-runs the audit LIVE for whoever opens it, so a local result is