@event4u/agent-config 9.20.0 → 9.22.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 (239) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +86 -0
  3. package/dist/agent-src/commands/worktree/cleanup.md +17 -0
  4. package/dist/agent-src/commands/worktree/status.md +6 -0
  5. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli-delegate/{chunk-XGA2NTU4.js → chunk-3EWYC3YK.js} +40 -0
  8. package/dist/cli-delegate/cmd_handoff.js +1 -1
  9. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  10. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  11. package/dist/discovery/deprecation-report.md +1 -1
  12. package/dist/discovery/discovery-manifest.json +5 -5
  13. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  14. package/dist/discovery/discovery-manifest.summary.md +1 -1
  15. package/dist/discovery/orphan-report.md +1 -1
  16. package/dist/discovery/packs.json +2 -2
  17. package/dist/discovery/trust-report.md +1 -1
  18. package/dist/discovery/workspaces.json +2 -2
  19. package/dist/hooks/dispatch.js +58 -2
  20. package/dist/mcp/registry-manifest.json +2 -2
  21. package/dist/mcp/server.json +2 -2
  22. package/dist/mcp/server.mjs +47 -29
  23. package/package.json +1 -1
  24. package/src/config/gate-coverage.yml +22 -4
  25. package/src/config/gate-violation-baselines.json +3 -8
  26. package/src/scripts/_lib/bench_ab_activation.ts +368 -0
  27. package/src/scripts/_lib/gate_population.ts +70 -0
  28. package/src/scripts/_lib/roadmap_granularity.ts +157 -0
  29. package/src/scripts/_lib/scan_scope.ts +44 -0
  30. package/src/scripts/_lib/stdin.ts +82 -0
  31. package/src/scripts/audit_adr_coverage.ts +38 -0
  32. package/src/scripts/audit_auto_rules.ts +17 -3
  33. package/src/scripts/audit_command_surface.ts +51 -0
  34. package/src/scripts/audit_initial_context.ts +39 -3
  35. package/src/scripts/audit_mcp_tools.ts +25 -0
  36. package/src/scripts/audit_overlap.ts +24 -1
  37. package/src/scripts/audit_skill_descriptions.ts +19 -0
  38. package/src/scripts/{verify_before_complete_hook.ts → before_complete_hook.ts} +16 -3
  39. package/src/scripts/bench_ab_task_runner.ts +18 -0
  40. package/src/scripts/bench_ab_v2_run.ts +147 -6
  41. package/src/scripts/bench_ab_v2_stats.ts +74 -2
  42. package/src/scripts/check_adr_frontmatter.ts +23 -3
  43. package/src/scripts/check_always_budget.ts +24 -0
  44. package/src/scripts/check_artefact_checksums.ts +24 -1
  45. package/src/scripts/check_artefact_count_messaging.ts +20 -0
  46. package/src/scripts/check_augmentignore.ts +22 -15
  47. package/src/scripts/check_backstop_debt.ts +22 -0
  48. package/src/scripts/check_beta_review_markers.ts +22 -1
  49. package/src/scripts/check_bridge_derivation.ts +22 -1
  50. package/src/scripts/check_bundle_path_leakage.ts +26 -0
  51. package/src/scripts/check_ci_strict_superset.ts +21 -3
  52. package/src/scripts/check_claims.ts +22 -0
  53. package/src/scripts/check_cli_registry_budget_sync.ts +33 -4
  54. package/src/scripts/check_cluster_patterns.ts +26 -4
  55. package/src/scripts/check_command_count_messaging.ts +20 -4
  56. package/src/scripts/check_comparison.ts +18 -0
  57. package/src/scripts/check_condensed_paths.ts +23 -2
  58. package/src/scripts/check_consumer_scope_flip.ts +21 -2
  59. package/src/scripts/check_council_config_location.ts +20 -0
  60. package/src/scripts/check_council_layout.ts +22 -2
  61. package/src/scripts/check_council_references.ts +23 -1
  62. package/src/scripts/check_dependency_floors.ts +19 -3
  63. package/src/scripts/check_discovery_determinism.ts +26 -0
  64. package/src/scripts/check_enforcement_coverage.ts +20 -0
  65. package/src/scripts/check_evaluator_budgets.ts +23 -0
  66. package/src/scripts/check_finding_dispositions.ts +29 -0
  67. package/src/scripts/check_gate_coverage.ts +63 -22
  68. package/src/scripts/check_gate_paths.ts +30 -5
  69. package/src/scripts/check_generated_artefact_headers.ts +21 -0
  70. package/src/scripts/check_generator_output_coverage.ts +23 -0
  71. package/src/scripts/check_gitignore_freshness.ts +23 -0
  72. package/src/scripts/check_host_loadability.ts +34 -8
  73. package/src/scripts/check_installer_import_purity.ts +22 -8
  74. package/src/scripts/check_kernel_prefix_stability.ts +19 -1
  75. package/src/scripts/check_kernel_rule_bundle.ts +23 -0
  76. package/src/scripts/check_knowledge_cards.ts +13 -0
  77. package/src/scripts/check_knowledge_pages.ts +23 -0
  78. package/src/scripts/check_knowledge_sharing.ts +27 -0
  79. package/src/scripts/check_md_language.ts +24 -0
  80. package/src/scripts/check_media_deps.ts +23 -0
  81. package/src/scripts/check_memory.ts +26 -1
  82. package/src/scripts/check_memory_contradiction.ts +16 -1
  83. package/src/scripts/check_memory_proposal.ts +15 -0
  84. package/src/scripts/check_memory_similarity.ts +16 -0
  85. package/src/scripts/check_module_management_neutral.ts +19 -11
  86. package/src/scripts/check_no_conflict_markers.ts +22 -1
  87. package/src/scripts/check_no_external_sources.ts +26 -1
  88. package/src/scripts/check_no_local_settings_committed.ts +37 -10
  89. package/src/scripts/check_no_new_legacy_path.ts +30 -1
  90. package/src/scripts/check_one_off_location.ts +43 -0
  91. package/src/scripts/check_originality_freshness.ts +25 -1
  92. package/src/scripts/check_overlay_cascade_subdirs.ts +21 -0
  93. package/src/scripts/check_pack_size.ts +22 -0
  94. package/src/scripts/check_preamble_payload_budget.ts +35 -5
  95. package/src/scripts/check_proposal.ts +24 -1
  96. package/src/scripts/check_public_catalog_links.ts +27 -0
  97. package/src/scripts/check_public_links.ts +19 -0
  98. package/src/scripts/check_quality_regression.ts +19 -0
  99. package/src/scripts/check_reach_channels.ts +44 -0
  100. package/src/scripts/check_reach_staleness.ts +27 -0
  101. package/src/scripts/check_release_adjacent_health.ts +28 -0
  102. package/src/scripts/check_release_highlights.ts +34 -0
  103. package/src/scripts/check_release_includes_discovery.ts +25 -6
  104. package/src/scripts/check_release_pr_shape.ts +18 -1
  105. package/src/scripts/check_release_published.ts +21 -0
  106. package/src/scripts/check_release_surface_equality.ts +22 -0
  107. package/src/scripts/check_release_trunk_sync.ts +19 -0
  108. package/src/scripts/check_reply_consistency.ts +22 -0
  109. package/src/scripts/check_roadmap_trackable.ts +20 -0
  110. package/src/scripts/check_role_doc_links.ts +23 -0
  111. package/src/scripts/check_rule_invariants.ts +14 -0
  112. package/src/scripts/check_secret_leak.ts +47 -0
  113. package/src/scripts/check_skill_gaps.ts +32 -0
  114. package/src/scripts/check_skill_requires.ts +19 -0
  115. package/src/scripts/check_source_pointer_freshness.ts +20 -0
  116. package/src/scripts/check_structural_breaking.ts +29 -2
  117. package/src/scripts/check_surface_tiers.ts +34 -0
  118. package/src/scripts/check_template_pin_drift.ts +23 -1
  119. package/src/scripts/check_test_coverage_diff.ts +17 -1
  120. package/src/scripts/check_token_optimizer_freshness.ts +18 -3
  121. package/src/scripts/check_token_quality_golden.ts +21 -0
  122. package/src/scripts/check_token_regression.ts +25 -0
  123. package/src/scripts/check_tracked_but_ignored.ts +26 -0
  124. package/src/scripts/check_trigger_eval_presence.ts +23 -1
  125. package/src/scripts/check_trigger_evals.ts +36 -0
  126. package/src/scripts/check_trunk_drift.ts +23 -0
  127. package/src/scripts/check_update_banner.ts +24 -0
  128. package/src/scripts/gate_scope_classify.ts +513 -0
  129. package/src/scripts/hook_manifest.yaml +1 -1
  130. package/src/scripts/hooks/block_kernel_rule_writes.ts +1 -1
  131. package/src/scripts/hooks/concern_registry.ts +2 -2
  132. package/src/scripts/lint_agent_security.ts +19 -0
  133. package/src/scripts/lint_agent_skill_names.ts +30 -0
  134. package/src/scripts/lint_agents_layout.ts +20 -0
  135. package/src/scripts/lint_agents_md.ts +20 -0
  136. package/src/scripts/lint_archived_skills.ts +24 -3
  137. package/src/scripts/lint_behavioural_eval_freshness.ts +29 -3
  138. package/src/scripts/lint_bench_ab.ts +25 -0
  139. package/src/scripts/lint_bench_corpus.ts +20 -4
  140. package/src/scripts/lint_breaking_changes_index.ts +24 -0
  141. package/src/scripts/lint_budget_ownership.ts +20 -3
  142. package/src/scripts/lint_changelog_rollback.ts +24 -0
  143. package/src/scripts/lint_command_flow_coverage.ts +22 -0
  144. package/src/scripts/lint_command_tiers.ts +29 -5
  145. package/src/scripts/lint_commit_subjects.ts +15 -0
  146. package/src/scripts/lint_confusables.ts +24 -0
  147. package/src/scripts/lint_consumer_internal_refs.ts +24 -0
  148. package/src/scripts/lint_design_quality.ts +22 -0
  149. package/src/scripts/lint_design_slop.ts +23 -0
  150. package/src/scripts/lint_discovery_manifest.ts +23 -0
  151. package/src/scripts/lint_discovery_vocabulary.ts +19 -0
  152. package/src/scripts/lint_documented_commands.ts +23 -0
  153. package/src/scripts/lint_empty_roadmaps.ts +23 -0
  154. package/src/scripts/lint_eval_fixture_citations.ts +19 -0
  155. package/src/scripts/lint_eval_freshness.ts +35 -0
  156. package/src/scripts/lint_examples.ts +18 -3
  157. package/src/scripts/lint_explain_trace.ts +23 -0
  158. package/src/scripts/lint_featured_skills.ts +26 -1
  159. package/src/scripts/lint_flows.ts +19 -0
  160. package/src/scripts/lint_frontmatter_boilerplate.ts +20 -0
  161. package/src/scripts/lint_ghostwriter_source.ts +26 -6
  162. package/src/scripts/lint_glama_drift.ts +24 -0
  163. package/src/scripts/lint_global_paths.ts +24 -0
  164. package/src/scripts/lint_governed_writes.ts +20 -0
  165. package/src/scripts/lint_hidden_unicode.ts +25 -0
  166. package/src/scripts/lint_hook_concern_budget.ts +39 -6
  167. package/src/scripts/lint_hook_manifest.ts +22 -0
  168. package/src/scripts/lint_instruction_smuggling.ts +23 -0
  169. package/src/scripts/lint_knowledge_scale.ts +32 -2
  170. package/src/scripts/lint_legal_pack.ts +31 -0
  171. package/src/scripts/lint_marketplace.ts +26 -0
  172. package/src/scripts/lint_marketplace_install_completeness.ts +23 -0
  173. package/src/scripts/lint_mcp_config_security.ts +24 -0
  174. package/src/scripts/lint_mcp_registry_manifest.ts +22 -1
  175. package/src/scripts/lint_memory_tripwire.ts +35 -0
  176. package/src/scripts/lint_missions.ts +23 -7
  177. package/src/scripts/lint_model_tier_coverage.ts +18 -0
  178. package/src/scripts/lint_namespace_collisions.ts +19 -0
  179. package/src/scripts/lint_no_new_atomic_commands.ts +33 -0
  180. package/src/scripts/lint_no_python_twin_rationale.ts +18 -0
  181. package/src/scripts/lint_one_off_age.ts +38 -2
  182. package/src/scripts/lint_orchestration_dsl.ts +23 -3
  183. package/src/scripts/lint_orchestrator_auto_detect.ts +19 -12
  184. package/src/scripts/lint_originality.ts +22 -4
  185. package/src/scripts/lint_originality_shingles.ts +28 -3
  186. package/src/scripts/lint_output_slop.ts +19 -0
  187. package/src/scripts/lint_override_kernel_guard.ts +30 -0
  188. package/src/scripts/lint_pack_first_win.ts +23 -0
  189. package/src/scripts/lint_pack_risk_class.ts +19 -3
  190. package/src/scripts/lint_persistence.ts +19 -0
  191. package/src/scripts/lint_persona_governance.ts +18 -3
  192. package/src/scripts/lint_positioning.ts +23 -0
  193. package/src/scripts/lint_pre_migration_refs.ts +24 -0
  194. package/src/scripts/lint_profile_overlay_set_only.ts +24 -0
  195. package/src/scripts/lint_provenance.ts +21 -0
  196. package/src/scripts/lint_provenance_vocabulary.ts +24 -1
  197. package/src/scripts/lint_readme_jargon.ts +21 -3
  198. package/src/scripts/lint_readme_serial_comma.ts +21 -3
  199. package/src/scripts/lint_readme_size.ts +21 -3
  200. package/src/scripts/lint_regression.ts +28 -0
  201. package/src/scripts/lint_roadmap_blockers.ts +43 -0
  202. package/src/scripts/lint_roadmap_ci_steps.ts +45 -0
  203. package/src/scripts/lint_roadmap_complexity.ts +47 -0
  204. package/src/scripts/lint_roadmap_later_disposition.ts +23 -0
  205. package/src/scripts/lint_rule_interactions.ts +16 -2
  206. package/src/scripts/lint_rule_tiers.ts +19 -6
  207. package/src/scripts/lint_showcase_sessions.ts +19 -3
  208. package/src/scripts/lint_skill_descriptions.ts +19 -0
  209. package/src/scripts/lint_skill_frontmatter_safety.ts +24 -0
  210. package/src/scripts/lint_skill_originality.ts +18 -3
  211. package/src/scripts/lint_skill_scripts_readonly.ts +20 -0
  212. package/src/scripts/lint_skill_tools.ts +84 -145
  213. package/src/scripts/lint_spawn_payload.ts +27 -1
  214. package/src/scripts/lint_store_boundary.ts +19 -0
  215. package/src/scripts/lint_subagent_determinism.ts +23 -1
  216. package/src/scripts/lint_supported_tools_matrix.ts +18 -0
  217. package/src/scripts/lint_surface_matrix.ts +19 -3
  218. package/src/scripts/lint_ticket_buildable.ts +23 -0
  219. package/src/scripts/lint_topics_yaml.ts +19 -12
  220. package/src/scripts/lint_trigger_precision.ts +27 -0
  221. package/src/scripts/lint_trust_coherence.ts +21 -0
  222. package/src/scripts/lint_ui_stack_bundles.ts +17 -3
  223. package/src/scripts/lint_value_dashboard.ts +19 -3
  224. package/src/scripts/lint_versioned_cache.ts +19 -1
  225. package/src/scripts/lint_workflow_security.ts +22 -14
  226. package/src/scripts/pr_url_reminder_hook.ts +1 -1
  227. package/src/scripts/skill_collision_clusters.ts +37 -3
  228. package/src/scripts/skill_discovery.ts +20 -0
  229. package/src/scripts/skill_eval_coverage.ts +22 -0
  230. package/src/scripts/skill_overlap.ts +32 -6
  231. package/src/scripts/skill_preview.ts +31 -0
  232. package/src/scripts/skill_trigger_eval.ts +18 -0
  233. package/src/scripts/skill_usage_collect.ts +21 -0
  234. package/src/scripts/skill_usage_report.ts +21 -0
  235. package/src/scripts/sweep_dead_scan_roots.ts +11 -3
  236. package/src/scripts/verify_physical_move.ts +24 -0
  237. package/src/scripts/worktree_cleanup_check.ts +319 -4
  238. package/src/scripts/check_bite_sized_granularity.ts +0 -116
  239. package/src/scripts/lint_workspace_boundary.ts +0 -267
