@event4u/agent-config 1.16.0 → 1.18.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 (224) hide show
  1. package/.agent-src/commands/{agents-audit.md → agents/audit.md} +4 -3
  2. package/.agent-src/commands/{agents-cleanup.md → agents/cleanup.md} +12 -6
  3. package/.agent-src/commands/{agents-prepare.md → agents/prepare.md} +4 -3
  4. package/.agent-src/commands/agents.md +46 -0
  5. package/.agent-src/commands/{chat-history-checkpoint.md → chat-history/checkpoint.md} +4 -4
  6. package/.agent-src/commands/{chat-history-clear.md → chat-history/clear.md} +4 -4
  7. package/.agent-src/commands/{chat-history-resume.md → chat-history/resume.md} +4 -4
  8. package/.agent-src/commands/chat-history/show.md +107 -0
  9. package/.agent-src/commands/chat-history.md +33 -89
  10. package/.agent-src/commands/{commit-in-chunks.md → commit/in-chunks.md} +15 -13
  11. package/.agent-src/commands/commit.md +22 -2
  12. package/.agent-src/commands/{context-create.md → context/create.md} +4 -3
  13. package/.agent-src/commands/{context-refactor.md → context/refactor.md} +4 -3
  14. package/.agent-src/commands/context.md +44 -0
  15. package/.agent-src/commands/{copilot-agents-init.md → copilot-agents/init.md} +4 -3
  16. package/.agent-src/commands/{copilot-agents-optimize.md → copilot-agents/optimize.md} +4 -3
  17. package/.agent-src/commands/copilot-agents.md +44 -0
  18. package/.agent-src/commands/council/default.md +221 -0
  19. package/.agent-src/commands/{council-design.md → council/design.md} +6 -5
  20. package/.agent-src/commands/{council-optimize.md → council/optimize.md} +7 -6
  21. package/.agent-src/commands/{council-pr.md → council/pr.md} +6 -5
  22. package/.agent-src/commands/council.md +47 -212
  23. package/.agent-src/commands/{create-pr-description.md → create-pr/description-only.md} +4 -2
  24. package/.agent-src/commands/create-pr.md +26 -5
  25. package/.agent-src/commands/{feature-dev.md → feature/dev.md} +5 -10
  26. package/.agent-src/commands/{feature-explore.md → feature/explore.md} +4 -8
  27. package/.agent-src/commands/{feature-plan.md → feature/plan.md} +4 -8
  28. package/.agent-src/commands/{feature-refactor.md → feature/refactor.md} +4 -8
  29. package/.agent-src/commands/{feature-roadmap.md → feature/roadmap.md} +6 -10
  30. package/.agent-src/commands/feature.md +6 -12
  31. package/.agent-src/commands/{fix-ci.md → fix/ci.md} +4 -8
  32. package/.agent-src/commands/{fix-portability.md → fix/portability.md} +4 -8
  33. package/.agent-src/commands/{fix-pr-bot-comments.md → fix/pr-bots.md} +4 -8
  34. package/.agent-src/commands/{fix-pr-developer-comments.md → fix/pr-developers.md} +4 -8
  35. package/.agent-src/commands/{fix-pr-comments.md → fix/pr.md} +7 -11
  36. package/.agent-src/commands/{fix-references.md → fix/refs.md} +4 -8
  37. package/.agent-src/commands/{fix-seeder.md → fix/seeder.md} +4 -8
  38. package/.agent-src/commands/fix.md +7 -13
  39. package/.agent-src/commands/{do-and-judge.md → judge/on-diff.md} +4 -3
  40. package/.agent-src/commands/judge/solo.md +90 -0
  41. package/.agent-src/commands/{do-in-steps.md → judge/steps.md} +4 -3
  42. package/.agent-src/commands/judge.md +35 -70
  43. package/.agent-src/commands/{memory-add.md → memory/add.md} +4 -3
  44. package/.agent-src/commands/{memory-full.md → memory/load.md} +4 -3
  45. package/.agent-src/commands/{memory-promote.md → memory/promote.md} +4 -3
  46. package/.agent-src/commands/{propose-memory.md → memory/propose.md} +4 -3
  47. package/.agent-src/commands/memory.md +48 -0
  48. package/.agent-src/commands/{module-create.md → module/create.md} +4 -3
  49. package/.agent-src/commands/{module-explore.md → module/explore.md} +4 -3
  50. package/.agent-src/commands/module.md +44 -0
  51. package/.agent-src/commands/{optimize-agents.md → optimize/agents.md} +4 -8
  52. package/.agent-src/commands/{optimize-augmentignore.md → optimize/augmentignore.md} +4 -9
  53. package/.agent-src/commands/{optimize-rtk-filters.md → optimize/rtk.md} +4 -8
  54. package/.agent-src/commands/{optimize-skills.md → optimize/skills.md} +4 -8
  55. package/.agent-src/commands/optimize.md +4 -10
  56. package/.agent-src/commands/{override-create.md → override/create.md} +4 -3
  57. package/.agent-src/commands/{override-manage.md → override/manage.md} +4 -3
  58. package/.agent-src/commands/override.md +44 -0
  59. package/.agent-src/commands/{roadmap-create.md → roadmap/create.md} +4 -3
  60. package/.agent-src/commands/{roadmap-execute.md → roadmap/execute.md} +4 -3
  61. package/.agent-src/commands/roadmap.md +44 -0
  62. package/.agent-src/commands/{tests-create.md → tests/create.md} +4 -3
  63. package/.agent-src/commands/{tests-execute.md → tests/execute.md} +4 -3
  64. package/.agent-src/commands/tests.md +44 -0
  65. package/.agent-src/contexts/communication/rules-auto/artifact-engagement-recording-mechanics.md +72 -0
  66. package/.agent-src/contexts/communication/rules-auto/augment-portability-mechanics.md +79 -0
  67. package/.agent-src/contexts/communication/rules-auto/augment-source-of-truth-mechanics.md +98 -0
  68. package/.agent-src/contexts/communication/rules-auto/cli-output-handling-mechanics.md +87 -0
  69. package/.agent-src/contexts/communication/rules-auto/command-suggestion-policy-mechanics.md +62 -0
  70. package/.agent-src/contexts/communication/rules-auto/docs-sync-mechanics.md +78 -0
  71. package/.agent-src/contexts/communication/rules-auto/package-ci-checks-mechanics.md +85 -0
  72. package/.agent-src/contexts/communication/rules-auto/review-routing-awareness-mechanics.md +65 -0
  73. package/.agent-src/contexts/communication/rules-auto/roadmap-progress-sync-mechanics.md +78 -0
  74. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +62 -0
  75. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +55 -0
  76. package/.agent-src/contexts/communication/rules-auto/ui-audit-gate-mechanics.md +53 -0
  77. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +77 -0
  78. package/.agent-src/contexts/judges/no-consolidate-rationale.md +102 -0
  79. package/.agent-src/contexts/judges/persona-voice-rubric.md +140 -0
  80. package/.agent-src/rules/artifact-engagement-recording.md +13 -69
  81. package/.agent-src/rules/ask-when-uncertain.md +27 -42
  82. package/.agent-src/rules/augment-portability.md +15 -61
  83. package/.agent-src/rules/augment-source-of-truth.md +27 -93
  84. package/.agent-src/rules/cli-output-handling.md +10 -76
  85. package/.agent-src/rules/command-suggestion-policy.md +18 -59
  86. package/.agent-src/rules/commit-conventions.md +17 -14
  87. package/.agent-src/rules/context-hygiene.md +6 -0
  88. package/.agent-src/rules/direct-answers.md +35 -59
  89. package/.agent-src/rules/docker-commands.md +5 -5
  90. package/.agent-src/rules/docs-sync.md +15 -69
  91. package/.agent-src/rules/language-and-tone.md +48 -72
  92. package/.agent-src/rules/missing-tool-handling.md +28 -22
  93. package/.agent-src/rules/no-cheap-questions.md +39 -53
  94. package/.agent-src/rules/no-roadmap-references.md +73 -0
  95. package/.agent-src/rules/onboarding-gate.md +7 -0
  96. package/.agent-src/rules/package-ci-checks.md +21 -61
  97. package/.agent-src/rules/preservation-guard.md +64 -29
  98. package/.agent-src/rules/review-routing-awareness.md +24 -43
  99. package/.agent-src/rules/roadmap-progress-sync.md +31 -65
  100. package/.agent-src/rules/rule-type-governance.md +28 -0
  101. package/.agent-src/rules/security-sensitive-stop.md +8 -8
  102. package/.agent-src/rules/skill-quality.md +16 -48
  103. package/.agent-src/rules/slash-command-routing-policy.md +7 -4
  104. package/.agent-src/rules/think-before-action.md +52 -42
  105. package/.agent-src/rules/tool-safety.md +19 -16
  106. package/.agent-src/rules/ui-audit-gate.md +24 -38
  107. package/.agent-src/rules/user-interaction.md +13 -68
  108. package/.agent-src/skills/ai-council/SKILL.md +2 -0
  109. package/.agent-src/skills/api-testing/SKILL.md +1 -1
  110. package/.agent-src/skills/check-refs/SKILL.md +59 -40
  111. package/.agent-src/skills/conventional-commits-writing/SKILL.md +86 -28
  112. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +5 -5
  113. package/.agent-src/skills/developer-like-execution/SKILL.md +4 -4
  114. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +101 -65
  115. package/.agent-src/skills/flux/SKILL.md +30 -10
  116. package/.agent-src/skills/github-ci/SKILL.md +2 -2
  117. package/.agent-src/skills/judge-code-quality/SKILL.md +7 -8
  118. package/.agent-src/skills/judge-security-auditor/SKILL.md +4 -5
  119. package/.agent-src/skills/judge-test-coverage/SKILL.md +3 -4
  120. package/.agent-src/skills/lint-skills/SKILL.md +57 -39
  121. package/.agent-src/skills/md-language-check/SKILL.md +61 -39
  122. package/.agent-src/skills/override-management/SKILL.md +5 -5
  123. package/.agent-src/skills/quality-tools/SKILL.md +2 -2
  124. package/.agent-src/skills/react-shadcn-ui/SKILL.md +116 -43
  125. package/.agent-src/skills/readme-reviewer/SKILL.md +30 -29
  126. package/.agent-src/skills/readme-writing/SKILL.md +78 -53
  127. package/.agent-src/skills/readme-writing-package/SKILL.md +50 -47
  128. package/.agent-src/skills/receiving-code-review/SKILL.md +52 -47
  129. package/.agent-src/skills/refine-prompt/SKILL.md +0 -1
  130. package/.agent-src/skills/requesting-code-review/SKILL.md +35 -30
  131. package/.agent-src/skills/security/SKILL.md +7 -2
  132. package/.agent-src/skills/security-audit/SKILL.md +7 -3
  133. package/.agent-src/skills/systematic-debugging/SKILL.md +68 -60
  134. package/.agent-src/skills/test-driven-development/SKILL.md +59 -57
  135. package/.agent-src/skills/test-performance/SKILL.md +0 -1
  136. package/.agent-src/skills/traefik/SKILL.md +4 -4
  137. package/.agent-src/skills/verify-completion-evidence/SKILL.md +28 -26
  138. package/.agent-src/templates/roadmaps.md +4 -0
  139. package/.claude-plugin/marketplace.json +22 -11
  140. package/AGENTS.md +2 -2
  141. package/CHANGELOG.md +125 -1
  142. package/README.md +18 -17
  143. package/docs/architecture.md +4 -6
  144. package/docs/catalog.md +67 -39
  145. package/docs/contracts/STABILITY.md +13 -7
  146. package/docs/contracts/adr-chat-history-split.md +1 -3
  147. package/docs/contracts/adr-command-suggestion.md +0 -2
  148. package/docs/contracts/adr-implement-ticket-runtime.md +1 -2
  149. package/docs/contracts/adr-product-ui-track.md +3 -6
  150. package/docs/contracts/adr-prompt-driven-execution.md +3 -4
  151. package/docs/contracts/agent-memory-contract.md +6 -11
  152. package/docs/contracts/artifact-engagement-flow.md +6 -9
  153. package/docs/contracts/command-clusters.md +56 -46
  154. package/docs/contracts/command-suggestion-flow.md +1 -3
  155. package/docs/contracts/context-paths.md +99 -0
  156. package/docs/contracts/file-ownership-matrix.json +6722 -0
  157. package/docs/contracts/file-ownership-matrix.md +134 -0
  158. package/docs/contracts/implement-ticket-flow.md +6 -9
  159. package/docs/contracts/linear-ai-rules-inclusion.md +0 -1
  160. package/docs/contracts/linear-ai-three-layers.md +0 -2
  161. package/docs/contracts/load-context-budget-model.md +258 -0
  162. package/docs/contracts/load-context-schema.md +21 -3
  163. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  164. package/docs/contracts/rule-interactions.md +0 -1
  165. package/docs/contracts/rule-priority-hierarchy.md +1 -1
  166. package/docs/contracts/ui-track-flow.md +7 -17
  167. package/docs/customization.md +2 -0
  168. package/docs/getting-started.md +5 -4
  169. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  170. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +100 -0
  171. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  172. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  173. package/package.json +1 -1
  174. package/scripts/_phase2_shim_helper.py +109 -0
  175. package/scripts/agent-config +30 -0
  176. package/scripts/ai_council/one_off_archive/2026-05/README.md +45 -0
  177. package/scripts/ai_council/one_off_archive/2026-05/_one_off_2a4_acceptance.py +208 -0
  178. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  179. package/scripts/ai_council/one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py +67 -0
  180. package/scripts/ai_council/one_off_archive/2026-05/_one_off_context_layer_v1_review.py +292 -0
  181. package/scripts/ai_council/one_off_archive/2026-05/_one_off_followups_review.py +259 -0
  182. package/scripts/ai_council/one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py +209 -0
  183. package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py +108 -0
  184. package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py +92 -0
  185. package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py +257 -0
  186. package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase_2a_post_revert.py +197 -0
  187. package/scripts/ai_council/one_off_archive/2026-05/_one_off_rule_hardening_v1.py +251 -0
  188. package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_open_questions.py +232 -0
  189. package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_optimization.py +144 -0
  190. package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_v3_gaps.py +252 -0
  191. package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_v3_review.py +240 -0
  192. package/scripts/build_rule_trigger_matrix.py +360 -0
  193. package/scripts/check_always_budget.py +402 -45
  194. package/scripts/check_cluster_patterns.py +159 -0
  195. package/scripts/check_command_count_messaging.py +14 -7
  196. package/scripts/check_context_paths.py +201 -0
  197. package/scripts/check_no_roadmap_refs.py +155 -0
  198. package/scripts/check_one_off_location.py +81 -0
  199. package/scripts/check_phase_coupling.py +148 -0
  200. package/scripts/check_portability.py +2 -0
  201. package/scripts/check_references.py +35 -2
  202. package/scripts/check_safety_floor_untouched.py +125 -0
  203. package/scripts/command_suggester/loader.py +4 -1
  204. package/scripts/compress.py +64 -15
  205. package/scripts/context_hygiene_hook.py +173 -0
  206. package/scripts/generate_index.py +6 -2
  207. package/scripts/generate_ownership_matrix.py +323 -0
  208. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  209. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  210. package/scripts/hooks/augment-roadmap-progress.sh +57 -0
  211. package/scripts/install.py +105 -45
  212. package/scripts/lint_examples.py +98 -0
  213. package/scripts/lint_no_new_atomic_commands.py +12 -11
  214. package/scripts/lint_roadmap_complexity.py +127 -0
  215. package/scripts/onboarding_gate_hook.py +137 -0
  216. package/scripts/requirements-evals.txt +1 -0
  217. package/scripts/roadmap_progress_hook.py +159 -0
  218. package/scripts/schemas/command.schema.json +4 -3
  219. package/scripts/schemas/rule.schema.json +5 -0
  220. package/scripts/skill_linter.py +1 -0
  221. package/scripts/sync_agent_settings.py +25 -2
  222. package/scripts/update_counts.py +7 -0
  223. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  224. /package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +0 -0
