@event4u/agent-config 9.25.0 → 9.27.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 (222) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +58 -143
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +21 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +256 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/active-remediation.md +2 -0
  10. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  11. package/dist/agent-src/rules/architecture.md +2 -0
  12. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  13. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  14. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  15. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  16. package/dist/agent-src/rules/brand-consistency.md +2 -0
  17. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  18. package/dist/agent-src/rules/broken-access-control.md +2 -0
  19. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  20. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  21. package/dist/agent-src/rules/code-provenance.md +2 -0
  22. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  23. package/dist/agent-src/rules/commit-conventions.md +2 -0
  24. package/dist/agent-src/rules/communication-through-line.md +2 -0
  25. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  26. package/dist/agent-src/rules/context-hygiene.md +10 -0
  27. package/dist/agent-src/rules/copilot-routing.md +2 -0
  28. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  29. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  30. package/dist/agent-src/rules/delegation-policy.md +5 -2
  31. package/dist/agent-src/rules/design-fidelity.md +19 -1
  32. package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
  33. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  34. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  35. package/dist/agent-src/rules/docker-commands.md +2 -0
  36. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  37. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  38. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  39. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  40. package/dist/agent-src/rules/downstream-changes.md +2 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +19 -3
  43. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  44. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  45. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  46. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  47. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  48. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  49. package/dist/agent-src/rules/guidelines.md +2 -0
  50. package/dist/agent-src/rules/history-discipline.md +2 -0
  51. package/dist/agent-src/rules/icon-consistency.md +2 -0
  52. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  53. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  54. package/dist/agent-src/rules/invite-challenge.md +2 -0
  55. package/dist/agent-src/rules/laravel-routing.md +2 -0
  56. package/dist/agent-src/rules/laravel-translations.md +2 -0
  57. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  58. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  59. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  60. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  61. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  62. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  63. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  64. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  65. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  66. package/dist/agent-src/rules/model-recommendation.md +2 -0
  67. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  68. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  69. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  70. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  71. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  72. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  73. package/dist/agent-src/rules/output-discipline.md +2 -0
  74. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  75. package/dist/agent-src/rules/persona-governance.md +2 -0
  76. package/dist/agent-src/rules/php-coding.md +2 -0
  77. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  78. package/dist/agent-src/rules/preservation-guard.md +2 -0
  79. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  80. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  81. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  82. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  83. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  84. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  85. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  86. package/dist/agent-src/rules/runtime-safety.md +2 -0
  87. package/dist/agent-src/rules/scale-discipline.md +2 -0
  88. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  89. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  90. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  91. package/dist/agent-src/rules/session-canary.md +63 -19
  92. package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
  93. package/dist/agent-src/rules/size-enforcement.md +2 -0
  94. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  95. package/dist/agent-src/rules/skill-quality.md +2 -0
  96. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  97. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  98. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  99. package/dist/agent-src/rules/source-of-truth.md +2 -0
  100. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  101. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  102. package/dist/agent-src/rules/symfony-routing.md +2 -0
  103. package/dist/agent-src/rules/think-before-action.md +2 -0
  104. package/dist/agent-src/rules/token-budget-discipline.md +8 -3
  105. package/dist/agent-src/rules/token-efficiency.md +2 -0
  106. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  107. package/dist/agent-src/rules/tool-safety.md +2 -0
  108. package/dist/agent-src/rules/ui-audit-gate.md +7 -0
  109. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  110. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  111. package/dist/agent-src/rules/user-interaction.md +5 -2
  112. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  113. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  114. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  115. package/dist/cli/registry.js +2 -0
  116. package/dist/cli/registry.js.map +1 -1
  117. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  118. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  119. package/dist/cli-delegate/cmd_conformance.js +1 -1
  120. package/dist/cli-delegate/cmd_doctor.js +1 -1
  121. package/dist/cli-delegate/cmd_export.js +1 -1
  122. package/dist/cli-delegate/cmd_prune.js +1 -1
  123. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  124. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  125. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  126. package/dist/cli-delegate/cmd_sync.js +1 -1
  127. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  128. package/dist/cli-delegate/cmd_validate.js +1 -1
  129. package/dist/discovery/deprecation-report.md +1 -1
  130. package/dist/discovery/discovery-manifest.json +191 -120
  131. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  132. package/dist/discovery/discovery-manifest.summary.md +4 -4
  133. package/dist/discovery/orphan-report.md +1 -1
  134. package/dist/discovery/packs.json +11 -8
  135. package/dist/discovery/trust-report.md +3 -3
  136. package/dist/discovery/workspaces.json +9 -6
  137. package/dist/hooks/dispatch.js +1148 -349
  138. package/dist/install/install.mjs +1 -1
  139. package/dist/mcp/registry-manifest.json +3 -3
  140. package/dist/mcp/server.json +2 -2
  141. package/dist/mcp/server.mjs +571 -417
  142. package/dist/router.json +1 -1
  143. package/dist/scripts/condense.js +33 -2
  144. package/dist/scripts/condense.js.map +1 -1
  145. package/dist/server/io/yamlIO.js +85 -0
  146. package/dist/server/io/yamlIO.js.map +1 -1
  147. package/dist/server/nicknameResolver.js +116 -0
  148. package/dist/server/nicknameResolver.js.map +1 -0
  149. package/dist/server/routes/ping.js +29 -11
  150. package/dist/server/routes/ping.js.map +1 -1
  151. package/dist/server/routes/settings.js +1 -12
  152. package/dist/server/routes/settings.js.map +1 -1
  153. package/dist/server/routes/wizard.js +52 -2
  154. package/dist/server/routes/wizard.js.map +1 -1
  155. package/dist/shared/interactiveContext.js +73 -0
  156. package/dist/shared/interactiveContext.js.map +1 -0
  157. package/dist/shared/nicknamePrefill.js +59 -0
  158. package/dist/shared/nicknamePrefill.js.map +1 -0
  159. package/dist/shared/settingsAsks.js +128 -0
  160. package/dist/shared/settingsAsks.js.map +1 -0
  161. package/dist/shared/settingsCarveOut.js +118 -0
  162. package/dist/shared/settingsCarveOut.js.map +1 -0
  163. package/dist/shared/settingsConsent.js +93 -0
  164. package/dist/shared/settingsConsent.js.map +1 -0
  165. package/dist/shared/settingsSurface.js +7 -1
  166. package/dist/shared/settingsSurface.js.map +1 -1
  167. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  168. package/package.json +1 -1
  169. package/src/config/agents-paths.yml +15 -0
  170. package/src/config/evaluator-budgets.json +4 -4
  171. package/src/config/gate-coverage.yml +29 -1
  172. package/src/config/gate-violation-baselines.json +13 -7
  173. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  174. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  175. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  176. package/src/scripts/_dispatch.bash +18 -0
  177. package/src/scripts/_lib/git_common_dir.ts +128 -0
  178. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  179. package/src/scripts/_lib/session_register.ts +361 -0
  180. package/src/scripts/ai-video/stitch.sh +27 -9
  181. package/src/scripts/before_complete_hook.ts +23 -4
  182. package/src/scripts/bench_ab_v2_run.ts +272 -16
  183. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  184. package/src/scripts/check_completion_review.ts +80 -3
  185. package/src/scripts/check_corpus_staleness.ts +692 -0
  186. package/src/scripts/check_enforcement_coverage.ts +192 -7
  187. package/src/scripts/check_gate_coverage.ts +67 -0
  188. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  189. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  190. package/src/scripts/condense.ts +36 -2
  191. package/src/scripts/conformance_scan.ts +82 -17
  192. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  193. package/src/scripts/generate_settings_reference.ts +232 -0
  194. package/src/scripts/git_authorization_hook.ts +75 -10
  195. package/src/scripts/hook_manifest.yaml +87 -27
  196. package/src/scripts/hooks/block_no_verify.ts +59 -2
  197. package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
  198. package/src/scripts/hooks/concern_registry.ts +2 -0
  199. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  200. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  201. package/src/scripts/hooks/evidence_independence.ts +40 -6
  202. package/src/scripts/language_mirror_hook.ts +256 -17
  203. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  204. package/src/scripts/lint_agents_layout.ts +2 -0
  205. package/src/scripts/lint_hook_manifest.ts +29 -0
  206. package/src/scripts/memory_learn_hook.ts +109 -6
  207. package/src/scripts/orchestration_backfill.ts +354 -0
  208. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  209. package/src/scripts/release.ts +180 -37
  210. package/src/scripts/schemas/review-findings.schema.json +99 -0
  211. package/src/scripts/schemas/rule.schema.json +5 -0
  212. package/src/scripts/session_canary_hook.ts +35 -3
  213. package/src/scripts/session_register_hook.ts +288 -0
  214. package/src/scripts/sessions_cli.ts +140 -0
  215. package/src/scripts/validate_frontmatter.ts +68 -0
  216. package/src/server/io/yamlIO.ts +87 -0
  217. package/src/shared/interactiveContext.ts +98 -0
  218. package/src/shared/nicknamePrefill.ts +96 -0
  219. package/src/shared/settingsAsks.ts +176 -0
  220. package/src/shared/settingsCarveOut.ts +143 -0
  221. package/src/shared/settingsConsent.ts +136 -0
  222. package/src/shared/settingsSurface.ts +7 -1
