@event4u/agent-config 9.27.0 → 9.29.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 (220) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +13 -12
  3. package/CHANGELOG.md +165 -0
  4. package/README.md +1 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/conformance.md +24 -0
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/team/adversarial.md +8 -7
  9. package/dist/agent-src/commands/team/delegate.md +13 -12
  10. package/dist/agent-src/commands/team/review.md +9 -8
  11. package/dist/agent-src/commands/team/status.md +9 -8
  12. package/dist/agent-src/commands/team.md +29 -19
  13. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  14. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  16. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +28 -3
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
  18. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  19. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  20. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  21. package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
  22. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  23. package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
  24. package/dist/agent-src/rules/context-hygiene.md +3 -1
  25. package/dist/agent-src/rules/council-availability.md +93 -0
  26. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  27. package/dist/agent-src/rules/delegation-policy.md +38 -10
  28. package/dist/agent-src/rules/design-fidelity.md +3 -0
  29. package/dist/agent-src/rules/evaluator-independence.md +2 -1
  30. package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
  31. package/dist/agent-src/rules/finance-safety-floor.md +1 -1
  32. package/dist/agent-src/rules/git-history-discipline.md +1 -1
  33. package/dist/agent-src/rules/history-discipline.md +3 -1
  34. package/dist/agent-src/rules/legal-safety-floor.md +1 -1
  35. package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
  36. package/dist/agent-src/rules/onboarding-gate.md +3 -1
  37. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
  38. package/dist/agent-src/rules/role-mode-adherence.md +1 -1
  39. package/dist/agent-src/rules/scale-discipline.md +3 -1
  40. package/dist/agent-src/rules/self-repair-loop.md +103 -0
  41. package/dist/agent-src/rules/session-canary.md +2 -1
  42. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  43. package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
  44. package/dist/agent-src/rules/token-budget-discipline.md +4 -2
  45. package/dist/agent-src/rules/tool-safety.md +5 -1
  46. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  47. package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
  48. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  49. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  50. package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
  51. package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
  52. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  53. package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
  54. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  55. package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
  56. package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
  57. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  58. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
  59. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  60. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  61. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  62. package/dist/agent-src/templates/AGENTS.md +8 -12
  63. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  64. package/dist/agent-src/templates/roadmaps.md +23 -0
  65. package/dist/cli/registry.js +7 -0
  66. package/dist/cli/registry.js.map +1 -1
  67. package/dist/cli-delegate/chunk-66OVWGB5.js +270 -0
  68. package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-LCFJIQ4J.js} +89 -7
  69. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  70. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
  71. package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-MP3JT3IU.js} +3 -3
  72. package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-NZ5N2XQL.js} +467 -305
  73. package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-OJM3MBJM.js} +1 -1
  74. package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-RCIA67XN.js} +117 -96
  75. package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
  76. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  77. package/dist/cli-delegate/cmd_brand_status.js +134 -0
  78. package/dist/cli-delegate/cmd_conformance.js +10 -47
  79. package/dist/cli-delegate/cmd_converge.js +3 -3
  80. package/dist/cli-delegate/cmd_doctor.js +10 -6
  81. package/dist/cli-delegate/cmd_explain.js +80 -330
  82. package/dist/cli-delegate/cmd_export.js +6 -6
  83. package/dist/cli-delegate/cmd_fleet.js +3 -3
  84. package/dist/cli-delegate/cmd_handoff.js +3 -3
  85. package/dist/cli-delegate/cmd_mcp_available.js +433 -0
  86. package/dist/cli-delegate/cmd_migrate.js +3 -3
  87. package/dist/cli-delegate/cmd_packs_active.js +153 -0
  88. package/dist/cli-delegate/cmd_preflight.js +1 -1
  89. package/dist/cli-delegate/cmd_prune.js +6 -6
  90. package/dist/cli-delegate/cmd_route_audit.js +8 -8
  91. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  92. package/dist/cli-delegate/cmd_settings_get.js +266 -0
  93. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  94. package/dist/cli-delegate/cmd_settings_set.js +8 -2968
  95. package/dist/cli-delegate/cmd_sync.js +6 -6
  96. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  97. package/dist/cli-delegate/cmd_update.js +4 -4
  98. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  99. package/dist/cli-delegate/cmd_validate.js +6 -6
  100. package/dist/cli-delegate/cmd_versions.js +4 -4
  101. package/dist/discovery/deprecation-report.md +1 -1
  102. package/dist/discovery/discovery-manifest.json +164 -55
  103. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  104. package/dist/discovery/discovery-manifest.summary.md +4 -3
  105. package/dist/discovery/orphan-report.md +1 -1
  106. package/dist/discovery/packs.json +44 -5
  107. package/dist/discovery/trust-report.md +11 -11
  108. package/dist/discovery/workspaces.json +16 -6
  109. package/dist/hooks/dispatch.js +9341 -6131
  110. package/dist/install/install.mjs +739 -524
  111. package/dist/mcp/registry-manifest.json +3 -3
  112. package/dist/mcp/server.json +2 -2
  113. package/dist/mcp/server.mjs +777 -281
  114. package/dist/router.json +1 -1
  115. package/dist/scripts/_lib/agent_settings.js +134 -15
  116. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  117. package/dist/scripts/condense.js +147 -2
  118. package/dist/scripts/condense.js.map +1 -1
  119. package/dist/server/schemas/settings.js +6 -7
  120. package/dist/server/schemas/settings.js.map +1 -1
  121. package/dist/shared/settingsCarveOut.js +0 -9
  122. package/dist/shared/settingsCarveOut.js.map +1 -1
  123. package/dist/shared/settingsClasses.js +11 -9
  124. package/dist/shared/settingsClasses.js.map +1 -1
  125. package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
  126. package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
  127. package/dist/ui/index.html +1 -1
  128. package/docs/contracts/settings-classes.md +68 -17
  129. package/docs/guidelines/agent-infra/memory-access.md +14 -3
  130. package/package.json +1 -1
  131. package/src/agent-src/templates/AGENTS.md +8 -12
  132. package/src/config/agent-settings.template.yml +52 -66
  133. package/src/config/budgets.yml +29 -0
  134. package/src/config/discovery/packs.yml +11 -0
  135. package/src/config/discovery/workspaces.yml +1 -1
  136. package/src/config/evaluator-budgets.json +4 -4
  137. package/src/config/gate-coverage.yml +26 -0
  138. package/src/config/gate-violation-baselines.json +3 -3
  139. package/src/scripts/_cli/cmd_brand_status.ts +174 -0
  140. package/src/scripts/_cli/cmd_conformance.ts +14 -42
  141. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  142. package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
  143. package/src/scripts/_cli/cmd_packs_active.ts +225 -0
  144. package/src/scripts/_cli/cmd_settings_get.ts +324 -0
  145. package/src/scripts/_dispatch.bash +57 -0
  146. package/src/scripts/_lib/agent_settings.ts +140 -16
  147. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  148. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  149. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  150. package/src/scripts/_lib/carrier_divergence.ts +135 -0
  151. package/src/scripts/_lib/host_capability.ts +144 -4
  152. package/src/scripts/_lib/judgment_ladder.ts +382 -0
  153. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  154. package/src/scripts/_lib/orchestration_record.ts +37 -0
  155. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  156. package/src/scripts/_lib/prompt_shape.ts +88 -0
  157. package/src/scripts/_lib/review_skipped_record.ts +102 -0
  158. package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
  159. package/src/scripts/_lib/self_repair.ts +489 -0
  160. package/src/scripts/_lib/self_repair_store.ts +128 -0
  161. package/src/scripts/_lib/subagent_capsule.ts +182 -0
  162. package/src/scripts/_lib/subagent_response.ts +19 -0
  163. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  164. package/src/scripts/_lib/subagent_steering.ts +9 -4
  165. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  166. package/src/scripts/_lib/transcript_entry.ts +80 -0
  167. package/src/scripts/_lib/worker_budget.ts +37 -2
  168. package/src/scripts/ai_council/clients.ts +76 -54
  169. package/src/scripts/ai_council/config.ts +94 -6
  170. package/src/scripts/ai_council/handoff.ts +113 -0
  171. package/src/scripts/ai_council/modes.ts +19 -4
  172. package/src/scripts/ai_council/orchestrator.ts +105 -0
  173. package/src/scripts/ai_council/quorum.ts +84 -0
  174. package/src/scripts/ai_council/session.ts +44 -0
  175. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  176. package/src/scripts/ai_team/availability.ts +110 -0
  177. package/src/scripts/ai_team/config.ts +34 -14
  178. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  179. package/src/scripts/bench_critic_protocol.ts +396 -0
  180. package/src/scripts/check_branch_freshness.ts +255 -0
  181. package/src/scripts/check_council_config_location.ts +125 -1
  182. package/src/scripts/check_detector_corpus.ts +202 -0
  183. package/src/scripts/check_hook_bundle_freshness.ts +4 -1
  184. package/src/scripts/check_standing_rule_delivery.ts +314 -0
  185. package/src/scripts/condense.ts +162 -2
  186. package/src/scripts/config/profiles.ts +13 -3
  187. package/src/scripts/conformance_scan.ts +272 -24
  188. package/src/scripts/council_availability_hook.ts +188 -0
  189. package/src/scripts/council_cli.ts +527 -32
  190. package/src/scripts/forensics_report.ts +333 -0
  191. package/src/scripts/hook_manifest.yaml +107 -26
  192. package/src/scripts/hooks/block_no_verify.ts +39 -2
  193. package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
  194. package/src/scripts/hooks/concern_registry.ts +10 -0
  195. package/src/scripts/hooks/delegation_nudge_hook.ts +484 -0
  196. package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
  197. package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
  198. package/src/scripts/install.ts +151 -0
  199. package/src/scripts/language_mirror_hook.ts +30 -37
  200. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  201. package/src/scripts/lint_never_silent.ts +276 -0
  202. package/src/scripts/lint_no_activation_gates.ts +271 -0
  203. package/src/scripts/measure_scope_dedup.ts +19 -13
  204. package/src/scripts/onboarding_gate_hook.ts +40 -1
  205. package/src/scripts/orchestration_record.ts +12 -1
  206. package/src/scripts/preamble_byte_census.ts +9 -20
  207. package/src/scripts/rebuild_hook_bundle.ts +177 -0
  208. package/src/scripts/release.ts +74 -2
  209. package/src/scripts/report_carrier_divergence.ts +364 -0
  210. package/src/scripts/report_skill_activation.ts +257 -0
  211. package/src/scripts/report_skill_obligation_violations.ts +579 -0
  212. package/src/scripts/routing_doctor.ts +95 -39
  213. package/src/scripts/score_skill_selection.ts +19 -5
  214. package/src/scripts/self_repair_cli.ts +470 -0
  215. package/src/scripts/self_repair_hook.ts +241 -0
  216. package/src/scripts/team_events_spike.ts +271 -0
  217. package/src/scripts/team_review_gate_hook.ts +31 -9
  218. package/src/server/schemas/settings.ts +10 -17
  219. package/src/shared/settingsCarveOut.ts +0 -10
  220. package/src/shared/settingsClasses.ts +11 -9
