@event4u/agent-config 9.19.0 → 9.21.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 (234) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +89 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/cli-delegate/{chunk-XGA2NTU4.js → chunk-3EWYC3YK.js} +40 -0
  5. package/dist/cli-delegate/cmd_handoff.js +1 -1
  6. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  7. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  8. package/dist/discovery/deprecation-report.md +1 -1
  9. package/dist/discovery/discovery-manifest.json +1 -1
  10. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  11. package/dist/discovery/discovery-manifest.summary.md +1 -1
  12. package/dist/discovery/orphan-report.md +1 -1
  13. package/dist/discovery/packs.json +1 -1
  14. package/dist/discovery/trust-report.md +1 -1
  15. package/dist/discovery/workspaces.json +1 -1
  16. package/dist/hooks/dispatch.js +58 -2
  17. package/dist/mcp/registry-manifest.json +2 -2
  18. package/dist/mcp/server.json +2 -2
  19. package/dist/mcp/server.mjs +47 -29
  20. package/package.json +1 -1
  21. package/src/config/gate-coverage.yml +38 -4
  22. package/src/config/gate-violation-baselines.json +3 -8
  23. package/src/scripts/_lib/gate_population.ts +70 -0
  24. package/src/scripts/_lib/roadmap_granularity.ts +157 -0
  25. package/src/scripts/_lib/scan_scope.ts +44 -0
  26. package/src/scripts/_lib/stdin.ts +82 -0
  27. package/src/scripts/audit_adr_coverage.ts +38 -0
  28. package/src/scripts/audit_auto_rules.ts +17 -3
  29. package/src/scripts/audit_command_surface.ts +51 -0
  30. package/src/scripts/audit_initial_context.ts +39 -3
  31. package/src/scripts/audit_mcp_tools.ts +25 -0
  32. package/src/scripts/audit_overlap.ts +24 -1
  33. package/src/scripts/audit_skill_descriptions.ts +19 -0
  34. package/src/scripts/{verify_before_complete_hook.ts → before_complete_hook.ts} +16 -3
  35. package/src/scripts/check_adr_frontmatter.ts +23 -3
  36. package/src/scripts/check_always_budget.ts +24 -0
  37. package/src/scripts/check_artefact_checksums.ts +24 -1
  38. package/src/scripts/check_artefact_count_messaging.ts +20 -0
  39. package/src/scripts/check_augmentignore.ts +22 -15
  40. package/src/scripts/check_backstop_debt.ts +22 -0
  41. package/src/scripts/check_beta_review_markers.ts +22 -1
  42. package/src/scripts/check_bridge_derivation.ts +22 -1
  43. package/src/scripts/check_bundle_path_leakage.ts +26 -0
  44. package/src/scripts/check_ci_strict_superset.ts +21 -3
  45. package/src/scripts/check_claims.ts +22 -0
  46. package/src/scripts/check_cli_registry_budget_sync.ts +33 -4
  47. package/src/scripts/check_cluster_patterns.ts +26 -4
  48. package/src/scripts/check_command_count_messaging.ts +20 -4
  49. package/src/scripts/check_comparison.ts +18 -0
  50. package/src/scripts/check_completion_review.ts +19 -4
  51. package/src/scripts/check_condensed_paths.ts +23 -2
  52. package/src/scripts/check_consumer_scope_flip.ts +21 -2
  53. package/src/scripts/check_council_config_location.ts +20 -0
  54. package/src/scripts/check_council_layout.ts +22 -2
  55. package/src/scripts/check_council_references.ts +23 -1
  56. package/src/scripts/check_dependency_floors.ts +19 -3
  57. package/src/scripts/check_discovery_determinism.ts +26 -0
  58. package/src/scripts/check_enforcement_coverage.ts +20 -0
  59. package/src/scripts/check_evaluator_budgets.ts +23 -0
  60. package/src/scripts/check_finding_dispositions.ts +29 -0
  61. package/src/scripts/check_gate_coverage.ts +63 -22
  62. package/src/scripts/check_gate_paths.ts +30 -5
  63. package/src/scripts/check_generated_artefact_headers.ts +21 -0
  64. package/src/scripts/check_generator_output_coverage.ts +23 -0
  65. package/src/scripts/check_gitignore_freshness.ts +23 -0
  66. package/src/scripts/check_host_loadability.ts +34 -8
  67. package/src/scripts/check_installer_import_purity.ts +22 -8
  68. package/src/scripts/check_kernel_prefix_stability.ts +19 -1
  69. package/src/scripts/check_kernel_rule_bundle.ts +23 -0
  70. package/src/scripts/check_knowledge_cards.ts +13 -0
  71. package/src/scripts/check_knowledge_pages.ts +23 -0
  72. package/src/scripts/check_knowledge_sharing.ts +27 -0
  73. package/src/scripts/check_md_language.ts +24 -0
  74. package/src/scripts/check_media_deps.ts +23 -0
  75. package/src/scripts/check_memory.ts +26 -1
  76. package/src/scripts/check_memory_contradiction.ts +16 -1
  77. package/src/scripts/check_memory_proposal.ts +15 -0
  78. package/src/scripts/check_memory_similarity.ts +16 -0
  79. package/src/scripts/check_module_management_neutral.ts +19 -11
  80. package/src/scripts/check_no_conflict_markers.ts +22 -1
  81. package/src/scripts/check_no_external_sources.ts +26 -1
  82. package/src/scripts/check_no_local_settings_committed.ts +37 -10
  83. package/src/scripts/check_no_new_legacy_path.ts +30 -1
  84. package/src/scripts/check_one_off_location.ts +43 -0
  85. package/src/scripts/check_originality_freshness.ts +25 -1
  86. package/src/scripts/check_overlay_cascade_subdirs.ts +21 -0
  87. package/src/scripts/check_pack_size.ts +22 -0
  88. package/src/scripts/check_preamble_payload_budget.ts +35 -5
  89. package/src/scripts/check_proposal.ts +24 -1
  90. package/src/scripts/check_public_catalog_links.ts +27 -0
  91. package/src/scripts/check_public_links.ts +19 -0
  92. package/src/scripts/check_quality_regression.ts +19 -0
  93. package/src/scripts/check_reach_channels.ts +44 -0
  94. package/src/scripts/check_reach_staleness.ts +27 -0
  95. package/src/scripts/check_release_adjacent_health.ts +28 -0
  96. package/src/scripts/check_release_highlights.ts +34 -0
  97. package/src/scripts/check_release_includes_discovery.ts +25 -6
  98. package/src/scripts/check_release_pr_shape.ts +18 -1
  99. package/src/scripts/check_release_published.ts +21 -0
  100. package/src/scripts/check_release_surface_equality.ts +22 -0
  101. package/src/scripts/check_release_trunk_sync.ts +19 -0
  102. package/src/scripts/check_reply_consistency.ts +22 -0
  103. package/src/scripts/check_review_dispositions.ts +188 -0
  104. package/src/scripts/check_roadmap_trackable.ts +20 -0
  105. package/src/scripts/check_role_doc_links.ts +23 -0
  106. package/src/scripts/check_rule_invariants.ts +14 -0
  107. package/src/scripts/check_secret_leak.ts +47 -0
  108. package/src/scripts/check_skill_gaps.ts +32 -0
  109. package/src/scripts/check_skill_requires.ts +19 -0
  110. package/src/scripts/check_source_pointer_freshness.ts +20 -0
  111. package/src/scripts/check_structural_breaking.ts +29 -2
  112. package/src/scripts/check_surface_tiers.ts +34 -0
  113. package/src/scripts/check_template_pin_drift.ts +23 -1
  114. package/src/scripts/check_test_coverage_diff.ts +17 -1
  115. package/src/scripts/check_token_optimizer_freshness.ts +18 -3
  116. package/src/scripts/check_token_quality_golden.ts +21 -0
  117. package/src/scripts/check_token_regression.ts +25 -0
  118. package/src/scripts/check_tracked_but_ignored.ts +26 -0
  119. package/src/scripts/check_trigger_eval_presence.ts +23 -1
  120. package/src/scripts/check_trigger_evals.ts +36 -0
  121. package/src/scripts/check_trunk_drift.ts +23 -0
  122. package/src/scripts/check_update_banner.ts +24 -0
  123. package/src/scripts/dispatch_r2_reviewer.ts +3 -0
  124. package/src/scripts/gate_scope_classify.ts +513 -0
  125. package/src/scripts/hook_manifest.yaml +1 -1
  126. package/src/scripts/hooks/block_kernel_rule_writes.ts +1 -1
  127. package/src/scripts/hooks/concern_registry.ts +2 -2
  128. package/src/scripts/lint_agent_security.ts +19 -0
  129. package/src/scripts/lint_agent_skill_names.ts +30 -0
  130. package/src/scripts/lint_agents_layout.ts +20 -0
  131. package/src/scripts/lint_agents_md.ts +20 -0
  132. package/src/scripts/lint_archived_skills.ts +24 -3
  133. package/src/scripts/lint_behavioural_eval_freshness.ts +29 -3
  134. package/src/scripts/lint_bench_ab.ts +25 -0
  135. package/src/scripts/lint_bench_corpus.ts +20 -4
  136. package/src/scripts/lint_breaking_changes_index.ts +24 -0
  137. package/src/scripts/lint_budget_ownership.ts +20 -3
  138. package/src/scripts/lint_changelog_rollback.ts +24 -0
  139. package/src/scripts/lint_command_flow_coverage.ts +22 -0
  140. package/src/scripts/lint_command_tiers.ts +29 -5
  141. package/src/scripts/lint_commit_subjects.ts +15 -0
  142. package/src/scripts/lint_confusables.ts +24 -0
  143. package/src/scripts/lint_consumer_internal_refs.ts +24 -0
  144. package/src/scripts/lint_design_quality.ts +22 -0
  145. package/src/scripts/lint_design_slop.ts +23 -0
  146. package/src/scripts/lint_discovery_manifest.ts +23 -0
  147. package/src/scripts/lint_discovery_vocabulary.ts +19 -0
  148. package/src/scripts/lint_documented_commands.ts +23 -0
  149. package/src/scripts/lint_empty_roadmaps.ts +23 -0
  150. package/src/scripts/lint_eval_fixture_citations.ts +19 -0
  151. package/src/scripts/lint_eval_freshness.ts +35 -0
  152. package/src/scripts/lint_examples.ts +18 -3
  153. package/src/scripts/lint_explain_trace.ts +23 -0
  154. package/src/scripts/lint_featured_skills.ts +26 -1
  155. package/src/scripts/lint_flows.ts +19 -0
  156. package/src/scripts/lint_frontmatter_boilerplate.ts +20 -0
  157. package/src/scripts/lint_ghostwriter_source.ts +26 -6
  158. package/src/scripts/lint_glama_drift.ts +24 -0
  159. package/src/scripts/lint_global_paths.ts +24 -0
  160. package/src/scripts/lint_governed_writes.ts +20 -0
  161. package/src/scripts/lint_hidden_unicode.ts +25 -0
  162. package/src/scripts/lint_hook_concern_budget.ts +39 -6
  163. package/src/scripts/lint_hook_manifest.ts +22 -0
  164. package/src/scripts/lint_instruction_smuggling.ts +23 -0
  165. package/src/scripts/lint_knowledge_scale.ts +32 -2
  166. package/src/scripts/lint_legal_pack.ts +31 -0
  167. package/src/scripts/lint_marketplace.ts +26 -0
  168. package/src/scripts/lint_marketplace_install_completeness.ts +23 -0
  169. package/src/scripts/lint_mcp_config_security.ts +24 -0
  170. package/src/scripts/lint_mcp_registry_manifest.ts +22 -1
  171. package/src/scripts/lint_memory_tripwire.ts +35 -0
  172. package/src/scripts/lint_missions.ts +23 -7
  173. package/src/scripts/lint_model_tier_coverage.ts +18 -0
  174. package/src/scripts/lint_namespace_collisions.ts +19 -0
  175. package/src/scripts/lint_no_new_atomic_commands.ts +33 -0
  176. package/src/scripts/lint_no_python_twin_rationale.ts +18 -0
  177. package/src/scripts/lint_one_off_age.ts +38 -2
  178. package/src/scripts/lint_orchestration_dsl.ts +23 -3
  179. package/src/scripts/lint_orchestrator_auto_detect.ts +19 -12
  180. package/src/scripts/lint_originality.ts +22 -4
  181. package/src/scripts/lint_originality_shingles.ts +28 -3
  182. package/src/scripts/lint_output_slop.ts +19 -0
  183. package/src/scripts/lint_override_kernel_guard.ts +30 -0
  184. package/src/scripts/lint_pack_first_win.ts +23 -0
  185. package/src/scripts/lint_pack_risk_class.ts +19 -3
  186. package/src/scripts/lint_persistence.ts +19 -0
  187. package/src/scripts/lint_persona_governance.ts +18 -3
  188. package/src/scripts/lint_positioning.ts +23 -0
  189. package/src/scripts/lint_pre_migration_refs.ts +24 -0
  190. package/src/scripts/lint_profile_overlay_set_only.ts +24 -0
  191. package/src/scripts/lint_provenance.ts +21 -0
  192. package/src/scripts/lint_provenance_vocabulary.ts +24 -1
  193. package/src/scripts/lint_readme_jargon.ts +21 -3
  194. package/src/scripts/lint_readme_serial_comma.ts +21 -3
  195. package/src/scripts/lint_readme_size.ts +21 -3
  196. package/src/scripts/lint_regression.ts +28 -0
  197. package/src/scripts/lint_roadmap_blockers.ts +43 -0
  198. package/src/scripts/lint_roadmap_ci_steps.ts +45 -0
  199. package/src/scripts/lint_roadmap_complexity.ts +47 -0
  200. package/src/scripts/lint_roadmap_later_disposition.ts +23 -0
  201. package/src/scripts/lint_rule_interactions.ts +16 -2
  202. package/src/scripts/lint_rule_tiers.ts +19 -6
  203. package/src/scripts/lint_showcase_sessions.ts +19 -3
  204. package/src/scripts/lint_skill_descriptions.ts +19 -0
  205. package/src/scripts/lint_skill_frontmatter_safety.ts +24 -0
  206. package/src/scripts/lint_skill_originality.ts +18 -3
  207. package/src/scripts/lint_skill_scripts_readonly.ts +20 -0
  208. package/src/scripts/lint_skill_tools.ts +84 -145
  209. package/src/scripts/lint_spawn_payload.ts +27 -1
  210. package/src/scripts/lint_store_boundary.ts +19 -0
  211. package/src/scripts/lint_subagent_determinism.ts +23 -1
  212. package/src/scripts/lint_supported_tools_matrix.ts +18 -0
  213. package/src/scripts/lint_surface_matrix.ts +19 -3
  214. package/src/scripts/lint_ticket_buildable.ts +23 -0
  215. package/src/scripts/lint_topics_yaml.ts +19 -12
  216. package/src/scripts/lint_trigger_precision.ts +27 -0
  217. package/src/scripts/lint_trust_coherence.ts +21 -0
  218. package/src/scripts/lint_ui_stack_bundles.ts +17 -3
  219. package/src/scripts/lint_value_dashboard.ts +19 -3
  220. package/src/scripts/lint_versioned_cache.ts +19 -1
  221. package/src/scripts/lint_workflow_security.ts +22 -14
  222. package/src/scripts/pr_url_reminder_hook.ts +1 -1
  223. package/src/scripts/skill_collision_clusters.ts +37 -3
  224. package/src/scripts/skill_discovery.ts +20 -0
  225. package/src/scripts/skill_eval_coverage.ts +22 -0
  226. package/src/scripts/skill_overlap.ts +32 -6
  227. package/src/scripts/skill_preview.ts +31 -0
  228. package/src/scripts/skill_trigger_eval.ts +18 -0
  229. package/src/scripts/skill_usage_collect.ts +21 -0
  230. package/src/scripts/skill_usage_report.ts +21 -0
  231. package/src/scripts/sweep_dead_scan_roots.ts +11 -3
  232. package/src/scripts/verify_physical_move.ts +24 -0
  233. package/src/scripts/check_bite_sized_granularity.ts +0 -116
  234. package/src/scripts/lint_workspace_boundary.ts +0 -267