@@ -38,6 +38,16 @@ import * as path from 'node:path';
38
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
+ import { KERNEL_RULE_ID_SET } from './_lib/kernel_rules.js';
42
+ import {
43
+ carrier_frequency_by_platform,
44
+ covers,
45
+ covers_any,
46
+ is_frequency,
47
+ parse_hook_platforms,
48
+ type Frequency,
49
+ type PlatformBinding,
50
+ } from './_lib/obligation_frequency.js';
41
51
 
42
52
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
43
53
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
@@ -73,6 +83,25 @@ export type Resolution =
73
83
  | 'missing' // declared target does not exist at all
74
84
  | 'none'; // honest, recorded gap
75
85
 
86
+ /**
87
+ * Whether the carrier's firing set covers the obligation's — the question
88
+ * `effective` does not answer.
89
+ *
90
+ * `covered` — some declared carrier covers the obligation on every
91
+ * hook-capable platform.
92
+ * `gap` — at least one hook-capable platform where no declared carrier
93
+ * covers it. `gap_platforms` names them.
94
+ * `declared-gap` — the rule declares `enforced_by: none`; the gap is stated in
95
+ * the rule's own text, so it is honest, not a defect.
96
+ * `unclassified` — no `obligation_frequency` to join against. The nine kernel
97
+ * rules sit here: `block_kernel_rule_writes.ts` denies agent
98
+ * writes to them, so the field cannot be populated by the same
99
+ * pass that populated the other 105.
100
+ * `unmeasured` — the rule declares no carrier at all; model-carried by design,
101
+ * making no claim this join could falsify.
102
+ */
103
+ export type FrequencyVerdict = 'covered' | 'gap' | 'declared-gap' | 'unclassified' | 'unmeasured';
104
+
76
105
  export interface RuleCoverage {
77
106
  id: string;
78
107
  tier: string;
@@ -82,6 +111,21 @@ export interface RuleCoverage {
82
111
  /** Strongest resolution, by the ordering in `RANK`. */
83
112
  effective: Resolution;
84
113
  notes: string[];
114
+ /** Declared obligation period (frontmatter), or null when undeclared. */
115
+ obligation_frequency: Frequency | null;
116
+ /**
117
+ * Carrier period per hook-capable platform — never a single scalar.
118
+ *
119
+ * A scalar is wrong twice over: copilot has no hook surface at all, so a
120
+ * weakest-platform collapse turns one platform property into a finding on
121
+ * every hook-carried rule; and cline maps `stop` from `TaskCancel`, so the
122
+ * same slot means per-turn on six platforms and per-interruption there. Both
123
+ * are invisible in one number.
124
+ */
125
+ carrier_frequency: Record<string, string> | null;
126
+ frequency_verdict: FrequencyVerdict;
127
+ /** Hook-capable platforms where no declared carrier covers the obligation. */
128
+ gap_platforms: string[];
85
129
  }
86
130
 
87
131
  /** Strength order — a rule is credited with its strongest resolving backstop. */
@@ -396,14 +440,87 @@ export function strongest(resolutions: Resolution[]): Resolution {
396
440
  return best;
397
441
  }
398
442
 
443
+ // ----------------------------------------------------------- frequency join
444
+
445
+ /**
446
+ * Join a rule's declared obligation period against its carriers' firing periods.
447
+ *
448
+ * Runs per hook-capable platform, never as one scalar. A rule is covered only
449
+ * when SOME declared carrier covers it on EVERY hook-capable platform; the
450
+ * platforms where none does are named, because "uncovered on windsurf" and
451
+ * "uncovered everywhere" are different findings with different fixes.
452
+ *
453
+ * `validator:` / `test:` carriers are SWEEP carriers — they fire once and read
454
+ * the whole tree, so their reach is bounded by what lands in an artefact rather
455
+ * than by how often they run. Modelling them as per-commit point carriers would
456
+ * make every `validator:`-carried rule with a per-edit obligation a finding at
457
+ * once: one modelling error rendered as a fifth of the corpus.
458
+ */
459
+ export function join_frequency(
460
+ obligation: Frequency | null,
461
+ declared: string[],
462
+ binding: PlatformBinding,
463
+ ): {
464
+ verdict: FrequencyVerdict;
465
+ carrier_frequency: Record<string, string> | null;
466
+ gap_platforms: string[];
467
+ } {
468
+ if (obligation === null) {
469
+ return { verdict: 'unclassified', carrier_frequency: null, gap_platforms: [] };
470
+ }
471
+ if (declared.length === 0) {
472
+ return { verdict: 'unmeasured', carrier_frequency: null, gap_platforms: [] };
473
+ }
474
+ if (declared.every((d) => d === 'none')) {
475
+ return { verdict: 'declared-gap', carrier_frequency: null, gap_platforms: [] };
476
+ }
477
+
478
+ const has_sweep = declared.some((d) => d.startsWith('validator:') || d.startsWith('test:'));
479
+ const hook_concerns = declared
480
+ .filter((d) => d.startsWith('hook:'))
481
+ .map((d) => d.slice('hook:'.length));
482
+
483
+ const per_platform: Record<string, string> = {};
484
+ const gap_platforms: string[] = [];
485
+
486
+ for (const platform of binding.slots.keys()) {
487
+ if (binding.fallback_only.has(platform)) continue;
488
+
489
+ let covered = false;
490
+ const seen: Frequency[] = [];
491
+
492
+ if (has_sweep && covers({ frequency: 'per-commit', mode: 'sweep' }, obligation)) {
493
+ covered = true;
494
+ }
495
+ for (const concern of hook_concerns) {
496
+ for (const f of carrier_frequency_by_platform(concern, binding)[platform] ?? []) {
497
+ if (!seen.includes(f)) seen.push(f);
498
+ }
499
+ }
500
+ if (covers_any(seen, obligation)) covered = true;
501
+
502
+ per_platform[platform] =
503
+ seen.length > 0 ? seen.join('+') : has_sweep ? 'sweep' : 'absent';
504
+ if (!covered) gap_platforms.push(platform);
505
+ }
506
+
507
+ return {
508
+ verdict: gap_platforms.length === 0 ? 'covered' : 'gap',
509
+ carrier_frequency: per_platform,
510
+ gap_platforms,
511
+ };
512
+ }
513
+
399
514
  // -------------------------------------------------------------------- report
400
515
 
401
516
  export function collect(): RuleCoverage[] {
402
517
  const ci_corpus = load_corpus(WORKFLOW_DIRS);
403
518
  const local_corpus = load_corpus(TASK_DIRS, TASK_FILES);
404
- const hooks = fs.existsSync(HOOK_MANIFEST)
405
- ? parse_hook_manifest(fs.readFileSync(HOOK_MANIFEST, 'utf-8'))
406
- : new Map<string, boolean>();
519
+ const manifest_text = fs.existsSync(HOOK_MANIFEST)
520
+ ? fs.readFileSync(HOOK_MANIFEST, 'utf-8')
521
+ : '';
522
+ const hooks = manifest_text ? parse_hook_manifest(manifest_text) : new Map<string, boolean>();
523
+ const binding = parse_hook_platforms(manifest_text);
407
524
  const exists = (rel: string): boolean => fs.existsSync(path.join(REPO_ROOT, rel));
408
525
  // Seeded separately so the umbrella expansion answers "reachable from CI"
409
526
  // rather than "reachable from anything". The expansion itself was correct;
@@ -425,14 +542,36 @@ export function collect(): RuleCoverage[] {
425
542
  resolutions.push(resolution);
426
543
  if (note) notes.push(note);
427
544
  }
545
+ const id = name.replace(/\.md$/, '');
546
+ const raw_freq = fm['obligation_frequency'];
547
+ const obligation: Frequency | null = is_frequency(raw_freq) ? raw_freq : null;
548
+ const { verdict, carrier_frequency, gap_platforms } = join_frequency(
549
+ obligation,
550
+ declared,
551
+ binding,
552
+ );
553
+ // Say WHY a row is unclassified. For the nine kernel rules the answer is
554
+ // structural rather than an authoring lapse — block_kernel_rule_writes
555
+ // denies the write — and a reader who cannot tell those apart will read
556
+ // the same bucket as either "expected" or "someone forgot".
557
+ if (verdict === 'unclassified' && KERNEL_RULE_ID_SET.has(id)) {
558
+ notes.push(
559
+ 'no obligation_frequency: kernel rule, and block_kernel_rule_writes denies ' +
560
+ 'the write with no agent-accessible override',
561
+ );
562
+ }
428
563
  out.push({
429
- id: name.replace(/\.md$/, ''),
564
+ id,
430
565
  tier: String(fm['tier'] ?? '—'),
431
566
  type: String(fm['type'] ?? '—'),
432
567
  declared,
433
568
  resolutions,
434
569
  effective: strongest(resolutions),
435
570
  notes,
571
+ obligation_frequency: obligation,
572
+ carrier_frequency,
573
+ frequency_verdict: verdict,
574
+ gap_platforms,
436
575
  });
437
576
  }
438
577
  return out;
@@ -450,6 +589,16 @@ export interface Summary {
450
589
  missing: number;
451
590
  undeclared: number;
452
591
  blocking_pct: number;
592
+ /**
593
+ * Rules whose declared carrier does not fire often enough to cover their
594
+ * declared obligation, on at least one hook-capable platform. The number
595
+ * this roadmap exists to produce — and the one `--check` ratchets, because
596
+ * `blocking` cannot see it: `session-canary` is declared, wired, firing, and
597
+ * in this bucket.
598
+ */
599
+ frequency_gap: number;
600
+ /** Rules with no `obligation_frequency` to join — the nine kernel rules. */
601
+ frequency_unclassified: number;
453
602
  }
454
603
 
455
604
  export function summarise(rows: RuleCoverage[]): Summary {
@@ -466,6 +615,8 @@ export function summarise(rows: RuleCoverage[]): Summary {
466
615
  missing: count((r) => r.effective === 'missing'),
467
616
  undeclared: rows.length - declared.length,
468
617
  blocking_pct: rows.length === 0 ? 0 : Math.round((blocking / rows.length) * 1000) / 10,
618
+ frequency_gap: count((r) => r.frequency_verdict === 'gap'),
619
+ frequency_unclassified: count((r) => r.frequency_verdict === 'unclassified'),
469
620
  };
470
621
  }
471
622
 
@@ -513,9 +664,13 @@ function main(argv: string[]): number {
513
664
  {
514
665
  _doc:
515
666
  'Enforcement-coverage baseline. `blocking` counts rules whose strongest ' +
516
- 'enforced_by resolves to something that can fail a build. Coverage is a ' +
517
- 'ratchet: --check fails when blocking falls or unwired rises. Regenerate ' +
518
- 'intentionally with --write-baseline when the change is the point.',
667
+ 'enforced_by resolves to something that can fail a build. `frequency_gap` ' +
668
+ 'counts rules whose carrier does not fire often enough to cover their ' +
669
+ 'declared obligation on some hook-capable platform — a rule can be ' +
670
+ 'blocking and in that bucket at once. Coverage is a ratchet: --check fails ' +
671
+ 'when blocking falls, or unwired / local_only / missing / frequency_gap ' +
672
+ 'rises. Regenerate intentionally with --write-baseline when the change is ' +
673
+ 'the point.',
519
674
  summary,
520
675
  },
521
676
  null,
@@ -543,6 +698,27 @@ function main(argv: string[]): number {
543
698
  );
544
699
  }
545
700
 
701
+ lines.push(
702
+ ` frequency: ${summary.frequency_gap} gap · ${summary.frequency_unclassified} unclassified ` +
703
+ `(kernel — block_kernel_rule_writes denies the field)`,
704
+ );
705
+
706
+ const gaps = rows.filter((r) => r.frequency_verdict === 'gap');
707
+ if (gaps.length > 0) {
708
+ lines.push('');
709
+ lines.push(' frequency gaps — carrier fires, but not often enough:');
710
+ for (const r of gaps) {
711
+ const where =
712
+ r.gap_platforms.length === Object.keys(r.carrier_frequency ?? {}).length
713
+ ? 'every hook-capable platform'
714
+ : r.gap_platforms.join(', ');
715
+ lines.push(
716
+ ` · ${r.id}: obligation ${r.obligation_frequency}, carrier ` +
717
+ `${r.declared.join(' + ')} — uncovered on ${where}`,
718
+ );
719
+ }
720
+ }
721
+
546
722
  const problems = rows.filter((r) => r.notes.length > 0);
547
723
  if (problems.length > 0) {
548
724
  lines.push('');
@@ -569,6 +745,15 @@ function main(argv: string[]): number {
569
745
  if (summary.unwired > base.unwired) {
570
746
  regressions.push(`unwired declarations rose: ${base.unwired} → ${summary.unwired}`);
571
747
  }
748
+ // The bucket `blocking` structurally cannot see: a rule can be declared,
749
+ // wired, firing and still mis-slotted. `??` tolerates a pre-frequency
750
+ // baseline so the field's own introduction does not red the ratchet.
751
+ if (summary.frequency_gap > (base.frequency_gap ?? Number.POSITIVE_INFINITY)) {
752
+ regressions.push(
753
+ `frequency gaps rose: ${base.frequency_gap} → ${summary.frequency_gap} ` +
754
+ `(a rule's carrier no longer fires often enough for its obligation)`,
755
+ );
756
+ }
572
757
  // A validator dropping out of a workflow back into taskfile-only is the
573
758
  // exact regression this split was built to name. Without this line the
574
759
  // headline would fall and only the `blocking` check would notice, which
@@ -363,6 +363,7 @@ export function main(argv: readonly string[]): number {
363
363
  );
364
364
  }
365
365
  const hardening = report_hardening_ratchet();
366
+ const selfTest = report_self_test_ratchet();
366
367
  if (failed.length > 0) {
367
368
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
368
369
  return 1;
@@ -370,6 +371,9 @@ export function main(argv: readonly string[]): number {
370
371
  if (hardening !== 0) {
371
372
  return hardening;
372
373
  }
374
+ if (selfTest !== 0) {
375
+ return selfTest;
376
+ }
373
377
  process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
374
378
  return 0;
375
379
  }
@@ -400,6 +404,69 @@ export function report_hardening_ratchet(): number {
400
404
  return verdict.ok ? 0 : 1;
401
405
  }
402
406
 
407
+ /**
408
+ * Registered gates that carry no `--self-test` and no exemption — the count
409
+ * that must not RISE.
410
+ *
411
+ * `_lib/gate_self_test.ts` proves a gate DISCRIMINATES: it drives the real
412
+ * binary through cases that must reject and cases that must accept, with a
413
+ * `minRejectCases` floor so an all-accepting suite cannot satisfy it. A gate
414
+ * that emits an enforced `scanned:` count has proven it read something; only a
415
+ * self-test proves the reading changes the verdict.
416
+ *
417
+ * ## Why this is a NON-ADOPTER count and not an adoption column
418
+ *
419
+ * The roadmap item asked for adoption as a column in `gate-coverage.yml`,
420
+ * ratcheted upward. That is the exact shape `report_hardening_ratchet` above
421
+ * rejects in this same file — a coverage-percentage ratchet "tracks how far the
422
+ * fix has spread and can never regress, so it grades the solution instead of
423
+ * the problem". Inverting it fixes that: the number below can regress (a new
424
+ * registered gate written without a self-test raises it), and the target is 0.
425
+ * That is also the shape the sibling adoption ratchet already uses —
426
+ * `check_gate_completeness`'s ledger baseline is a shrink-only count of
427
+ * non-adopters, not a rising percentage.
428
+ *
429
+ * Population is the MANIFEST, not every script under `src/scripts`: a self-test
430
+ * is a claim about a gate CI runs against a floor. Padding the manifest to move
431
+ * this number would add a floor per row, which is the opposite of gaming.
432
+ *
433
+ * Exemption marker: `// self-test-exempt: <reason>` with a non-empty reason. A
434
+ * bare marker does not count — same discipline as `// no-index:` and
435
+ * `// ledger-exempt:`.
436
+ */
437
+ export function list_self_test_non_adopters(
438
+ dir = path.join(REPO_ROOT, 'src/scripts'),
439
+ ids: ReadonlySet<string> = enforced_manifest_ids(),
440
+ ): string[] {
441
+ const out: string[] = [];
442
+ for (const id of [...ids].sort()) {
443
+ let src: string;
444
+ try {
445
+ src = fs.readFileSync(path.join(dir, `${id}.ts`), 'utf8');
446
+ } catch {
447
+ // A registered id with no readable script cannot prove discrimination.
448
+ out.push(id);
449
+ continue;
450
+ }
451
+ const adopts = /_lib\/gate_self_test\.js/.test(src);
452
+ const exempt = /\/\/\s*self-test-exempt:\s*\S/.test(src);
453
+ if (!adopts && !exempt) out.push(id);
454
+ }
455
+ return out;
456
+ }
457
+
458
+ /** Ratchet wrapper for {@link list_self_test_non_adopters}. */
459
+ export function report_self_test_ratchet(): number {
460
+ const missing = list_self_test_non_adopters();
461
+ const verdict = checkRatchet({
462
+ gate: 'gate-self-test:registered-non-adopters',
463
+ actual: missing.length,
464
+ repoRoot: REPO_ROOT,
465
+ });
466
+ process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
467
+ return verdict.ok ? 0 : 1;
468
+ }
469
+
403
470
  /**
404
471
  * Manifest ids whose `scanned:` line is actually ENFORCED against a floor.
405
472
  *
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Fail when the self-hosted hook bundle is older than the hook sources it
4
+ * bundles — i.e. when this repo's own agent is running a hook fix that has
5
+ * not been built.
6
+ *
7
+ * WHY THIS EXISTS — measured, not hypothetical.
8
+ *
9
+ * The Claude hook command resolves its dispatcher in two steps: the installed
10
+ * package copy (`node_modules/@event4u/agent-config/dist/hooks/dispatch.js`)
11
+ * if present, else this repo's OWN `dist/hooks/dispatch.js`. In the
12
+ * agent-config repo the second branch always wins — that is the dogfooding
13
+ * path, and it is the only place these hooks are exercised before release.
14
+ *
15
+ * `dist/hooks/dispatch.js` is UNTRACKED (a build artifact) and is produced
16
+ * only by `npm run build:hooks`, which no Taskfile target, no `task ci`
17
+ * target, and no workflow invokes. `prepack` runs the full build, so a
18
+ * PUBLISHED package is always fresh and consumers are unaffected. The gap is
19
+ * local and it is exactly where the testing happens.
20
+ *
21
+ * Consequence, observed on 2026-08-06: the conformance round-2 PR (#1195)
22
+ * merged four hook fixes — among them the language-mirror pin — while the
23
+ * running bundle was from 07:27 that morning, before any of them existed.
24
+ * Ten hook sources were newer than the bundle. In the very session that
25
+ * shipped the language fix, the scanner counted four German-prompt /
26
+ * English-reply violations: the fix was merged, correct, and not loaded.
27
+ *
28
+ * A hook fix that cannot reach the agent that wrote it is indistinguishable
29
+ * from no fix, and nothing said so. This gate says so.
30
+ *
31
+ * WHAT IT DOES NOT DO: it never builds. Building into a live hook path is a
32
+ * documented hazard in this repo (an esbuild run that overwrites the
33
+ * dispatcher a running hook is executing can wedge the tool loop), so the
34
+ * remedy is printed for a human to run deliberately.
35
+ *
36
+ * Exit codes: 0 = fresh, or no self-hosted bundle to check · 1 = stale.
37
+ */
38
+ import * as fs from "node:fs";
39
+ import * as path from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ import { parse as parseYaml } from "yaml";
43
+
44
+ import { reportScanned } from "./_lib/scan_scope.js";
45
+
46
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
47
+ const REPO_ROOT = path.resolve(HERE, "..", "..");
48
+ const BUNDLE = path.join("dist", "hooks", "dispatch.js");
49
+ const MANIFEST = path.join("src", "scripts", "hook_manifest.yaml");
50
+ const HOOK_DIR = path.join("src", "scripts", "hooks");
51
+ const REMEDY = "npm run build:hooks";
52
+
53
+ /** Every source whose edit changes the bundle's behaviour. */
54
+ export function bundledSources(root: string): string[] {
55
+ const out = new Set<string>();
56
+
57
+ const manifestAbs = path.join(root, MANIFEST);
58
+ if (fs.existsSync(manifestAbs)) {
59
+ out.add(MANIFEST);
60
+ try {
61
+ const doc = parseYaml(fs.readFileSync(manifestAbs, "utf-8")) as {
62
+ concerns?: Record<string, { script?: string }>;
63
+ };
64
+ for (const def of Object.values(doc?.concerns ?? {})) {
65
+ const s = def?.script;
66
+ // A concern script is bundled wherever it lives, not only under hooks/.
67
+ if (typeof s === "string" && s.endsWith(".ts")) out.add(s);
68
+ }
69
+ } catch {
70
+ // A malformed manifest is another gate's problem; still check the dir.
71
+ }
72
+ }
73
+
74
+ const hookDirAbs = path.join(root, HOOK_DIR);
75
+ if (fs.existsSync(hookDirAbs)) {
76
+ for (const e of fs.readdirSync(hookDirAbs, { withFileTypes: true })) {
77
+ if (e.isFile() && e.name.endsWith(".ts")) out.add(path.join(HOOK_DIR, e.name));
78
+ }
79
+ }
80
+
81
+ return [...out].filter((rel) => fs.existsSync(path.join(root, rel))).sort();
82
+ }
83
+
84
+ export interface Result {
85
+ /** No self-hosted bundle → the installed package copy is authoritative. */
86
+ skipped: boolean;
87
+ bundleMtimeMs: number;
88
+ /** Sources strictly newer than the bundle, newest first. */
89
+ stale: { file: string; mtimeMs: number }[];
90
+ checked: number;
91
+ }
92
+
93
+ export function check(root: string): Result {
94
+ const bundleAbs = path.join(root, BUNDLE);
95
+ if (!fs.existsSync(bundleAbs)) {
96
+ return { skipped: true, bundleMtimeMs: 0, stale: [], checked: 0 };
97
+ }
98
+ const bundleMtimeMs = fs.statSync(bundleAbs).mtimeMs;
99
+ const sources = bundledSources(root);
100
+ const stale = sources
101
+ .map((file) => ({ file, mtimeMs: fs.statSync(path.join(root, file)).mtimeMs }))
102
+ .filter((s) => s.mtimeMs > bundleMtimeMs)
103
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
104
+ return { skipped: false, bundleMtimeMs, stale, checked: sources.length };
105
+ }
106
+
107
+ function stamp(ms: number): string {
108
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 16);
109
+ }
110
+
111
+ export function main(argv: string[] = process.argv.slice(2)): number {
112
+ const root = REPO_ROOT;
113
+ const quiet = argv.includes("--quiet");
114
+ const r = check(root);
115
+
116
+ // Scope hardening: publish the count and refuse a silent zero. The one
117
+ // legitimate zero is the absent self-hosted bundle — an optional input, and
118
+ // the reason survives the manifest's own test ("if the scan root were
119
+ // deleted, would this still make sense?"): with no bundle there is no stale
120
+ // dispatcher to catch, which is a correct verdict rather than blindness.
121
+ reportScanned({
122
+ gate: "check_hook_bundle_freshness",
123
+ scanned: r.checked,
124
+ units: "bundled hook source(s)",
125
+ roots: [BUNDLE, HOOK_DIR, MANIFEST],
126
+ ...(r.skipped
127
+ ? {
128
+ allowEmpty:
129
+ "OPTIONAL_INPUT: no self-hosted dist/hooks/dispatch.js — this repo is not " +
130
+ "running its own bundle (fresh checkout / CI), so there is nothing to compare against.",
131
+ }
132
+ : {}),
133
+ });
134
+
135
+ // A gate that scans nothing must SAY it scanned nothing — a silent green
136
+ // here is indistinguishable from a pass.
137
+ if (r.skipped) {
138
+ if (!quiet) {
139
+ process.stdout.write(
140
+ `✅ OK hook bundle: no self-hosted ${BUNDLE} — the installed package copy is in use, nothing to check\n`,
141
+ );
142
+ }
143
+ return 0;
144
+ }
145
+
146
+ if (r.stale.length === 0) {
147
+ if (!quiet) {
148
+ process.stdout.write(
149
+ `✅ OK hook bundle: fresh (built ${stamp(r.bundleMtimeMs)}, ${String(r.checked)} bundled source(s) checked)\n`,
150
+ );
151
+ }
152
+ return 0;
153
+ }
154
+
155
+ console.error(
156
+ `❌ hook bundle is STALE — the running dispatcher predates ${r.stale.length} of ${r.checked} bundled source(s).`,
157
+ );
158
+ console.error(` ${BUNDLE} built ${stamp(r.bundleMtimeMs)}; newer sources:`);
159
+ for (const s of r.stale.slice(0, 10)) {
160
+ console.error(` ${stamp(s.mtimeMs)} ${s.file}`);
161
+ }
162
+ if (r.stale.length > 10) console.error(` … and ${r.stale.length - 10} more`);
163
+ console.error("");
164
+ console.error(` Your hooks are running the OLD code. Rebuild: ${REMEDY}`);
165
+ console.error(
166
+ " (This gate never builds for you — writing into a live hook path can wedge the tool loop.)",
167
+ );
168
+ return 1;
169
+ }
170
+
171
+ const invokedDirectly = ((): boolean => {
172
+ const entry = process.argv[1];
173
+ if (entry === undefined) return false;
174
+ try {
175
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
176
+ } catch {
177
+ return false;
178
+ }
179
+ })();
180
+
181
+ if (invokedDirectly) {
182
+ process.exit(main());
183
+ }