vigiles 38.2.0 → 39.1.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 (129) hide show
  1. package/action.yml +18 -0
  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/audit-report.d.ts +14 -1
  6. package/dist/audit-report.js +21 -5
  7. package/dist/audit-report.template.html +33 -28
  8. package/dist/audit-score.d.ts +4 -1
  9. package/dist/audit-score.js +3 -1
  10. package/dist/audit-verdict.d.ts +3 -1
  11. package/dist/cli-flag-check.d.ts +36 -0
  12. package/dist/cli-flag-check.js +76 -1
  13. package/dist/cli-flags.d.ts +1 -1
  14. package/dist/cli-main.d.ts +2 -2
  15. package/dist/cli-main.js +684 -434
  16. package/dist/core/CLAUDE.md.spec.js +2 -2
  17. package/dist/core/apply-rules.d.ts +71 -0
  18. package/dist/core/apply-rules.js +48 -0
  19. package/dist/core/bash-effects.d.ts +0 -57
  20. package/dist/core/bash-effects.js +0 -147
  21. package/dist/core/compile-generator.d.ts +6 -6
  22. package/dist/core/compile-generator.js +4 -4
  23. package/dist/core/compile.d.ts +72 -8
  24. package/dist/core/compile.js +276 -133
  25. package/dist/core/config-migrations.d.ts +69 -0
  26. package/dist/core/config-migrations.js +145 -0
  27. package/dist/core/config-schema.d.ts +166 -160
  28. package/dist/core/config-schema.js +207 -244
  29. package/dist/core/coverage.d.ts +16 -0
  30. package/dist/core/coverage.js +22 -0
  31. package/dist/core/doc-refs.d.ts +3 -2
  32. package/dist/core/doc-refs.js +2 -2
  33. package/dist/core/enforce-marker.d.ts +28 -0
  34. package/dist/core/enforce-marker.js +34 -0
  35. package/dist/core/eslint-inventory.d.ts +96 -0
  36. package/dist/core/eslint-inventory.js +251 -0
  37. package/dist/core/event-capability.d.ts +5 -1
  38. package/dist/core/frame.d.ts +43 -2
  39. package/dist/core/frame.js +85 -2
  40. package/dist/core/generate-schema.d.ts +3 -2
  41. package/dist/core/generate-schema.js +2 -2
  42. package/dist/core/generate-types.d.ts +11 -2
  43. package/dist/core/generate-types.js +12 -5
  44. package/dist/core/hook-command.d.ts +172 -0
  45. package/dist/core/hook-command.js +277 -0
  46. package/dist/core/hook-delivery.d.ts +133 -0
  47. package/dist/core/hook-delivery.js +150 -0
  48. package/dist/core/hook-paths.d.ts +14 -0
  49. package/dist/core/hook-paths.js +22 -0
  50. package/dist/core/hook-program.d.ts +4 -4
  51. package/dist/core/hook-program.js +13 -25
  52. package/dist/core/hook-state.d.ts +1 -1
  53. package/dist/core/hook-state.js +1 -1
  54. package/dist/core/instruction-remedies.d.ts +35 -0
  55. package/dist/core/instruction-remedies.js +70 -1
  56. package/dist/core/integrity-header.d.ts +54 -0
  57. package/dist/core/integrity-header.js +90 -0
  58. package/dist/core/integrity.d.ts +1 -25
  59. package/dist/core/integrity.js +16 -74
  60. package/dist/core/linter-adapter.d.ts +28 -4
  61. package/dist/core/linters.d.ts +8 -1
  62. package/dist/core/linters.js +116 -81
  63. package/dist/core/orphans.d.ts +3 -5
  64. package/dist/core/orphans.js +9 -10
  65. package/dist/core/presets.d.ts +31 -34
  66. package/dist/core/presets.js +25 -38
  67. package/dist/core/rule-catalog.d.ts +11 -12
  68. package/dist/core/rule-catalog.js +25 -94
  69. package/dist/core/rule.d.ts +213 -0
  70. package/dist/core/rule.js +37 -0
  71. package/dist/core/skill-resources.d.ts +85 -15
  72. package/dist/core/skill-resources.js +63 -37
  73. package/dist/core/source-refs.d.ts +3 -23
  74. package/dist/core/source-refs.js +3 -27
  75. package/dist/core/test-utils.d.ts +13 -0
  76. package/dist/core/test-utils.js +40 -1
  77. package/dist/core/types.d.ts +19 -329
  78. package/dist/core/validate.d.ts +14 -14
  79. package/dist/core/validate.js +87 -73
  80. package/dist/coverage-evidence.js +2 -3
  81. package/dist/deprecated-key-files.d.ts +6 -0
  82. package/dist/deprecated-key-files.js +7 -6
  83. package/dist/exclude.d.ts +3 -1
  84. package/dist/exclude.js +9 -33
  85. package/dist/harness-assert.d.ts +18 -0
  86. package/dist/harness-assert.js +38 -0
  87. package/dist/hook-check-install.js +15 -6
  88. package/dist/hook-check.d.ts +1 -3
  89. package/dist/hook-check.js +3 -5
  90. package/dist/hook-install.d.ts +7 -41
  91. package/dist/hook-install.js +29 -96
  92. package/dist/hook-runtime.js +2 -1
  93. package/dist/load-hook.d.ts +3 -2
  94. package/dist/load-hook.js +28 -6
  95. package/dist/optimize.d.ts +2 -1
  96. package/dist/overrides.d.ts +29 -0
  97. package/dist/overrides.js +51 -0
  98. package/dist/plugin-loader.d.ts +23 -0
  99. package/dist/plugin-loader.js +42 -12
  100. package/dist/root-globs.d.ts +9 -0
  101. package/dist/root-globs.js +48 -0
  102. package/dist/rule-routing.js +6 -3
  103. package/dist/rules/CLAUDE.md.spec.d.ts +3 -0
  104. package/dist/rules/CLAUDE.md.spec.js +22 -0
  105. package/dist/rules/groups.d.ts +32 -0
  106. package/dist/rules/groups.js +27 -0
  107. package/dist/rules/index.d.ts +412 -0
  108. package/dist/{core/rule-meta.js → rules/index.js} +329 -200
  109. package/dist/rules/skill-resource-resolves.d.ts +36 -0
  110. package/dist/rules/skill-resource-resolves.js +51 -0
  111. package/dist/run-hook.d.ts +27 -3
  112. package/dist/run-hook.js +48 -12
  113. package/dist/scan-core.d.ts +103 -29
  114. package/dist/scan-core.js +178 -186
  115. package/dist/scan-files.d.ts +0 -8
  116. package/dist/scan-files.js +79 -24
  117. package/dist/scan.d.ts +23 -28
  118. package/dist/scan.js +64 -18
  119. package/dist/score-core.js +3 -9
  120. package/dist/setup-plan.d.ts +13 -27
  121. package/dist/setup-plan.js +46 -71
  122. package/dist/test-coverage.d.ts +3 -8
  123. package/dist/test-coverage.js +6 -6
  124. package/dist/test.d.ts +1 -1
  125. package/dist/vigilesrc.schema.json +1347 -121
  126. package/package.json +2 -2
  127. package/packages/eslint-config/index.mjs +61 -0
  128. package/skills/adopt-spec/SKILL.md +2 -1
  129. package/dist/core/rule-meta.d.ts +0 -82
package/dist/cli-main.js CHANGED
@@ -32,10 +32,17 @@ 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");
36
+ const config_schema_js_1 = require("./core/config-schema.js");
37
+ const rule_js_1 = require("./core/rule.js");
38
+ const apply_rules_js_1 = require("./core/apply-rules.js");
39
+ const index_js_1 = require("./rules/index.js");
35
40
  const eval_lock_js_1 = require("./eval-lock.js");
36
41
  const eval_entry_js_1 = require("./eval-entry.js");
37
42
  const zod_1 = require("zod");
38
43
  const cli_flags_js_1 = require("./cli-flags.js");
44
+ const config_schema_js_2 = require("./core/config-schema.js");
45
+ const presets_js_1 = require("./core/presets.js");
39
46
  const cli_commands_js_1 = require("./cli-commands.js");
40
47
  const cli_flag_check_js_1 = require("./cli-flag-check.js");
41
48
  const setup_plan_js_1 = require("./setup-plan.js");
@@ -69,6 +76,7 @@ const instruction_sources_js_1 = require("./instruction-sources.js");
69
76
  const rule_catalog_js_1 = require("./core/rule-catalog.js");
70
77
  const adoptability_js_1 = require("./adoptability.js");
71
78
  const compile_js_1 = require("./core/compile.js");
79
+ const enforce_marker_js_1 = require("./core/enforce-marker.js");
72
80
  const proofs_js_1 = require("./core/proofs.js");
73
81
  const inline_js_1 = require("./core/inline.js");
74
82
  const frontmatter_js_1 = require("./core/frontmatter.js");
@@ -79,6 +87,7 @@ const action_gate_js_1 = require("./action-gate.js");
79
87
  const guards_js_1 = require("./core/guards.js");
80
88
  const hook_program_js_1 = require("./core/hook-program.js");
81
89
  const hook_install_js_1 = require("./hook-install.js");
90
+ const hook_command_js_1 = require("./core/hook-command.js");
82
91
  const hook_check_install_js_1 = require("./hook-check-install.js");
83
92
  const hook_providers_js_1 = require("./core/hook-providers.js");
84
93
  const toml_1 = require("@iarna/toml");
@@ -96,8 +105,25 @@ const skill_runtime_js_1 = require("./adapters/claude-code/skill-runtime.js");
96
105
  const linters_js_1 = require("./core/linters.js");
97
106
  const harness_test_js_1 = require("./harness-test.js");
98
107
  const run_scripts_js_1 = require("./adapters/claude-code/run-scripts.js");
108
+ /**
109
+ * THE RULE SET, WIRED IN — the composition root's one job for rules. Core
110
+ * declares the port and takes the set as a parameter; this file (and the
111
+ * browser demo's entry) is the only place `src/rules/` is imported. `CONFIG`
112
+ * is `.vigilesrc.json` built for this set, `loadConfig` the loader bound to it
113
+ * (so every verb reads the same schema), and `INIT_POLICY` what `init` writes,
114
+ * derived from the groups.
115
+ */
116
+ const CONFIG = (0, config_schema_js_1.configFor)(index_js_1.RULES, index_js_1.RULE_GROUPS);
117
+ const INIT_POLICY = (0, rule_js_1.initPolicyOf)(index_js_1.RULES, index_js_1.RULE_GROUPS);
118
+ /** Every rule at its shipped default — what a missing `.vigilesrc.json` loads as. */
119
+ const DEFAULT_CONFIG = (0, validate_js_1.configDefaults)(CONFIG);
120
+ /** `.vigilesrc.json` for this rule set; see `core/validate.ts` `loadConfig`. */
121
+ function loadConfig(searchFrom, opts) {
122
+ return (0, validate_js_1.loadConfig)(CONFIG, searchFrom, opts);
123
+ }
99
124
  const coverage_artifact_js_1 = require("./coverage-artifact.js");
100
125
  const integrity_js_1 = require("./core/integrity.js");
126
+ const instruction_remedies_js_1 = require("./core/instruction-remedies.js");
101
127
  const adopt_js_1 = require("./core/adopt.js");
102
128
  const coverage_js_1 = require("./core/coverage.js");
103
129
  const orphans_js_1 = require("./core/orphans.js");
@@ -343,6 +369,27 @@ function printErrors(specFile, errors) {
343
369
  console.log(`::error file=${specFile}::${err.message}`);
344
370
  }
345
371
  }
372
+ /**
373
+ * The rule count line, split by what compile VERIFIED (#351): "linter-verified"
374
+ * counts only references rendered "**Enforced by:**"; every other `enforce()` was
375
+ * rendered "**Claimed (unverified):**" and gets one line saying why and what
376
+ * turns it into "Enforced by". Never changes the exit code.
377
+ */
378
+ function printRuleSummary(specFile, ruleCount, verdicts) {
379
+ const unverified = verdicts.flatMap((v) => v.verdict.kind === "unverified"
380
+ ? [{ ref: v.ref, reason: v.verdict.reason }]
381
+ : []);
382
+ const verified = verdicts.length - unverified.length;
383
+ const claimed = unverified.length > 0
384
+ ? `, ${String(unverified.length)} claimed unverified`
385
+ : "";
386
+ console.log(` ${String(ruleCount)} rules (${String(verified)} linter-verified${claimed})`);
387
+ unverified.forEach((u) => {
388
+ const advice = (0, compile_js_1.unverifiedAdvice)(u.ref, u.reason);
389
+ console.log(` ⚠ Claimed (unverified) — ${advice}`);
390
+ console.log(`::warning file=${specFile}::${advice}`);
391
+ });
392
+ }
346
393
  /** Non-blocking advisories — printed, but never fail the compile. */