@@ -1,50 +1,273 @@
1
1
  #!/usr/bin/env python3
2
- """Always-rule budget gate (Phases 7.1 + 7.4 of road-to-pr-34-followups).
2
+ """Always-rule budget gate (Phases 7.1 + 7.4 of road-to-pr-34-followups,
3
+ extended by Phase 0.2 of road-to-structural-optimization).
3
4
 
4
- Enforces a stricter budget contract than `tests/test_always_budget.py`:
5
- the test suite fails only at 100% utilization (49,000 chars). This
6
- script lives in CI as:
5
+ Enforces the budget contract under **Model (b) literal** — see
6
+ `docs/contracts/load-context-budget-model.md`. Effective size of a
7
+ `type: "always"` rule is its own char count plus the char count of
8
+ every context it loads (transitively, depth ≤ 2).
7
9
 
10
+ Caps:
8
11
  - Warn-at-80% / fail-at-90% global trend gate (Phase 7.1).
9
- - Per-rule cap (≤ 6,000 chars per always-rule, Phase 7.4).
10
- - Top-3 cap (top-3 combined 50% of TOTAL_CAP, Phase 7.4).
12
+ - Per-rule cap (≤ 6,000 chars per always-rule, Phase 7.4) — measured
13
+ on extended size, with a transitional `KNOWN_PER_RULE_BREACHES`
14
+ allowlist that Phase 2A retires.
15
+ - Top-3 cap (top-3 combined ≤ 50% of TOTAL_CAP, Phase 7.4) — extended.
16
+ - Depth-2 nesting cap on `load_context:` chains.
11
17
 