@@ -23,6 +23,7 @@
23
23
  import * as fs from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
 
26
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
26
27
  import { censusClaudeMdHierarchy, censusRuleDir, censusSkillsCatalog } from './preamble_byte_census.js';
27
28
 
28
29
  const HERE = path.dirname(new URL(import.meta.url).pathname);
@@ -40,7 +41,7 @@ export interface BudgetVerdict {
40
41
  baseline: number;
41
42
  ceiling: number;
42
43
  withinBudget: boolean;
43
- buckets: Array<{ name: string; tokens: number }>;
44
+ buckets: Array<{ name: string; tokens: number; files: number }>;
44
45
  }
45
46
 
46
47
  function tokens(chars: number): number {
@@ -65,7 +66,7 @@ export function readBudget(file: string = BUDGET_FILE): Budget {
65
66
  /** Measure only what the repo tree determines — no `~`, no transcripts, no network. */
66
67
  export function measureDeterministicPayload(
67
68
  repoRoot: string = REPO_ROOT,
68
- ): Array<{ name: string; tokens: number }> {
69
+ ): Array<{ name: string; tokens: number; files: number }> {
69
70
  const projectRules = censusRuleDir(path.join(repoRoot, 'dist', 'agent-src', 'rules'));
70
71
  const skills = censusSkillsCatalog(path.join(repoRoot, 'dist', 'agent-src', 'skills'));
71
72
  // Only the PROJECT half of the CLAUDE.md hierarchy is deterministic — the
@@ -73,10 +74,20 @@ export function measureDeterministicPayload(
73
74
  const claudeMd = censusClaudeMdHierarchy(repoRoot, path.join(repoRoot, '.no-such-home'));
74
75
  const projectClaudeMdChars =
75
76
  claudeMd.project_claude_md_chars + claudeMd.project_claude_local_md_chars;
77
+ // `files` carries the count of sources each census actually read. A token
78
+ // total cannot tell "the root moved" from "the payload is genuinely tiny",
79
+ // so the scope assertion in `main` needs the count, not the bytes.
80
+ const claudeMdFiles =
81
+ (claudeMd.project_claude_md_present ? 1 : 0) +
82
+ (claudeMd.project_claude_local_md_present ? 1 : 0);
76
83
  return [
77
- { name: 'project-scope rules', tokens: tokens(projectRules.chars) },
78
- { name: 'preloaded skills catalog', tokens: tokens(skills.chars) },
79
- { name: 'CLAUDE.md hierarchy (project only)', tokens: tokens(projectClaudeMdChars) },
84
+ { name: 'project-scope rules', tokens: tokens(projectRules.chars), files: projectRules.files },
85
+ { name: 'preloaded skills catalog', tokens: tokens(skills.chars), files: skills.skills },
86
+ {
87
+ name: 'CLAUDE.md hierarchy (project only)',
88
+ tokens: tokens(projectClaudeMdChars),
89
+ files: claudeMdFiles,
90
+ },
80
91
  ];
81
92
  }
82
93
 
@@ -98,6 +109,25 @@ export function main(argv: string[] = process.argv.slice(2)): number {
98
109
  return 2;
99
110
  }
100
111
 
112
+ // A ratchet over a measurement of nothing always passes: move
113
+ // `dist/agent-src/` and every census returns zero, which is trivially under
114
+ // any ceiling. Exit 2 (misuse / unreadable budget — the could-not-run
115
+ // code), never 1, which asserts the payload actually grew.
116
+ try {
117
+ assertScanned({
118
+ gate: 'check_preamble_payload_budget',
119
+ scanned: verdict.buckets.reduce((n, b) => n + b.files, 0),
120
+ units: 'payload source file(s)',
121
+ roots: ['dist/agent-src/rules', 'dist/agent-src/skills', 'CLAUDE.md'],
122
+ });
123
+ } catch (err) {
124
+ if (err instanceof DeadScopeError) {
125
+ process.stderr.write(`❌ preamble-payload budget: ${err.message}\n`);
126
+ return 2;
127
+ }
128
+ throw err;
129
+ }
130
+
101
131
  if (json) {
102
132
  process.stdout.write(JSON.stringify(verdict, null, 2) + '\n');
103
133
  return verdict.withinBudget ? 0 : 1;
@@ -37,6 +37,8 @@ import process from 'node:process';
37
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
38
38
  import { parse as parseYaml } from 'yaml';
39
39
 
40
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
+
40
42
  type Severity = 'error' | 'warning';
41
43
 
42
44
  const REQUIRED_FRONTMATTER: ReadonlySet<string> = new Set([
@@ -456,7 +458,28 @@ function main(): number {
456
458
  process.stderr.write(`error: ${p} not found\n`);
457
459
  return 3;
458
460
  }
459
- const findings = _runChecks(fs.readFileSync(p, 'utf-8'), p);
461
+ const text = fs.readFileSync(p, 'utf-8');
462
+ // No corpus walk: this gate's scope is the single document named on argv.
463
+ // The unit is therefore the content lines actually read — every check below
464
+ // is a search over this text, so a zero-byte target makes the negative ones
465
+ // ("no TODO/TBD markers") vacuously true against nothing.
466
+ try {
467
+ assertScanned({
468
+ gate: 'check_proposal',
469
+ scanned: text.split('\n').filter((l) => l.trim() !== '').length,
470
+ units: 'content line(s)',
471
+ roots: [p],
472
+ });
473
+ } catch (exc) {
474
+ if (exc instanceof DeadScopeError) {
475
+ // 1 = gate failure, which is what an unusable proposal already
476
+ // returns today (3 is reserved for the not-found path above).
477
+ process.stderr.write(`❌ ${exc.message}\n`);
478
+ return 1;
479
+ }
480
+ throw exc;
481
+ }
482
+ const findings = _runChecks(text, p);
460
483
  const errors = findings.filter((f) => f.severity === 'error');
461
484
  if (args.format === 'json') {
462
485
  process.stdout.write(
@@ -26,6 +26,8 @@ import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
 
29
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
30
+
29
31
  const QUIET = process.argv.slice(2).includes('--quiet');
30
32
 
31
33
  const _HERE = fileURLToPath(import.meta.url);
@@ -117,6 +119,10 @@ function main(): number {
117
119
  const missing: Array<[number, string]> = [];
118
120
  const unshipped: Array<[number, string]> = [];
119
121
 
122
+ // Counted BEFORE any filtering — external hrefs and anchors included — so
123
+ // the number answers "did the catalog still carry links", not "how many
124
+ // survived the checks".
125
+ let links_seen = 0;
120
126
  const lines = _splitlines(text);
121
127
  for (let i = 0; i < lines.length; i++) {
122
128
  const lineno = i + 1;
@@ -124,6 +130,7 @@ function main(): number {
124
130
  LINK_RE.lastIndex = 0;
125
131
  let m: RegExpExecArray | null;
126
132
  while ((m = LINK_RE.exec(line)) !== null) {
133
+ links_seen += 1;
127
134
  const href = m[1]!;
128
135
  if (href.includes(FORBIDDEN_PREFIX)) {
129
136
  forbidden.push([lineno, href]);
@@ -143,6 +150,26 @@ function main(): number {
143
150
  }
144
151
  }
145
152
 
153
+ // The catalog is generated. A renderer that stops emitting links leaves a
154
+ // file that still exists and still parses, so the three violation buckets
155
+ // stay empty and this reports "all links resolve" over no links at all.
156
+ // Exit 1 is the only failure code here, and it already carries the
157
+ // could-not-run meaning (the missing-catalog path above returns it too).
158
+ try {
159
+ assertScanned({
160
+ gate: 'check_public_catalog_links',
161
+ scanned: links_seen,
162
+ units: 'markdown link(s)',
163
+ roots: ['docs/catalog.md'],
164
+ });
165
+ } catch (exc) {
166
+ if (exc instanceof DeadScopeError) {
167
+ process.stdout.write(`❌ ${exc.message}\n`);
168
+ return 1;
169
+ }
170
+ throw exc;
171
+ }
172
+
146
173
  const total_violations = forbidden.length + missing.length + unshipped.length;
147
174
  if (total_violations === 0) {
148
175
  if (!QUIET) {
@@ -22,6 +22,8 @@ import * as fs from 'node:fs';
22
22
  import * as path from 'node:path';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
 
25
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
26
+
25
27
  const _HERE = fileURLToPath(import.meta.url);
26
28
  const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
27
29
  const PUBLIC_FILES = ['README.md', 'AGENTS.md', 'docs/architecture.md'] as const;
@@ -269,6 +271,23 @@ function main(): number {
269
271
  const args = parse_args(process.argv.slice(2));
270
272
 
271
273
  const contracts = collect_contracts();
274
+ // Every verdict below is decided against this map: an empty contracts dir
275
+ // turns "no stability violations" into "nothing was ever compared".
276
+ try {
277
+ assertScanned({
278
+ gate: 'check_public_links',
279
+ scanned: contracts.size,
280
+ units: 'contract file(s)',
281
+ roots: [CONTRACTS_DIR],
282
+ });
283
+ } catch (exc) {
284
+ if (exc instanceof DeadScopeError) {
285
+ // 1 = violations found (2 is argparse, 3 internal error).
286
+ process.stderr.write(`❌ ${exc.message}\n`);
287
+ return 1;
288
+ }
289
+ throw exc;
290
+ }
272
291
  if (args.list) {
273
292
  for (const [p, lvl] of contracts) {
274
293
  const label = lvl ?? '(no frontmatter)';
@@ -51,6 +51,7 @@ import process from 'node:process';
51
51
  import { fileURLToPath, pathToFileURL } from 'node:url';
52
52
 
53
53
  import { wilcoxon } from './bench_ab_v2_stats.js';
54
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
54
55
 
55
56
  const _HERE = fileURLToPath(import.meta.url);
56
57
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -299,6 +300,24 @@ function main(argv: string[]): number {
299
300
  }
300
301
 
301
302
  const results = Array.isArray(report.results) ? report.results : [];
303
+ // A MISSING report is the documented inert path and already returned above. A
304
+ // report that exists and carries no pairs is a different thing entirely — a
305
+ // truncated or half-written judge run — yet it aggregates to `no-data` and
306
+ // exits 0 outside flip-gate mode, i.e. reads as "quality held" on zero data.
307
+ try {
308
+ assertScanned({
309
+ gate: 'check_quality_regression',
310
+ scanned: results.length,
311
+ units: 'judged pair(s)',
312
+ roots: [path.relative(REPO_ROOT, reportPath)],
313
+ });
314
+ } catch (e) {
315
+ if (e instanceof DeadScopeError) {
316
+ process.stderr.write(`error: ${e.message}\n`);
317
+ return 1; // the gate's file-error code — the report is present but unusable
318
+ }
319
+ throw e;
320
+ }
302
321
  const thr = typeof report.threshold === 'number' ? report.threshold : threshold;
303
322
  const agg = aggregate(results, thr);
304
323
 
@@ -53,6 +53,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
53
53
 
54
54
  import { parse as parseYaml } from 'yaml';
55
55
 
56
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
56
57
  import { SchemaError, validate, type YamlValue } from './validate_frontmatter.js';
57
58
 
58
59
  const _HERE = fileURLToPath(import.meta.url);
@@ -305,6 +306,27 @@ export function check_probe_cmd_binding(registry: unknown): SchemaError[] {
305
306
  return findings;
306
307
  }
307
308
 
309
+ /**
310
+ * Backends declared across every channel — the units the schema rules and the
311
+ * `probe_cmd-binding` rule actually judge. Same deliberate shape-tolerance as
312
+ * `check_probe_cmd_binding`: anything unreadable is skipped, so the number is
313
+ * "entries a rule could be applied to", never "entries that passed".
314
+ */
315
+ function count_backends(registry: unknown): number {
316
+ const root =
317
+ registry !== null && typeof registry === 'object' && !Array.isArray(registry)
318
+ ? (registry as Record<string, unknown>)
319
+ : null;
320
+ const channels = Array.isArray(root?.['channels']) ? (root['channels'] as unknown[]) : [];
321
+ return channels.reduce<number>((total, rawChannel) => {
322
+ if (rawChannel === null || typeof rawChannel !== 'object' || Array.isArray(rawChannel)) {
323
+ return total;
324
+ }
325
+ const backends = (rawChannel as Record<string, unknown>)['backends'];
326
+ return total + (Array.isArray(backends) ? backends.length : 0);
327
+ }, 0);
328
+ }
329
+
308
330
  /**
309
331
  * Validate one registry file against the schema.
310
332
  *
@@ -376,6 +398,28 @@ export function main(argv: string[] = process.argv.slice(2)): number {
376
398
  return 1;
377
399
  }
378
400
 
401
+ // Reaching a CLEAN verdict over zero backends means the SCHEMA stopped
402
+ // constraining, not that the registry is empty: `minItems: 1` sits on both
403
+ // `channels` and `backends`, so an empty registry is an error-severity
404
+ // finding and returned above. A gutted or repointed schema is therefore
405
+ // the only way to arrive here with nothing scanned — and it would print ✅
406
+ // over a file no rule was applied to. Exit 3 is the could-not-run class
407
+ // `RegistryLoadError` already uses; 1 asserts real violations were found.
408
+ try {
409
+ assertScanned({
410
+ gate: 'check_reach_channels',
411
+ scanned: count_backends(load_registry(targetPath)),
412
+ units: 'backend(s)',
413
+ roots: [relTarget],
414
+ });
415
+ } catch (err) {
416
+ if (err instanceof DeadScopeError || err instanceof RegistryLoadError) {
417
+ process.stderr.write(`❌ check_reach_channels: ${err.message}\n`);
418
+ return 3;
419
+ }
420
+ throw err;
421
+ }
422
+
379
423
  if (!quiet) {
380
424
  process.stdout.write(`✅ ${relTarget}: schema-valid (reach channel registry).\n`);
381
425
  }
@@ -51,6 +51,7 @@ import * as path from 'node:path';
51
51
  import process from 'node:process';
52
52
  import { fileURLToPath, pathToFileURL } from 'node:url';
53
53
 
54
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
54
55
  import { RegistryLoadError, load_registry } from './check_reach_channels.js';
55
56
 
56
57
  const _HERE = fileURLToPath(import.meta.url);
@@ -313,8 +314,16 @@ export function main(argv: string[] = process.argv.slice(2)): number {
313
314
  const relTarget = path.relative(ROOT, registryPath) || registryPath;
314
315
 
315
316
  let findings: StalenessFinding[];
317
+ let channelCount: number;
316
318
  try {
317
319
  findings = run_checks({ registryPath, today });
320
+ // `load_registry` already refuses a missing / unparseable file (exit 3),
321
+ // but a registry that PARSES with no `channels:` list is the blind case:
322
+ // `extract_channels` is defensive by contract and returns [], so every
323
+ // per-channel check iterates nothing and the gate prints the ✅ line.
324
+ // Re-reading the (small) YAML keeps run_checks / extract_channels — both
325
+ // exported and pinned by fixtures — untouched.
326
+ channelCount = extract_channels(load_registry(registryPath)).length;
318
327
  } catch (err) {
319
328
  if (err instanceof RegistryLoadError) {
320
329
  process.stderr.write(`❌ check_reach_staleness: ${err.message}\n`);
@@ -323,6 +332,24 @@ export function main(argv: string[] = process.argv.slice(2)): number {
323
332
  throw err;
324
333
  }
325
334
 
335
+ // No `allowEmpty`: a registry with zero channels is a registry this gate
336
+ // cannot judge, and it reads exactly like a clean one. Exit 3 (unusable
337
+ // input) over 1 (violations) — a dead scope means the gate could not run.
338
+ try {
339
+ assertScanned({
340
+ gate: 'check_reach_staleness',
341
+ scanned: channelCount,
342
+ units: 'channel(s)',
343
+ roots: [`${path.relative(ROOT, registryPath) || registryPath} → channels[]`],
344
+ });
345
+ } catch (err) {
346
+ if (err instanceof DeadScopeError) {
347
+ process.stderr.write(`❌ ${err.message}\n`);
348
+ return 3;
349
+ }
350
+ throw err;
351
+ }
352
+
326
353
  if (findings.length > 0) {
327
354
  process.stdout.write(`❌ ${relTarget}: ${findings.length} staleness violation(s):\n`);
328
355
  for (const finding of findings) {
@@ -18,7 +18,14 @@
18
18
  * Exit codes: 0 all green/absent · 1 durably-red workflow(s) · 2 usage/env.
19
19
  */
20
20
 
21
+ import * as path from 'node:path';
21
22
  import { argv, env, exit } from 'node:process';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
26
+
27
+ // src/scripts/check_release_adjacent_health.ts → two levels up is the repo root.
28
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
22
29
 
23
30
  const WATCHED = [
24
31
  'publish-npm.yml',
@@ -71,6 +78,27 @@ async function main(): Promise<number> {
71
78
  return 2;
72
79
  }
73
80
 
81
+ // The scope is the watch list itself. A renamed or deleted workflow answers
82
+ // 404 → the loop logs "no completed runs (or workflow absent) — skipping"
83
+ // and continues, so a fully stale WATCHED list still prints "✅ healthy" —
84
+ // the same silent-tripwire failure this gate exists to detect. Checking the
85
+ // names against `.github/workflows/` catches the rename locally, before the
86
+ // API can launder it into a skip. Exit 2 (usage/env) over 1 (durably red):
87
+ // a phantom watch list means the tripwire could not run.
88
+ try {
89
+ assertWatchlistResolves({
90
+ gate: 'check_release_adjacent_health',
91
+ candidates: WATCHED.map((wf) => `.github/workflows/${wf}`),
92
+ repoRoot: REPO,
93
+ });
94
+ } catch (err) {
95
+ if (err instanceof DeadScopeError) {
96
+ process.stderr.write(`❌ ${err.message}\n`);
97
+ return 2;
98
+ }
99
+ throw err;
100
+ }
101
+
74
102
  // Tag/release-triggered workflows have no branch association — query
75
103
  // without the branch filter for those; branch-scoped for PR/push ones.
76
104
  const branchless = new Set(['publish-npm.yml', 'deploy-mcp-worker.yml', 'release.yml']);
@@ -47,6 +47,7 @@ import {
47
47
  stale_draft_labels,
48
48
  } from './_lib/release_highlights.js';
49
49
  import { extract_changelog_section } from './_lib/release_material.js';
50
+ import { assertScanned, assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
50
51
 
51
52
  const _HERE = fileURLToPath(import.meta.url);
52
53
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -142,6 +143,23 @@ function main(argv: readonly string[]): number {
142
143
  return 2;
143
144
  }
144
145
  }
146
+ // The changelog is the curated half of the comparison and it is read
147
+ // unconditionally below; a moved or renamed file would otherwise surface as
148
+ // an ENOENT stack trace rather than a gate verdict. Exit 2 is this gate's
149
+ // could-not-run code (same as a missing section); 1 means a contradiction.
150
+ try {
151
+ assertWatchlistResolves({
152
+ gate: 'check_release_highlights',
153
+ candidates: [path.relative(REPO_ROOT, path.resolve(changelogPath))],
154
+ repoRoot: REPO_ROOT,
155
+ });
156
+ } catch (err) {
157
+ if (err instanceof DeadScopeError) {
158
+ process.stderr.write(`❌ ${err.message}\n`);
159
+ return 2;
160
+ }
161
+ throw err;
162
+ }
145
163
  const section = extract_changelog_section(fs.readFileSync(changelogPath, 'utf-8'), version);
146
164
  if (!section) {
147
165
  process.stderr.write(`CHANGELOG carries no section for ${version}\n`);
@@ -159,6 +177,22 @@ function main(argv: readonly string[]): number {
159
177
  process.stderr.write(`${(err as Error).message}\n`);
160
178
  return 2;
161
179
  }
180
+ // Every derived category comes from this span, so an empty one makes the
181
+ // contradiction check pass over nothing.
182
+ assertScanned({
183
+ gate: 'check_release_highlights',
184
+ scanned: span.length,
185
+ units: 'span commit(s)',
186
+ roots: [`git log ${from}..${to}`],
187
+ allowEmpty:
188
+ 'EMPTY_VALID: git already answered for the range — a from..to it could not read ' +
189
+ 'throws in collect_span_commits above and exits 2, so reaching here with zero ' +
190
+ 'commits means git read the range successfully and it is genuinely empty. ' +
191
+ '"Empty span with _none_ everywhere is green" is a pre-registered fixture of this ' +
192
+ 'gate, not a scope failure: with no commits there is no evidence a curated `_none_` ' +
193
+ 'could contradict. Deleting the scan root is not the same state — that is the ' +
194
+ 'throwing branch, not this one.',
195
+ });
162
196
  // A generator-derived line that nobody rewrote is a prose gap, never a
163
197
  // contradiction: the line's evidence is true, it is just unpolished. Warn
164
198
  // so the omission is visible, and keep the exit code owned solely by the
@@ -20,6 +20,8 @@ import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
 
23
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
24
+
23
25
  const _HERE = fileURLToPath(import.meta.url);
24
26
  // src/scripts/check_release_includes_discovery.ts → two dirs up is repo root.
25
27
  // Mirrors Python `Path(__file__).resolve().parents[2]`.
@@ -68,17 +70,34 @@ function main(): number {
68
70
  data !== null && typeof data === 'object'
69
71
  ? (data as Record<string, unknown>)['artefacts']
70
72
  : undefined;
71
- if (!Array.isArray(artefacts) || artefacts.length === 0) {
72
- return _die(
73
- `${_rel(MANIFEST)} carries no artefacts — discovery` +
74
- ' scanner produced an empty manifest.',
75
- );
73
+ // The empty-manifest check IS this gate's scan-scope assertion: a manifest
74
+ // listing nothing means the discovery scanner walked a dead root, and
75
+ // shipping it would tell every consumer the package has no artefacts.
76
+ const artefact_count = Array.isArray(artefacts) ? artefacts.length : 0;
77
+ try {
78
+ assertScanned({
79
+ gate: 'check_release_includes_discovery',
80
+ scanned: artefact_count,
81
+ units: 'artefact(s)',
82
+ roots: [_rel(MANIFEST)],
83
+ });
84
+ } catch (exc) {
85
+ if (exc instanceof DeadScopeError) {
86
+ // 1 is this gate's only failure code, and the wording below is the
87
+ // pinned py2ts message — so the DeadScopeError text is not
88
+ // substituted for it.
89
+ return _die(
90
+ `${_rel(MANIFEST)} carries no artefacts — discovery` +
91
+ ' scanner produced an empty manifest.',
92
+ );
93
+ }
94
+ throw exc;
76
95
  }
77
96
  if (!_isFile(SUMMARY)) {
78
97
  return _die(`${_rel(SUMMARY)} is missing.`);
79
98
  }
80
99
  process.stdout.write(
81
- `check-release-discovery: OK (${artefacts.length} artefacts in` +
100
+ `check-release-discovery: OK (${artefact_count} artefacts in` +
82
101
  ` ${_rel(MANIFEST)})\n`,
83
102
  );
84
103
  return 0;
@@ -20,6 +20,8 @@ import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
 
23
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
24
+
23
25
  const _HERE = fileURLToPath(import.meta.url);
24
26
 
25
27
  const ALLOWLIST_GLOBS = [
@@ -127,7 +129,22 @@ function _gh_diff_files(pr: string): string[] {
127
129
  }
128
130
 
129
131
  function check(files: readonly string[]): number {
130
- if (files.length === 0) {
132
+ // This gate already refused an empty diff by hand; routing that refusal
133
+ // through the shared primitive keeps the behaviour (pinned stdout line,
134
+ // exit 1) while making the scope assertion explicit. No `allowEmpty`: a
135
+ // release PR that touches nothing is the failure, not a clean state — and
136
+ // an empty `gh pr diff` (bad PR number, auth failure) reads identically.
137
+ try {
138
+ assertScanned({
139
+ gate: 'check_release_pr_shape',
140
+ scanned: files.length,
141
+ units: 'changed file(s)',
142
+ roots: ['gh pr diff --name-only <pr> | --files <list>'],
143
+ });
144
+ } catch (exc) {
145
+ if (!(exc instanceof DeadScopeError)) {
146
+ throw exc;
147
+ }
131
148
  process.stdout.write(
132
149
  'OUT-OF-SHAPE: empty diff — release PR must touch at least one file.\n',
133
150
  );
@@ -26,6 +26,8 @@ import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
 
29
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
30
+
29
31
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
30
32
  const _HERE = fileURLToPath(import.meta.url);
31
33
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -131,6 +133,25 @@ const _hooks: Hooks = {
131
133
  function main(argv: readonly string[] = [], hooks: Hooks = _hooks): number {
132
134
  const args = parse_args(argv);
133
135
 
136
+ // This gate walks no tree: one named file supplies BOTH sides of every
137
+ // invariant — the version the git tag must match and the package name npm
138
+ // is queried for. A repointed PACKAGE_JSON constant is the whole failure
139
+ // surface, so the watch list is the scope assertion. Exit 3 (internal
140
+ // error — the gate could not run), never 1, which asserts real drift.
141
+ try {
142
+ assertWatchlistResolves({
143
+ gate: 'check_release_published',
144
+ candidates: [path.relative(REPO_ROOT, PACKAGE_JSON)],
145
+ repoRoot: REPO_ROOT,
146
+ });
147
+ } catch (exc) {
148
+ if (exc instanceof DeadScopeError) {
149
+ process.stderr.write(`❌ ${exc.message}\n`);
150
+ return 3;
151
+ }
152
+ throw exc;
153
+ }
154
+
134
155
  let version: string;
135
156
  try {
136
157
  version = hooks._package_version();
@@ -28,6 +28,7 @@ import {
28
28
  strip_pr_wrapper,
29
29
  surface_divergence,
30
30
  } from './_lib/release_material.js';
31
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
31
32
 
32
33
  const _HERE = fileURLToPath(import.meta.url);
33
34
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -102,6 +103,27 @@ function main(argv: readonly string[]): number {
102
103
  ) as Record<string, unknown>;
103
104
  version = String(pkg['version']);
104
105
  }
106
+ // The changelog is this gate's one fixed named input — the other surface is
107
+ // either fetched over the network (`--pr`) or a caller-supplied fixture. It
108
+ // is read unguarded below, so a moved or renamed CHANGELOG would surface as
109
+ // an ENOENT stack trace rather than a verdict. Listed alone on purpose: a
110
+ // watch list passes as soon as ANY candidate resolves, so pairing it with
111
+ // `--body-file` would let an existing fixture mask an absent changelog.
112
+ // Exit 2 — this gate's usage/extraction class — never 1, which asserts
113
+ // the two surfaces genuinely diverged.
114
+ try {
115
+ assertWatchlistResolves({
116
+ gate: 'check_release_surface_equality',
117
+ candidates: [path.relative(REPO_ROOT, path.resolve(changelogPath))],
118
+ repoRoot: REPO_ROOT,
119
+ });
120
+ } catch (err) {
121
+ if (err instanceof DeadScopeError) {
122
+ process.stderr.write(`❌ ${err.message}\n`);
123
+ return 2;
124
+ }
125
+ throw err;
126
+ }
105
127
  const prBody = pr !== null ? _pr_body(pr) : fs.readFileSync(bodyFile!, 'utf-8');
106
128
  const changelogText = fs.readFileSync(changelogPath, 'utf-8');
107
129
  const result = check_surface_equality({ prBody, changelogText, version });
@@ -21,6 +21,8 @@ import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
 
24
+ import { assertScanned } from './_lib/scan_scope.js';
25
+
24
26
  const RELEASE_BRANCH_RE = /^release\/(\d+)\.(\d+)\.(\d+)$/;
25
27
  const SEMVER_TAG_RE = /^(\d+)\.(\d+)\.(\d+)$/;
26
28
  const BOOTSTRAP_FILE = 'docs/contracts/release-trunk-sync.bootstrap';
@@ -137,6 +139,23 @@ function main(): number {
137
139
  }
138
140
  const m = RELEASE_BRANCH_RE.exec(branch);
139
141
  if (!m) {
142
+ // Declared no-op, not a silent one. The gate's other empty states —
143
+ // no semver tags, no tag reachable from main — are NOT swallowed
144
+ // either: each prints its own `::warning::… skipped` line below, and
145
+ // that wording is the pinned py2ts contract, so neither is converted
146
+ // into a DeadScopeError here.
147
+ assertScanned({
148
+ gate: 'check_release_trunk_sync',
149
+ scanned: 0,
150
+ units: 'release-prep branch(es)',
151
+ roots: ['git rev-parse --abbrev-ref HEAD'],
152
+ allowEmpty:
153
+ 'OPTIONAL_INPUT: the checked-out branch is not release/X.Y.Z, so there is no ' +
154
+ 'target version to compare main against — an absent question, not an empty ' +
155
+ 'corpus. Deletion test: wiping every git ref would not produce this branch; ' +
156
+ 'the input read here is the branch NAME, it was read successfully, and a ' +
157
+ 'branch that could not be read exits at the detached-HEAD warning above.',
158
+ });
140
159
  return 0; // non-release branch class — gate is a no-op
141
160
  }
142
161
  const target: SemVer = [Number(m[1]), Number(m[2]), Number(m[3])];