347
394
  function printWarnings(specFile, warnings) {
348
395
  for (const w of warnings) {
@@ -355,10 +402,10 @@ function printWarnings(specFile, warnings) {
355
402
  // Commands
356
403
  // ---------------------------------------------------------------------------
357
404
  /** Compile a generator-skill spec from source → SKILL.md. Returns validity. */
358
- function compileGeneratorSkillToFile(specPath, source) {
405
+ function compileGeneratorSkillToFile(root, specPath, source) {
359
406
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
360
407
  const { artifact, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
361
- basePath: process.cwd(),
408
+ basePath: root,
362
409
  specFile: specPath,
363
410
  });
364
411
  // Written only when the compile is clean: `artifact` is null otherwise, and
@@ -375,9 +422,9 @@ function compileGeneratorSkillToFile(specPath, source) {
375
422
  return false;
376
423
  }
377
424
  /** 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, {
425
+ async function compileClaudeToFile(root, spec, specPath, config, dialect) {
426
+ const basePath = root;
427
+ const { markdown, errors, warnings, enforceVerdicts, outputs } = await (0, compile_js_1.compileClaude)(spec, {
381
428
  basePath,
382
429
  specFile: specPath,
383
430
  dialect,
@@ -386,7 +433,7 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
386
433
  maxSectionLines: config.maxSectionLines,
387
434
  catalogOnly: config.catalogOnly,
388
435
  linters: config.linters,
389
- ...compileBudgetOptions(config),
436
+ ...compileBudgetOptions(config, specPath.replace(/\.spec\.ts$/, "")),
390
437
  });
391
438
  const primaryOutput = specPath.replace(/\.spec\.ts$/, "");
392
439
  // Budget findings print BEFORE the pass/fail line and never change the exit
@@ -418,20 +465,12 @@ async function compileClaudeToFile(spec, specPath, config, dialect) {
418
465
  console.log(` → ${primaryOutput} was NOT written; the previous version is left in place.`);
419
466
  return false;
420
467
  }
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)`);
468
+ // Each target as compile rendered it for its own path: a secondary in
469
+ // another directory has its own links, so it is never a re-headed copy.
470
+ for (const o of outputs)
471
+ (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(basePath, o.path), o.markdown);
472
+ console.log(`\n✓ ${specPath} → ${outputs.map((o) => o.path).join(", ")} (${formatArtifactSize(markdown)})`);
473
+ printRuleSummary(specPath, Object.keys(spec.rules).length, enforceVerdicts);
435
474
  return true;
436
475
  }
437
476
  /**
@@ -512,10 +551,10 @@ function formatArtifactSize(markdown) {
512
551
  return `${size} · ~${String(kTokens)}k tokens est.`;
513
552
  }
514
553
  /** Compile a declarative SkillSpec → SKILL.md. */
515
- async function compileSkillToFile(spec, specPath, dialect) {
554
+ async function compileSkillToFile(root, spec, specPath, dialect) {
516
555
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
517
556
  const { artifact, errors, warnings } = await (0, compile_js_1.compileSkill)(spec, {
518
- basePath: process.cwd(),
557
+ basePath: root,
519
558
  specFile: specPath,
520
559
  // The SKILL.md frontmatter profile comes from the resolved harness — a Codex
521
560
  // repo gets a minimal (name + description) SKILL.md; CC gets the full set.
@@ -536,12 +575,15 @@ async function compileSkillToFile(spec, specPath, dialect) {
536
575
  return false;
537
576
  }
538
577
  /** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
539
- async function compileAgentToFile(spec, specPath, dialect) {
578
+ async function compileAgentToFile(root, spec, specPath, config, dialect) {
540
579
  const outputPath = specPath.replace(/\.spec\.ts$/, "");
580
+ // The same verify options an instruction file gets (`compileClaudeToFile`).
541
581
  const { artifact, errors, warnings } = await (0, compile_js_1.compileAgent)(spec, {
542
- basePath: process.cwd(),
582
+ basePath: root,
543
583
  specFile: specPath,
544
584
  dialect,
585
+ catalogOnly: config.catalogOnly,
586
+ linters: config.linters,
545
587
  });
546
588
  // Written only when the compile is clean: `artifact` is null otherwise, and
547
589
  // `writeArtifact` takes nothing else.
@@ -591,6 +633,7 @@ async function collectAgentNames(excludes) {
591
633
  return names;
592
634
  }
593
635
  async function compile(specPaths, config, excludes, opts = {}) {
636
+ const root = excludes.root;
594
637
  let allValid = true;
595
638
  // Parse the declared harness set ONCE (alias-normalized) and feed both the
596
639
  // dialect pick and the mirror from it — no re-parsing, no cwd-sniffing in the
@@ -598,7 +641,7 @@ async function compile(specPaths, config, excludes, opts = {}) {
598
641
  // ambiguous-detection pick.
599
642
  const declaredHarnesses = (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses);
600
643
  const selection = (0, adapter_registry_js_1.resolveHarnessSelection)({
601
- root: process.cwd(),
644
+ root,
602
645
  flag: opts.harnessFlag,
603
646
  configHarness: declaredHarnesses,
604
647
  });
@@ -610,9 +653,9 @@ async function compile(specPaths, config, excludes, opts = {}) {
610
653
  let knownAgents = null;
611
654
  for (const specPath of specPaths) {
612
655
  // 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");
656
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, specPath), "utf-8");
614
657
  if (/\bgenSkill\s*\(/.test(source)) {
615
- if (!compileGeneratorSkillToFile(specPath, source))
658
+ if (!compileGeneratorSkillToFile(root, specPath, source))
616
659
  allValid = false;
617
660
  continue;
618
661
  }
@@ -633,7 +676,7 @@ async function compile(specPaths, config, excludes, opts = {}) {
633
676
  const specDialect = opts.harnessFlag === undefined
634
677
  ? ((0, adapter_registry_js_1.adapterForInstructionFile)(targetFile)?.dialect ?? dialect)
635
678
  : dialect;
636
- if (await compileClaudeToFile(spec, specPath, config, specDialect)) {
679
+ if (await compileClaudeToFile(root, spec, specPath, config, specDialect)) {
637
680
  writeInstructionMirrors(specPath.replace(/\.spec\.ts$/, ""), declaredHarnesses);
638
681
  }
639
682
  else {
@@ -649,11 +692,11 @@ async function compile(specPaths, config, excludes, opts = {}) {
649
692
  for (const w of (0, skill_harness_js_1.skillFrontmatterDropWarnings)(spec, forHarnesses)) {
650
693
  console.log(`⚠ ${w}`);
651
694
  }
652
- if (!(await compileSkillToFile(spec, specPath, dialect)))
695
+ if (!(await compileSkillToFile(root, spec, specPath, dialect)))
653
696
  allValid = false;
654
697
  }
655
698
  else if (spec._specType === "agent") {
656
- if (!(await compileAgentToFile(spec, specPath, dialect)))
699
+ if (!(await compileAgentToFile(root, spec, specPath, config, dialect)))
657
700
  allValid = false;
658
701
  }
659
702
  else if (spec._specType === "railway") {
@@ -690,14 +733,14 @@ function ghAnnotate(level, message, file, line) {
690
733
  const loc = locParts.length > 0 ? " " + locParts.join(",") : "";
691
734
  console.log(`::${level}${loc}::${message}`);
692
735
  }
693
- function verifyHashes(frame, filePaths, silent = false) {
736
+ function verifyHashes(root, frame, filePaths, silent = false) {
694
737
  let errorCount = 0;
695
738
  const log = (msg) => {
696
739
  if (!silent)
697
740
  console.log(msg);
698
741
  };
699
742
  for (const filePath of filePaths) {
700
- const fullPath = (0, node_path_1.resolve)(process.cwd(), filePath);
743
+ const fullPath = (0, node_path_1.resolve)(root, filePath);
701
744
  // If the file doesn't exist at all (typo, deleted), that's an error —
702
745
  // not a "no hash" informational message. Without this check, a scoped
703
746
  // lint like `vigiles lint typo.md` would silently exit clean.
@@ -727,14 +770,14 @@ function verifyHashes(frame, filePaths, silent = false) {
727
770
  }
728
771
  return { valid: errorCount === 0, errorCount };
729
772
  }
730
- function validateSpecs(filePaths, rulesConfig, silent = false) {
773
+ function validateSpecs(root, filePaths, rulesConfig, rules, silent = false) {
731
774
  let allValid = true;
732
775
  const log = (msg) => {
733
776
  if (!silent)
734
777
  console.log(msg);
735
778
  };
736
779
  for (const filePath of filePaths) {
737
- const fullPath = (0, node_path_1.resolve)(process.cwd(), filePath);
780
+ const fullPath = (0, node_path_1.resolve)(root, filePath);
738
781
  let content;
739
782
  try {
740
783
  content = (0, node_fs_1.readFileSync)(fullPath, "utf-8");
@@ -747,16 +790,24 @@ function validateSpecs(filePaths, rulesConfig, silent = false) {
747
790
  const hashMatch = content.match(/<!-- vigiles:sha256:[a-f0-9]+ compiled from (.+) -->/);
748
791
  if (hashMatch) {
749
792
  // Verify the referenced spec still exists
750
- const specRef = (0, node_path_1.resolve)(process.cwd(), hashMatch[1]);
793
+ const specRef = (0, node_path_1.resolve)(root, hashMatch[1]);
751
794
  if (!(0, node_fs_1.existsSync)(specRef)) {
752
795
  log(` ✗ [require-instructions-spec] ${filePath} references "${hashMatch[1]}" but that spec no longer exists.`);
753
796
  allValid = false;
754
797
  }
755
798
  continue;
756
799
  }
800
+ // The path as discovered (cwd-relative), not `fullPath`: the finding prints
801
+ // it, and its `exclude` snippet must be a root-relative glob.
757
802
  const result = (0, validate_js_1.validate)(content, {
758
- filePath: fullPath,
759
- rules: rulesConfig,
803
+ filePath,
804
+ // The two spec-presence rules resolved at THIS file (`overrides`), the
805
+ // rest as configured — validate reads only these two per file.
806
+ rules: {
807
+ ...rulesConfig,
808
+ "require-instructions-spec": rules.severityAt("require-instructions-spec", filePath),
809
+ "require-skill-spec": rules.severityAt("require-skill-spec", filePath),
810
+ },
760
811
  });
761
812
  for (const err of result.errors) {
762
813
  log(` ✗ [${err.rule}] ${err.message}`);
@@ -768,10 +819,13 @@ function validateSpecs(filePaths, rulesConfig, silent = false) {
768
819
  }
769
820
  return allValid;
770
821
  }
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);
822
+ function check(root, frame, filePaths, silent = false,
823
+ // The run's config when the caller has it — so a `--preset=` the run was
824
+ // given reaches the spec checks too, rather than a second read of the file.
825
+ config) {
826
+ const hashes = verifyHashes(root, frame, filePaths, silent);
827
+ const vConfig = config ?? loadConfig(root);
828
+ const specsValid = validateSpecs(root, filePaths, vConfig.rules, (0, overrides_js_1.ruleResolver)(vConfig.rules, vConfig.overrides), silent);
775
829
  return {
776
830
  valid: hashes.valid && specsValid,
777
831
  hashErrors: hashes.errorCount,
@@ -787,7 +841,8 @@ function check(frame, filePaths, silent = false) {
787
841
  * Catches spec bloat — rules that likely say the same thing in different words.
788
842
  * Uses information-theoretic distance (gzip-based) — no LLM, fully deterministic.
789
843
  */
790
- async function findDuplicateRules(excludes, threshold = 0.3, silent = false, scopeFiles) {
844
+ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, scopeFiles, severityAt = () => "warn") {
845
+ const root = excludes.root;
791
846
  const log = (msg) => {
792
847
  if (!silent)
793
848
  console.log(msg);
@@ -810,28 +865,33 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
810
865
  for (const f of scopeFiles) {
811
866
  let resolved;
812
867
  try {
813
- const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), f), "utf-8");
868
+ const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(root, f), "utf-8");
814
869
  const m = compiledFromRe.exec(content);
815
870
  if (m) {
816
- resolved = (0, node_path_1.resolve)(process.cwd(), m[1].trim());
871
+ resolved = (0, node_path_1.resolve)(root, m[1].trim());
817
872
  }
818
873
  }
819
874
  catch {
820
875
  // File unreadable — fall through to the naming convention
821
876
  }
822
877
  if (!resolved) {
823
- resolved = (0, node_path_1.resolve)(process.cwd(), `${f}.spec.ts`);
878
+ resolved = (0, node_path_1.resolve)(root, `${f}.spec.ts`);
824
879
  }
825
880
  wanted.add(resolved);
826
881
  }
827
- return allSpecs.filter((specPath) => wanted.has((0, node_path_1.resolve)(process.cwd(), specPath)));
882
+ return allSpecs.filter((specPath) => wanted.has((0, node_path_1.resolve)(root, specPath)));
828
883
  })()
829
884
  : allSpecs;
830
885
  if (specs.length === 0)
831
- return { valid: true, pairCount: 0 };
886
+ return { valid: true, pairCount: 0, errorPairs: 0 };
832
887
  let totalPairs = 0;
888
+ let errorPairs = 0;
833
889
  let specsWithDuplicates = 0;
834
890
  for (const specPath of specs) {
891
+ // Per spec: an `overrides` entry may turn `duplicate-rules` off for it.
892
+ const sev = severityAt(specPath);
893
+ if (sev === false)
894
+ continue;
835
895
  const spec = await loadSpec(specPath);
836
896
  if (!spec || spec._specType !== "claude")
837
897
  continue;
@@ -847,6 +907,8 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
847
907
  }
848
908
  specsWithDuplicates++;
849
909
  totalPairs += pairs.length;
910
+ if (sev === "error")
911
+ errorPairs += pairs.length;
850
912
  log(` ${specPath}`);
851
913
  for (const pair of pairs.slice(0, 5)) {
852
914
  log(` ${pair.idA} ↔ ${pair.idB} (distance: ${pair.distance.toFixed(3)})`);
@@ -857,10 +919,10 @@ async function findDuplicateRules(excludes, threshold = 0.3, silent = false, sco
857
919
  }
858
920
  if (totalPairs === 0) {
859
921
  log("No near-duplicate rules detected.");
860
- return { valid: true, pairCount: 0 };
922
+ return { valid: true, pairCount: 0, errorPairs: 0 };
861
923
  }
862
924
  log(`\n ${String(totalPairs)} duplicate pair(s) in ${String(specsWithDuplicates)} spec(s). Consider merging or rewording.`);
863
- return { valid: false, pairCount: totalPairs };
925
+ return { valid: false, pairCount: totalPairs, errorPairs };
864
926
  }
865
927
  /**
866
928
  * Verify the file-qualified symbol references (`path.ext#symbol`) in instruction
@@ -1005,12 +1067,12 @@ function lintExitCode(report) {
1005
1067
  report.skillFrontmatterErrors > 0 ||
1006
1068
  report.mcpToolErrors > 0 ||
1007
1069
  report.hookScriptErrors > 0 ||
1070
+ report.handWrittenHookErrors > 0 ||
1008
1071
  report.disallowedToolErrors > 0 ||
1009
1072
  report.descriptionOverlapErrors > 0 ||
1010
1073
  report.descriptionBudgetErrors > 0 ||
1011
1074
  report.frontmatterValidErrors > 0 ||
1012
1075
  report.mcpHookErrors > 0 ||
1013
- report.preferCompiledHookErrors > 0 ||
1014
1076
  report.lethalTrifectaErrors > 0 ||
1015
1077
  report.skillResourceErrors > 0 ||
1016
1078
  report.skillFenceErrors > 0 ||
@@ -1338,6 +1400,31 @@ function discoverNestedBundles(root, excludes) {
1338
1400
  }
1339
1401
  return out.sort();
1340
1402
  }
1403
+ /**
1404
+ * Each finding with the severity `rule` has at its path (`overrides`
1405
+ * applied), the ones an override turns off removed. `pathOf` returns
1406
+ * `undefined` for a finding about the repository as a whole, which keeps the
1407
+ * severity `rules` gives it.
1408
+ */
1409
+ function graded(rules, rule, found, pathOf) {
1410
+ return found.flatMap((f) => {
1411
+ const sev = rules.severityAt(rule, pathOf(f));
1412
+ return sev === false ? [] : [{ ...f, sev }];
1413
+ });
1414
+ }
1415
+ /** The `{ issues, errors }` a per-surface check returns, from its graded findings. */
1416
+ function tally(found) {
1417
+ return {
1418
+ issues: found.length,
1419
+ errors: found.filter((f) => f.sev === "error").length,
1420
+ };
1421
+ }
1422
+ /** The terminal mark and annotation level for a severity. */
1423
+ function markOf(sev) {
1424
+ return sev === "error"
1425
+ ? { mark: "✗", level: "error" }
1426
+ : { mark: "⚠", level: "warning" };
1427
+ }
1341
1428
  /**
1342
1429
  * Run one per-surface check over EVERY bundle and sum its counters.
1343
1430
  *
@@ -1397,13 +1484,13 @@ function overBundles(fn, run, bundles) {
1397
1484
  * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1398
1485
  * so a repo with no specs does no extra work at all.
1399
1486
  */
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)
1487
+ async function checkSpecRefs(excludes, config, rules, silent, dialect) {
1488
+ // Both rules are resolved per compiled target below (`overrides`). The scan
1489
+ // is skipped only when neither can fire anywhere. `compile-budget` is
1490
+ // reported here only at "error": at its default it is a compile-time
1491
+ // warning, and `lint` printed nothing about size before the rule had a name
1492
+ // — the default output does not change.
1493
+ if (!rules.mayFire("spec-refs") && !rules.mayFire("compile-budget"))
1407
1494
  return { issues: 0, errors: 0, budgetErrors: 0 };
1408
1495
  const derived = [];
1409
1496
  let knownAgents;
@@ -1422,7 +1509,12 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1422
1509
  derived.push({
1423
1510
  target,
1424
1511
  specPath,
1425
- findings: await specCompileErrors(spec, specPath, dialect, config, knownAgents ?? []),
1512
+ findings: await specCompileErrors(spec, specPath, {
1513
+ root: excludes.root,
1514
+ dialect,
1515
+ config,
1516
+ knownAgents: knownAgents ?? [],
1517
+ }),
1426
1518
  });
1427
1519
  }
1428
1520
  catch {
@@ -1431,50 +1523,32 @@ async function checkSpecRefs(excludes, config, silent, dialect) {
1431
1523
  continue;
1432
1524
  }
1433
1525
  }
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);
1526
+ const messages = (pick) => derived.flatMap((d) => pick(d.findings).map((e) => ({
1527
+ target: d.target,
1528
+ msg: `${d.target}: ${e.message} (from ${d.specPath})`,
1529
+ })));
1530
+ const found = graded(rules, "spec-refs", messages((f) => f.errors), (m) => m.target);
1531
+ const overBudget = graded(rules, "compile-budget", messages((f) => f.budget), (m) => m.target).filter((m) => m.sev === "error");
1437
1532
  if (found.length > 0 && !silent) {
1438
1533
  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);
1534
+ for (const { msg, sev } of found) {
1535
+ console.log(` ${markOf(sev).mark} ${msg}`);
1536
+ ghAnnotate(markOf(sev).level, msg);
1442
1537
  }
1443
1538
  }
1444
1539
  if (overBudget.length > 0 && !silent) {
1445
1540
  console.log("\nCompile budget check (compile-budget):\n");
1446
- overBudget.forEach((msg) => {
1541
+ overBudget.forEach(({ msg }) => {
1447
1542
  console.log(` ✗ ${msg}`);
1448
1543
  ghAnnotate("error", `compile-budget: ${msg}`);
1449
1544
  });
1450
1545
  }
1451
- return {
1452
- issues: found.length,
1453
- errors: sev === "error" ? found.length : 0,
1454
- budgetErrors: overBudget.length,
1455
- };
1546
+ return { ...tally(found), budgetErrors: overBudget.length };
1456
1547
  }
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();
1548
+ async function specCompileErrors(spec, specPath, { root, dialect, config, knownAgents }) {
1549
+ const basePath = root;
1550
+ // The compiled file this spec writes — `overrides` resolve at its path.
1551
+ const target = specPath.replace(/\.spec\.ts$/, "");
1478
1552
  switch (spec._specType) {
1479
1553
  case "claude": {
1480
1554
  const r = await (0, compile_js_1.compileClaude)(spec, {
@@ -1486,7 +1560,7 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1486
1560
  maxSectionLines: config?.maxSectionLines,
1487
1561
  catalogOnly: config?.catalogOnly,
1488
1562
  linters: config?.linters,
1489
- ...compileBudgetOptions(config),
1563
+ ...compileBudgetOptions(config, target),
1490
1564
  });
1491
1565
  // A size finding is `compile-budget`'s, not a dead reference: split by
1492
1566
  // IDENTITY against the compiler's own list, never by the type's name.
@@ -1502,7 +1576,13 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1502
1576
  };
1503
1577
  case "agent":
1504
1578
  return {
1505
- errors: (await (0, compile_js_1.compileAgent)(spec, { basePath, specFile: specPath, dialect })).errors,
1579
+ errors: (await (0, compile_js_1.compileAgent)(spec, {
1580
+ basePath,
1581
+ specFile: specPath,
1582
+ dialect,
1583
+ catalogOnly: config?.catalogOnly,
1584
+ linters: config?.linters,
1585
+ })).errors,
1506
1586
  budget: [],
1507
1587
  };
1508
1588
  case "railway":
@@ -1518,17 +1598,18 @@ async function specCompileErrors(spec, specPath, dialect, config, knownAgents) {
1518
1598
  }
1519
1599
  }
1520
1600
  /**
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.
1601
+ * The `compile-budget` rule, as `compileClaude` options: its severity at the
1602
+ * compiled `target` (`overrides` applied) and any thresholds `rules` set. The
1603
+ * ONE translation, so `compile` (which writes) and `lint` (which re-derives)
1604
+ * can never measure against different numbers.
1524
1605
  */
1525
- function compileBudgetOptions(config) {
1606
+ function compileBudgetOptions(config, target) {
1526
1607
  const rule = config?.rules?.["compile-budget"];
1527
1608
  // No config at all (a programmatic caller, a missing file) is the default.
1528
1609
  if (rule === undefined)
1529
1610
  return { budgetSeverity: "warn" };
1530
1611
  return {
1531
- budgetSeverity: (0, types_js_1.ruleSeverity)(rule),
1612
+ budgetSeverity: (0, overrides_js_1.ruleResolver)(config?.rules ?? DEFAULT_CONFIG.rules, config?.overrides).severityAt("compile-budget", target),
1532
1613
  ...(0, types_js_1.ruleOptions)(rule),
1533
1614
  };
1534
1615
  }
@@ -1545,6 +1626,8 @@ function writeArtifact(outputPath, artifact) {
1545
1626
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
1546
1627
  }
1547
1628
  async function runLint(restArgs, flags, excludes, config) {
1629
+ // The project root the CLI resolved once; the excludes are rooted at it.
1630
+ const root = excludes.root;
1548
1631
  const summary = flags.includes("--summary");
1549
1632
  const json = flags.includes("--json");
1550
1633
  const silent = summary || json;
@@ -1561,7 +1644,7 @@ async function runLint(restArgs, flags, excludes, config) {
1561
1644
  (0, node_fs_1.existsSync)(positional[0]) &&
1562
1645
  (0, node_fs_1.lstatSync)(positional[0]).isDirectory()
1563
1646
  ? (0, node_path_1.resolve)(positional[0])
1564
- : process.cwd();
1647
+ : root;
1565
1648
  // Resolve the active harness ONCE so the harness-specific checks below run
1566
1649
  // against the right adapter's dialect (tool/event catalogs) and surfaces —
1567
1650
  // not a hard-coded Claude Code default. A subagent-surface rule reports n/a
@@ -1595,8 +1678,9 @@ async function runLint(restArgs, flags, excludes, config) {
1595
1678
  // ONE frame for the whole run (#281): every path a check prints or annotates is
1596
1679
  // relative to it, and config globs resolve from it — whichever bundle is being
1597
1680
  // 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 };
1681
+ const frame = (0, frame_js_1.frameFor)(root, scanRoot);
1682
+ const rules = (0, overrides_js_1.ruleResolver)(config?.rules ?? DEFAULT_CONFIG.rules, config?.overrides);
1683
+ const run = { config, rules, silent, adapter, excludes, frame };
1600
1684
  if (!silent && nestedBundles.length > 0) {
1601
1685
  const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1602
1686
  console.log(scoreAll
@@ -1622,7 +1706,7 @@ async function runLint(restArgs, flags, excludes, config) {
1622
1706
  }
1623
1707
  }
1624
1708
  const hashResult = files.length > 0
1625
- ? check(frame, files, silent)
1709
+ ? check(root, frame, files, silent, config)
1626
1710
  : { valid: true, hashErrors: 0, validationErrors: 0 };
1627
1711
  // 1b. Verify inline + frontmatter rules in instruction files not managed
1628
1712
  // by a spec. See verifyMarkdownModeRules / docs/markdown-mode.md.
@@ -1637,16 +1721,15 @@ async function runLint(restArgs, flags, excludes, config) {
1637
1721
  // fail on unrelated duplicates elsewhere in the repo.
1638
1722
  if (!silent)
1639
1723
  console.log("\nDuplicate rule detection:\n");
1640
- const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined);
1724
+ const dups = await findDuplicateRules(excludes, 0.3, silent, restArgs.length > 0 ? files : undefined, (specPath) => rules.severityAt("duplicate-rules", specPath));
1641
1725
  // 4. Guidance rule count (strengthen suggestions moved to /strengthen skill)
1642
1726
  const guidanceCount = await countGuidanceRules(excludes, silent);
1643
1727
  // 5. Integrity check (hand-edit detection via SHA-256 hash)
1644
- const integritySeverity = config?.rules.integrity ?? "warn";
1645
1728
  let integrityErrors = 0;
1646
- if (integritySeverity) {
1729
+ if (rules.mayFire("integrity")) {
1647
1730
  if (!silent)
1648
1731
  console.log("\nIntegrity check:\n");
1649
- integrityErrors = checkIntegrityForFiles(files, integritySeverity, silent);
1732
+ integrityErrors = checkIntegrityForFiles(root, files, rules, silent);
1650
1733
  }
1651
1734
  // 6. Coverage thresholds (gates CI when severity is "error")
1652
1735
  const coverageErrors = await checkCoverageThresholds(excludes, coverage, config, silent);
@@ -1659,13 +1742,12 @@ async function runLint(restArgs, flags, excludes, config) {
1659
1742
  // opted into explicitly). `enforce("vigiles/orphan-docs")` in a spec only
1660
1743
  // validates the rule NAME — the block is what drives the scan.
1661
1744
  // 🔴 SEVERITY IS READ, not just the block's presence (#181). `orphan-docs` had
1662
- // a RULE_META entry and a documented severity, and nothing ever read it: both
1745
+ // a declared meta and a documented severity, and nothing ever read it: both
1663
1746
  // `"warn"` and `"off"` still exited 1, so a repo could only choose between an
1664
1747
  // always-blocking check and deleting the `orphans` block. `warn` now reports
1665
1748
  // without touching the exit code and `false`/`"off"` skips the scan, exactly
1666
1749
  // like every other rule.
1667
- const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
1668
- const orphansCfg = orphanSeverity ? config?.orphans : undefined;
1750
+ const orphansCfg = rules.mayFire("orphan-docs") ? config?.orphans : undefined;
1669
1751
  if (!silent)
1670
1752
  console.log("\nOrphan docs check:\n");
1671
1753
  let orphanReport = {
@@ -1675,12 +1757,10 @@ async function runLint(restArgs, flags, excludes, config) {
1675
1757
  orphans: [],
1676
1758
  };
1677
1759
  if (orphansCfg) {
1678
- orphanReport = (0, orphans_js_1.findOrphanDocs)({
1679
- basePath: process.cwd(),
1760
+ const found = (0, orphans_js_1.findOrphanDocs)({
1761
+ basePath: root,
1680
1762
  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
1763
+ // The repo-wide `exclude`: an excluded corpus is neither an orphan
1684
1764
  // candidate nor a source of references that keep a doc alive (#192).
1685
1765
  repoExclude: excludes.globIgnore,
1686
1766
  // Exempt every registered harness's surface files (instruction file,
@@ -1688,6 +1768,10 @@ async function runLint(restArgs, flags, excludes, config) {
1688
1768
  // core carries no harness literal (see src/core/orphans.ts).
1689
1769
  layouts: adapter_registry_js_1.ADAPTERS.map((a) => a.layout),
1690
1770
  });
1771
+ // Per doc: an `overrides` entry with `orphan-docs` off drops it from the
1772
+ // report (it still counts as a doc that references others).
1773
+ const orphanGraded = graded(rules, "orphan-docs", found.orphans.map((doc) => ({ doc })), (o) => o.doc);
1774
+ orphanReport = { ...found, orphans: orphanGraded.map((o) => o.doc) };
1691
1775
  if (!silent) {
1692
1776
  for (const line of (0, orphans_js_1.formatOrphanReport)(orphanReport).split("\n")) {
1693
1777
  console.log(` ${line}`);
@@ -1702,7 +1786,7 @@ async function runLint(restArgs, flags, excludes, config) {
1702
1786
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1703
1787
  // A compiled artifact's refs, re-derived from its spec — the hash says the file
1704
1788
  // is unchanged, not that what it names still exists (#173).
1705
- const specRefs = await checkSpecRefs(excludes, config, silent, adapter.dialect);
1789
+ const specRefs = await checkSpecRefs(excludes, config, rules, silent, adapter.dialect);
1706
1790
  const untested = overBundles(checkUntestedSurfaces, run, lintRoots);
1707
1791
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1708
1792
  // rail against the harness catalog (the moat). n/a on a harness with no
@@ -1728,6 +1812,8 @@ async function runLint(restArgs, flags, excludes, config) {
1728
1812
  // validate`"; measured false on 2026-09-08 (Claude Code 2.1.263) — see
1729
1813
  // docs/rules/hook-script-exists.md and tools/measure-validate-overlap.mjs.
1730
1814
  const hookScripts = overBundles(checkHookScriptExists, run, lintRoots);
1815
+ // 7i². Hand-written hooks — every hook line vigiles did not write, by name.
1816
+ const handWrittenHooks = overBundles(checkHandWrittenHooks, run, lintRoots);
1731
1817
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1732
1818
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1733
1819
  const disallowedTools = overBundles(checkDisallowedTools, run, lintRoots);
@@ -1744,16 +1830,18 @@ async function runLint(restArgs, flags, excludes, config) {
1744
1830
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1745
1831
  // targets an undeclared server (the moat applied to the hook surface).
1746
1832
  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
1833
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1751
1834
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1752
1835
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1753
1836
  const lethalTrifecta = overBundles(checkLethalTrifecta, run, lintRoots);
1754
- // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1755
- // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1756
- const skillResources = overBundles(checkSkillResourceResolves, run, lintRoots);
1837
+ // 7p. The rules that are modules (src/rules/<id>.ts), each through the one
1838
+ // generic check. The LintReport keeps its per-rule wire keys until #390:
1839
+ // today the one module rule (the bundled-resource check) fills the
1840
+ // `skillResource*` pair below.
1841
+ const ruleModules = RULE_MODULES.map((rule) => overBundles((ctx) => checkRuleModule(ctx, rule), run, lintRoots)).reduce((acc, t) => ({
1842
+ issues: acc.issues + t.issues,
1843
+ errors: acc.errors + t.errors,
1844
+ }), { issues: 0, errors: 0 });
1757
1845
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1758
1846
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1759
1847
  const skillFence = overBundles(checkSkillMissingFence, run, lintRoots);
@@ -1792,11 +1880,12 @@ async function runLint(restArgs, flags, excludes, config) {
1792
1880
  // over the repository, like the ratchet: a nested bundle's spec would
1793
1881
  // otherwise be reported again by every bundle that encloses it.
1794
1882
  const deprecatedKeyFiles = (0, deprecated_key_files_js_1.checkDeprecatedKeyFiles)({
1795
- severity: (0, types_js_1.ruleSeverity)(config?.rules?.["deprecated-key-files"]),
1796
- find: () => specsUsingKeyFiles(process.cwd(), excludes.globIgnore),
1883
+ severity: rules.mayFire("deprecated-key-files") ? "warn" : false,
1884
+ severityAt: (path) => rules.severityAt("deprecated-key-files", path),
1885
+ find: () => specsUsingKeyFiles(root, excludes.globIgnore),
1797
1886
  silent,
1798
1887
  annotate: (level, message, path) => {
1799
- ghAnnotate(level, message, frame.repo((0, node_path_1.resolve)(process.cwd(), path)));
1888
+ ghAnnotate(level, message, frame.repo((0, node_path_1.resolve)(root, path)));
1800
1889
  },
1801
1890
  });
1802
1891
  // 8. Validate vigiles builder calls inside markdown code blocks — the
@@ -1819,7 +1908,7 @@ async function runLint(restArgs, flags, excludes, config) {
1819
1908
  // globs without `dot`, so `.claude/**` — where real skills and agents live —
1820
1909
  // has never been scanned. The rule has never once looked at a deployed
1821
1910
  // instruction file; every ref it has ever judged was in prose.
1822
- const docRefSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["doc-refs"]);
1911
+ const docRefSeverity = rules.mayFire("doc-refs");
1823
1912
  if (!silent && docRefSeverity)
1824
1913
  console.log("\nMarkdown code block refs:\n");
1825
1914
  // 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
@@ -1835,8 +1924,8 @@ async function runLint(restArgs, flags, excludes, config) {
1835
1924
  // With no way to reach 0 the step was made `continue-on-error: true`, and a lint
1836
1925
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1837
1926
  // grew to do the gating instead. One unpassed argument, that whole chain.
1838
- const docRefReport = docRefSeverity
1839
- ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: excludes.globIgnore })
1927
+ const docRefFound = docRefSeverity
1928
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: root, ignore: excludes.globIgnore })
1840
1929
  : {
1841
1930
  filesScanned: 0,
1842
1931
  filesIgnored: 0,
@@ -1846,10 +1935,24 @@ async function runLint(restArgs, flags, excludes, config) {
1846
1935
  unverified: 0,
1847
1936
  placeholders: 0,
1848
1937
  };
1938
+ // Per file: an `overrides` entry may turn `doc-refs` off, or set its
1939
+ // severity, for a folder. Printed once per severity present, so a `✗` never
1940
+ // stands next to a finding that does not gate.
1941
+ const docRefErrors = graded(rules, "doc-refs", docRefFound.errors, (e) => e.file);
1942
+ const docRefReport = { ...docRefFound, errors: docRefErrors };
1849
1943
  if (!silent && docRefSeverity) {
1850
- const rendered = (0, doc_refs_js_1.formatDocRefReport)(docRefReport, docRefSeverity === "error" ? "error" : "warn");
1851
- for (const line of rendered.split("\n")) {
1852
- console.log(` ${line}`);
1944
+ const groups = ["error", "warn"]
1945
+ .map((sev) => ({
1946
+ sev,
1947
+ errors: docRefErrors.filter((e) => e.sev === sev),
1948
+ }))
1949
+ .filter((g, i, all) => g.errors.length > 0 ||
1950
+ (i === 0 && all.every((x) => x.errors.length === 0)));
1951
+ for (const g of groups) {
1952
+ const rendered = (0, doc_refs_js_1.formatDocRefReport)({ ...docRefFound, errors: g.errors }, g.sev);
1953
+ for (const line of rendered.split("\n")) {
1954
+ console.log(` ${line}`);
1955
+ }
1853
1956
  }
1854
1957
  }
1855
1958
  // Per-line GitHub annotations for each broken doc ref — each carries file+line,
@@ -1859,7 +1962,7 @@ async function runLint(restArgs, flags, excludes, config) {
1859
1962
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1860
1963
  if (isGitHubActions() && !silent && docRefSeverity) {
1861
1964
  for (const e of docRefReport.errors) {
1862
- ghAnnotate(docRefSeverity === "error" ? "error" : "warning", `${e.kind}("${e.value}") — ${e.message}`, frame.repo((0, node_path_1.resolve)(process.cwd(), e.file)), e.line);
1965
+ ghAnnotate(markOf(e.sev).level, `${e.kind}("${e.value}") — ${e.message}`, frame.repo((0, node_path_1.resolve)(root, e.file)), e.line);
1863
1966
  }
1864
1967
  }
1865
1968
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
@@ -1878,14 +1981,16 @@ async function runLint(restArgs, flags, excludes, config) {
1878
1981
  specRefErrors: specRefs.errors,
1879
1982
  compileBudgetErrors: specRefs.budgetErrors,
1880
1983
  duplicatePairs: dups.pairCount,
1881
- duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1984
+ duplicateSeverity: dups.errorPairs > 0 ? "error" : "warn",
1882
1985
  coverageEnabled: coverage.enabled,
1883
1986
  coverageDocumented: coverage.documented,
1884
1987
  strengthenSuggestions: guidanceCount,
1885
1988
  integrityErrors,
1886
1989
  coverageErrors,
1887
1990
  orphanCount: orphanReport.orphans.length,
1888
- orphanSeverity: orphanSeverity,
1991
+ orphanSeverity: orphanReport.orphans.some((doc) => rules.severityAt("orphan-docs", doc) === "error")
1992
+ ? "error"
1993
+ : "warn",
1889
1994
  untestedSurfaces: untested.untested,
1890
1995
  untestedErrors: untested.errors,
1891
1996
  toolContractIssues: toolContract.issues,
@@ -1902,6 +2007,8 @@ async function runLint(restArgs, flags, excludes, config) {
1902
2007
  mcpToolErrors: mcpToolResolves.errors,
1903
2008
  hookScriptIssues: hookScripts.issues,
1904
2009
  hookScriptErrors: hookScripts.errors,
2010
+ handWrittenHookIssues: handWrittenHooks.issues,
2011
+ handWrittenHookErrors: handWrittenHooks.errors,
1905
2012
  disallowedToolIssues: disallowedTools.issues,
1906
2013
  disallowedToolErrors: disallowedTools.errors,
1907
2014
  descriptionOverlapIssues: descriptionOverlap.issues,
@@ -1912,12 +2019,10 @@ async function runLint(restArgs, flags, excludes, config) {
1912
2019
  frontmatterValidErrors: frontmatterValid.errors,
1913
2020
  mcpHookIssues: mcpHookTargets.issues,
1914
2021
  mcpHookErrors: mcpHookTargets.errors,
1915
- preferCompiledHookIssues: preferCompiledHooks.issues,
1916
- preferCompiledHookErrors: preferCompiledHooks.errors,
1917
2022
  lethalTrifectaIssues: lethalTrifecta.issues,
1918
2023
  lethalTrifectaErrors: lethalTrifecta.errors,
1919
- skillResourceIssues: skillResources.issues,
1920
- skillResourceErrors: skillResources.errors,
2024
+ skillResourceIssues: ruleModules.issues,
2025
+ skillResourceErrors: ruleModules.errors,
1921
2026
  skillFenceIssues: skillFence.issues,
1922
2027
  skillFenceErrors: skillFence.errors,
1923
2028
  pluginLayoutIssues: pluginLayout.issues,
@@ -1935,7 +2040,7 @@ async function runLint(restArgs, flags, excludes, config) {
1935
2040
  // Only the "error" tier gates. At "warn" the findings are printed and
1936
2041
  // annotated, and the exit code is untouched — same contract as every other
1937
2042
  // opt-in rule here.
1938
- docRefErrors: docRefSeverity === "error" ? docRefReport.errors.length : 0,
2043
+ docRefErrors: docRefErrors.filter((e) => e.sev === "error").length,
1939
2044
  symbolRefErrors,
1940
2045
  mcpRefErrors,
1941
2046
  files,
@@ -2021,10 +2126,9 @@ function collectDocumentedRules(excludes) {
2021
2126
  });
2022
2127
  for (const mdFile of mdFiles) {
2023
2128
  const content = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), mdFile), "utf-8");
2024
- const enforcedRe = /\*\*Enforced by:\*\*\s*`([^`]+)`/g;
2025
- let m;
2026
- while ((m = enforcedRe.exec(content)) !== null) {
2027
- documented.add(m[1]);
2129
+ // Both compiled spellings (#351): an unverified claim is still documented.
2130
+ for (const m of content.matchAll(new RegExp(enforce_marker_js_1.RULE_MARKER_SOURCE, "g"))) {
2131
+ documented.add(m[2]);
2028
2132
  }
2029
2133
  }
2030
2134
  return documented;
@@ -2079,10 +2183,12 @@ function discover(excludes, silent = false) {
2079
2183
  }
2080
2184
  const totalPct = totalEnabled > 0 ? Math.round((totalDocumented / totalEnabled) * 100) : 0;
2081
2185
  log(`Coverage: ${String(totalDocumented)}/${String(totalEnabled)} rules documented (${String(totalPct)}%)`);
2082
- if (totalDocumented < totalEnabled) {
2083
- log(`\nConsider adding enforce() rules for frequently-triggered undocumented rules.`);
2084
- log(`The agent encounters these rules but has no context about WHY.`);
2085
- }
2186
+ const advice = (0, coverage_js_1.undocumentedRulesAdvice)({
2187
+ enabled: totalEnabled,
2188
+ documented: totalDocumented,
2189
+ });
2190
+ if (advice.length > 0)
2191
+ log(["", ...advice].join("\n"));
2086
2192
  return { enabled: totalEnabled, documented: totalDocumented };
2087
2193
  }
2088
2194
  /** This package's own version (from the installed package.json). */
@@ -2178,21 +2284,40 @@ function discoverAdoptableSurfaces(cwd) {
2178
2284
  }
2179
2285
  return out;
2180
2286
  }
2181
- /** The full adoptable-surface list `audit` reports: the instruction file (when it
2182
- * exists hand-written, no spec) PLUS every skill/subagent surface without a spec.
2183
- * Same notion `init` adopts; surfaced in the AuditReport + the terminal nudge so
2184
- * the report's "Create spec" / "Create all specs" affordances have their paths.
2185
- * Composition-root only — CC paths are intentional here (like discoverAdoptableSurfaces). */
2186
- function discoverAdoptableForAudit(root, instructionFile) {
2187
- const out = [];
2188
- const instrAbs = (0, node_path_1.resolve)(root, instructionFile);
2189
- if ((0, node_fs_1.existsSync)(instrAbs) &&
2190
- !targetHasHash(instrAbs) &&
2191
- !(0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, `${instructionFile}.spec.ts`))) {
2192
- out.push(instructionFile);
2193
- }
2194
- out.push(...discoverAdoptableSurfaces(root));
2195
- return out;
2287
+ /** Every hand-written instruction file under `root` — root AND nested
2288
+ * `CLAUDE.md` / `AGENTS.md`, the same files `gatherInstructionFiles` reads —
2289
+ * by the one predicate `lint`'s `require-instructions-spec` asks
2290
+ * (`instructionSpecState`). Measured on a repo with six hand-written files
2291
+ * (root + five nested): `audit` counted 1 (root only) while `lint` warned about
2292
+ * all six. A spec-managed or opted-out file is not listed. */
2293
+ function handWrittenInstructionFiles(root, instructionFile, excludes, rules) {
2294
+ return (gatherInstructionFiles(root, instructionFile, excludes)
2295
+ .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")
2296
+ // A folder an `overrides` entry turned the rule off for keeps its files
2297
+ // hand-written on purpose: that decline is remembered, not re-offered.
2298
+ .filter((f) => rules.severityAt("require-instructions-spec", f.path) !== false)
2299
+ .map((f) => f.path));
2300
+ }
2301
+ /** The hand-written instruction files BELOW the root — the ones `init`'s
2302
+ * root-level `determineTargets` does not see. Bare `init` adopts them too, so a
2303
+ * repo it sets up passes `require-instructions-spec` (in `vigiles:recommended`)
2304
+ * on its first `lint`. Read with the repo's own `exclude`. */
2305
+ function nestedHandWrittenInstructionFiles(cwd) {
2306
+ const config = loadConfig();
2307
+ 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("/"));
2308
+ }
2309
+ /** The full adoptable-surface list `audit` reports: every hand-written
2310
+ * instruction file (nested included) PLUS every skill/subagent surface without
2311
+ * a spec. Same notion `init` adopts; surfaced in the AuditReport + the terminal
2312
+ * nudge so the report's "Create spec" / "Create all specs" affordances have
2313
+ * their paths. Composition-root only — CC paths are intentional here (like
2314
+ * discoverAdoptableSurfaces). */
2315
+ function discoverAdoptableForAudit(root, { instructionFile, excludes, config, }) {
2316
+ const rules = (0, overrides_js_1.ruleResolver)(config.rules, config.overrides);
2317
+ return [
2318
+ ...handWrittenInstructionFiles(root, instructionFile, excludes, rules),
2319
+ ...discoverAdoptableSurfaces(root),
2320
+ ].filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)));
2196
2321
  }
2197
2322
  /** Lint-config file BASENAMES whose CONTENTS (textual, NEVER executed) reveal
2198
2323
  * which rules a repo has configured — read best-effort for the rule-inventory
@@ -2401,7 +2526,7 @@ function computeRuleRouting(root, instructionFile, excludes) {
2401
2526
  // routing. Enumerating EXECUTES the linter (plugin code loads), hence own-repo
2402
2527
  // + consent — same posture for both.
2403
2528
  const ownRepo = (0, node_path_1.resolve)(root) === (0, node_path_1.resolve)(process.cwd());
2404
- const consented = (0, validate_js_1.loadConfig)().audit?.measure === true;
2529
+ const consented = loadConfig().audit?.measure === true;
2405
2530
  const availableRules = ownRepo && consented
2406
2531
  ? (0, rule_catalog_js_1.mergeCatalogs)((0, rule_catalog_js_1.enumerateEslintCatalog)(root),
2407
2532
  // Only spawn pylint when the repo actually has a Python surface —
@@ -2425,32 +2550,47 @@ function computeRuleRouting(root, instructionFile, excludes) {
2425
2550
  return undefined;
2426
2551
  }
2427
2552
  }
2428
- /** The terminal "adoptable surfaces" nudge — N un-spec'd surfaces + the create-all
2429
- * command and up to ~5 per-surface commands (then "+K more"). "" when nothing to
2553
+ /** The terminal "adoptable surfaces" nudge. Instruction files are listed in
2554
+ * full (up to {@link NUDGE_LIST_CAP}) with the shared adoption remedy — they
2555
+ * have two adopters, `init --target` and the `adopt-spec` skill; skills and
2556
+ * subagents keep the per-surface `init --target` command. "" when nothing to
2430
2557
  * adopt (a fully spec-managed repo says nothing). */
2431
2558
  function formatAdoptableNudge(surfaces) {
2432
2559
  if (surfaces.length === 0)
2433
2560
  return "";
2561
+ const instructions = surfaces.filter((s) => surfaceKind(s) === "instruction");
2562
+ const others = surfaces.filter((s) => surfaceKind(s) !== "instruction");
2434
2563
  const n = surfaces.length;
2564
+ const capped = (items, line) => {
2565
+ const shown = items.slice(0, NUDGE_LIST_CAP).map(line);
2566
+ const more = items.length - shown.length;
2567
+ return more > 0
2568
+ ? [...shown, ` • +${String(more)} more (all in --json)`]
2569
+ : shown;
2570
+ };
2435
2571
  // Lead with the VALUE, not the chore: a spec is the pull up from the gate to
2436
2572
  // the richer layer, so say what it BUYS (drift-proof references + testable),
2437
2573
  // not just "create one". The invitation is where richer-feature adoption is
2438
2574
  // won — keep it compelling, never a bare to-do (research/adoption-design.md §1).
2439
2575
  const lines = [
2440
2576
  `ℹ ${String(n)} surface${n === 1 ? "" : "s"} could be spec-managed — a spec re-verifies its references on every change (a hand-edit can't silently break them) and makes it testable.`,
2441
- ` Adopt now, or one at a time with \`npx vigiles init --target=<path>\`:`,
2442
2577
  ];
2443
- const shown = surfaces.slice(0, 5);
2444
- for (const s of shown)
2445
- lines.push(` • npx vigiles init --target=${s}`);
2446
- const more = n - shown.length;
2447
- if (more > 0)
2448
- lines.push(` • +${String(more)} more`);
2578
+ if (instructions.length > 0) {
2579
+ const k = instructions.length;
2580
+ lines.push(` ${String(k)} hand-written instruction file${k === 1 ? "" : "s"}:`, ...capped(instructions, (s) => ` • ${s}`), ...(0, instruction_remedies_js_1.adoptInstructionFileRemedy)("<path>")
2581
+ .split("\n")
2582
+ .map((l) => ` ${l}`));
2583
+ }
2584
+ if (others.length > 0) {
2585
+ lines.push(` Skills and subagents, one at a time with \`npx vigiles init --target=<path>\`:`, ...capped(others, (s) => ` • npx vigiles init --target=${s}`));
2586
+ }
2449
2587
  // The remembered-decline affordance (non-evil contract): one keystroke to
2450
2588
  // silence, stated where it's shown so it's never a surprise nag.
2451
2589
  lines.push(` (set "nudge": "dismissed" in .vigilesrc.json to hide this)`);
2452
2590
  return lines.join("\n");
2453
2591
  }
2592
+ /** How many paths one group of the adoption nudge prints before "+N more". */
2593
+ const NUDGE_LIST_CAP = 10;
2454
2594
  /** A small, terse behavioral nudge — the deterministic read can't tell whether a
2455
2595
  * skill actually FIRES. "" when there are no model-invocable skills. */
2456
2596
  function formatTriggerNudge(triggerableSkills) {
@@ -3168,7 +3308,11 @@ async function setupPillar1(detected, targetValue, harnesses) {
3168
3308
  // file. An explicit --target stays scoped to that one surface.
3169
3309
  const targets = targetValue
3170
3310
  ? instructionTargets
3171
- : [...instructionTargets, ...discoverAdoptableSurfaces(cwd)];
3311
+ : [
3312
+ ...instructionTargets,
3313
+ ...nestedHandWrittenInstructionFiles(cwd).filter((p) => !instructionTargets.includes(p)),
3314
+ ...discoverAdoptableSurfaces(cwd),
3315
+ ];
3172
3316
  // Create specs. An existing hand-written target is faithfully ADOPTED into a
3173
3317
  // spec (scaffoldSpec() does the convert), not clobbered with a blank one — so the
3174
3318
  // compile below reproduces it (the user reviews the diff). A greenfield target
@@ -3199,6 +3343,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3199
3343
  for (const l of typesResult.linters) {
3200
3344
  console.log(` ${l.linter}: ${String(l.rules.length)} rules`);
3201
3345
  }
3346
+ printUnreadableLinters(typesResult.unreadable);
3202
3347
  if (typesResult.scripts.length > 0) {
3203
3348
  console.log(` npm scripts: ${String(typesResult.scripts.length)}`);
3204
3349
  }
@@ -3206,7 +3351,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3206
3351
  written.push(GENERATED_TYPES);
3207
3352
  const schemaResult = (0, generate_schema_js_1.generateSchema)({
3208
3353
  basePath: cwd,
3209
- linters: (0, validate_js_1.loadConfig)().linters,
3354
+ linters: loadConfig().linters,
3210
3355
  });
3211
3356
  (0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, GENERATED_SCHEMA), schemaResult.json);
3212
3357
  console.log("✓ Generated .vigiles/schema.json (YAML-LSP frontmatter schema)");
@@ -3223,7 +3368,7 @@ async function setupPillar1(detected, targetValue, harnesses) {
3223
3368
  // resolve hook in src/self-resolve.mts), so compiling needs no
3224
3369
  // `node_modules/vigiles` in the user's repo — and a repo with no
3225
3370
  // `package.json` at all (Python, Rust, Go) has no install to run.
3226
- const initConfig = (0, validate_js_1.loadConfig)();
3371
+ const initConfig = loadConfig();
3227
3372
  const excludes = (0, exclude_js_1.excludeSet)(cwd, initConfig.exclude);
3228
3373
  const specs = findSpecs(excludes).filter((s) => {
3229
3374
  const tf = (0, node_path_1.resolve)(cwd, s.replace(/\.spec\.ts$/, ""));
@@ -3758,7 +3903,7 @@ async function setup(args) {
3758
3903
  // Detect project. An existing `.vigilesrc.json` `harness` (from a prior init /
3759
3904
  // a hand-authored config) wins over auto-detection (dogfood I3).
3760
3905
  const detected = detectProject();
3761
- const harnesses = resolveHarnesses(parsed, detected, (0, adapter_registry_js_1.declaredHarnessNames)((0, validate_js_1.loadConfig)().harnesses));
3906
+ const harnesses = resolveHarnesses(parsed, detected, (0, adapter_registry_js_1.declaredHarnessNames)(loadConfig().harnesses));
3762
3907
  printDetection(detected, harnesses);
3763
3908
  // Files actually written, accumulated for an honest commit hint.
3764
3909
  const written = [];
@@ -3811,6 +3956,7 @@ async function setup(args) {
3811
3956
  strict,
3812
3957
  reportOnly: parsed.reportOnly,
3813
3958
  lint: plan.lint,
3959
+ specs: plan.scaffoldSpecs,
3814
3960
  written,
3815
3961
  });
3816
3962
  printSetupSummary({
@@ -3848,7 +3994,7 @@ async function setup(args) {
3848
3994
  function minimalRerun(parsed, state) {
3849
3995
  const plan = (0, setup_plan_js_1.resolvePlan)(parsed);
3850
3996
  const detected = detectProject();
3851
- const harnesses = resolveHarnesses(parsed, detected, (0, adapter_registry_js_1.declaredHarnessNames)((0, validate_js_1.loadConfig)().harnesses));
3997
+ const harnesses = resolveHarnesses(parsed, detected, (0, adapter_registry_js_1.declaredHarnessNames)(loadConfig().harnesses));
3852
3998
  console.log(`vigiles is already set up here (found ${state.evidence}) — adding only what is missing.\n`);
3853
3999
  printDetection(detected, harnesses);
3854
4000
  const links = plan.plugin ? linkSkillsOnly(harnesses) : [];
@@ -3856,7 +4002,7 @@ function minimalRerun(parsed, state) {
3856
4002
  const written = [...links, ...devDep];
3857
4003
  if (written.length === 0)
3858
4004
  console.log("\nNothing was missing.");
3859
- const followUps = (0, setup_plan_js_1.fullSetupFollowUps)(fullSetupGap(plan, detected, harnesses));
4005
+ const followUps = (0, setup_plan_js_1.fullSetupFollowUps)(fullSetupGap(plan, detected, harnesses), INIT_POLICY.preset);
3860
4006
  if (followUps.length > 0)
3861
4007
  console.log(`\n${followUps.join("\n")}`);
3862
4008
  if (written.length > 0)
@@ -3930,6 +4076,7 @@ function fullSetupGap(plan, detected, harnesses) {
3930
4076
  const unspecced = pillar1
3931
4077
  ? [
3932
4078
  ...determineTargets(detected, undefined, [...harnesses]),
4079
+ ...nestedHandWrittenInstructionFiles(cwd),
3933
4080
  ...discoverAdoptableSurfaces(cwd),
3934
4081
  ].filter((t, i, all) => all.indexOf(t) === i && !has(`${t}.spec.ts`))
3935
4082
  : [];
@@ -3954,7 +4101,12 @@ function fullSetupGap(plan, detected, harnesses) {
3954
4101
  function configGap(config, lint) {
3955
4102
  if (config === null)
3956
4103
  return "missing";
3957
- return config.extends === undefined && lint ? "no-preset" : "has-preset";
4104
+ // "Has the preset" means extends RECOMMENDED: `vigiles:experimental` alone
4105
+ // gates nothing. A value that is not a preset list is not ours to judge here.
4106
+ const listed = (0, presets_js_1.extendedPresets)(CONFIG.presetNames, config.extends);
4107
+ return listed !== null && !listed.includes(INIT_POLICY.preset) && lint
4108
+ ? "no-preset"
4109
+ : "has-preset";
3958
4110
  }
3959
4111
  /** `.vigilesrc.json` as a plain object, or null when absent or unreadable. */
3960
4112
  function readProjectConfig(path) {
@@ -4006,10 +4158,12 @@ function writeProjectConfig(opts) {
4006
4158
  }
4007
4159
  }
4008
4160
  const merged = (0, setup_plan_js_1.mergeProjectConfig)(existing, {
4161
+ policy: INIT_POLICY,
4009
4162
  harnesses: harnessConfigKeys(opts.harnesses),
4010
4163
  strict: opts.strict,
4011
4164
  reportOnly: opts.reportOnly,
4012
4165
  lint: opts.lint,
4166
+ specs: opts.specs,
4013
4167
  });
4014
4168
  if (!merged)
4015
4169
  return;
@@ -4022,31 +4176,34 @@ function writeProjectConfig(opts) {
4022
4176
  // ---------------------------------------------------------------------------
4023
4177
  // Strengthen: guidance() → enforce() suggestions
4024
4178
  // ---------------------------------------------------------------------------
4025
- function checkIntegrityForFiles(files, severity, silent) {
4179
+ function checkIntegrityForFiles(root, files, rules, silent) {
4026
4180
  const log = (msg) => {
4027
4181
  if (!silent)
4028
4182
  console.log(msg);
4029
4183
  };
4030
- let errorCount = 0;
4031
- const basePath = process.cwd();
4032
- for (const filePath of files) {
4184
+ const basePath = root;
4185
+ // Per file: an `overrides` entry may turn `integrity` off for a folder.
4186
+ const checked = files.flatMap((filePath) => {
4187
+ const severity = rules.severityAt("integrity", filePath);
4033
4188
  const abs = (0, node_path_1.resolve)(basePath, filePath);
4034
- if (!(0, node_fs_1.existsSync)(abs))
4035
- continue;
4189
+ if (severity === false || !(0, node_fs_1.existsSync)(abs))
4190
+ return [];
4036
4191
  const result = (0, integrity_js_1.checkIntegrity)((0, node_fs_1.readFileSync)(abs, "utf-8"));
4192
+ return [{ filePath, severity, result }];
4193
+ });
4194
+ for (const { filePath, severity, result } of checked) {
4037
4195
  if (!result.intact) {
4038
- errorCount++;
4039
- const marker = severity === "error" ? "✗" : "⚠";
4040
- log(` ${marker} ${filePath} — ${result.reason ?? "tampered"}`);
4196
+ log(` ${markOf(severity).mark} ${filePath} — ${result.reason ?? "tampered"}`);
4041
4197
  }
4042
4198
  else if (!silent) {
4043
4199
  log(` ✓ ${filePath}`);
4044
4200
  }
4045
4201
  }
4046
- if (errorCount === 0) {
4202
+ const broken = checked.filter((c) => !c.result.intact);
4203
+ if (broken.length === 0) {
4047
4204
  log(" All compiled files intact.");
4048
4205
  }
4049
- return severity === "error" ? errorCount : 0;
4206
+ return broken.filter((c) => c.severity === "error").length;
4050
4207
  }
4051
4208
  /**
4052
4209
  * The layout of the harness this repo actually targets — `--harness=`/config/
@@ -4093,7 +4250,7 @@ const UNTESTED_RULE = {
4093
4250
  /**
4094
4251
  * The `untested-*` rules AS THE DETECTOR TAKES THEM: which kinds this repo
4095
4252
  * enabled, and the discovery options merged from whichever of the three rules
4096
- * carries them (`include` / `exclude` / `testExtension` are shared).
4253
+ * carries them (`include` / `testExtension` are shared).
4097
4254
  *
4098
4255
  * 🔴 ONE READER, BECAUSE THE SECOND ONE DRIFTED. `vigiles lint` read the config
4099
4256
  * here; the PostToolUse nudge (`evalLockNudgeHookCommand`) called the same
@@ -4104,25 +4261,24 @@ const UNTESTED_RULE = {
4104
4261
  * while the nudge said "no test or eval covers it". A nudge contradicting the
4105
4262
  * linter of the same repo, in the same second, teaches people to ignore both.
4106
4263
  */
4107
- function untestedRules(config) {
4108
- const rules = config?.rules;
4109
- const severity = (kind) => (0, types_js_1.ruleSeverity)(rules?.[UNTESTED_RULE[kind]]);
4264
+ function untestedRules(config, rules) {
4110
4265
  const names = Object.values(UNTESTED_RULE);
4111
4266
  const opts = names.reduce((acc, rule) => ({
4112
4267
  ...acc,
4113
- ...(0, types_js_1.ruleOptions)(rules?.[rule]),
4268
+ ...(0, types_js_1.ruleOptions)(config?.rules?.[rule]),
4114
4269
  }), {});
4115
- const on = (kind) => severity(kind) !== false;
4270
+ // A kind is SCANNED when its rule can fire anywhere — an override may turn
4271
+ // it on for one folder while `rules` has it off.
4272
+ const on = (kind) => rules.mayFire(UNTESTED_RULE[kind]);
4116
4273
  return {
4117
- severity,
4118
- anyEnabled: names.some((rule) => (0, types_js_1.ruleSeverity)(rules?.[rule]) !== false),
4274
+ severity: (kind, path) => rules.severityAt(UNTESTED_RULE[kind], path),
4275
+ anyEnabled: names.some((rule) => rules.mayFire(rule)),
4119
4276
  options: {
4120
4277
  skills: on("skill"),
4121
4278
  agents: on("agent"),
4122
4279
  hooks: on("hook"),
4123
4280
  outputStyles: on("output-style"),
4124
4281
  include: opts.include,
4125
- exclude: opts.exclude,
4126
4282
  // Without this the `testExtension` documented on TestCoverageOptions was a
4127
4283
  // config key nothing read: a TypeScript-shaped repo got `.ts` suggestions
4128
4284
  // however the author configured it. Prose isn't policy, in our own CLI.
@@ -4139,33 +4295,37 @@ function untestedRules(config) {
4139
4295
  */
4140
4296
  function checkUntestedSurfaces(ctx) {
4141
4297
  const { config, silent, adapter, frame, bundle } = ctx;
4142
- const { severity: sevFor, anyEnabled, options } = untestedRules(config);
4298
+ const { severity: sevFor, anyEnabled, options, } = untestedRules(config, ctx.rules);
4143
4299
  if (!anyEnabled)
4144
4300
  return { untested: 0, errors: 0 };
4145
- const report = (0, test_coverage_js_1.findUntestedSurfaces)({
4301
+ const scanned = (0, test_coverage_js_1.findUntestedSurfaces)({
4146
4302
  ...options,
4147
4303
  basePath: bundle.abs,
4148
- // #281: `include`/`exclude` and every reported path are relative to the
4304
+ // #281: `include` and every reported path are relative to the
4149
4305
  // frame root (where `.vigilesrc.json` lives), not to the bundle being scored.
4150
4306
  root: frame.root,
4151
4307
  layout: adapter.layout,
4152
- // The rule's own `exclude` NARROWS; the repo-wide one is the floor under
4153
- // it. Union, never override — a rule option must not re-admit a vendored
4154
- // corpus the repo excluded (#192). Passed WHOLE so it carries its own root.
4308
+ // The repo-wide `exclude` — passed WHOLE so it carries its own root (#192).
4155
4309
  excludes: ctx.excludes,
4156
4310
  });
4311
+ // Per surface: an `overrides` entry may turn its kind's rule off for a folder.
4312
+ const untested = scanned.untested.flatMap((s) => {
4313
+ const sev = sevFor(s.kind, s.path);
4314
+ return sev === false ? [] : [{ ...s, sev }];
4315
+ });
4316
+ const report = { ...scanned, untested };
4157
4317
  if (!silent) {
4158
4318
  console.log("\nUntested surfaces:\n");
4159
4319
  for (const line of (0, test_coverage_js_1.formatUntestedReport)(report).split("\n")) {
4160
4320
  console.log(` ${line}`);
4161
4321
  }
4162
4322
  for (const s of report.untested) {
4163
- ctx.annotate(sevFor(s.kind) === "error" ? "error" : "warning", `${s.kind} ${s.path} ships without a test or eval`, frame.repo((0, node_path_1.join)(frame.root, s.path)));
4323
+ ctx.annotate(markOf(s.sev).level, `${s.kind} ${s.path} ships without a test or eval`, frame.repo((0, node_path_1.join)(frame.root, s.path)));
4164
4324
  }
4165
4325
  }
4166
4326
  return {
4167
4327
  untested: report.untested.length,
4168
- errors: report.untested.filter((s) => sevFor(s.kind) === "error").length,
4328
+ errors: untested.filter((s) => s.sev === "error").length,
4169
4329
  };
4170
4330
  }
4171
4331
  /**
@@ -4190,9 +4350,8 @@ function reportNotApplicable(check, surface, adapter, silent) {
4190
4350
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
4191
4351
  */
4192
4352
  function checkSubagentToolContracts(ctx) {
4193
- const { config, silent, adapter } = ctx;
4194
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
4195
- if (!sev)
4353
+ const { silent, adapter } = ctx;
4354
+ if (!ctx.rules.mayFire("subagent-tool-contract"))
4196
4355
  return { issues: 0, errors: 0 };
4197
4356
  if (!adapter.subagents) {
4198
4357
  reportNotApplicable("Subagent tool-contract check", "subagents", adapter, silent);
@@ -4208,24 +4367,16 @@ function checkSubagentToolContracts(ctx) {
4208
4367
  catch {
4209
4368
  return { issues: 0, errors: 0 };
4210
4369
  }
4211
- let issues = 0;
4212
- let printedHeader = false;
4213
- for (const agent of agents) {
4214
- if (agent.toolIssues.length === 0)
4215
- continue;
4216
- issues += agent.toolIssues.length;
4217
- if (!silent) {
4218
- if (!printedHeader) {
4219
- console.log("\nSubagent tool-contract check:\n");
4220
- printedHeader = true;
4221
- }
4222
- for (const issue of agent.toolIssues) {
4223
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(agent.path)}: ${issue.message}`);
4224
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(agent.path));
4225
- }
4370
+ 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));
4371
+ if (found.length > 0 && !silent) {
4372
+ console.log("\nSubagent tool-contract check:\n");
4373
+ for (const issue of found) {
4374
+ const at = ctx.bundle.scanned(issue.path);
4375
+ console.log(` ${markOf(issue.sev).mark} ${at}: ${issue.message}`);
4376
+ ctx.annotate(markOf(issue.sev).level, issue.message, at);
4226
4377
  }
4227
4378
  }
4228
- return { issues, errors: sev === "error" ? issues : 0 };
4379
+ return tally(found);
4229
4380
  }
4230
4381
  /**
4231
4382
  * Apply the `hook-events` rule: flag a hook registered under an event name the
@@ -4266,30 +4417,30 @@ function checkHookEvents(ctx) {
4266
4417
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
4267
4418
  */
4268
4419
  function checkFrontmatterSchema(ctx) {
4269
- const { config, silent, adapter } = ctx;
4270
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
4271
- if (!sev)
4420
+ const { silent, adapter } = ctx;
4421
+ if (!ctx.rules.mayFire("subagent-frontmatter"))
4272
4422
  return { issues: 0, errors: 0 };
4273
4423
  if (!adapter.subagents) {
4274
4424
  reportNotApplicable("Subagent-frontmatter check", "subagents", adapter, silent);
4275
4425
  return { issues: 0, errors: 0 };
4276
4426
  }
4277
- let found;
4427
+ let raw;
4278
4428
  try {
4279
4429
  const r = ctx.scan();
4280
- found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4430
+ raw = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
4281
4431
  }
4282
4432
  catch {
4283
4433
  return { issues: 0, errors: 0 };
4284
4434
  }
4435
+ const found = graded(ctx.rules, "subagent-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4285
4436
  if (found.length > 0 && !silent) {
4286
4437
  console.log("\nFrontmatter-schema check:\n");
4287
4438
  for (const issue of found) {
4288
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4289
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4439
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4440
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4290
4441
  }
4291
4442
  }
4292
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4443
+ return tally(found);
4293
4444
  }
4294
4445
  /**
4295
4446
  * Apply the `skill-frontmatter` rule: RECOMMEND (not require) that a SKILL.md
@@ -4300,54 +4451,25 @@ function checkFrontmatterSchema(ctx) {
4300
4451
  * `skillMetaIssues`.
4301
4452
  */
4302
4453
  function checkSkillFrontmatter(ctx) {
4303
- const { config, silent } = ctx;
4304
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
4305
- if (!sev)
4454
+ const { silent } = ctx;
4455
+ if (!ctx.rules.mayFire("skill-frontmatter"))
4306
4456
  return { issues: 0, errors: 0 };
4307
- let found;
4457
+ let raw;
4308
4458
  try {
4309
- found = ctx.scan().skillMetaIssues;
4459
+ raw = ctx.scan().skillMetaIssues;
4310
4460
  }
4311
4461
  catch {
4312
4462
  return { issues: 0, errors: 0 };
4313
4463
  }
4464
+ const found = graded(ctx.rules, "skill-frontmatter", raw, (f) => ctx.bundle.scanned(f.path));
4314
4465
  if (found.length > 0 && !silent) {
4315
4466
  console.log("\nSkill-frontmatter check:\n");
4316
4467
  for (const issue of found) {
4317
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4318
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4468
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4469
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4319
4470
  }
4320
4471
  }
4321
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4322
- }
4323
- /**
4324
- * Apply the `prefer-compiled-hooks` rule: a SINGLE repo-level recommendation
4325
- * (one finding regardless of hook count) nudging hand-written hooks toward
4326
- * compiled `vigiles/hook` artifacts. A discovery nudge, not a defect — the shell
4327
- * lane stays first-class — so it fires once and the message links the guide.
4328
- * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
4329
- */
4330
- function checkPreferCompiledHooks(ctx) {
4331
- const { config, silent } = ctx;
4332
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
4333
- if (!sev)
4334
- return { issues: 0, errors: 0 };
4335
- let count;
4336
- try {
4337
- count = ctx.scan().manualHookCount;
4338
- }
4339
- catch {
4340
- return { issues: 0, errors: 0 };
4341
- }
4342
- if (count === 0)
4343
- return { issues: 0, errors: 0 };
4344
- const message = (0, scan_js_1.preferCompiledHooksMessage)(count);
4345
- if (!silent) {
4346
- console.log("\nCompiled-hooks check:\n");
4347
- console.log(` ${sev === "error" ? "✗" : "ℹ"} ${ctx.label(message)}`);
4348
- ctx.annotate(sev === "error" ? "error" : "warning", message);
4349
- }
4350
- return { issues: 1, errors: sev === "error" ? 1 : 0 };
4472
+ return tally(found);
4351
4473
  }
4352
4474
  /**
4353
4475
  * Apply the `mcp-config` rule: a declared MCP server with neither a `command`
@@ -4383,17 +4505,16 @@ function checkMcpConfig(ctx) {
4383
4505
  * "error" gates CI.
4384
4506
  */
4385
4507
  function checkDisallowedTools(ctx) {
4386
- const { config, silent, adapter } = ctx;
4387
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
4388
- if (!sev)
4508
+ const { silent, adapter } = ctx;
4509
+ if (!ctx.rules.mayFire("disallowed-tools-contract"))
4389
4510
  return { issues: 0, errors: 0 };
4390
4511
  if (!adapter.subagents) {
4391
4512
  reportNotApplicable("Disallowed-tools check", "subagents", adapter, silent);
4392
4513
  return { issues: 0, errors: 0 };
4393
4514
  }
4394
- let found;
4515
+ let raw;
4395
4516
  try {
4396
- found = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4517
+ raw = ctx.scan().agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({
4397
4518
  message: i.message,
4398
4519
  path: a.path,
4399
4520
  })));
@@ -4401,14 +4522,15 @@ function checkDisallowedTools(ctx) {
4401
4522
  catch {
4402
4523
  return { issues: 0, errors: 0 };
4403
4524
  }
4525
+ const found = graded(ctx.rules, "disallowed-tools-contract", raw, (f) => ctx.bundle.scanned(f.path));
4404
4526
  if (found.length > 0 && !silent) {
4405
4527
  console.log("\nDisallowed-tools check:\n");
4406
4528
  for (const issue of found) {
4407
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4408
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4529
+ console.log(` ${markOf(issue.sev).mark} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4530
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4409
4531
  }
4410
4532
  }
4411
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4533
+ return tally(found);
4412
4534
  }
4413
4535
  /**
4414
4536
  * Apply the `frontmatter-valid` rule: a skill/agent `---` block that EXISTS but
@@ -4419,25 +4541,25 @@ function checkDisallowedTools(ctx) {
4419
4541
  * (verify before setting "error").
4420
4542
  */
4421
4543
  function checkFrontmatterValid(ctx) {
4422
- const { config, silent } = ctx;
4423
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
4424
- if (!sev)
4544
+ const { silent } = ctx;
4545
+ if (!ctx.rules.mayFire("frontmatter-valid"))
4425
4546
  return { issues: 0, errors: 0 };
4426
- let found;
4547
+ let raw;
4427
4548
  try {
4428
- found = ctx.scan().malformedFrontmatter;
4549
+ raw = ctx.scan().malformedFrontmatter;
4429
4550
  }
4430
4551
  catch {
4431
4552
  return { issues: 0, errors: 0 };
4432
4553
  }
4554
+ const found = graded(ctx.rules, "frontmatter-valid", raw, (f) => ctx.bundle.scanned(f.path));
4433
4555
  if (found.length > 0 && !silent) {
4434
4556
  console.log("\nFrontmatter-validity check:\n");
4435
4557
  for (const issue of found) {
4436
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(issue.message)}`);
4437
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4558
+ console.log(` ${markOf(issue.sev).mark} ${ctx.label(issue.message)}`);
4559
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4438
4560
  }
4439
4561
  }
4440
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4562
+ return tally(found);
4441
4563
  }
4442
4564
  /**
4443
4565
  * Apply the `description-overlap` rule: two model-invocable skills with
@@ -4512,77 +4634,81 @@ function checkDescriptionBudget(ctx) {
4512
4634
  * unaffected by the collapse.
4513
4635
  */
4514
4636
  function checkLethalTrifecta(ctx) {
4515
- const { config, silent } = ctx;
4516
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
4517
- if (!sev)
4637
+ const { silent } = ctx;
4638
+ if (!ctx.rules.mayFire("lethal-trifecta"))
4518
4639
  return { issues: 0, errors: 0 };
4519
- let found;
4640
+ let raw;
4520
4641
  try {
4521
- found = ctx.scan().trifectaFindings;
4642
+ raw = ctx.scan().trifectaFindings;
4522
4643
  }
4523
4644
  catch {
4524
4645
  return { issues: 0, errors: 0 };
4525
4646
  }
4647
+ const found = graded(ctx.rules, "lethal-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4526
4648
  if (found.length > 0 && !silent) {
4527
- const mark = sev === "error" ? "✗" : "⚠";
4528
- const level = sev === "error" ? "error" : "warning";
4529
4649
  console.log("\nLethal-trifecta check:\n");
4530
4650
  const unfenced = found.filter((t) => t.kind === "skill" && t.finding.fence === "none");
4531
4651
  for (const t of found) {
4532
4652
  if (unfenced.includes(t))
4533
4653
  continue;
4534
4654
  const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
4535
- console.log(` ${mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4536
- ctx.annotate(level, msg, ctx.bundle.scanned(t.path));
4655
+ console.log(` ${markOf(t.sev).mark} ${ctx.bundle.scanned(t.path)}: ${msg}`);
4656
+ ctx.annotate(markOf(t.sev).level, msg, ctx.bundle.scanned(t.path));
4537
4657
  }
4538
4658
  if (unfenced.length > 0) {
4539
- console.log(` ${mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
4659
+ // One line for the group, at the worst severity any of its skills has.
4660
+ const groupSev = unfenced.some((t) => t.sev === "error")
4661
+ ? "error"
4662
+ : "warn";
4663
+ console.log(` ${markOf(groupSev).mark} ${String(unfenced.length)} skill(s) declare no \`disallowed-tools:\` fence, so each ` +
4540
4664
  `inherits every tool the session grants and holds all three legs. ` +
4541
4665
  `\`allowed-tools:\` pre-approves, it does not restrict. ` +
4542
4666
  `Add one \`disallowed-tools:\` line per skill to drop a leg: ` +
4543
4667
  unfenced.map((t) => t.name).join(", "));
4544
4668
  }
4545
4669
  }
4546
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4670
+ return tally(found);
4547
4671
  }
4672
+ /** The rules of the set that are modules — each runs through {@link checkRuleModule}. */
4673
+ const RULE_MODULES = index_js_1.RULES.filter((r) => "findings" in r);
4548
4674
  /**
4549
- * Apply the `skill-resource-resolves` rule: a SKILL.md body referencing a bundled
4550
- * file (`scripts/`/`references/`/`assets/` or a relative markdown link with an
4551
- * extension) that doesn't exist on disk — the agent reads the instruction and gets
4552
- * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
4553
- * one detector, no drift). Warning by default; "error" gates CI.
4675
+ * Apply one rule MODULE: what its `findings` reports, each at the severity its
4676
+ * own path gets (`overrides` applied). A `finding` counts and prints at that
4677
+ * severity; a `note` (informational — e.g. a resource that resolves only from
4678
+ * the repository root, #382) prints with `ℹ` and a `::notice` annotation and
4679
+ * never counts, so it can never fail CI. The scan is the same one every other
4680
+ * check reads (`ctx.scan`), with the repo's `sharedDirs` at the frame root.
4681
+ *
4682
+ * The first piece of the one loop the per-rule `check*` functions become
4683
+ * (vigiles#390): nothing here names a rule.
4554
4684
  */
4555
- function checkSkillResourceResolves(ctx) {
4556
- const { config, silent } = ctx;
4557
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
4558
- if (!sev)
4685
+ function checkRuleModule(ctx, rule) {
4686
+ if (!ctx.rules.mayFire(rule.id))
4559
4687
  return { issues: 0, errors: 0 };
4560
4688
  let found;
4561
4689
  try {
4562
- found = ctx.scan({
4563
- sharedDirs: config?.sharedDirs,
4564
- // sharedDirs live at the frame root — the repo that OWNS the scan target:
4565
- // cwd for a scoped subdir or a nested bundle, the target for a foreign lint.
4690
+ found = rule.findings(ctx.scan({
4691
+ sharedDirs: ctx.config?.sharedDirs,
4566
4692
  sharedDirsRoot: ctx.frame.root,
4567
- }).skillResourceIssues;
4693
+ }));
4568
4694
  }
4569
4695
  catch {
4570
4696
  return { issues: 0, errors: 0 };
4571
4697
  }
4572
- if (found.length > 0 && !silent) {
4573
- console.log("\nSkill-resource check:\n");
4574
- for (const s of found) {
4575
- const msg = `${s.name}: bundled resource "${s.finding.ref}" (line ${String(s.finding.line)}) is referenced but missing — the agent reads the instruction and gets nothing.` +
4576
- // The main false-positive source in a skills monorepo, where a SKILL.md
4577
- // legitimately names a repo-root path. The fix already exists and works;
4578
- // it was documented only in docs/skills-monorepo.md, so a CI log gave no
4579
- // hint and the rule read as broken rather than misconfigured.
4580
- ` If it resolves from the repo root instead, add its directory to \`sharedDirs\` in .vigilesrc.json.`;
4581
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4582
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4698
+ const pathOf = (f) => ctx.bundle.scanned(f.path);
4699
+ const shown = graded(ctx.rules, rule.id, found, pathOf);
4700
+ if (shown.length > 0 && !ctx.silent) {
4701
+ console.log(`\n${rule.id}:\n`);
4702
+ for (const f of shown) {
4703
+ const { mark, level } = f.kind === "note"
4704
+ ? { mark: "ℹ", level: "notice" }
4705
+ : markOf(f.sev);
4706
+ const msg = `${f.subject}: ${f.message}`;
4707
+ console.log(` ${mark} ${pathOf(f)}: ${msg}`);
4708
+ ctx.annotate(level, msg, pathOf(f));
4583
4709
  }
4584
4710
  }
4585
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4711
+ return tally(shown.filter((f) => f.kind === "finding"));
4586
4712
  }
4587
4713
  /**
4588
4714
  * Apply the `skill-missing-fence` rule: a SKILL.md that opens with
@@ -4592,26 +4718,26 @@ function checkSkillResourceResolves(ctx) {
4592
4718
  * default; "error" gates CI.
4593
4719
  */
4594
4720
  function checkSkillMissingFence(ctx) {
4595
- const { config, silent } = ctx;
4596
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
4597
- if (!sev)
4721
+ const { silent } = ctx;
4722
+ if (!ctx.rules.mayFire("skill-missing-fence"))
4598
4723
  return { issues: 0, errors: 0 };
4599
- let found;
4724
+ let raw;
4600
4725
  try {
4601
- found = ctx.scan().skillFenceIssues;
4726
+ raw = ctx.scan().skillFenceIssues;
4602
4727
  }
4603
4728
  catch {
4604
4729
  return { issues: 0, errors: 0 };
4605
4730
  }
4731
+ const found = graded(ctx.rules, "skill-missing-fence", raw, (f) => ctx.bundle.scanned(f.path));
4606
4732
  if (found.length > 0 && !silent) {
4607
4733
  console.log("\nSkill-missing-fence check:\n");
4608
4734
  for (const s of found) {
4609
4735
  const msg = `${s.name}: ${s.finding.message}`;
4610
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4611
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(s.path));
4736
+ console.log(` ${markOf(s.sev).mark} ${ctx.bundle.scanned(s.path)}: ${msg}`);
4737
+ ctx.annotate(markOf(s.sev).level, msg, ctx.bundle.scanned(s.path));
4612
4738
  }
4613
4739
  }
4614
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4740
+ return tally(found);
4615
4741
  }
4616
4742
  /**
4617
4743
  * Apply the `plugin-dir-layout` rule: functional surface dirs (skills/agents/
@@ -4621,25 +4747,25 @@ function checkSkillMissingFence(ctx) {
4621
4747
  * gates CI.
4622
4748
  */
4623
4749
  function checkPluginDirLayout(ctx) {
4624
- const { config, silent } = ctx;
4625
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
4626
- if (!sev)
4750
+ const { silent } = ctx;
4751
+ if (!ctx.rules.mayFire("plugin-dir-layout"))
4627
4752
  return { issues: 0, errors: 0 };
4628
- let found;
4753
+ let raw;
4629
4754
  try {
4630
- found = ctx.scan().pluginLayoutIssues;
4755
+ raw = ctx.scan().pluginLayoutIssues;
4631
4756
  }
4632
4757
  catch {
4633
4758
  return { issues: 0, errors: 0 };
4634
4759
  }
4760
+ const found = graded(ctx.rules, "plugin-dir-layout", raw, (f) => ctx.bundle.scanned(f.dir));
4635
4761
  if (found.length > 0 && !silent) {
4636
4762
  console.log("\nPlugin-dir-layout check:\n");
4637
4763
  for (const p of found) {
4638
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(p.message)}`);
4639
- ctx.annotate(sev === "error" ? "error" : "warning", p.message);
4764
+ console.log(` ${markOf(p.sev).mark} ${ctx.label(p.message)}`);
4765
+ ctx.annotate(markOf(p.sev).level, p.message);
4640
4766
  }
4641
4767
  }
4642
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4768
+ return tally(found);
4643
4769
  }
4644
4770
  /**
4645
4771
  * Apply the `delegation-trifecta` rule: a lethal trifecta that EMERGES across a
@@ -4650,26 +4776,26 @@ function checkPluginDirLayout(ctx) {
4650
4776
  * capability the way a surface-specific rule is.
4651
4777
  */
4652
4778
  function checkDelegationTrifecta(ctx) {
4653
- const { config, silent } = ctx;
4654
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
4655
- if (!sev)
4779
+ const { silent } = ctx;
4780
+ if (!ctx.rules.mayFire("delegation-trifecta"))
4656
4781
  return { issues: 0, errors: 0 };
4657
- let found;
4782
+ let raw;
4658
4783
  try {
4659
- found = ctx.scan().delegationTrifecta;
4784
+ raw = ctx.scan().delegationTrifecta;
4660
4785
  }
4661
4786
  catch {
4662
4787
  return { issues: 0, errors: 0 };
4663
4788
  }
4789
+ const found = graded(ctx.rules, "delegation-trifecta", raw, (f) => ctx.bundle.scanned(f.path));
4664
4790
  if (found.length > 0 && !silent) {
4665
4791
  console.log("\nDelegation-trifecta check:\n");
4666
4792
  for (const d of found) {
4667
4793
  const msg = `${d.finding.name}: ${d.finding.message}`;
4668
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4669
- ctx.annotate(sev === "error" ? "error" : "warning", msg, ctx.bundle.scanned(d.path));
4794
+ console.log(` ${markOf(d.sev).mark} ${ctx.bundle.scanned(d.path)}: ${msg}`);
4795
+ ctx.annotate(markOf(d.sev).level, msg, ctx.bundle.scanned(d.path));
4670
4796
  }
4671
4797
  }
4672
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4798
+ return tally(found);
4673
4799
  }
4674
4800
  /**
4675
4801
  * Apply the `hook-block-ineffective` rule: a hook that LOOKS like it blocks but
@@ -4787,43 +4913,57 @@ function checkMcpHookTargets(ctx) {
4787
4913
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
4788
4914
  }
4789
4915
  /**
4790
- * Apply the `hook-script-exists` rule: a hook command references a script file
4791
- * that doesn't exist on disk (with `${CLAUDE_PLUGIN_ROOT}` resolved) → the hook
4792
- * silently never runs. Reuses `scanPlugin`'s `hooks` (status "missing"); the
4793
- * shared resolver already excludes the FP-prone cases (unresolved vars,
4794
- * existence-guarded one-liners, inline commands). Matches Anthropic's own
4795
- * `claude plugin validate`. Warning by default; "error" gates CI.
4916
+ * Apply the `hook-script-exists` rule: a file a COMPILED hook line needs is
4917
+ * missing — the runtime, the program or its stamp — so the hook cannot run.
4918
+ * The files come from the hook-line plan (`hookLinePlan`), never from reading a
4919
+ * line: a line is compiled only by identity with what `compile` writes.
4796
4920
  */
4797
4921
  function checkHookScriptExists(ctx) {
4922
+ return reportHookLines(ctx, "hook-script-exists", "Hook-script existence check", (scan) => [
4923
+ ...scan.hooks
4924
+ .filter((h) => h.status === "missing")
4925
+ // `script` is absolute (the root variable resolved) — printed as-is it
4926
+ // was a machine path (#281); `scanned` makes it the bundle's own.
4927
+ .map((h) => (0, scan_js_1.hookScriptMissingMessage)(ctx.bundle.scanned(h.script))),
4928
+ // A line vigiles wrote that is now wrong: its own wiring, so here.
4929
+ ...scan.staleHooks.map(scan_js_1.staleHookMessage),
4930
+ ]);
4931
+ }
4932
+ /**
4933
+ * Apply the `hand-written-hook` rule: one finding per hook line vigiles did not
4934
+ * write (or wrote and someone edited). vigiles never parses such a line, so it
4935
+ * cannot say what it runs; the finding names it and points at compiled hooks.
4936
+ */
4937
+ function checkHandWrittenHooks(ctx) {
4938
+ return reportHookLines(ctx, "hand-written-hook", "Hand-written hooks", (scan) => scan.handWrittenHooks.map(scan_js_1.handWrittenHookMessage));
4939
+ }
4940
+ /** Print and count one hook-line rule's messages at its configured severity. */
4941
+ function reportHookLines(ctx, rule, heading, messagesOf) {
4798
4942
  const { config, silent, adapter } = ctx;
4799
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
4943
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.[rule]);
4800
4944
  if (!sev)
4801
4945
  return { issues: 0, errors: 0 };
4802
4946
  if (!adapter.shellHooks) {
4803
- reportNotApplicable("Hook-script existence check", "shell hooks", adapter, silent);
4947
+ reportNotApplicable(heading, "shell hooks", adapter, silent);
4804
4948
  return { issues: 0, errors: 0 };
4805
4949
  }
4806
- let missing;
4950
+ let messages;
4807
4951
  try {
4808
- missing = ctx.scan().hooks.filter((h) => h.status === "missing");
4952
+ messages = messagesOf(ctx.scan());
4809
4953
  }
4810
4954
  catch {
4811
4955
  return { issues: 0, errors: 0 };
4812
4956
  }
4813
- if (missing.length > 0 && !silent) {
4814
- console.log("\nHook-script existence check:\n");
4815
- for (const h of missing) {
4816
- // `script` arrives with the plugin-root token EXPANDED, i.e. absolute —
4817
- // printed as-is it was a machine path (#281). A relative one is the
4818
- // bundle's own, which is what `scanned` assumes.
4819
- const msg = `hook script "${ctx.bundle.scanned(h.script)}" is referenced but missing — the hook never runs.`;
4957
+ if (messages.length > 0 && !silent) {
4958
+ console.log(`\n${heading}:\n`);
4959
+ for (const msg of messages) {
4820
4960
  console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.label(msg)}`);
4821
4961
  ctx.annotate(sev === "error" ? "error" : "warning", msg);
4822
4962
  }
4823
4963
  }
4824
4964
  return {
4825
- issues: missing.length,
4826
- errors: sev === "error" ? missing.length : 0,
4965
+ issues: messages.length,
4966
+ errors: sev === "error" ? messages.length : 0,
4827
4967
  };
4828
4968
  }
4829
4969
  /**
@@ -4834,31 +4974,31 @@ function checkHookScriptExists(ctx) {
4834
4974
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
4835
4975
  */
4836
4976
  function checkMcpToolResolves(ctx) {
4837
- const { config, silent, adapter } = ctx;
4838
- const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
4839
- if (!sev)
4977
+ const { silent, adapter } = ctx;
4978
+ if (!ctx.rules.mayFire("mcp-tool-resolves"))
4840
4979
  return { issues: 0, errors: 0 };
4841
4980
  if (!adapter.subagents) {
4842
4981
  reportNotApplicable("MCP tool-resolution check", "subagents", adapter, silent);
4843
4982
  return { issues: 0, errors: 0 };
4844
4983
  }
4845
- let found;
4984
+ let raw;
4846
4985
  try {
4847
- found = ctx
4986
+ raw = ctx
4848
4987
  .scan()
4849
4988
  .agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
4850
4989
  }
4851
4990
  catch {
4852
4991
  return { issues: 0, errors: 0 };
4853
4992
  }
4993
+ const found = graded(ctx.rules, "mcp-tool-resolves", raw, (f) => ctx.bundle.scanned(f.path));
4854
4994
  if (found.length > 0 && !silent) {
4855
4995
  console.log("\nMCP tool-resolution check:\n");
4856
4996
  for (const issue of found) {
4857
- console.log(` ${sev === "error" ? "✗" : "⚠"} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4858
- ctx.annotate(sev === "error" ? "error" : "warning", issue.message, ctx.bundle.scanned(issue.path));
4997
+ console.log(` ${markOf(issue.sev).mark} ${ctx.bundle.scanned(issue.path)}: ${issue.message}`);
4998
+ ctx.annotate(markOf(issue.sev).level, issue.message, ctx.bundle.scanned(issue.path));
4859
4999
  }
4860
5000
  }
4861
- return { issues: found.length, errors: sev === "error" ? found.length : 0 };
5001
+ return tally(found);
4862
5002
  }
4863
5003
  /**
4864
5004
  * Apply the configured coverage thresholds. Returns the number of failing
@@ -5006,7 +5146,7 @@ function resolveCommandHarness(dir, harnessFlag) {
5006
5146
  return (0, adapter_registry_js_1.resolveHarnessSelection)({
5007
5147
  root: dir,
5008
5148
  flag: harnessFlag,
5009
- configHarness: (0, adapter_registry_js_1.declaredHarnessNames)((0, validate_js_1.loadConfig)().harnesses),
5149
+ configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(loadConfig().harnesses),
5010
5150
  });
5011
5151
  }
5012
5152
  async function handleMeasure(restArgs, args) {
@@ -5082,6 +5222,17 @@ async function handleGenerate(restArgs, args) {
5082
5222
  process.exit(2);
5083
5223
  }
5084
5224
  }
5225
+ /**
5226
+ * A linter whose config could not be read is missing from the generated types;
5227
+ * say so on stderr (and as a CI annotation) instead of a silent short list.
5228
+ */
5229
+ function printUnreadableLinters(unreadable) {
5230
+ unreadable.forEach(({ linter, reason }) => {
5231
+ const msg = `could not read the ${linter} config: ${reason} (its rules are not listed)`;
5232
+ console.error(` ⚠ ${msg}`);
5233
+ console.error(`::warning::${msg}`);
5234
+ });
5235
+ }
5085
5236
  function handleGenerateTypes(args, restArgs) {
5086
5237
  const checkOnly = args.includes("--check");
5087
5238
  const outPath = restArgs[0] ?? ".vigiles/generated.d.ts";
@@ -5097,6 +5248,7 @@ function handleGenerateTypes(args, restArgs) {
5097
5248
  for (const l of result.linters) {
5098
5249
  console.log(` ${l.linter}: ${String(l.rules.length)} enabled rules (via ${l.via})`);
5099
5250
  }
5251
+ printUnreadableLinters(result.unreadable);
5100
5252
  if (result.scripts.length > 0) {
5101
5253
  console.log(` npm scripts: ${String(result.scripts.length)}`);
5102
5254
  }
@@ -5138,7 +5290,7 @@ function handleGenerateSchema(args, restArgs) {
5138
5290
  console.log("Scanning linters...\n");
5139
5291
  const result = (0, generate_schema_js_1.generateSchema)({
5140
5292
  basePath: process.cwd(),
5141
- linters: (0, validate_js_1.loadConfig)().linters,
5293
+ linters: loadConfig().linters,
5142
5294
  });
5143
5295
  for (const l of result.linters) {
5144
5296
  console.log(` ${l.linter}: ${String(l.count)} rules`);
@@ -5321,7 +5473,7 @@ function resolveEvalLockEnv(args) {
5321
5473
  if (wantUpdate)
5322
5474
  env.VIGILES_EVAL_LOCK = "update";
5323
5475
  if (wantCheck || wantUpdate) {
5324
- const apiVersion = (0, validate_js_1.loadConfig)().eval?.apiVersion;
5476
+ const apiVersion = loadConfig().eval?.apiVersion;
5325
5477
  if (apiVersion !== undefined)
5326
5478
  env.VIGILES_EVAL_API_VERSION = String(apiVersion);
5327
5479
  }
@@ -5417,7 +5569,7 @@ function resolveRecords(cwd, runs, tier, harnessFlag) {
5417
5569
  }
5418
5570
  return [...names];
5419
5571
  }
5420
- const recordsConfig = (0, validate_js_1.loadConfig)();
5572
+ const recordsConfig = loadConfig();
5421
5573
  const layout = harnessLayoutFor(cwd, recordsConfig, harnessFlag);
5422
5574
  // The repo-wide `exclude` only — the per-rule severities/include stay out
5423
5575
  // of record resolution on purpose (a run's probes must map to a surface
@@ -5751,6 +5903,13 @@ function capabilitiesOfReport(report, dialect) {
5751
5903
  }));
5752
5904
  return (0, generate_harness_js_1.computeHarnessCapabilities)(agents, dialect);
5753
5905
  }
5906
+ /**
5907
+ * The `--preset=` line `audit` and `lint` share. The names are the ones
5908
+ * `.vigilesrc.json#extends` takes; `experimental` is the group of checks not yet
5909
+ * measured reliable enough to grade on — on at `warn`, never scored.
5910
+ */
5911
+ const PRESET_HELP = " --preset=<name> add a preset for this run, on top of your config: experimental · recommended\n" +
5912
+ " (experimental = checks not yet measured reliable enough to grade you on: warn, never scored)";
5754
5913
  const COMMAND_HELP = {
5755
5914
  init: {
5756
5915
  usage: " vigiles init [flags] Set up this repo — specs, plugin, and CI.",
@@ -5765,6 +5924,7 @@ const COMMAND_HELP = {
5765
5924
  },
5766
5925
  lint: {
5767
5926
  usage: " vigiles lint [files...] Gate it in CI. The same checks — but a finding fails the build.",
5927
+ detail: [PRESET_HELP],
5768
5928
  },
5769
5929
  audit: {
5770
5930
  // "Reports everything, fails nothing" states always-exit-0 as the FEATURE it is. The line
@@ -5775,6 +5935,7 @@ const COMMAND_HELP = {
5775
5935
  detail: [
5776
5936
  " 2+ dirs → a leaderboard. Writes vigiles-report.html + .json (auto-gitignored).",
5777
5937
  " --single audit this dir as ONE harness, even if it holds many bundles",
5938
+ PRESET_HELP,
5778
5939
  " The executing checks (run your hooks · live MCP · do skills fire?) run only",
5779
5940
  " interactively — audit asks once and remembers; automation uses the testing API.",
5780
5941
  ],
@@ -6381,8 +6542,9 @@ function evalLockNudgeHookCommand() {
6381
6542
  // editing session, and a malformed `.vigilesrc.json` must not turn a JSON typo
6382
6543
  // into a failed edit: the nudge not firing is the cheaper failure. The verbs
6383
6544
  // throw on the same config; what is CHECKED is identical.
6384
- const config = (0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" });
6385
- const { options } = untestedRules(config);
6545
+ const config = loadConfig(cwd, { onInvalid: "warn" });
6546
+ const nudgeRules = (0, overrides_js_1.ruleResolver)(config.rules, config.overrides);
6547
+ const { options } = untestedRules(config, nudgeRules);
6386
6548
  // 🔴 THE SAME LAYOUT `vigiles lint` RESOLVES, for the same reason as the config
6387
6549
  // above. This used to pass `basePath` alone, so the detector fell back to the
6388
6550
  // Claude Code layout: in a Codex repo whose surfaces live at
@@ -6397,9 +6559,8 @@ function evalLockNudgeHookCommand() {
6397
6559
  // heard nothing at all, while a repo that already tests got reminded. That is
6398
6560
  // backwards, and `untested-skill` already stated the missing half correctly;
6399
6561
  // it just lived in `vigiles lint`, which someone has to run by hand.
6400
- // Same union `vigiles lint` applies: the rule's exclude narrows, the
6401
- // repo-wide exclude is the floor (#192) — the nudge must not report a
6402
- // vendored skill the linter would never list.
6562
+ // The repo-wide exclude `vigiles lint` applies (#192) — the nudge must not
6563
+ // report a vendored skill the linter would never list.
6403
6564
  const excludes = (0, exclude_js_1.excludeSet)(cwd, config.exclude);
6404
6565
  // 🔴 THE BUNDLE THAT HOLDS THE EDIT, found the way `lint` finds it (#281, D6).
6405
6566
  // The nudge scanned the root bundle only and matched by SUFFIX, so an edit to
@@ -6416,13 +6577,18 @@ function evalLockNudgeHookCommand() {
6416
6577
  return r !== "" && !r.startsWith("..") && !(0, node_path_1.isAbsolute)(r);
6417
6578
  })
6418
6579
  : undefined) ?? cwd;
6419
- const msg = (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
6420
- ...options,
6421
- basePath: home,
6422
- root: cwd,
6423
- layout,
6424
- excludes,
6425
- }) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
6580
+ // The same per-path answer `lint` gives: no untested nudge for a skill whose
6581
+ // folder an `overrides` entry turned `untested-skill` off for.
6582
+ const untestedHere = nudgeRules.severityAt("untested-skill", frame.repo(edited)) !== false;
6583
+ const msg = (untestedHere
6584
+ ? (0, test_coverage_js_1.skillTestNudge)(frame.repo(edited), {
6585
+ ...options,
6586
+ basePath: home,
6587
+ root: cwd,
6588
+ layout,
6589
+ excludes,
6590
+ })
6591
+ : undefined) ?? (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
6426
6592
  if (!msg)
6427
6593
  return;
6428
6594
  process.stdout.write(JSON.stringify({
@@ -6462,7 +6628,10 @@ async function refsHookCommand() {
6462
6628
  // from the process's directory is how a disabled rule comes back to life.
6463
6629
  const cwd = runtimeRoot(eventRoot(raw));
6464
6630
  // A hook rail — see `evalLockNudgeHookCommand` for why it warns, not throws.
6465
- const severity = (0, types_js_1.ruleSeverity)((0, validate_js_1.loadConfig)(cwd, { onInvalid: "warn" }).rules["unmarked-refs"]);
6631
+ // Resolved at the edited file's own path, so an `overrides` entry that turns
6632
+ // `unmarked-refs` off for a folder is honoured at edit time too.
6633
+ const refsConfig = loadConfig(cwd, { onInvalid: "warn" });
6634
+ const severity = (0, overrides_js_1.ruleResolver)(refsConfig.rules, refsConfig.overrides).severityAt("unmarked-refs", (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)));
6466
6635
  if (severity === false)
6467
6636
  return;
6468
6637
  const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
@@ -6542,7 +6711,7 @@ async function compileProviders() {
6542
6711
  * the string the user typed — `x.hook.ts` and `./x.hook.ts` are the same wiring),
6543
6712
  * so recompiling updates in place and never clobbers the user's own hooks.
6544
6713
  */
6545
- async function installHookFile(file, adapter, registeredProviders = []) {
6714
+ async function installHookFile(file, adapter, registeredProviders, runtime) {
6546
6715
  const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
6547
6716
  const program = await (0, hook_runtime_js_1.loadHookProgram)(file);
6548
6717
  // Emit the CANONICAL path so two spellings of the same file produce the same
@@ -6561,16 +6730,17 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6561
6730
  // cache, thirteen times, on every tool call. The `|| exit N` ending is what
6562
6731
  // the shell does when that file cannot start at all. `compileHookProgram`
6563
6732
  // adds it (see `hookRuntimeMissingExit`), so it is not written here.
6564
- gateCommand: `${(0, hook_install_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens)} hook-runtime run-program ` +
6565
- (0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens),
6733
+ gateCommand: (0, hook_command_js_1.runProgramCommand)((0, hook_command_js_1.hookRuntimeRef)(adapter.layout.projectRootTokens, runtime), (0, hook_command_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)),
6566
6734
  dialect: adapter.dialect,
6567
6735
  hookProtocol: adapter.hookProtocol,
6568
6736
  settings: adapter.layout.settings,
6569
6737
  registeredProviders,
6570
6738
  });
6571
- // Tamper-evident stamp beside the source (one dir → basename is unique).
6739
+ // Tamper-evident stamp beside the source (one dir → basename is unique). The
6740
+ // role is recorded with it: `lint` rebuilds the exact line compile writes
6741
+ // from it (`hookLinePlan`), so a wired line is read by identity, not parsed.
6572
6742
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)((0, hook_runtime_js_1.hookStampPath)(file)), { recursive: true });
6573
- (0, node_fs_1.writeFileSync)((0, hook_runtime_js_1.hookStampPath)(file), JSON.stringify({ file, stamp: compiled.stamp }, null, 2) + "\n");
6743
+ (0, node_fs_1.writeFileSync)((0, hook_runtime_js_1.hookStampPath)(file), JSON.stringify({ file, stamp: compiled.stamp, role: (0, hook_program_js_1.dispatchKind)(program) }, null, 2) + "\n");
6574
6744
  // Merge into the harness's native config, idempotently.
6575
6745
  //
6576
6746
  // 🔴 THREE FORMAT BRANCHES BECAME ZERO, and they were three because the ONE
@@ -6646,7 +6816,14 @@ async function installHookFile(file, adapter, registeredProviders = []) {
6646
6816
  `yet automatic).`;
6647
6817
  }
6648
6818
  }
6649
- return { role, settingsPath: adapter.layout.settingsPath, warning };
6819
+ // 🔴 `compiled.warnings` WAS COMPUTED AND NEVER PRINTED until 2026-10-11
6820
+ // (#356): a degraded role/event fit reached the author's terminal by no
6821
+ // path at all — a nudge with no delivery, inside the check for exactly that.
6822
+ return {
6823
+ role,
6824
+ settingsPath: adapter.layout.settingsPath,
6825
+ warnings: [...(compiled.warnings ?? []), ...(warning ? [warning] : [])],
6826
+ };
6650
6827
  }
6651
6828
  /**
6652
6829
  * Compile + install every hook (explicit paths, else discovered under
@@ -6680,15 +6857,29 @@ async function installHooks(hookFiles, harnessFlag, configHarness, validateProvi
6680
6857
  }
6681
6858
  if (hookFiles.length === 0)
6682
6859
  return true; // providers validated; nothing to wire
6860
+ // WHICH runtime the wiring starts — the installed package for a consumer, the
6861
+ // package's own built bin when this IS vigiles (see `hookRuntimeFor`).
6862
+ const runtime = (0, hook_install_js_1.readHookRuntime)(process.cwd());
6863
+ // 🔴 SAY IT NOW, because nothing will later. A nudge whose runtime cannot
6864
+ // start ends in `|| exit 0` by design, so from then on it is indistinguishable
6865
+ // from a nudge with nothing to say. Measured 2026-10-11 on this repository:
6866
+ // dead for three weeks, every gate green. Not an error — the file may arrive
6867
+ // with the next install or build — but never silent.
6868
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.resolve)(process.cwd(), runtime.path))) {
6869
+ console.warn(`⚠ the hook runtime ${runtime.path} is not on disk — every hook compiled ` +
6870
+ `here fails to load until it is: a reminder is skipped without a word, a ` +
6871
+ `gate refuses every command. Run ${runtime.cure}.`);
6872
+ }
6683
6873
  let ok = true;
6684
6874
  for (const file of hookFiles) {
6685
6875
  try {
6686
6876
  // Fan out: the same compiled hook lands in each enabled harness's config.
6687
6877
  for (const adapter of adapters) {
6688
- const r = await installHookFile(file, adapter, registeredProviders);
6878
+ const r = await installHookFile(file, adapter, registeredProviders, runtime);
6689
6879
  console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
6690
- if (r.warning)
6691
- console.warn(`⚠ ${r.warning}`);
6880
+ r.warnings.forEach((w) => {
6881
+ console.warn(`⚠ ${w}`);
6882
+ });
6692
6883
  }
6693
6884
  }
6694
6885
  catch (e) {
@@ -7091,6 +7282,30 @@ async function askOnce(q) {
7091
7282
  rl.close();
7092
7283
  }
7093
7284
  }
7285
+ /**
7286
+ * 🔴 THE PROJECT ROOT, resolved ONCE (#371) — the only place the CLI asks where
7287
+ * it was started. Every command then runs from the root, so `vigiles lint` in
7288
+ * `src/` reads the root's `.vigilesrc.json`, its overrides and its compiled
7289
+ * files exactly as it does at the root, and passes the root on explicitly. A
7290
+ * path the user typed means "from where I typed it", so path arguments are
7291
+ * re-expressed from the root first (`PATH_ARGUMENTS` says which arguments are
7292
+ * paths). `hook-runtime` is exempt: its cwd and argv come from the harness.
7293
+ */
7294
+ function enterProjectRoot() {
7295
+ const typed = process.argv.slice(2);
7296
+ const [command = "", ...rest] = typed;
7297
+ const invocationDir = process.cwd();
7298
+ if (!(0, cli_flag_check_js_1.runsFromProjectRoot)(command))
7299
+ return { command, projectRoot: invocationDir, args: typed };
7300
+ const projectRoot = (0, frame_js_1.findProjectRoot)(invocationDir, node_fs_1.existsSync);
7301
+ const args = [
7302
+ command,
7303
+ ...(0, cli_flag_check_js_1.rebasePathArgs)(command, rest, (p) => (0, frame_js_1.rebaseArg)(p, invocationDir, projectRoot)),
7304
+ ];
7305
+ if (projectRoot !== invocationDir)
7306
+ process.chdir(projectRoot);
7307
+ return { command, projectRoot, args };
7308
+ }
7094
7309
  /**
7095
7310
  * Persist the audit consent (`audit.measure`) into `.vigilesrc.json` without
7096
7311
  * clobbering existing keys — the "ask once, remember" sticky choice. Best-effort:
@@ -7119,9 +7334,49 @@ function rememberAuditMeasure(value) {
7119
7334
  /* non-fatal — the run already happened; only the remembered choice is lost */
7120
7335
  }
7121
7336
  }
7337
+ /**
7338
+ * The config this run uses: `.vigilesrc.json`, with any `--preset=<name>`
7339
+ * (audit, lint) applied ON TOP of what the file extends — through the one
7340
+ * validator `extends` uses, so an unknown name is "could not do what you
7341
+ * asked": exit 2, before anything runs. Then the shared flags (--max-rules,
7342
+ * --catalog-only) override it, so every GitHub Action input maps to a real CLI
7343
+ * flag (src/cli-flags.ts).
7344
+ */
7345
+ function loadRunConfig(projectRoot, args) {
7346
+ const presetFlag = (0, config_schema_js_2.parsePresetFlags)(CONFIG.presetNames, args
7347
+ .filter((a) => a.startsWith("--preset="))
7348
+ .map((a) => a.slice("--preset=".length)));
7349
+ if (!presetFlag.ok) {
7350
+ console.error(`✗ ${presetFlag.message}`);
7351
+ process.exit(2);
7352
+ }
7353
+ return (0, cli_flags_js_1.applyConfigFlags)(loadConfig(projectRoot, { presets: presetFlag.presets }), args);
7354
+ }
7355
+ /**
7356
+ * The scan `audit` reports on, with this run's config applied (`applyRules`):
7357
+ * an off rule's findings are gone before anything renders.
7358
+ */
7359
+ function auditScan(target, adapter, config, at) {
7360
+ const scan = (0, scan_js_1.scanPlugin)(target, adapter.layout, adapter.dialect, {
7361
+ deprecatedKeyFiles: specsUsingKeyFiles(at.root, at.excludes.globIgnore),
7362
+ sharedDirs: config.sharedDirs,
7363
+ sharedDirsRoot: at.frameRoot,
7364
+ // `.vigilesrc.json#exclude` reaches surface DISCOVERY, not just the
7365
+ // instruction file. Measured 2026-09-21 before this line existed: a repo
7366
+ // with `{"exclude": [".claude"]}` and one skill at `.claude/skills/demo`
7367
+ // still printed `Skills (1): ✓ demo` and docked Safety to 90 for it — the
7368
+ // grade was computed over a tree the user had told the tool to ignore.
7369
+ excludes: at.excludes,
7370
+ // The repo owner's answer to "N skills no harness reads": these are
7371
+ // mine, grade them — each root under the harness whose layout reads
7372
+ // it, so no array order decides which half of the repo is seen.
7373
+ // `exclude` still wins over it; the walk drops an excluded path first.
7374
+ harnesses: at.harnesses,
7375
+ });
7376
+ return (0, apply_rules_js_1.applyRules)(index_js_1.RULES, index_js_1.RULE_GROUPS, (0, overrides_js_1.ruleResolver)(config.rules, config.overrides), scan);
7377
+ }
7122
7378
  async function main() {
7123
- const args = process.argv.slice(2);
7124
- const command = args[0];
7379
+ const { command, projectRoot, args } = enterProjectRoot();
7125
7380
  // `--version` / `-v` / `version` prints the version number, not the help
7126
7381
  // banner — so `npx vigiles --version` reports e.g. `3.0.0`.
7127
7382
  if (command === "--version" || command === "-v" || command === "version") {
@@ -7154,13 +7409,11 @@ async function main() {
7154
7409
  }
7155
7410
  }
7156
7411
  const restArgs = args.slice(1).filter((a) => !a.startsWith("--"));
7157
- // Shared flags (--max-rules, --catalog-only) override the loaded config so
7158
- // every GitHub Action input maps to a real CLI flag. See src/cli-flags.ts.
7159
- const config = (0, cli_flags_js_1.applyConfigFlags)((0, validate_js_1.loadConfig)(), args);
7412
+ const config = loadRunConfig(projectRoot, args);
7160
7413
  // `.vigilesrc.json#exclude`, parsed ONCE here and threaded to every walk that
7161
7414
  // polices the repo (src/exclude.ts). Built where the config is loaded so a
7162
7415
  // command cannot re-derive it differently — the drift #192 measured.
7163
- const excludes = (0, exclude_js_1.excludeSet)(process.cwd(), config.exclude);
7416
+ const excludes = (0, exclude_js_1.excludeSet)(projectRoot, config.exclude);
7164
7417
  switch (command) {
7165
7418
  // --- Primary commands ---
7166
7419
  case "init": {
@@ -7193,7 +7446,7 @@ async function main() {
7193
7446
  // is refused below, out loud, with the command that fixes it.
7194
7447
  const found = restArgs.length > 0
7195
7448
  ? (0, hook_install_js_1.partitionHookArgs)(restArgs.filter((f) => !f.endsWith(".spec.ts")))
7196
- : (0, hook_install_js_1.discoveredHookArgs)(process.cwd());
7449
+ : (0, hook_install_js_1.discoveredHookArgs)(projectRoot);
7197
7450
  const hooks = [...found.hooks];
7198
7451
  const unclaimed = found.unclaimed;
7199
7452
  if (specs.length === 0 &&
@@ -7228,8 +7481,8 @@ async function main() {
7228
7481
  // committed baseline — shown at the moment of change. Report-only:
7229
7482
  // `lint` is the gate (rule `instruction-weight`).
7230
7483
  if (specs.length > 0) {
7231
- const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
7232
- root: process.cwd(),
7484
+ const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(projectRoot, (0, scan_js_1.measureInstructionWeight)(projectRoot, (0, adapter_registry_js_1.resolveHarnessSelection)({
7485
+ root: projectRoot,
7233
7486
  flag: harnessFlag,
7234
7487
  configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
7235
7488
  }).adapter));
@@ -7432,21 +7685,15 @@ async function main() {
7432
7685
  }
7433
7686
  // The same single owner of "relative to which directory" `lint` uses
7434
7687
  // (#281): cwd, unless the audited dir is somebody else's repository.
7435
- const frame = (0, frame_js_1.frameFor)(process.cwd(), root);
7436
- const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
7437
- deprecatedKeyFiles: specsUsingKeyFiles(root, excludes.globIgnore),
7438
- sharedDirs: config.sharedDirs,
7439
- sharedDirsRoot: frame.root,
7440
- // `.vigilesrc.json#exclude` reaches surface DISCOVERY, not just the
7441
- // instruction file. Measured 2026-09-21 before this line existed: a repo
7442
- // with `{"exclude": [".claude"]}` and one skill at `.claude/skills/demo`
7443
- // still printed `Skills (1): ✓ demo` and docked Safety to 90 for it — the
7444
- // grade was computed over a tree the user had told the tool to ignore.
7688
+ const frame = (0, frame_js_1.frameFor)(projectRoot, root);
7689
+ // The resolved config, applied once at the source: what an off rule
7690
+ // found is gone from everything below (terminal, JSON, HTML).
7691
+ // The resolved config is applied once, at the source: what an off
7692
+ // rule found is gone from everything below (terminal, JSON, HTML).
7693
+ const report = auditScan(targets[0], adapter, config, {
7694
+ root,
7695
+ frameRoot: frame.root,
7445
7696
  excludes,
7446
- // The repo owner's answer to "N skills no harness reads": these are
7447
- // mine, grade them — each root under the harness whose layout reads
7448
- // it, so no array order decides which half of the repo is seen.
7449
- // `exclude` still wins over it; the walk drops an excluded path first.
7450
7697
  harnesses: scanHarnesses,
7451
7698
  });
7452
7699
  if (!json) {
@@ -7478,9 +7725,11 @@ async function main() {
7478
7725
  // --target=skills/x/SKILL.md` named a file that does not exist from where
7479
7726
  // you run it, and offered to adopt a skill `exclude` had removed.
7480
7727
  const auditedBundle = frame.bundle(root);
7481
- const adoptableSurfaces = discoverAdoptableForAudit(root, adapter.layout.instructionFile)
7482
- .filter((p) => !(0, exclude_js_1.excludedBy)(excludes)((0, node_path_1.resolve)(root, p)))
7483
- .map((p) => auditedBundle.scanned(p));
7728
+ const adoptableSurfaces = discoverAdoptableForAudit(root, {
7729
+ instructionFile: adapter.layout.instructionFile,
7730
+ excludes,
7731
+ config,
7732
+ }).map((p) => auditedBundle.scanned(p));
7484
7733
  // Read the local flight recorder ONCE — feeds both the JSON report
7485
7734
  // (structured summary, the product boundary) and the terminal render.
7486
7735
  const ledgerRecords = (0, observe_js_1.readObservations)(root);
@@ -7488,7 +7737,7 @@ async function main() {
7488
7737
  // report is built (it's a pure read off the scan) because the report's
7489
7738
  // `Evaluated` ring needs to know whether this run will ever ask the
7490
7739
  // skill-firing question — see `firingMeasured` below.
7491
- const isForeign = root !== process.cwd();
7740
+ const isForeign = root !== projectRoot;
7492
7741
  // ONE read of "can we reach a model, and on whose bill", from the driving
7493
7742
  // adapter. It replaces a name check OR-ed with one harness's env vars at
7494
7743
  // four sites below, and the two hard-coded "authenticate the `claude` CLI"
@@ -7574,7 +7823,7 @@ async function main() {
7574
7823
  console.error(formatNothingToAudit(root, adapter.name, market));
7575
7824
  process.exit(2);
7576
7825
  }
7577
- const plan = (0, optimize_js_1.optimize)(report);
7826
+ const plan = (0, optimize_js_1.optimize)((0, apply_rules_js_1.gradedView)(report));
7578
7827
  // The deterministic READ leads: rings + report + fixes + nudges print
7579
7828
  // BEFORE the consent prompt, so a plain `audit` shows its findings first.
7580
7829
  // (JSON stays silent until the single blob below, after consent.)
@@ -7583,6 +7832,8 @@ async function main() {
7583
7832
  // shown before the detailed report so the headline signal leads.
7584
7833
  console.log((0, audit_score_js_1.formatAuditScore)(sc));
7585
7834
  console.log("");
7835
+ // The ungraded blocks come off the filtered scan — the same data
7836
+ // the JSON and HTML render — so the three cannot drift.
7586
7837
  console.log((0, scan_js_1.formatScanReport)(report));
7587
7838
  // Fold each finding's fix inline (replaces the former --fix-plan/--explain
7588
7839
  // flags): the deterministic, free recommendation list under the report.
@@ -7601,8 +7852,7 @@ async function main() {
7601
7852
  console.log("\n" + adoptNudge);
7602
7853
  // A small behavioral nudge — the deterministic read can't tell whether
7603
7854
  // skills actually FIRE; point at the interactive measure + the API.
7604
- const fireNudge = formatTriggerNudge(report.skills.filter((s) => s.hasDescription && !s.userInvoked)
7605
- .length);
7855
+ const fireNudge = formatTriggerNudge(surfaces.triggerableSkills);
7606
7856
  if (fireNudge)
7607
7857
  console.log("\n" + fireNudge);
7608
7858
  }
@@ -7758,8 +8008,8 @@ async function main() {
7758
8008
  // CI upload / a custom location). The dir is created if missing.
7759
8009
  const outFlag = args.find((a) => a.startsWith("--out="));
7760
8010
  const outDir = outFlag
7761
- ? (0, node_path_1.resolve)(process.cwd(), outFlag.slice("--out=".length))
7762
- : process.cwd();
8011
+ ? (0, node_path_1.resolve)(projectRoot, outFlag.slice("--out=".length))
8012
+ : projectRoot;
7763
8013
  const openReport = !args.includes("--no-open");
7764
8014
  const wroteReports = [];
7765
8015
  // The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
@@ -7805,14 +8055,14 @@ async function main() {
7805
8055
  // `--out` pointing elsewhere yields nothing to write rather than a
7806
8056
  // dead `../../..` entry.
7807
8057
  const rel = wroteReports
7808
- .map((f) => (0, repo_path_js_1.repoRelative)(process.cwd(), (0, node_path_1.resolve)(outDir, f), {
8058
+ .map((f) => (0, repo_path_js_1.repoRelative)(projectRoot, (0, node_path_1.resolve)(outDir, f), {
7809
8059
  relative: node_path_1.relative,
7810
8060
  resolve: node_path_1.resolve,
7811
8061
  isAbsolute: node_path_1.isAbsolute,
7812
8062
  sep: node_path_1.sep,
7813
8063
  }))
7814
8064
  .filter((p) => p !== null);
7815
- ensureReportGitignored(process.cwd(), rel);
8065
+ ensureReportGitignored(projectRoot, rel);
7816
8066
  }
7817
8067
  // A shareable deep-link for a public GitHub repo: the in-browser demo
7818
8068
  // re-runs the audit LIVE for whoever opens it, so a local result is