@@ -37,6 +37,14 @@
37
37
  * (ADR-104); any project-tree path is drift. Corrective mentions ("is
38
38
  * ignored", "never read", "no project-local", "superseded") pass on the
39
39
  * same line. (PATH_CHECK_GLOBS)
40
+ * 4. NO always-loaded carrier for the user-global fact. §1–§3 keep the tree
41
+ * honest but say nothing about REACH: before this check the fact lived
42
+ * only in `src/skills/ai-council/SKILL.md`, a skill body that reaches
43
+ * context only on activation, so consumer sessions repeatedly inferred
44
+ * "council not configured" from a missing `.agent-settings.yml` while the
45
+ * council was configured all along. §4 requires one rule under
46
+ * `src/rules/` to carry the fact and to project at least as widely as the
47
+ * kernel reference. (RULES_GLOB)
40
48
  *
41
49
  * Escape hatch: a line carrying `<!-- council-config-allowed -->` is exempt
42
50
  * (for a legitimate non-council `.agent-settings.yml` reference, e.g.
@@ -64,6 +72,7 @@ const SCAN_GLOBS = [
64
72
  'src/domains/meta/council/**/*.md',
65
73
  'src/domains/product-basic/roadmap/ai-council/**/*.md',
66
74
  'src/skills/ai-council/**/*.md',
75
+ 'src/rules/council-availability.md',
67
76
  'docs/contracts/ai-council-config.md',
68
77
  ] as const;
69
78
 
@@ -105,6 +114,35 @@ const PROJECT_PATH_COUNCIL_RE =
105
114
  const PATH_NEGATION_RE =
106
115
  /\b(not|never|removed|no\s+longer|neither|instead|ignored|superseded|no\s+project-local|not\s+read|does\s+not\s+read)\b/i;
107
116
 
117
+ // ── §4 always-loaded carrier check ─────────────────────────────────
118
+ // The user-global fact is useless where the agent cannot see it. Before this
119
+ // check the fact lived only in `src/skills/ai-council/SKILL.md` — a skill body
120
+ // that only reaches context on activation — so consumer sessions repeatedly
121
+ // inferred "council not configured" from a missing `.agent-settings.yml` and
122
+ // substituted a subagent fan-out while the council was configured all along.
123
+ // §4 pins the fix: at least one rule under `src/rules/` must carry the fact,
124
+ // declare a council trigger, and project at least as widely as the kernel.
125
+ const RULES_GLOB = 'src/rules/**/*.md';
126
+ // Reference for the reach requirement: a carrier scoped more narrowly than an
127
+ // always-loaded kernel rule would not reach the consumer repos that failed.
128
+ const REACH_REFERENCE_RULE = 'src/rules/direct-answers.md';
129
+ const WORKSPACES_RE = /^workspaces:\s*\[([^\]]*)\]/m;
130
+ // The three markers a carrier must show, all in the same file.
131
+ const CARRIER_MARKERS = [
132
+ {
133
+ re: /^\s*-\s*keyword:\s*"council"\s*$/m,
134
+ what: 'a `- keyword: "council"` trigger',
135
+ },
136
+ {
137
+ re: /~\/\.event4u\/agent-config\/settings\/\.ai-council\.yml/,
138
+ what: 'the one real user-global config path',
139
+ },
140
+ {
141
+ re: /NEVER\s+INFER\s+"NOT\s+CONFIGURED"/i,
142
+ what: 'the never-infer-from-project-files obligation',
143
+ },
144
+ ] as const;
145
+
108
146
  /**
109
147
  * Mirror Python `str.lstrip()` — strips all leading Unicode whitespace.
110
148
  * Python's `str.lstrip()` (no args) strips the Unicode whitespace class;
@@ -318,6 +356,84 @@ function find_path_violations(root: string): string[] {
318
356
  return findings;
319
357
  }
320
358
 
359
+ /** Parse a rule's `workspaces: [a, b, c]` frontmatter line into a set. */
360
+ function _workspaces(text: string): Set<string> {
361
+ const m = WORKSPACES_RE.exec(text);
362
+ if (m === null) {
363
+ return new Set<string>();
364
+ }
365
+ return new Set(
366
+ m[1]!
367
+ .split(',')
368
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
369
+ .filter((s) => s.length > 0),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * §4 — the user-global fact must have an always-loaded carrier (reach check).
375
+ *
376
+ * A correct resolver, a correct skill, and a green §1–§3 still left consumer
377
+ * sessions claiming "council not configured" — because the fact reached no
378
+ * always-loaded surface. This check fails when no rule under `src/rules/`
379
+ * carries it, and when every carrier that does is scoped more narrowly than
380
+ * the kernel reference (i.e. would not reach the consumer repos that failed).
381
+ */
382
+ function find_carrier_violations(root: string): string[] {
383
+ // Scope guard: only a tree that HAS a rule layer can carry the fact. A
384
+ // consumer checkout or a tmp fixture without `src/rules/` is out of scope,
385
+ // exactly as a missing `src/skills/ai-council/` is for §1. The real-repo
386
+ // test asserts this scope is live here, so the skip cannot go unnoticed.
387
+ if (!_isDir(path.join(root, 'src', 'rules'))) {
388
+ return [];
389
+ }
390
+ const rules = [...iter_files(root, [RULES_GLOB])];
391
+ if (rules.length === 0) {
392
+ return [
393
+ `${RULES_GLOB}: \`src/rules/\` exists but resolved to no files — ` +
394
+ 'the council user-global fact is unpinned.',
395
+ ];
396
+ }
397
+ const carriers = rules.filter((p) => {
398
+ const text = fs.readFileSync(p, 'utf-8');
399
+ return CARRIER_MARKERS.every((m) => m.re.test(text));
400
+ });
401
+ if (carriers.length === 0) {
402
+ return [
403
+ 'src/rules/: no always-loaded rule carries the council user-global ' +
404
+ 'fact. One rule must show all of — ' +
405
+ CARRIER_MARKERS.map((m) => m.what).join('; ') +
406
+ '. Without it the fact lives only in ' +
407
+ '`src/skills/ai-council/SKILL.md`, whose body reaches context ' +
408
+ 'only on skill activation.',
409
+ ];
410
+ }
411
+ const refPath = path.join(root, REACH_REFERENCE_RULE);
412
+ if (!_isFile(refPath)) {
413
+ return [];
414
+ }
415
+ const refWs = _workspaces(fs.readFileSync(refPath, 'utf-8'));
416
+ if (refWs.size === 0) {
417
+ return [];
418
+ }
419
+ const findings: string[] = [];
420
+ for (const p of carriers) {
421
+ const missing = [...refWs].filter(
422
+ (w) => !_workspaces(fs.readFileSync(p, 'utf-8')).has(w),
423
+ );
424
+ if (missing.length > 0) {
425
+ findings.push(
426
+ `${_relPosix(root, p)}: council carrier is scoped more narrowly ` +
427
+ `than \`${REACH_REFERENCE_RULE}\` — missing workspace(s): ` +
428
+ `${missing.join(', ')}. A carrier the consumer never receives ` +
429
+ 'is exactly the gap this check exists to close.',
430
+ );
431
+ }
432
+ }
433
+ // One carrier with full reach is enough; only report when none has it.
434
+ return findings.length === carriers.length ? findings : [];
435
+ }
436
+
321
437
  function main(): number {
322
438
  const root = process.cwd();
323
439
  // PATH_CHECK_GLOBS is the superset of both passes, so its resolution is the
@@ -338,7 +454,11 @@ function main(): number {
338
454
  }
339
455
  throw exc;
340
456
  }
341
- const findings = [...find_violations(root), ...find_path_violations(root)];
457
+ const findings = [
458
+ ...find_violations(root),
459
+ ...find_path_violations(root),
460
+ ...find_carrier_violations(root),
461
+ ];
342
462
  if (findings.length) {
343
463
  process.stdout.write('❌ Council config-location violations:\n\n');
344
464
  for (const f of findings) {
@@ -396,5 +516,9 @@ export {
396
516
  iter_files,
397
517
  find_violations,
398
518
  find_path_violations,
519
+ find_carrier_violations,
520
+ RULES_GLOB,
521
+ REACH_REFERENCE_RULE,
522
+ CARRIER_MARKERS,
399
523
  main,
400
524
  };
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detector corpus gate (P4.3 of road-to-rule-delivery-integrity).
4
+ *
5
+ * Every self-repair detector must carry all THREE fixture classes, and every
6
+ * fixture must behave as its class says:
7
+ *
8
+ * fire a recorded real failure the detector must catch
9
+ * near-miss-fire a real failure phrased unlike the canonical one, which must
10
+ * still be caught — recall at the boundary
11
+ * must-not-fire text that superficially matches and must stay silent
12
+ *
13
+ * The three-class rule is the point of the gate rather than a stylistic one.
14
+ * Precision and recall fail in opposite directions and a single corpus hides
15
+ * that: a detector with `fire` cases only looks perfect while opening records on
16
+ * praise, which is exactly what `detectUserReport` did until 2026-08-09 (it
17
+ * fired on "das ist fine, du hast nichts falsch gemacht"). A missing class is
18
+ * therefore a gate failure, not a warning — the absent half is where the defects
19
+ * live.
20
+ *
21
+ * Registration is explicit: a detector added to `self_repair.ts` without a
22
+ * matching corpus entry fails here, which is the coupling that keeps the corpus
23
+ * from silently lagging the code.
24
+ */
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+
29
+ import * as YAML from 'yaml';
30
+
31
+ import {
32
+ type DefectFinding,
33
+ type TurnSnapshot,
34
+ detectCouncilClaim,
35
+ detectLanguageMirror,
36
+ detectUserReport,
37
+ } from './_lib/self_repair.js';
38
+ import { assertScanned } from './_lib/scan_scope.js';
39
+
40
+ export const CORPUS_REL = path.join('tests', 'fixtures', 'self-repair-detector-corpus.yml');
41
+
42
+ const GATE = 'check_detector_corpus';
43
+ const CLASSES = ['fire', 'near-miss-fire', 'must-not-fire'] as const;
44
+ type FixtureClass = (typeof CLASSES)[number];
45
+
46
+ /** One corpus entry, in the union of shapes the three detectors need. */
47
+ export interface Fixture {
48
+ text?: string;
49
+ reply?: string;
50
+ pinned_language?: 'de' | 'en';
51
+ tool_commands?: string[];
52
+ note?: string;
53
+ }
54
+
55
+ export type Corpus = Record<string, Partial<Record<FixtureClass, Fixture[]>>>;
56
+
57
+ /**
58
+ * Every detector that must appear in the corpus, with how to run it.
59
+ *
60
+ * Keyed by the `defect_class` the detector produces, so the corpus reads as a
61
+ * list of defect classes rather than of function names.
62
+ */
63
+ export const DETECTORS: Record<string, (f: Fixture) => DefectFinding | null> = {
64
+ 'user-reported': (f) => detectUserReport(f.text ?? f.reply ?? ''),
65
+ 'council-availability-claim': (f) => detectCouncilClaim(asTurn(f)),
66
+ 'language-mirror': (f) => detectLanguageMirror(asTurn(f)),
67
+ };
68
+
69
+ function asTurn(f: Fixture): TurnSnapshot {
70
+ return {
71
+ prompt: f.text ?? '',
72
+ reply: f.reply ?? '',
73
+ toolCommands: f.tool_commands ?? [],
74
+ pinnedLanguage: f.pinned_language ?? null,
75
+ };
76
+ }
77
+
78
+ export interface Finding {
79
+ detector: string;
80
+ kind: 'missing-class' | 'empty-class' | 'wrong-outcome' | 'unregistered';
81
+ message: string;
82
+ }
83
+
84
+ /** Pure: audit a parsed corpus against the registered detectors. */
85
+ export function auditCorpus(corpus: Corpus): { findings: Finding[]; fixtures: number } {
86
+ const findings: Finding[] = [];
87
+ let fixtures = 0;
88
+
89
+ for (const name of Object.keys(corpus)) {
90
+ if (!(name in DETECTORS)) {
91
+ findings.push({
92
+ detector: name,
93
+ kind: 'unregistered',
94
+ message: `corpus names "${name}", which is not a registered detector`,
95
+ });
96
+ }
97
+ }
98
+
99
+ for (const [name, run] of Object.entries(DETECTORS)) {
100
+ const entry = corpus[name];
101
+ if (entry === undefined) {
102
+ findings.push({
103
+ detector: name,
104
+ kind: 'missing-class',
105
+ message: `no corpus entry at all — needs all of: ${CLASSES.join(', ')}`,
106
+ });
107
+ continue;
108
+ }
109
+ for (const cls of CLASSES) {
110
+ const cases = entry[cls];
111
+ if (cases === undefined) {
112
+ findings.push({
113
+ detector: name,
114
+ kind: 'missing-class',
115
+ message: `missing the \`${cls}\` class`,
116
+ });
117
+ continue;
118
+ }
119
+ if (cases.length === 0) {
120
+ // An empty class is the same hole as a missing one wearing a key.
121
+ findings.push({
122
+ detector: name,
123
+ kind: 'empty-class',
124
+ message: `\`${cls}\` is present but empty`,
125
+ });
126
+ continue;
127
+ }
128
+ for (const f of cases) {
129
+ fixtures += 1;
130
+ const fired = run(f) !== null;
131
+ const shouldFire = cls !== 'must-not-fire';
132
+ if (fired !== shouldFire) {
133
+ const what = f.text ?? f.reply ?? '(empty)';
134
+ findings.push({
135
+ detector: name,
136
+ kind: 'wrong-outcome',
137
+ message:
138
+ `\`${cls}\` fixture ${shouldFire ? 'did NOT fire' : 'FIRED'}: ` +
139
+ JSON.stringify(what.slice(0, 90)),
140
+ });
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return { findings, fixtures };
146
+ }
147
+
148
+ export function loadCorpus(repoRoot: string): Corpus {
149
+ const p = path.join(repoRoot, CORPUS_REL);
150
+ const parsed = YAML.parse(fs.readFileSync(p, 'utf-8')) as { detectors?: Corpus };
151
+ return parsed.detectors ?? {};
152
+ }
153
+
154
+ export function main(repoRoot: string = process.cwd()): number {
155
+ const corpus = loadCorpus(repoRoot);
156
+ const { findings, fixtures } = auditCorpus(corpus);
157
+
158
+ // A corpus that parsed to nothing is the dead-scope case, not a pass.
159
+ assertScanned({
160
+ gate: GATE,
161
+ scanned: fixtures,
162
+ units: 'detector fixtures',
163
+ roots: [CORPUS_REL],
164
+ });
165
+
166
+ if (findings.length > 0) {
167
+ process.stderr.write(`${GATE}: ${String(findings.length)} finding(s)\n\n`);
168
+ for (const f of findings) {
169
+ process.stderr.write(` ${f.detector}: ${f.message}\n`);
170
+ }
171
+ process.stderr.write(
172
+ `\nEvery detector needs all three classes (${CLASSES.join(', ')}) and every\n` +
173
+ 'fixture must behave as its class says. Precision and recall fail in\n' +
174
+ 'opposite directions; a corpus missing one half hides the other.\n',
175
+ );
176
+ return 1;
177
+ }
178
+
179
+ process.stdout.write(
180
+ `✅ ${GATE}: ${String(Object.keys(DETECTORS).length)} detector(s) × ` +
181
+ `${String(CLASSES.length)} classes, ${String(fixtures)} fixtures, all behaving\n`,
182
+ );
183
+ return 0;
184
+ }
185
+
186
+ function isCliEntry(): boolean {
187
+ const argv1 = process.argv[1];
188
+ if (argv1 === undefined) {
189
+ return false;
190
+ }
191
+ try {
192
+ return (
193
+ fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(argv1))
194
+ );
195
+ } catch {
196
+ return pathToFileURL(argv1).href === import.meta.url;
197
+ }
198
+ }
199
+
200
+ if (isCliEntry()) {
201
+ process.exit(main());
202
+ }
@@ -31,7 +31,10 @@
31
31
  * WHAT IT DOES NOT DO: it never builds. Building into a live hook path is a
32
32
  * documented hazard in this repo (an esbuild run that overwrites the
33
33
  * dispatcher a running hook is executing can wedge the tool loop), so the
34
- * remedy is printed for a human to run deliberately.
34
+ * remedy is printed for a human to run deliberately. The SAFE rebuild
35
+ * (build to .new → probe → atomic rename) lives in `rebuild_hook_bundle.ts`,
36
+ * which preflight runs right before this gate — so a red here means the heal
37
+ * itself failed or was bypassed, not merely that a merge landed.
35
38
  *
36
39
  * Exit codes: 0 = fresh, or no self-hosted bundle to check · 1 = stale.
37
40
  */
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Gate — how many rule tokens does a session actually RECEIVE?
4
+ *
5
+ * P1.2 of `road-to-rule-delivery-integrity`. Claude Code loads
6
+ * `~/.claude/rules/` and `<project>/.claude/rules/` **both**, user layer first,
7
+ * with no dedup between them (host contract, quoted with citations in
8
+ * `agents/evidence/analysis/claude-code-rules-dir-contract.md`). So the number
9
+ * that matters is not what one tree contains — it is the sum of what arrives.
10
+ *
11
+ * Measured 2026-08-08 on a maintainer machine, exact BPE: **176,354 tokens
12
+ * standing, 74,137 of them (42%) the same 91 rules twice.** Full six-topology
13
+ * accounting: `agents/evidence/analysis/standing-rule-delivery-topologies.md`.
14
+ *
15
+ * Three deliberate properties:
16
+ *
17
+ * 1. **Measure what arrived, not what was projected.** Where an
18
+ * `InstructionsLoaded` record exists (the host logs which instruction files
19
+ * loaded, when, and why) the gate reads it and the filesystem sum becomes a
20
+ * cross-check. Where it does not, the filesystem sum stands alone. The gate
21
+ * always NAMES which input it used — a filesystem-only sum cannot see a rule
22
+ * that is projected but never loaded, nor one that loads from a layer nobody
23
+ * projected, and the second of those is the defect itself.
24
+ * 2. **`assertScanned` posture.** Zero rule files is exit 3, not green. This
25
+ * repo has shipped gates that scanned an emptied tree and certified coverage
26
+ * that did not exist; the posture is the recorded fix, not boilerplate.
27
+ * 3. **The ceiling cannot be raised silently.** `cap_raise_reason` in
28
+ * `src/config/budgets.yml` must be a real sentence naming which rule added
29
+ * the tokens. A placeholder fails the gate.
30
+ *
31
+ * Exit codes: `0` under cap · `1` over cap or an unstated cap raise · `2` usage
32
+ * error · `3` dead scan scope.
33
+ *
34
+ * ## This is deliberately NOT a CI gate
35
+ *
36
+ * Both inputs are machine-local by construction. `.claude/rules/` is gitignored
37
+ * (`.gitignore:123` — it is generated by `task sync` / `task generate-tools`),
38
+ * and no CI workflow performs a user-scope install, so on a fresh runner
39
+ * **neither** layer exists. Wired into `ci` it would either red on a dead scope
40
+ * or, with the empty case allowed, sit permanently green while certifying a
41
+ * property it cannot observe — the exact failure `src/config/gate-coverage.yml`
42
+ * was written to end. It is therefore registered as a local / doctor-surface
43
+ * check and is absent from `gate-coverage.yml`, whose contract is CI-identical
44
+ * invocation.
45
+ *
46
+ * What that costs, stated plainly: the duplicate-delivery defect is not
47
+ * mechanically caught for anyone who never runs this locally. The enforced layer
48
+ * is the installer gate (`install.ts::_gate_rule_layer_overlap`), which refuses
49
+ * to CREATE the doubled state; this script measures a state that already exists.
50
+ * Making the measurement CI-observable needs the `InstructionsLoaded` record
51
+ * committed from a real session, which is a different piece of work.
52
+ */
53
+
54
+ import * as fs from 'node:fs';
55
+ import * as os from 'node:os';
56
+ import * as path from 'node:path';
57
+ import process from 'node:process';
58
+ import { fileURLToPath } from 'node:url';
59
+
60
+ import { assertScanned, DeadScopeError, reportScanned } from './_lib/scan_scope.js';
61
+ import { measure, method_note } from './_lib/token_count.js';
62
+ import * as rule_layer_overlap from './_lib/rule_layer_overlap.js';
63
+
64
+ const PROG = 'check_standing_rule_delivery';
65
+ const _HERE = fileURLToPath(import.meta.url);
66
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
67
+ const BUDGETS = path.join(REPO_ROOT, 'src', 'config', 'budgets.yml');
68
+
69
+ /** Placeholder reasons that do not count as a stated cause for a cap raise. */
70
+ const PLACEHOLDER_REASONS: readonly string[] = ['', '-', 'tbd', 'todo', 'n/a', 'na', 'none', 'bump'];
71
+
72
+ interface Budget {
73
+ readonly total_cap_tokens: number;
74
+ readonly warn_threshold: number;
75
+ readonly cap_raise_reason: string;
76
+ }
77
+
78
+ /**
79
+ * Read the `standing_rule_delivery` block from `budgets.yml`.
80
+ *
81
+ * Deliberately a narrow line scan rather than a YAML dependency: this gate must
82
+ * run in a consumer tree where `yaml` may not be installed, and the three keys
83
+ * it needs are flat scalars. An absent block is a configuration error (exit 2),
84
+ * never a permissive default — a budget gate that invents its own ceiling is
85
+ * the same class of failure as one that scans nothing.
86
+ */
87
+ export function readBudget(text: string): Budget | null {
88
+ const lines = text.split('\n');
89
+ const start = lines.findIndex((l) => l.trimEnd() === 'standing_rule_delivery:');
90
+ if (start === -1) return null;
91
+ let cap: number | null = null;
92
+ let warn: number | null = null;
93
+ let reason: string | null = null;
94
+ for (let i = start + 1; i < lines.length; i++) {
95
+ const line = lines[i] ?? '';
96
+ if (/^\S/.test(line)) break; // next top-level key ends the block
97
+ const m = /^\s+([a-z_]+):\s*(.*)$/.exec(line);
98
+ if (m === null) continue;
99
+ const [, key, rawValue] = m;
100
+ const value = (rawValue ?? '').trim().replace(/^['"]|['"]$/g, '');
101
+ if (key === 'total_cap_tokens') cap = Number.parseInt(value, 10);
102
+ if (key === 'warn_threshold') warn = Number.parseFloat(value);
103
+ if (key === 'cap_raise_reason') reason = value;
104
+ }
105
+ if (cap === null || Number.isNaN(cap) || warn === null || Number.isNaN(warn) || reason === null) {
106
+ return null;
107
+ }
108
+ return { total_cap_tokens: cap, warn_threshold: warn, cap_raise_reason: reason };
109
+ }
110
+
111
+ /** Is `reason` a real stated cause, or a placeholder? */
112
+ export function reasonIsStated(reason: string): boolean {
113
+ return !PLACEHOLDER_REASONS.includes(reason.trim().toLowerCase());
114
+ }
115
+
116
+ /** One measured layer. */
117
+ interface LayerMeasure {
118
+ readonly label: string;
119
+ readonly dir: string;
120
+ readonly files: number;
121
+ readonly tokens: number;
122
+ readonly exact: boolean;
123
+ }
124
+
125
+ function measureLayer(label: string, dir: string): LayerMeasure | null {
126
+ const layer = rule_layer_overlap.readRuleLayer(dir);
127
+ if (layer === null) return null;
128
+ const joined = [...layer.files.values()].join('\n');
129
+ const m = measure(joined);
130
+ return {
131
+ label,
132
+ dir,
133
+ files: layer.files.size,
134
+ tokens: m.tokens_gpt,
135
+ exact: m.tokens_gpt_exact,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Where the loaded-instruction record would live if the host wrote one.
141
+ *
142
+ * Returning a path that does not exist is the expected case today: the
143
+ * `InstructionsLoaded` hook is a host feature this suite does not yet bind, so
144
+ * the gate reports `filesystem` as its input and says so. The probe exists now
145
+ * so that the day the hook is bound, the gate reads the better input without a
146
+ * second change — and so that "which input did this number come from" is
147
+ * answerable rather than assumed.
148
+ */
149
+ export function instructionsLoadedRecord(repo_root: string): string {
150
+ return path.join(repo_root, 'agents', 'runtime', 'metrics', 'instructions-loaded.jsonl');
151
+ }
152
+
153
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
154
+ const unknown = argv.filter((a) => a !== '--quiet');
155
+ if (unknown.length > 0) {
156
+ process.stderr.write(`usage: ${PROG} [--quiet]\n`);
157
+ process.stderr.write(`${PROG}: error: unrecognized arguments: ${unknown.join(' ')}\n`);
158
+ return 2;
159
+ }
160
+ const quiet = argv.includes('--quiet');
161
+ const out = (s: string): void => {
162
+ if (!quiet) process.stdout.write(s);
163
+ };
164
+
165
+ let budget: Budget | null;
166
+ try {
167
+ budget = readBudget(fs.readFileSync(BUDGETS, 'utf-8'));
168
+ } catch {
169
+ process.stderr.write(`❌ ${PROG}: cannot read ${path.relative(REPO_ROOT, BUDGETS)}\n`);
170
+ return 2;
171
+ }
172
+ if (budget === null) {
173
+ process.stderr.write(
174
+ `❌ ${PROG}: no complete \`standing_rule_delivery\` block in `
175
+ + `${path.relative(REPO_ROOT, BUDGETS)} — needs total_cap_tokens, `
176
+ + 'warn_threshold and cap_raise_reason. A budget gate does not invent its own ceiling.\n',
177
+ );
178
+ return 2;
179
+ }
180
+ if (!reasonIsStated(budget.cap_raise_reason)) {
181
+ process.stderr.write(
182
+ `❌ ${PROG}: cap_raise_reason is a placeholder (${JSON.stringify(budget.cap_raise_reason)}). `
183
+ + 'Every ceiling value needs a stated cause naming which rule added the tokens and why.\n',
184
+ );
185
+ return 1;
186
+ }
187
+
188
+ const layers = [
189
+ measureLayer('global', path.join(os.homedir(), '.claude', 'rules')),
190
+ measureLayer('project', path.join(REPO_ROOT, '.claude', 'rules')),
191
+ ].filter((l): l is LayerMeasure => l !== null);
192
+
193
+ const scanned = layers.reduce((n, l) => n + l.files, 0);
194
+ // Two zeroes that mean opposite things, and conflating them is how this gate
195
+ // would become the thing it guards against:
196
+ //
197
+ // - **No layer directory at all** — a machine that has not installed the
198
+ // suite. Zero rule tokens are delivered, which IS the success state, so
199
+ // `allowEmpty` is honest here and passes scan_scope's own operational test
200
+ // ("if the scan root were deleted, would the reason still make sense?" —
201
+ // yes: nothing installed, nothing loaded).
202
+ // - **A layer directory that exists and holds no `.md`** — a projection that
203
+ // ran and produced nothing. That is blindness, not cleanliness, and it
204
+ // stays exit 3.
205
+ const no_layer_present = layers.length === 0;
206
+ try {
207
+ assertScanned({
208
+ gate: PROG,
209
+ scanned,
210
+ units: 'rule file(s) across the layers a session loads',
211
+ roots: ['~/.claude/rules', '.claude/rules'],
212
+ ...(no_layer_present
213
+ ? {
214
+ allowEmpty:
215
+ 'EMPTY_VALID: neither ~/.claude/rules nor .claude/rules exists on this '
216
+ + 'machine, so zero rule tokens are delivered — the success state, not a dead scope.',
217
+ }
218
+ : {}),
219
+ });
220
+ } catch (exc) {
221
+ if (exc instanceof DeadScopeError) {
222
+ process.stderr.write(`❌ ${exc.message}\n`);
223
+ return 3;
224
+ }
225
+ throw exc;
226
+ }
227
+ if (no_layer_present) {
228
+ out(
229
+ `${PROG}: no rule layer on this machine (neither ~/.claude/rules nor .claude/rules) — `
230
+ + 'nothing is delivered, nothing to cap.\n',
231
+ );
232
+ reportScanned({
233
+ gate: PROG,
234
+ scanned: 0,
235
+ units: 'rule file(s)',
236
+ roots: ['~/.claude/rules', '.claude/rules'],
237
+ allowEmpty: 'EMPTY_VALID: no layer installed on this machine.',
238
+ });
239
+ return 0;
240
+ }
241
+
242
+ const record = instructionsLoadedRecord(REPO_ROOT);
243
+ const input = fs.existsSync(record) ? 'instructions-loaded' : 'filesystem';
244
+ const total = layers.reduce((n, l) => n + l.tokens, 0);
245
+ const exact = layers.every((l) => l.exact);
246
+
247
+ out(`${PROG} · input: ${input} · ${method_note()}\n`);
248
+ if (input === 'filesystem') {
249
+ out(
250
+ ' note: no InstructionsLoaded record present, so this is what the filesystem\n'
251
+ + ' projects, not what the host confirmed loading. A rule projected but never\n'
252
+ + ' loaded, or loaded from an unprojected layer, is invisible to this input.\n',
253
+ );
254
+ }
255
+ for (const l of layers) {
256
+ out(` ${l.label.padEnd(8)} ${String(l.files).padStart(4)} file(s) ${String(l.tokens).padStart(8)} tok\n`);
257
+ }
258
+
259
+ if (layers.length === 2) {
260
+ const g = rule_layer_overlap.readRuleLayer(layers[0]!.dir);
261
+ const p = rule_layer_overlap.readRuleLayer(layers[1]!.dir);
262
+ if (g !== null && p !== null) {
263
+ const r = rule_layer_overlap.compareLayers(g.files, p.files);
264
+ if (r.overlap.length > 0) {
265
+ out(
266
+ ` overlap ${String(r.overlap.length).padStart(4)} rule(s) in both layers `
267
+ + `(${r.duplicate.length} duplicate, ${r.divergent.length} divergent)\n`,
268
+ );
269
+ }
270
+ }
271
+ }
272
+
273
+ const pct = total / budget.total_cap_tokens;
274
+ out(
275
+ ` TOTAL ${String(total).padStart(13)} tok / ${budget.total_cap_tokens} cap `
276
+ + `(${(pct * 100).toFixed(1)}%)${exact ? '' : ' — proxy, not exact'}\n`,
277
+ );
278
+ reportScanned({
279
+ gate: PROG,
280
+ scanned,
281
+ units: 'rule file(s)',
282
+ roots: ['~/.claude/rules', '.claude/rules'],
283
+ });
284
+
285
+ if (pct > 1) {
286
+ process.stderr.write(
287
+ `❌ ${PROG}: ${total} tok of standing rule prose exceeds the ${budget.total_cap_tokens} cap. `
288
+ + 'If two layers overlap, `agent-config install --layer=<global|project>` suppresses one '
289
+ + 'without deleting a file; otherwise scope rules with `paths:` or move bodies into the digest.\n',
290
+ );
291
+ return 1;
292
+ }
293
+ if (pct >= budget.warn_threshold) {
294
+ process.stderr.write(
295
+ `⚠️ ${PROG}: ${(pct * 100).toFixed(1)}% of the standing rule cap used `
296
+ + `(warn at ${(budget.warn_threshold * 100).toFixed(0)}%).\n`,
297
+ );
298
+ }
299
+ out(`✅ ${PROG}: standing rule delivery within cap\n`);
300
+ return 0;
301
+ }
302
+
303
+ function _isCliEntry(): boolean {
304
+ try {
305
+ if (process.argv[1] === undefined) return false;
306
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ if (_isCliEntry()) {
313
+ process.exit(main());
314
+ }