@@ -19,6 +19,8 @@ import * as path from 'node:path';
19
19
  import * as fs from 'node:fs';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
21
 
22
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
23
+
22
24
  const _HERE = fileURLToPath(import.meta.url);
23
25
  const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
24
26
  const SCANNER = path.join(ROOT, 'src', 'scripts', 'build_discovery_manifest.ts');
@@ -76,6 +78,30 @@ class ExitError extends Error {}
76
78
  function main(): number {
77
79
  const a = _normalise(_run());
78
80
  const b = _normalise(_run());
81
+
82
+ // Determinism over an EMPTY manifest is vacuously true: if the scanner's
83
+ // artefact roots moved, both runs emit zero artefacts, the two payloads
84
+ // compare equal, and this gate reports "OK: deterministic" having compared
85
+ // nothing. Assert the corpus the comparison ran over, not the comparison.
86
+ // Deletion test: wiping the artefact tree would give 0 — which is blindness
87
+ // here, never success — so no `allowEmpty`. Exit 1 is this pinned CLI's
88
+ // only failure code (the scanner-failed path also exits 1, via ExitError).
89
+ const artefacts = a['artefacts'];
90
+ try {
91
+ assertScanned({
92
+ gate: 'check_discovery_determinism',
93
+ scanned: Array.isArray(artefacts) ? artefacts.length : 0,
94
+ units: 'manifest artefact(s)',
95
+ roots: ['src/scripts/build_discovery_manifest.ts → manifest.artefacts'],
96
+ });
97
+ } catch (exc) {
98
+ if (exc instanceof DeadScopeError) {
99
+ process.stderr.write(`${exc.message}\n`);
100
+ return 1;
101
+ }
102
+ throw exc;
103
+ }
104
+
79
105
  const sa = _dumps(a);
80
106
  const sb = _dumps(b);
81
107
  if (sa !== sb) {
@@ -37,6 +37,8 @@ import * as fs from 'node:fs';
37
37
  import * as path from 'node:path';
38
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
+
40
42
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
41
43
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
42
44
  const RULES_DIR = path.join(REPO_ROOT, 'src', 'rules');
@@ -478,6 +480,24 @@ function main(argv: string[]): number {
478
480
  }
479
481
 
480
482
  const rows = collect();
483
+ // Count every rule read, not the declared subset: with an empty rules dir
484
+ // the report renders "0/0 rules (0%)" and exits green, and `summarise`
485
+ // special-cases the zero denominator rather than rejecting it. Exit 2 is
486
+ // the code the missing-rules-dir guard above already uses for this class.
487
+ try {
488
+ assertScanned({
489
+ gate: 'check_enforcement_coverage',
490
+ scanned: rows.length,
491
+ units: 'rule file(s)',
492
+ roots: ['src/rules'],
493
+ });
494
+ } catch (exc) {
495
+ if (exc instanceof DeadScopeError) {
496
+ process.stderr.write(`❌ ${exc.message}\n`);
497
+ return 2;
498
+ }
499
+ throw exc;
500
+ }
481
501
  const summary = summarise(rows);
482
502
 
483
503
  if (as_json) {
@@ -40,6 +40,8 @@ import * as fs from 'node:fs';
40
40
  import * as path from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
42
 
43
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
44
+
43
45
  const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
44
46
  const BUDGETS_PATH = path.join(REPO_ROOT, 'src', 'config', 'evaluator-budgets.json');
45
47
 
@@ -230,6 +232,27 @@ export function main(argv: string[] = process.argv.slice(2)): number {
230
232
  return 2;
231
233
  }
232
234
 
235
+ // The budget document IS the corpus: every finding below is produced by
236
+ // iterating it, so a budgets file that parses to no entries reports
237
+ // "✅ evaluator budgets met (0 metrics)" — green over nothing, the exact
238
+ // shape this harness exists to end. A missing `budgets` key counts as 0 for
239
+ // the same reason. Exit 2 (documented internal error — the gate could not
240
+ // run), never 1, which means a metric actually breached.
241
+ try {
242
+ assertScanned({
243
+ gate: 'check_evaluator_budgets',
244
+ scanned: Object.keys(budgetsDoc.budgets ?? {}).length,
245
+ units: 'budgeted metric(s)',
246
+ roots: ['src/config/evaluator-budgets.json (budgets)'],
247
+ });
248
+ } catch (e) {
249
+ if (e instanceof DeadScopeError) {
250
+ process.stderr.write(`❌ ${e.message}\n`);
251
+ return 2;
252
+ }
253
+ throw e;
254
+ }
255
+
233
256
  // The committed record is optional: a fresh checkout before the first
234
257
  // nightly has none, and a missing record must not fake a green drift check.
235
258
  const recordPath = argValue(argv, '--recorded') ?? path.join(REPO_ROOT, RECORD_REL);
@@ -34,6 +34,8 @@ import * as path from 'node:path';
34
34
  import process from 'node:process';
35
35
  import { fileURLToPath } from 'node:url';
36
36
 
37
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
38
+
37
39
  const _HERE = fileURLToPath(import.meta.url);
38
40
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
39
41
 
@@ -235,6 +237,33 @@ function main(argv: readonly string[]): number {
235
237
  }
236
238
  }
237
239
 
240
+ // Scope declaration, not a scope guard. The ledger is written per release by
241
+ // `--ingest`, so "no <release>.json" is the normal state of a release whose
242
+ // self-review reported nothing — the gate's own success line says so. That
243
+ // makes an absent file indistinguishable from a moved ledger dir here, and
244
+ // the honest reading is that the corpus count cannot carry that signal: the
245
+ // durable trigger for an un-ingested finding is `--pr` mode, which compares
246
+ // the ledger against what the self-review actually reported.
247
+ try {
248
+ assertScanned({
249
+ gate: 'check_finding_dispositions',
250
+ scanned: ledger.findings.length,
251
+ units: 'recorded finding(s)',
252
+ roots: [path.relative(REPO_ROOT, ledgerPath)],
253
+ allowEmpty:
254
+ 'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
255
+ + 'self-review reported none — the ledger file is created on first --ingest, so a '
256
+ + 'clean release legitimately has no <version>.json. Un-ingested findings are '
257
+ + 'caught by --pr (unrecorded_findings), never by this count.',
258
+ });
259
+ } catch (exc) {
260
+ if (exc instanceof DeadScopeError) {
261
+ process.stderr.write(`❌ ${exc.message}\n`);
262
+ return 1;
263
+ }
264
+ throw exc;
265
+ }
266
+
238
267
  const problems = missing_dispositions(ledger.findings);
239
268
  if (pr) {
240
269
  const reported = parse_comment_findings(_gh_comment_bodies(pr));
@@ -51,6 +51,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
51
51
  import * as yaml from 'js-yaml';
52
52
  import { checkRatchet } from './_lib/gate_baseline.js';
53
53
  import { runCountedProbe } from './_lib/counted_probe.js';
54
+ import { listGateScripts } from './_lib/gate_population.js';
55
+ import { assertScanned } from './_lib/scan_scope.js';
54
56
 
55
57
  const _HERE = fileURLToPath(import.meta.url);
56
58
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -260,6 +262,18 @@ export function main(argv: readonly string[]): number {
260
262
  let specs: GateSpec[];
261
263
  try {
262
264
  specs = load_manifest();
265
+ // This guard's own scan scope is the manifest. It already refused an empty
266
+ // gate list ("a coverage guard over an empty set is vacuous"), but as a
267
+ // bespoke throw — invisible to the very population test below, which is how
268
+ // the meta-gate ended up outside its own definition of hardened. Routing the
269
+ // same condition through the shared assertion is the honest fix: no new
270
+ // behaviour, one shape for "this gate read nothing".
271
+ assertScanned({
272
+ gate: 'check_gate_coverage',
273
+ scanned: specs.length,
274
+ units: 'manifest gate(s)',
275
+ roots: [path.relative(REPO_ROOT, MANIFEST)],
276
+ });
263
277
  } catch (e) {
264
278
  process.stderr.write(`❌ gate-coverage manifest: ${(e as Error).message}\n`);
265
279
  return 2;
@@ -358,33 +372,66 @@ export function report_hardening_ratchet(): number {
358
372
  }
359
373
 
360
374
  /**
361
- * Gate scripts that neither assert their scan scope nor publish a count — the
362
- * population still able to report success over an empty corpus.
375
+ * Manifest ids whose `scanned:` line is actually ENFORCED against a floor.
363
376
  *
364
- * A gate counts as hardened when it routes through `_lib/scan_scope` (the
365
- * assertion that turns a zero scan into a red exit) OR emits the machine-
366
- * readable `scanned:` line the coverage guard reads. Either one makes a dead
367
- * root visible; neither is satisfiable by a gate that read nothing.
377
+ * A `pending` entry, or an enforced one with `min_scanned: 0`, reads the line
378
+ * without being able to fail on it — so it cannot make a gate hardened either.
379
+ * Read best-effort: an unreadable manifest yields an empty set, which pushes
380
+ * every emit-only gate back into the unhardened count. That direction is
381
+ * deliberate — a coverage guard that cannot read its own manifest should
382
+ * over-report exposure, never under-report it.
368
383
  */
369
- export function list_unhardened_gates(dir = path.join(REPO_ROOT, 'src/scripts')): string[] {
370
- let entries: string[];
384
+ export function enforced_manifest_ids(file = MANIFEST): Set<string> {
371
385
  try {
372
- entries = fs.readdirSync(dir);
386
+ return new Set(
387
+ load_manifest(file)
388
+ .filter((s) => s.status === 'enforced' && s.min_scanned >= 1)
389
+ .map((s) => s.id),
390
+ );
373
391
  } catch {
374
- return [];
392
+ return new Set<string>();
375
393
  }
376
- const gates = entries.filter((f) => /^(lint|check|audit)_.*\.ts$/.test(f) && !f.endsWith('.d.ts'));
394
+ }
395
+
396
+ /**
397
+ * Gate scripts that neither assert their scan scope nor publish an ENFORCED
398
+ * count — the population still able to report success over an empty corpus.
399
+ *
400
+ * ```
401
+ * hardened ⇔ routes through _lib/scan_scope
402
+ * ∨ (emits `scanned:` ∧ registered enforced in gate-coverage.yml with a floor)
403
+ * ```
404
+ *
405
+ * The second clause used to be a bare "emits `scanned:`". Measured 2026-08-04
406
+ * while chartering the conversion of the remaining 189: a printed count is only
407
+ * a *guard* for the gates this file actually runs against a floor. For an
408
+ * unregistered gate the line is decoration — it can print `scanned: 0` out of a
409
+ * deleted root and still exit 0, with nobody reading it. That made the cheapest
410
+ * route to a green ratchet (add one write per gate) also the route that changes
411
+ * nothing, while "pad the manifest" is an explicit non-goal of the conversion
412
+ * roadmap. Requiring registration closes the loop: to harden by emitting you
413
+ * must accept a floor, and everything else asserts.
414
+ *
415
+ * The tightening cost the existing population nothing — all 14 emit-only gates
416
+ * were already registered enforced — which is why it is a drift repair against
417
+ * this guard's own charter ("asserts its scan scope … or carries a justified
418
+ * `allowEmpty`") rather than a moved goalpost.
419
+ */
420
+ export function list_unhardened_gates(
421
+ dir = path.join(REPO_ROOT, 'src/scripts'),
422
+ registered: ReadonlySet<string> = enforced_manifest_ids(),
423
+ ): string[] {
377
424
  const out: string[] = [];
378
- for (const f of gates) {
425
+ for (const id of listGateScripts(dir, (d) => fs.readdirSync(d))) {
379
426
  let src: string;
380
427
  try {
381
- src = fs.readFileSync(path.join(dir, f), 'utf8');
428
+ src = fs.readFileSync(path.join(dir, `${id}.ts`), 'utf8');
382
429
  } catch {
383
430
  continue;
384
431
  }
385
- const asserts = /assertScanned\(|assertWatchlistResolves\(/.test(src);
432
+ const asserts = /assertScanned\(|assertWatchlistResolves\(|reportScanned\(/.test(src);
386
433
  const emits = /(?:process\.(?:stdout|stderr)\.write|lines\.push)\(\s*`scanned: /.test(src);
387
- if (!asserts && !emits) out.push(f.replace(/\.ts$/, ''));
434
+ if (!asserts && !(emits && registered.has(id))) out.push(id);
388
435
  }
389
436
  return out.sort();
390
437
  }
@@ -637,13 +684,7 @@ export function render_ledger(
637
684
 
638
685
  /** Count of gate-shaped scripts, for the honest denominator in the ledger. */
639
686
  export function count_gate_scripts(dir = path.join(REPO_ROOT, 'src/scripts')): number {
640
- try {
641
- return fs
642
- .readdirSync(dir)
643
- .filter((f) => /^(lint|check|audit)_.*\.ts$/.test(f) && !f.endsWith('.d.ts')).length;
644
- } catch {
645
- return 0;
646
- }
687
+ return listGateScripts(dir, (d) => fs.readdirSync(d)).length;
647
688
  }
648
689
 
649
690
  function run_canary_mode(specs: readonly GateSpec[], argv: readonly string[]): number {
@@ -20,7 +20,9 @@
20
20
  * synchronous. No behaviour changes.
21
21
  *
22
22
  * Exit codes: 0 = all enforced targets resolve under the source tree ·
23
- * 1 = at least one missing / out-of-tree target · 2 = a gate failed to import.
23
+ * 1 = at least one missing / out-of-tree target · 2 = a gate failed to import
24
+ * (which now includes a dead scan scope — zero enforced targets across the
25
+ * whole GATES list — reported through `_lib/scan_scope`).
24
26
  */
25
27
 
26
28
  import { spawnSync } from 'node:child_process';
@@ -28,6 +30,8 @@ import * as fs from 'node:fs';
28
30
  import * as path from 'node:path';
29
31
  import { fileURLToPath, pathToFileURL } from 'node:url';
30
32
 
33
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
34
+
31
35
  const _HERE = fileURLToPath(import.meta.url);
32
36
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
33
37
 
@@ -190,6 +194,31 @@ function main(): number {
190
194
  process.stderr.write(`❌ check-gate-paths: ${msg}\n`);
191
195
  return 2;
192
196
  }
197
+ let total = 0;
198
+ for (const v of named.values()) {
199
+ total += v.length;
200
+ }
201
+ // The enforced targets ARE the corpus. A per-gate empty GATE_CORE_PATHS is
202
+ // already exit 2 inside the subprocess, but an emptied GATES list is not:
203
+ // it yields zero targets, zero failures, and a green "0 enforced target(s)
204
+ // … resolve" — the path-integrity gate certifying that nothing is enforced.
205
+ try {
206
+ assertScanned({
207
+ gate: 'check_gate_paths',
208
+ scanned: total,
209
+ units: 'enforced target(s)',
210
+ roots: ['src/scripts (GATE_CORE_PATHS of the GATES list)'],
211
+ });
212
+ } catch (exc) {
213
+ if (exc instanceof DeadScopeError) {
214
+ // Exit 2, not 1: 1 means "a declared target failed to resolve" (a
215
+ // finding), while 2 is this file's existing "a gate cannot be
216
+ // checked" code — which is exactly what an empty target set is.
217
+ process.stderr.write(`❌ ${exc.message}\n`);
218
+ return 2;
219
+ }
220
+ throw exc;
221
+ }
193
222
  const failures = check_paths(named);
194
223
  if (failures.length > 0) {
195
224
  process.stdout.write(
@@ -202,10 +231,6 @@ function main(): number {
202
231
  process.stdout.write(' GATE_CORE_PATHS or the move.\n');
203
232
  return 1;
204
233
  }
205
- let total = 0;
206
- for (const v of named.values()) {
207
- total += v.length;
208
- }
209
234
  process.stdout.write(
210
235
  `✅ check-gate-paths: ${total} enforced target(s) across ` +
211
236
  `${named.size} gate(s) resolve under the source tree.\n`,
@@ -15,6 +15,8 @@ import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
 
18
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
19
+
18
20
  const _HERE = fileURLToPath(import.meta.url);
19
21
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
20
22
 
@@ -55,6 +57,25 @@ function main(argv?: readonly string[]): number {
55
57
  const args = argv ?? process.argv.slice(2);
56
58
  const quiet = args.includes('--quiet');
57
59
 
60
+ // Every artefact is skipped when absent, so all three moving at once prints
61
+ // "✅ All 3 generated artefacts carry the freshness marker" over zero reads.
62
+ // The per-file skip below stays — a single gitignored artefact is a real
63
+ // state; none of them existing is a moved root. 1 is the only failure code
64
+ // this gate has, so "could not run" and "found something" share it.
65
+ try {
66
+ assertWatchlistResolves({
67
+ gate: 'check_generated_artefact_headers',
68
+ candidates: GENERATED_ARTEFACTS.map((e) => e.path),
69
+ repoRoot: REPO_ROOT,
70
+ });
71
+ } catch (err) {
72
+ if (err instanceof DeadScopeError) {
73
+ process.stderr.write(`❌ ${err.message}\n`);
74
+ return 1;
75
+ }
76
+ throw err;
77
+ }
78
+
58
79
  const errors: string[] = [];
59
80
  const warnings: string[] = [];
60
81
 
@@ -18,6 +18,8 @@ import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
21
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
22
+
21
23
  const _HERE = fileURLToPath(import.meta.url);
22
24
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
23
25
 
@@ -103,6 +105,27 @@ function main(argv?: readonly string[]): number {
103
105
  .filter((l) => Boolean(l) && !l.startsWith('#')),
104
106
  );
105
107
 
108
+ // The registry below is the corpus — the manifest and .gitignore are only
109
+ // what each root is checked AGAINST, and both already fail closed above. An
110
+ // emptied registry (a refactor that moves the list, a bad merge) leaves the
111
+ // loop with nothing to classify and prints "✅ All 0 generator output roots
112
+ // are classified". Exit 2, the code this gate already uses for "a required
113
+ // input is not there", never 1, which means a root is unclassified.
114
+ try {
115
+ assertScanned({
116
+ gate: 'check_generator_output_coverage',
117
+ scanned: GENERATOR_OUTPUT_ROOTS.length,
118
+ units: 'generator output root(s)',
119
+ roots: ['src/scripts/check_generator_output_coverage.ts (GENERATOR_OUTPUT_ROOTS)'],
120
+ });
121
+ } catch (err) {
122
+ if (err instanceof DeadScopeError) {
123
+ process.stderr.write(`❌ ${err.message}\n`);
124
+ return 2;
125
+ }
126
+ throw err;
127
+ }
128
+
106
129
  const unclassified: typeof GENERATOR_OUTPUT_ROOTS[number][] = [];
107
130
  for (const entry of GENERATOR_OUTPUT_ROOTS) {
108
131
  if (!isClassified(entry.root, manifestPaths, gitignoreLines)) {
@@ -17,6 +17,8 @@ import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
 
20
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
21
+
20
22
  const _HERE = fileURLToPath(import.meta.url);
21
23
  const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
22
24
 
@@ -83,6 +85,27 @@ function main(argv?: readonly string[]): number {
83
85
  const blockLines = new Set(blockText.split('\n').map((l) => l.trim()).filter(Boolean));
84
86
 
85
87
  const entries = parseManifest(manifestText);
88
+ // Counted on the whole parse, not on the consumer-scoped subset the loop
89
+ // judges: `_isFile` above only proves the manifest exists, and a shape change
90
+ // that makes `- path:` stop matching yields zero entries, zero missing paths,
91
+ // and "✅ covers all 0 consumer-scoped ignored paths". Exit 2 (documented
92
+ // "invalid args or missing file" — the gate could not run), not 1, which
93
+ // means the block is genuinely missing a path.
94
+ try {
95
+ assertScanned({
96
+ gate: 'check_gitignore_freshness',
97
+ scanned: entries.length,
98
+ units: 'manifest entr(y|ies)',
99
+ roots: ['src/config/agents-paths.yml'],
100
+ });
101
+ } catch (err) {
102
+ if (err instanceof DeadScopeError) {
103
+ process.stderr.write(`❌ ${err.message}\n`);
104
+ return 2;
105
+ }
106
+ throw err;
107
+ }
108
+
86
109
  const missing: string[] = [];
87
110
 
88
111
  for (const e of entries) {
@@ -16,10 +16,14 @@ import * as path from 'node:path';
16
16
 
17
17
  import { parse as parseYaml } from 'yaml';
18
18
 
19
- export function check_claude_skills(root: string, errors: string[]): void {
19
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
20
+
21
+ /** @returns how many skill directories were examined. */
22
+ export function check_claude_skills(root: string, errors: string[]): number {
20
23
  const dir = path.join(root, '.claude', 'skills');
21
- if (!fs.existsSync(dir)) return;
22
- for (const name of fs.readdirSync(dir)) {
24
+ if (!fs.existsSync(dir)) return 0;
25
+ const names = fs.readdirSync(dir);
26
+ for (const name of names) {
23
27
  const skillMd = path.join(dir, name, 'SKILL.md');
24
28
  if (!fs.existsSync(skillMd)) {
25
29
  errors.push(`${skillMd}: missing SKILL.md`);
@@ -35,13 +39,17 @@ export function check_claude_skills(root: string, errors: string[]): void {
35
39
  else if (meta['name'] !== name) errors.push(`${skillMd}: name '${meta['name']}' != dir '${name}'`);
36
40
  if (typeof meta['description'] !== 'string' || !meta['description']) errors.push(`${skillMd}: missing description`);
37
41
  }
42
+ return names.length;
38
43
  }
39
44
 
40
- export function check_cursor_rules(root: string, errors: string[]): void {
45
+ /** @returns how many `.mdc` rule files were examined. */
46
+ export function check_cursor_rules(root: string, errors: string[]): number {
41
47
  const dir = path.join(root, '.cursor', 'rules');
42
- if (!fs.existsSync(dir)) return;
48
+ if (!fs.existsSync(dir)) return 0;
49
+ let scanned = 0;
43
50
  for (const name of fs.readdirSync(dir)) {
44
51
  if (!name.endsWith('.mdc')) continue;
52
+ scanned += 1;
45
53
  const p = path.join(dir, name);
46
54
  const src = fs.readFileSync(p, 'utf-8');
47
55
  const m = src.match(/^---\n([\s\S]*?)\n---/);
@@ -57,12 +65,22 @@ export function check_cursor_rules(root: string, errors: string[]): void {
57
65
  try { parseYaml(lenient); }
58
66
  catch (e) { errors.push(`${p}: frontmatter YAML invalid (${(e as Error).message})`); }
59
67
  }
68
+ return scanned;
60
69
  }
61
70
 
62
71
  export function run(root: string): string[] {
63
72
  const errors: string[] = [];
64
- check_claude_skills(root, errors);
65
- check_cursor_rules(root, errors);
73
+ // Both host trees are generated and gitignored, and each checker returns
74
+ // early when its tree is absent — so "no malformed artefacts" and "no
75
+ // artefacts" are the same green. Thrown, not returned: `errors` names
76
+ // malformed files, and an unprojected tree is not one.
77
+ const scanned = check_claude_skills(root, errors) + check_cursor_rules(root, errors);
78
+ assertScanned({
79
+ gate: 'check_host_loadability',
80
+ scanned,
81
+ units: 'host artefact(s)',
82
+ roots: ['.claude/skills', '.cursor/rules'],
83
+ });
66
84
  return errors;
67
85
  }
68
86
 
@@ -71,7 +89,15 @@ const isMain = process.argv[1] !== undefined
71
89
  if (isMain) {
72
90
  const rootIdx = process.argv.indexOf('--root');
73
91
  const root = rootIdx !== -1 ? process.argv[rootIdx + 1] ?? '.' : '.';
74
- const errors = run(root);
92
+ let errors: string[];
93
+ try {
94
+ errors = run(root);
95
+ } catch (exc) {
96
+ if (!(exc instanceof DeadScopeError)) throw exc;
97
+ // Exit 1 is this gate's only failure code.
98
+ process.stderr.write(`❌ ${exc.message}\n`);
99
+ process.exit(1);
100
+ }
75
101
  if (errors.length > 0) {
76
102
  for (const e of errors) process.stderr.write(`❌ ${e}\n`);
77
103
  process.exit(1);
@@ -59,6 +59,8 @@ import { fileURLToPath } from 'node:url';
59
59
 
60
60
  import ts from 'typescript';
61
61
 
62
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
63
+
62
64
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
63
65
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
64
66
 
@@ -208,14 +210,6 @@ function parse(file: string): ts.SourceFile | null {
208
210
  */
209
211
  function auditEntry(entryRel: string, root: string = REPO_ROOT): Finding[] {
210
212
  const entryAbs = path.join(root, entryRel);
211
- if (!fs.existsSync(entryAbs)) {
212
- process.stderr.write(
213
- `check-installer-import-purity: entry ${entryRel} does not exist — ` +
214
- 'update BUNDLE_ENTRIES\n',
215
- );
216
- process.exitCode = 1;
217
- return [];
218
- }
219
213
  const findings: Finding[] = [];
220
214
  const seen = new Set<string>([entryAbs]);
221
215
  // BFS so the reported chain is a shortest path — the least confusing one.
@@ -245,6 +239,26 @@ function auditEntry(entryRel: string, root: string = REPO_ROOT): Finding[] {
245
239
  }
246
240
 
247
241
  function main(): number {
242
+ // BUNDLE_ENTRIES is the whole scan scope — every module examined is reached
243
+ // from one of them. This REPLACES the per-entry existence guard in
244
+ // `auditEntry`, which could not fail: it set `process.exitCode = 1` and
245
+ // returned no findings, and `process.exit(main())` then overwrote that with
246
+ // main's 0, so a moved entry printed "✅ … reach no module-level
247
+ // process.exit()". 1 is the only failure code this gate has.
248
+ try {
249
+ assertWatchlistResolves({
250
+ gate: 'check_installer_import_purity',
251
+ candidates: BUNDLE_ENTRIES,
252
+ repoRoot: REPO_ROOT,
253
+ });
254
+ } catch (err) {
255
+ if (err instanceof DeadScopeError) {
256
+ process.stderr.write(`❌ ${err.message}\n`);
257
+ return 1;
258
+ }
259
+ throw err;
260
+ }
261
+
248
262
  const all: Finding[] = [];
249
263
  for (const entry of BUNDLE_ENTRIES) {
250
264
  all.push(...auditEntry(entry));
@@ -37,6 +37,8 @@ import * as path from 'node:path';
37
37
  import process from 'node:process';
38
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
+
40
42
  const _HERE = fileURLToPath(import.meta.url);
41
43
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
42
44
  const ROUTER = path.join(REPO_ROOT, 'dist/router.json');
@@ -96,7 +98,17 @@ function _live_prefix(): KernelPrefix | null {
96
98
  const router = _readJson(ROUTER);
97
99
  if (router === null) return null;
98
100
  const ids = kernel_ids(router);
99
- if (ids.length === 0) return null;
101
+ // Scope only — the baseline and the comparison are untouched. An empty kernel
102
+ // list used to collapse into the same `null` as an unreadable router, so a
103
+ // router that compiled to zero kernel rules was reported as a missing file;
104
+ // it is a dead scan scope, and a digest over no bodies is a constant that
105
+ // would then look byte-stable forever.
106
+ assertScanned({
107
+ gate: 'check_kernel_prefix_stability',
108
+ scanned: ids.length,
109
+ units: 'kernel rule(s)',
110
+ roots: ['dist/router.json (kernel)', 'dist/agent-src/rules'],
111
+ });
100
112
  return compute_prefix(ids, (id) => fs.readFileSync(path.join(RULES_DIR, `${id}.md`), 'utf-8'));
101
113
  }
102
114
 
@@ -118,6 +130,12 @@ function main(argv: string[]): number {
118
130
  try {
119
131
  current = _live_prefix();
120
132
  } catch (e) {
133
+ // 1 is the documented "file error" code — the gate could not run. 2 is
134
+ // reserved for drift, i.e. a prefix that was read and did change.
135
+ if (e instanceof DeadScopeError) {
136
+ process.stderr.write(`error: ${e.message}\n`);
137
+ return 1;
138
+ }
121
139
  process.stderr.write(`error: cannot read kernel bodies: ${(e as Error).message}\n`);
122
140
  return 1;
123
141
  }
@@ -37,6 +37,7 @@ import * as path from 'node:path';
37
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
38
38
  import { KERNEL_RULE_FILENAMES } from './_lib/kernel_rules.js';
39
39
  import { runCountedProbe } from './_lib/counted_probe.js';
40
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
40
41
 
41
42
  const KERNEL_RULES: ReadonlySet<string> = KERNEL_RULE_FILENAMES;
42
43
 
@@ -55,6 +56,8 @@ const KERNEL_RULES: ReadonlySet<string> = KERNEL_RULE_FILENAMES;
55
56
  */
56
57
  const KERNEL_DIR = 'src/rules';
57
58
  const DEFAULT_LABEL = 'bundled-always-rules-acknowledged';
59
+ // src/scripts/check_kernel_rule_bundle.ts → two levels up is the repo root.
60
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
58
61
 
59
62
  function _git_changed_files(base_ref: string): string[] {
60
63
  // Bounded read: a truncated diff would shrink the change set and let the
@@ -188,6 +191,26 @@ function parse_args(argv: readonly string[]): Args {
188
191
  export function main(argv?: readonly string[]): number {
189
192
  const args = parse_args(argv ?? process.argv.slice(2));
190
193
 
194
+ // This gate has no corpus — it matches changed paths against a fixed set of
195
+ // 9 kernel rules, so its scope IS that watch list. It has already been
196
+ // inert once for exactly this reason (see KERNEL_DIR above): a prefix that
197
+ // no longer exists never matches, and every kernel-rule edit scored
198
+ // "no kernel rule touched". Exit 3 (internal error) over 1 (bundle
199
+ // violated) — a phantom watch list means the gate could not run.
200
+ try {
201
+ assertWatchlistResolves({
202
+ gate: 'check_kernel_rule_bundle',
203
+ candidates: [...KERNEL_RULES].sort().map((name) => `${KERNEL_DIR}/${name}`),
204
+ repoRoot: REPO,
205
+ });
206
+ } catch (exc) {
207
+ if (exc instanceof DeadScopeError) {
208
+ process.stderr.write(`❌ ${exc.message}\n`);
209
+ return 3;
210
+ }
211
+ throw exc;
212
+ }
213
+
191
214
  // Python: args.files or _git_changed_files(...). An empty list from
192
215
  // `--files` (no values) is falsy in Python → falls through to git diff.
193
216
  const files =