12
- The same caps are enforced as hard assertions in
13
- `tests/test_always_budget.py`; this script duplicates them so a
14
- contributor sees a single, fast pre-test signal during local edits.
15
-
16
- Exit codes: 0 = pass (or warn), 1 = fail (≥ 90% utilization,
17
- per-rule breach, or top-3 breach), 3 = internal error.
18
+ Exit codes: 0 = pass (or warn), 1 = fail (≥ 90% utilization, per-rule
19
+ breach above ceiling, top-3 breach, or depth violation), 3 = internal
20
+ error.
18
21
  """
19
22
 
20
23
  from __future__ import annotations
21
24
 
22
25
  import argparse
26
+ import json
23
27
  import sys
28
+ from datetime import datetime, timezone
24
29
  from pathlib import Path
25
30
 
31
+ import yaml
32
+
26
33
  REPO_ROOT = Path(__file__).resolve().parents[1]
27
34
  RULES_DIR = REPO_ROOT / ".agent-src" / "rules"
35
+ SRC_PREFIX = ".agent-src.uncompressed/"
36
+ COMP_PREFIX = ".agent-src/"
28
37
 
29
38
  TOTAL_CAP = 49_000
30
- WARN_THRESHOLD = 0.80 # 80% — emit warning, exit 0
31
- FAIL_THRESHOLD = 0.90 # 90% — emit error, exit 1
32
- PER_RULE_CAP = 6_000 # Phase 7.4 — no single always-rule may exceed this
33
- TOP3_CAP = TOTAL_CAP // 2 # Phase 7.4 top-3 combined ≤ 50% of total budget
39
+ WARN_THRESHOLD = 0.80
40
+ FAIL_THRESHOLD = 0.90
41
+
42
+ # Phase 5.2.1 concentration thresholds (non-safety-floor rules only).
43
+ # Beyond the total-budget cap, fail CI when any single non-safety-floor
44
+ # rule exceeds SINGLE_PCT of used budget OR the top-3 non-safety-floor
45
+ # sum exceeds TOP3_PCT of used budget. Prevents post-slim concentration
46
+ # regrowth (risk #12 in road-to-structural-optimization).
47
+ CONCENTRATION_SINGLE_PCT = 0.12
48
+ CONCENTRATION_TOP3_PCT = 0.30
49
+
50
+ # Q3=A locked safety-floor rules — out of scope for slimming and for the
51
+ # concentration check. Their size is intentional (Iron Laws + obligation
52
+ # surface), not drift. See road-to-structural-optimization Phase 5.
53
+ SAFETY_FLOOR_RULES: frozenset[str] = frozenset({
54
+ "non-destructive-by-default.md",
55
+ "commit-policy.md",
56
+ "scope-control.md",
57
+ "verify-before-complete.md",
58
+ })
59
+
60
+ # Phase 5.3 — per-rule trend log. JSONL, one record per linter run.
61
+ # Each line: {"ts": iso8601, "total": int, "rules": {name: ext}}.
62
+ TREND_LOG = REPO_ROOT / ".github" / "budget-trend.jsonl"
63
+ TREND_LOG_MAX_RECORDS = 500
64
+ # Phase 0.2 G3 tolerance band — overshoot ≤ 2 % of cap is accepted by
65
+ # the model (b) contract; > 2 % rejects model (b) and escalates. The
66
+ # linter treats the [100 %, 100 % + tolerance] window as a hardened
67
+ # WARN that documents the transition; Phase 2A drops total below 100 %.
68
+ TOLERANCE_BAND = 0.02
69
+ PER_RULE_CAP = 6_000
70
+ TOP3_CAP = TOTAL_CAP // 2
71
+ MAX_DEPTH = 2
72
+ # Phase 1.3 Q2 (road-to-context-layer-maturity) — per-rule context count
73
+ # cap. Counts top-level `load_context:` + `load_context_eager:` entries
74
+ # per rule (not transitive depth). Empirical max in the rule set is 3
75
+ # (autonomous-execution); a 4th declared context is the structural
76
+ # signal that the rule should split, not load more.
77
+ MAX_CONTEXTS_PER_RULE = 3
78
+
79
+ # Recovery band (AI Council session 2026-05-03T12-02-42Z, verdict A1).
80
+ # When enabled, a branch in the 90–100 % gap zone passes as WARN iff its
81
+ # extended total is strictly below the last-green main baseline AND every
82
+ # per-rule / top-3 / depth cap holds. Resolves the paradox where main at
83
+ # 100.6 % passed via TOLERANCE_BAND while a strictly-better branch at
84
+ # 96.8 % failed the gap-zone gate. Phase 5 of road-to-structural-
85
+ # optimization flips this to False and enforces total < TOTAL_CAP strictly.
86
+ RECOVERY_BAND_ENABLED = True
87
+ BASELINE_FILE = REPO_ROOT / ".github" / "budget-baseline.txt"
88
+
89
+ # Transitional allowlist — per-rule extended-size breaches that Phase 2A
90
+ # of road-to-structural-optimization is contracted to retire. Each entry
91
+ # records the measured ceiling on the day Phase 0.2 was committed; a
92
+ # growth above the ceiling fails CI even while the entry remains.
93
+ # When Phase 2A retires a rule, drop its entry here AND in
94
+ # `tests/test_always_budget.py::KNOWN_PER_RULE_BREACHES`.
95
+ KNOWN_PER_RULE_BREACHES: dict[str, int] = {
96
+ "non-destructive-by-default.md": 7_887,
97
+ "scope-control.md": 8_529,
98
+ }
99
+
100
+
101
+ def _load_baseline() -> int | None:
102
+ """Return the last-green main baseline char total, or None if absent.
103
+
104
+ Reads `.github/budget-baseline.txt`; the first non-comment, non-blank
105
+ line is parsed as an integer. Missing file or malformed content
106
+ disables the recovery band silently — the linter falls back to the
107
+ pre-band gate.
108
+ """
109
+ if not BASELINE_FILE.exists():
110
+ return None
111
+ for line in BASELINE_FILE.read_text(encoding="utf-8").splitlines():
112
+ line = line.strip()
113
+ if not line or line.startswith("#"):
114
+ continue
115
+ try:
116
+ return int(line)
117
+ except ValueError:
118
+ return None
119
+ return None
120
+
121
+
122
+ def _frontmatter(path: Path) -> dict:
123
+ text = path.read_text(encoding="utf-8")
124
+ if not text.startswith("---\n"):
125
+ return {}
126
+ end = text.find("\n---\n", 4)
127
+ if end == -1:
128
+ return {}
129
+ try:
130
+ return yaml.safe_load(text[4:end]) or {}
131
+ except yaml.YAMLError:
132
+ return {}
133
+
134
+
135
+ def _is_always(path: Path) -> bool:
136
+ return _frontmatter(path).get("type") == "always"
137
+
138
+
139
+ def _load_context_paths(path: Path) -> list[str]:
140
+ fm = _frontmatter(path)
141
+ out: list[str] = []
142
+ for key in ("load_context", "load_context_eager"):
143
+ for entry in fm.get(key) or []:
144
+ out.append(str(entry))
145
+ return out
146
+
147
+
148
+ def _src_to_compressed(entry: str) -> Path:
149
+ if entry.startswith(SRC_PREFIX):
150
+ return REPO_ROOT / (COMP_PREFIX + entry[len(SRC_PREFIX):])
151
+ return REPO_ROOT / entry
152
+
153
+
154
+ def _walk_contexts(rule: Path) -> tuple[set[Path], list[tuple[str, str]]]:
155
+ """Return (set of context files counted, list of depth-violation chains)."""
156
+ seen: set[Path] = set()
157
+ violations: list[tuple[str, str]] = []
158
+ stack: list[tuple[Path, int, str]] = [(rule, 0, rule.name)]
159
+ while stack:
160
+ node, depth, chain = stack.pop()
161
+ for entry in _load_context_paths(node):
162
+ comp = _src_to_compressed(entry)
163
+ new_chain = f"{chain} → {entry}"
164
+ if depth + 1 > MAX_DEPTH:
165
+ violations.append((rule.name, new_chain))
166
+ continue
167
+ if not comp.exists():
168
+ continue
169
+ if comp in seen:
170
+ continue
171
+ seen.add(comp)
172
+ stack.append((comp, depth + 1, new_chain))
173
+ return seen, violations
34
174
 
35
175
 
36
176
  def _always_rules() -> list[Path]:
37
- rules: list[Path] = []
38
- for path in sorted(RULES_DIR.glob("*.md")):
39
- head = path.read_text(encoding="utf-8").splitlines()[1:2]
40
- if head == ['type: "always"']:
41
- rules.append(path)
42
- return rules
177
+ return sorted(p for p in RULES_DIR.glob("*.md") if _is_always(p))
178
+
179
+
180
+ def _all_rules() -> list[Path]:
181
+ return sorted(RULES_DIR.glob("*.md"))
182
+
183
+
184
+ def _context_count(rule: Path) -> int:
185
+ fm = _frontmatter(rule)
186
+ lazy = fm.get("load_context") or []
187
+ eager = fm.get("load_context_eager") or []
188
+ return (len(lazy) if isinstance(lazy, list) else 0) + (
189
+ len(eager) if isinstance(eager, list) else 0
190
+ )
43
191
 
44
192
 
45
- def _summary(rules: list[Path]) -> tuple[int, list[tuple[str, int]]]:
46
- sizes = [(p.name, p.stat().st_size) for p in rules]
47
- return sum(s for _, s in sizes), sorted(sizes, key=lambda x: -x[1])
193
+ def _per_rule_count_breaches() -> list[tuple[str, int]]:
194
+ """Phase 1.3 Q2 return rules whose declared context count exceeds the cap."""
195
+ out: list[tuple[str, int]] = []
196
+ for rule in _all_rules():
197
+ n = _context_count(rule)
198
+ if n > MAX_CONTEXTS_PER_RULE:
199
+ out.append((rule.name, n))
200
+ return out
201
+
202
+
203
+ def _extended_size(rule: Path) -> tuple[int, list[tuple[str, str]]]:
204
+ raw = rule.stat().st_size
205
+ contexts, violations = _walk_contexts(rule)
206
+ ext = raw + sum(c.stat().st_size for c in contexts)
207
+ return ext, violations
208
+
209
+
210
+ def _concentration_check(
211
+ sizes: list[tuple[str, int, int]],
212
+ total_ext: int,
213
+ ) -> tuple[list[tuple[str, int, float]], tuple[int, float] | None]:
214
+ """Phase 5.2.1 concentration check (non-safety-floor rules only).
215
+
216
+ Returns (single-rule breaches, top-3 breach or None). Q3=A locked
217
+ safety-floor rules are excluded from both numerator and the top-3
218
+ selection — their size is intentional, not drift.
219
+ """
220
+ non_floor = [
221
+ (name, raw, ext) for name, raw, ext in sizes
222
+ if name not in SAFETY_FLOOR_RULES
223
+ ]
224
+ single_cap = total_ext * CONCENTRATION_SINGLE_PCT
225
+ top3_cap = total_ext * CONCENTRATION_TOP3_PCT
226
+
227
+ single_breaches = [
228
+ (name, ext, ext / total_ext)
229
+ for name, _, ext in non_floor
230
+ if ext > single_cap
231
+ ]
232
+ top3_sum = sum(ext for _, _, ext in non_floor[:3])
233
+ top3_breach = (
234
+ (top3_sum, top3_sum / total_ext)
235
+ if top3_sum > top3_cap else None
236
+ )
237
+ return single_breaches, top3_breach
238
+
239
+
240
+ def _record_trend(total_ext: int, sizes: list[tuple[str, int, int]]) -> None:
241
+ """Append the current run to the trend log (Phase 5.3)."""
242
+ TREND_LOG.parent.mkdir(parents=True, exist_ok=True)
243
+ record = {
244
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
245
+ "total": total_ext,
246
+ "rules": {name: ext for name, _, ext in sizes},
247
+ }
248
+ lines: list[str] = []
249
+ if TREND_LOG.exists():
250
+ lines = TREND_LOG.read_text(encoding="utf-8").splitlines()
251
+ lines.append(json.dumps(record, separators=(",", ":")))
252
+ if len(lines) > TREND_LOG_MAX_RECORDS:
253
+ lines = lines[-TREND_LOG_MAX_RECORDS:]
254
+ TREND_LOG.write_text("\n".join(lines) + "\n", encoding="utf-8")
255
+
256
+
257
+ def _last_trend() -> dict | None:
258
+ """Return the most recent trend record, or None if log is empty."""
259
+ if not TREND_LOG.exists():
260
+ return None
261
+ lines = [
262
+ line for line in TREND_LOG.read_text(encoding="utf-8").splitlines()
263
+ if line.strip()
264
+ ]
265
+ if not lines:
266
+ return None
267
+ try:
268
+ return json.loads(lines[-1])
269
+ except json.JSONDecodeError:
270
+ return None
48
271
 
49
272
 
50
273
  def main() -> int:
@@ -54,6 +277,11 @@ def main() -> int:
54
277
  action="store_true",
55
278
  help="suppress the per-rule breakdown unless threshold is crossed",
56
279
  )
280
+ parser.add_argument(
281
+ "--no-trend",
282
+ action="store_true",
283
+ help="skip writing to .github/budget-trend.jsonl (Phase 5.3)",
284
+ )
57
285
  args = parser.parse_args()
58
286
 
59
287
  if not RULES_DIR.is_dir():
@@ -65,44 +293,112 @@ def main() -> int:
65
293
  print(f"❌ no always-rules found under {RULES_DIR}", file=sys.stderr)
66
294
  return 3
67
295
 
68
- total, sizes = _summary(rules)
69
- pct = total / TOTAL_CAP
70
- over_per_rule = [(n, s) for n, s in sizes if s > PER_RULE_CAP]
71
- top3 = sum(s for _, s in sizes[:3])
296
+ sizes: list[tuple[str, int, int]] = []
297
+ all_violations: list[tuple[str, str]] = []
298
+ for rule in rules:
299
+ ext, violations = _extended_size(rule)
300
+ sizes.append((rule.name, rule.stat().st_size, ext))
301
+ all_violations.extend(violations)
302
+
303
+ sizes.sort(key=lambda x: -x[2])
304
+ total_ext = sum(s[2] for s in sizes)
305
+ pct = total_ext / TOTAL_CAP
306
+ top3 = sum(s[2] for s in sizes[:3])
72
307
  top3_breach = top3 > TOP3_CAP
73
308
 
74
- if pct >= FAIL_THRESHOLD or over_per_rule or top3_breach:
75
- status = "❌ FAIL"
76
- rc = 1
309
+ over_per_rule: list[tuple[str, int]] = []
310
+ grew_over_ceiling: list[tuple[str, int, int]] = []
311
+ for name, _, ext in sizes:
312
+ if ext <= PER_RULE_CAP:
313
+ continue
314
+ ceiling = KNOWN_PER_RULE_BREACHES.get(name)
315
+ if ceiling is None:
316
+ over_per_rule.append((name, ext))
317
+ elif ext > ceiling:
318
+ grew_over_ceiling.append((name, ext, ceiling))
319
+
320
+ in_tolerance = 1.0 <= pct <= 1.0 + TOLERANCE_BAND
321
+ baseline = _load_baseline() if RECOVERY_BAND_ENABLED else None
322
+ in_recovery_band = (
323
+ baseline is not None
324
+ and FAIL_THRESHOLD <= pct < 1.0
325
+ and total_ext < baseline
326
+ )
327
+ single_breaches, top3_concentration_breach = _concentration_check(
328
+ sizes, total_ext
329
+ )
330
+ count_breaches = _per_rule_count_breaches()
331
+ failing = (
332
+ (
333
+ pct >= FAIL_THRESHOLD
334
+ and not in_tolerance
335
+ and not in_recovery_band
336
+ and pct < 1.0
337
+ )
338
+ or pct > 1.0 + TOLERANCE_BAND
339
+ or over_per_rule
340
+ or grew_over_ceiling
341
+ or top3_breach
342
+ or all_violations
343
+ or single_breaches
344
+ or top3_concentration_breach is not None
345
+ or count_breaches
346
+ )
347
+ if failing:
348
+ status, rc = "❌ FAIL", 1
349
+ elif in_tolerance:
350
+ status, rc = "⚠️ WARN (G3 tolerance band)", 0
351
+ elif in_recovery_band:
352
+ status, rc = (
353
+ f"⚠️ WARN (recovery band, baseline {baseline:,})",
354
+ 0,
355
+ )
77
356
  elif pct >= WARN_THRESHOLD:
78
- status = "⚠️ WARN"
79
- rc = 0
357
+ status, rc = "⚠️ WARN", 0
80
358
  else:
81
- status = "✅ OK"
82
- rc = 0
359
+ status, rc = "✅ OK", 0
83
360
 
84
361
  print(
85
- f"{status} always-rule budget: {total:,} / {TOTAL_CAP:,} chars "
86
- f"({pct * 100:.1f}%) across {len(rules)} rule(s)"
362
+ f"{status} always-rule extended budget: {total_ext:,} / "
363
+ f"{TOTAL_CAP:,} chars ({pct * 100:.1f}%) across {len(rules)} rule(s)"
87
364
  )
88
365
  print(
89
366
  f" thresholds: warn {WARN_THRESHOLD * 100:.0f}% · "
90
367
  f"fail {FAIL_THRESHOLD * 100:.0f}% · "
91
- f"per-rule ≤ {PER_RULE_CAP:,} · top-3 ≤ {TOP3_CAP:,}"
368
+ f"per-rule ≤ {PER_RULE_CAP:,} (ext) · top-3 ≤ {TOP3_CAP:,} (ext) · "
369
+ f"depth ≤ {MAX_DEPTH}"
92
370
  )
93
371
 
94
372
  if rc != 0 or pct >= WARN_THRESHOLD or not args.quiet:
95
373
  print()
96
- print(f" breakdown (largest first; top-3 sum = {top3:,}):")
97
- for i, (name, size) in enumerate(sizes):
98
- mark = " ❌" if size > PER_RULE_CAP else ""
374
+ print(f" breakdown (largest extended first; top-3 sum = {top3:,}):")
375
+ for i, (name, raw, ext) in enumerate(sizes):
99
376
  tag = " (top-3)" if i < 3 else ""
100
- print(f" {size:>5} {name}{tag}{mark}")
377
+ ceiling = KNOWN_PER_RULE_BREACHES.get(name)
378
+ if ceiling is not None:
379
+ marker = f" ⚠️ allowlisted ≤ {ceiling:,}"
380
+ elif ext > PER_RULE_CAP:
381
+ marker = " ❌ per-rule breach"
382
+ else:
383
+ marker = ""
384
+ print(
385
+ f" ext={ext:>5} raw={raw:>5} {name}{tag}{marker}"
386
+ )
101
387
 
102
388
  if over_per_rule:
103
389
  names = ", ".join(f"{n}={s:,}" for n, s in over_per_rule)
104
390
  print(
105
- f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars): {names}"
391
+ f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars, not allowlisted): "
392
+ f"{names}"
393
+ )
394
+
395
+ if grew_over_ceiling:
396
+ details = ", ".join(
397
+ f"{n}={ext:,} > ceiling {ceiling:,}"
398
+ for n, ext, ceiling in grew_over_ceiling
399
+ )
400
+ print(
401
+ f"\n Allowlisted-breach growth (regression): {details}"
106
402
  )
107
403
 
108
404
  if top3_breach:
@@ -111,12 +407,73 @@ def main() -> int:
111
407
  f"(top-3 must stay ≤ 50% of {TOTAL_CAP:,} total budget)."
112
408
  )
113
409
 
410
+ if all_violations:
411
+ print(
412
+ f"\n Depth-{MAX_DEPTH} nesting cap violations:"
413
+ )
414
+ for rule_name, chain in all_violations:
415
+ print(f" {rule_name}: {chain}")
416
+
417
+ if single_breaches:
418
+ details = ", ".join(
419
+ f"{n}={ext:,} ({frac * 100:.1f}%)"
420
+ for n, ext, frac in single_breaches
421
+ )
422
+ print(
423
+ f"\n Concentration breach (single rule > "
424
+ f"{CONCENTRATION_SINGLE_PCT * 100:.0f}% of used budget, "
425
+ f"non-allowlisted): {details}"
426
+ )
427
+
428
+ if top3_concentration_breach is not None:
429
+ sum_, frac = top3_concentration_breach
430
+ print(
431
+ f"\n Concentration breach (top-3 non-allowlisted > "
432
+ f"{CONCENTRATION_TOP3_PCT * 100:.0f}% of used budget): "
433
+ f"{sum_:,} ({frac * 100:.1f}%)"
434
+ )
435
+
436
+ if count_breaches:
437
+ details = ", ".join(f"{n}={c}" for n, c in count_breaches)
438
+ print(
439
+ f"\n Per-rule context-count cap breach "
440
+ f"(> {MAX_CONTEXTS_PER_RULE} declared contexts, Q2 "
441
+ f"road-to-context-layer-maturity Phase 1.3): {details}"
442
+ )
443
+
444
+ # Phase 5.3 — per-rule trend delta vs. previous run.
445
+ prev = _last_trend()
446
+ if prev is not None and not args.quiet:
447
+ prev_total = prev.get("total")
448
+ prev_rules = prev.get("rules") or {}
449
+ if isinstance(prev_total, int):
450
+ delta_total = total_ext - prev_total
451
+ sign = "+" if delta_total >= 0 else ""
452
+ print(
453
+ f"\n Trend vs. previous run "
454
+ f"({prev.get('ts', '?')}): total {sign}{delta_total:,} chars"
455
+ )
456
+ deltas: list[tuple[str, int, int]] = []
457
+ for name, _, ext in sizes:
458
+ old = prev_rules.get(name)
459
+ if isinstance(old, int) and old != ext:
460
+ deltas.append((name, ext - old, ext))
461
+ if deltas:
462
+ deltas.sort(key=lambda x: -abs(x[1]))
463
+ for name, d, ext in deltas[:5]:
464
+ s = "+" if d >= 0 else ""
465
+ print(f" {name}: {s}{d:,} (now {ext:,})")
466
+
467
+ if not args.no_trend:
468
+ _record_trend(total_ext, sizes)
469
+
114
470
  if rc == 1:
115
471
  print(
116
472
  f"\n Action: trim the offending rule(s) via load_context: "
117
473
  f"extraction (see contexts/execution + contexts/authority) "
118
474
  f"until utilization drops below {FAIL_THRESHOLD * 100:.0f}% "
119
- f"and all per-rule / top-3 caps hold."
475
+ f"and all per-rule / top-3 / depth caps hold. See "
476
+ f"docs/contracts/load-context-budget-model.md."
120
477
  )
121
478
 
122
479
  return rc
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """Cluster-pattern compliance check.
3
+
4
+ Compares each cluster dispatcher under
5
+ `.agent-src.uncompressed/commands/<cluster>.md` against the Phase 1
6
+ reference patterns (`fix.md`, `optimize.md`, `feature.md`).
7
+
8
+ Required structure:
9
+
10
+ Frontmatter:
11
+ - `name: <cluster>`
12
+ - `cluster: <cluster>`
13
+ - `disable-model-invocation: true`
14
+
15
+ Body:
16
+ - `# /<cluster>` H1
17
+ - `## Sub-commands` section with a markdown table whose header is
18
+ exactly `Sub-command | Routes to | Purpose`
19
+ - `## Dispatch` section
20
+ - `## Rules` section
21
+
22
+ Cluster files are detected by reading the locked-clusters table in
23
+ `docs/contracts/command-clusters.md` (column-1 backticks).
24
+
25
+ Exit codes: 0 = clean, 1 = pattern violations, 3 = internal error.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import sys
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+
34
+ ROOT = Path(__file__).resolve().parent.parent
35
+ COMMANDS_DIR = ROOT / ".agent-src.uncompressed/commands"
36
+ CONTRACT = ROOT / "docs/contracts/command-clusters.md"
37
+
38
+ REQUIRED_SECTIONS = ["## Sub-commands", "## Dispatch", "## Rules"]
39
+ TABLE_HEADER_RE = re.compile(
40
+ r"\|\s*Sub-command\s*\|\s*Routes to\s*\|\s*Purpose\s*\|", re.IGNORECASE
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class FileReport:
46
+ path: Path
47
+ cluster: str
48
+ errors: list[str] = field(default_factory=list)
49
+
50
+
51
+ def load_cluster_table() -> list[tuple[str, str]]:
52
+ """Return [(cluster_name, kind)] where kind ∈ {"dispatch", "flag"}."""
53
+ text = CONTRACT.read_text(encoding="utf-8")
54
+ in_table = False
55
+ rows: list[tuple[str, str]] = []
56
+ row_re = re.compile(
57
+ r"\|\s*`([a-z][a-z0-9-]*)`\s*\|\s*\d+\s*\|\s*([^|]+)\|"
58
+ )
59
+ for line in text.splitlines():
60
+ if line.startswith("## Locked clusters"):
61
+ in_table = True
62
+ continue
63
+ if in_table and line.startswith("## "):
64
+ break
65
+ if in_table:
66
+ m = row_re.match(line)
67
+ if m:
68
+ name, sub_col = m.group(1), m.group(2).strip().lower()
69
+ kind = "flag" if sub_col.startswith("flag:") else "dispatch"
70
+ rows.append((name, kind))
71
+ return rows
72
+
73
+
74
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
75
+ if not text.startswith("---\n"):
76
+ return {}, text
77
+ end = text.find("\n---\n", 4)
78
+ if end == -1:
79
+ return {}, text
80
+ fm: dict[str, str] = {}
81
+ for line in text[4:end].splitlines():
82
+ if line and not line.startswith(" ") and ":" in line:
83
+ k, _, v = line.partition(":")
84
+ fm[k.strip()] = v.strip()
85
+ body = text[end + len("\n---\n"):]
86
+ return fm, body
87
+
88
+
89
+ def check_dispatcher(cluster: str) -> FileReport:
90
+ path = COMMANDS_DIR / f"{cluster}.md"
91
+ rep = FileReport(path=path, cluster=cluster)
92
+ if not path.exists():
93
+ rep.errors.append(f"dispatcher file missing: {path.relative_to(ROOT)}")
94
+ return rep
95
+ text = path.read_text(encoding="utf-8")
96
+ fm, body = parse_frontmatter(text)
97
+
98
+ # Frontmatter checks.
99
+ if fm.get("name") != cluster:
100
+ rep.errors.append(f"frontmatter `name:` is {fm.get('name')!r}, expected {cluster!r}")
101
+ if fm.get("cluster") != cluster:
102
+ rep.errors.append(f"frontmatter `cluster:` is {fm.get('cluster')!r}, expected {cluster!r}")
103
+ if fm.get("disable-model-invocation") != "true":
104
+ rep.errors.append("frontmatter `disable-model-invocation: true` missing")
105
+
106
+ # H1 check.
107
+ h1 = f"# /{cluster}"
108
+ if h1 not in body.splitlines()[:5]:
109
+ rep.errors.append(f"missing top-level heading {h1!r} in first 5 body lines")
110
+
111
+ # Section presence.
112
+ for section in REQUIRED_SECTIONS:
113
+ if section not in body:
114
+ rep.errors.append(f"missing section header {section!r}")
115
+
116
+ # Sub-commands table header (only meaningful if Sub-commands section exists).
117
+ if "## Sub-commands" in body and not TABLE_HEADER_RE.search(body):
118
+ rep.errors.append(
119
+ "Sub-commands table header must be `| Sub-command | Routes to | Purpose |`"
120
+ )
121
+ return rep
122
+
123
+
124
+ def main() -> int:
125
+ rows = load_cluster_table()
126
+ if not rows:
127
+ print(f"❌ No clusters parsed from {CONTRACT.relative_to(ROOT)}",
128
+ file=sys.stderr)
129
+ return 3
130
+
131
+ dispatch_clusters = [n for n, k in rows if k == "dispatch"]
132
+ flag_clusters = [n for n, k in rows if k == "flag"]
133
+
134
+ reports = [check_dispatcher(n) for n in dispatch_clusters]
135
+ bad = [r for r in reports if r.errors]
136
+
137
+ # Flag clusters: only assert the file exists; legacy shape is preserved.
138
+ flag_missing = [n for n in flag_clusters
139
+ if not (COMMANDS_DIR / f"{n}.md").exists()]
140
+ if flag_missing:
141
+ print(f"❌ Flag-cluster file(s) missing: {flag_missing}")
142
+ return 1
143
+
144
+ if bad:
145
+ print(f"❌ {len(bad)}/{len(reports)} cluster dispatcher(s) deviate "
146
+ f"from the Phase-1 reference pattern:")
147
+ for r in bad:
148
+ print(f" • {r.path.relative_to(ROOT)} (cluster `{r.cluster}`)")
149
+ for err in r.errors:
150
+ print(f" - {err}")
151
+ print(f"\nReference: commands/fix.md, commands/optimize.md, commands/feature.md")
152
+ return 1
153
+ print(f"✅ {len(reports)} cluster dispatcher(s) match the Phase-1 reference "
154
+ f"pattern; {len(flag_clusters)} flag-cluster(s) verified present.")
155
+ return 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())