@event4u/agent-config 1.16.0 → 1.17.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 (203) 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/direct-answers.md +34 -49
  88. package/.agent-src/rules/docker-commands.md +5 -5
  89. package/.agent-src/rules/docs-sync.md +15 -69
  90. package/.agent-src/rules/language-and-tone.md +48 -72
  91. package/.agent-src/rules/missing-tool-handling.md +28 -22
  92. package/.agent-src/rules/no-cheap-questions.md +45 -52
  93. package/.agent-src/rules/no-roadmap-references.md +73 -0
  94. package/.agent-src/rules/package-ci-checks.md +21 -61
  95. package/.agent-src/rules/preservation-guard.md +64 -29
  96. package/.agent-src/rules/review-routing-awareness.md +24 -43
  97. package/.agent-src/rules/roadmap-progress-sync.md +10 -71
  98. package/.agent-src/rules/security-sensitive-stop.md +8 -8
  99. package/.agent-src/rules/skill-quality.md +16 -48
  100. package/.agent-src/rules/slash-command-routing-policy.md +7 -4
  101. package/.agent-src/rules/think-before-action.md +52 -42
  102. package/.agent-src/rules/tool-safety.md +19 -16
  103. package/.agent-src/rules/ui-audit-gate.md +24 -38
  104. package/.agent-src/rules/user-interaction.md +13 -68
  105. package/.agent-src/skills/ai-council/SKILL.md +2 -0
  106. package/.agent-src/skills/api-testing/SKILL.md +1 -1
  107. package/.agent-src/skills/check-refs/SKILL.md +59 -40
  108. package/.agent-src/skills/conventional-commits-writing/SKILL.md +86 -28
  109. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +5 -5
  110. package/.agent-src/skills/developer-like-execution/SKILL.md +4 -4
  111. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +101 -65
  112. package/.agent-src/skills/flux/SKILL.md +30 -10
  113. package/.agent-src/skills/github-ci/SKILL.md +2 -2
  114. package/.agent-src/skills/judge-code-quality/SKILL.md +7 -8
  115. package/.agent-src/skills/judge-security-auditor/SKILL.md +4 -5
  116. package/.agent-src/skills/judge-test-coverage/SKILL.md +3 -4
  117. package/.agent-src/skills/lint-skills/SKILL.md +57 -39
  118. package/.agent-src/skills/md-language-check/SKILL.md +61 -39
  119. package/.agent-src/skills/override-management/SKILL.md +5 -5
  120. package/.agent-src/skills/quality-tools/SKILL.md +2 -2
  121. package/.agent-src/skills/react-shadcn-ui/SKILL.md +116 -43
  122. package/.agent-src/skills/readme-reviewer/SKILL.md +30 -29
  123. package/.agent-src/skills/readme-writing/SKILL.md +78 -53
  124. package/.agent-src/skills/readme-writing-package/SKILL.md +50 -47
  125. package/.agent-src/skills/receiving-code-review/SKILL.md +52 -47
  126. package/.agent-src/skills/refine-prompt/SKILL.md +0 -1
  127. package/.agent-src/skills/requesting-code-review/SKILL.md +35 -30
  128. package/.agent-src/skills/security/SKILL.md +7 -2
  129. package/.agent-src/skills/security-audit/SKILL.md +7 -3
  130. package/.agent-src/skills/systematic-debugging/SKILL.md +68 -60
  131. package/.agent-src/skills/test-driven-development/SKILL.md +59 -57
  132. package/.agent-src/skills/test-performance/SKILL.md +0 -1
  133. package/.agent-src/skills/traefik/SKILL.md +4 -4
  134. package/.agent-src/skills/verify-completion-evidence/SKILL.md +28 -26
  135. package/.claude-plugin/marketplace.json +22 -11
  136. package/AGENTS.md +2 -2
  137. package/CHANGELOG.md +90 -1
  138. package/README.md +18 -17
  139. package/docs/architecture.md +4 -6
  140. package/docs/catalog.md +67 -39
  141. package/docs/contracts/STABILITY.md +13 -7
  142. package/docs/contracts/adr-chat-history-split.md +1 -3
  143. package/docs/contracts/adr-command-suggestion.md +0 -2
  144. package/docs/contracts/adr-implement-ticket-runtime.md +1 -2
  145. package/docs/contracts/adr-product-ui-track.md +3 -6
  146. package/docs/contracts/adr-prompt-driven-execution.md +3 -4
  147. package/docs/contracts/agent-memory-contract.md +6 -11
  148. package/docs/contracts/artifact-engagement-flow.md +6 -9
  149. package/docs/contracts/command-clusters.md +56 -46
  150. package/docs/contracts/command-suggestion-flow.md +1 -3
  151. package/docs/contracts/context-paths.md +99 -0
  152. package/docs/contracts/file-ownership-matrix.json +6722 -0
  153. package/docs/contracts/file-ownership-matrix.md +134 -0
  154. package/docs/contracts/implement-ticket-flow.md +6 -9
  155. package/docs/contracts/linear-ai-rules-inclusion.md +0 -1
  156. package/docs/contracts/linear-ai-three-layers.md +0 -2
  157. package/docs/contracts/load-context-budget-model.md +178 -0
  158. package/docs/contracts/load-context-schema.md +1 -3
  159. package/docs/contracts/rule-interactions.md +0 -1
  160. package/docs/contracts/rule-priority-hierarchy.md +1 -1
  161. package/docs/contracts/ui-track-flow.md +7 -17
  162. package/docs/customization.md +2 -0
  163. package/docs/getting-started.md +5 -4
  164. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +100 -0
  165. package/package.json +1 -1
  166. package/scripts/_one_off_phase4_dispatch_latency.py +108 -0
  167. package/scripts/_one_off_phase6_trigger_jaccard.py +92 -0
  168. package/scripts/_phase2_shim_helper.py +109 -0
  169. package/scripts/agent-config +10 -0
  170. package/scripts/ai_council/_one_off_2a4_acceptance.py +208 -0
  171. package/scripts/ai_council/_one_off_context_layer_v1_estimate.py +67 -0
  172. package/scripts/ai_council/_one_off_context_layer_v1_review.py +292 -0
  173. package/scripts/ai_council/_one_off_followups_review.py +259 -0
  174. package/scripts/ai_council/_one_off_nondestructive_inline_audit.py +209 -0
  175. package/scripts/ai_council/_one_off_phase_2a_budget_rebalance.py +257 -0
  176. package/scripts/ai_council/_one_off_phase_2a_post_revert.py +197 -0
  177. package/scripts/ai_council/_one_off_rule_hardening_v1.py +251 -0
  178. package/scripts/ai_council/_one_off_structural_open_questions.py +232 -0
  179. package/scripts/ai_council/_one_off_structural_optimization.py +144 -0
  180. package/scripts/ai_council/_one_off_structural_v3_gaps.py +252 -0
  181. package/scripts/ai_council/_one_off_structural_v3_review.py +240 -0
  182. package/scripts/check_always_budget.py +363 -45
  183. package/scripts/check_cluster_patterns.py +159 -0
  184. package/scripts/check_command_count_messaging.py +14 -7
  185. package/scripts/check_context_paths.py +201 -0
  186. package/scripts/check_no_roadmap_refs.py +155 -0
  187. package/scripts/check_phase_coupling.py +148 -0
  188. package/scripts/check_portability.py +2 -0
  189. package/scripts/check_references.py +29 -2
  190. package/scripts/check_safety_floor_untouched.py +125 -0
  191. package/scripts/command_suggester/loader.py +4 -1
  192. package/scripts/compress.py +59 -13
  193. package/scripts/generate_index.py +6 -2
  194. package/scripts/generate_ownership_matrix.py +323 -0
  195. package/scripts/hooks/augment-roadmap-progress.sh +57 -0
  196. package/scripts/install.py +49 -28
  197. package/scripts/lint_no_new_atomic_commands.py +12 -11
  198. package/scripts/requirements-evals.txt +1 -0
  199. package/scripts/roadmap_progress_hook.py +159 -0
  200. package/scripts/schemas/command.schema.json +4 -3
  201. package/scripts/skill_linter.py +1 -0
  202. package/scripts/sync_agent_settings.py +25 -2
  203. package/scripts/update_counts.py +7 -0
@@ -1,50 +1,244 @@
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
+
73
+ # Recovery band (AI Council session 2026-05-03T12-02-42Z, verdict A1).
74
+ # When enabled, a branch in the 90–100 % gap zone passes as WARN iff its
75
+ # extended total is strictly below the last-green main baseline AND every
76
+ # per-rule / top-3 / depth cap holds. Resolves the paradox where main at
77
+ # 100.6 % passed via TOLERANCE_BAND while a strictly-better branch at
78
+ # 96.8 % failed the gap-zone gate. Phase 5 of road-to-structural-
79
+ # optimization flips this to False and enforces total < TOTAL_CAP strictly.
80
+ RECOVERY_BAND_ENABLED = True
81
+ BASELINE_FILE = REPO_ROOT / ".github" / "budget-baseline.txt"
82
+
83
+ # Transitional allowlist — per-rule extended-size breaches that Phase 2A
84
+ # of road-to-structural-optimization is contracted to retire. Each entry
85
+ # records the measured ceiling on the day Phase 0.2 was committed; a
86
+ # growth above the ceiling fails CI even while the entry remains.
87
+ # When Phase 2A retires a rule, drop its entry here AND in
88
+ # `tests/test_always_budget.py::KNOWN_PER_RULE_BREACHES`.
89
+ KNOWN_PER_RULE_BREACHES: dict[str, int] = {
90
+ "non-destructive-by-default.md": 7_887,
91
+ "scope-control.md": 8_529,
92
+ }
93
+
94
+
95
+ def _load_baseline() -> int | None:
96
+ """Return the last-green main baseline char total, or None if absent.
97
+
98
+ Reads `.github/budget-baseline.txt`; the first non-comment, non-blank
99
+ line is parsed as an integer. Missing file or malformed content
100
+ disables the recovery band silently — the linter falls back to the
101
+ pre-band gate.
102
+ """
103
+ if not BASELINE_FILE.exists():
104
+ return None
105
+ for line in BASELINE_FILE.read_text(encoding="utf-8").splitlines():
106
+ line = line.strip()
107
+ if not line or line.startswith("#"):
108
+ continue
109
+ try:
110
+ return int(line)
111
+ except ValueError:
112
+ return None
113
+ return None
114
+
115
+
116
+ def _frontmatter(path: Path) -> dict:
117
+ text = path.read_text(encoding="utf-8")
118
+ if not text.startswith("---\n"):
119
+ return {}
120
+ end = text.find("\n---\n", 4)
121
+ if end == -1:
122
+ return {}
123
+ try:
124
+ return yaml.safe_load(text[4:end]) or {}
125
+ except yaml.YAMLError:
126
+ return {}
127
+
128
+
129
+ def _is_always(path: Path) -> bool:
130
+ return _frontmatter(path).get("type") == "always"
131
+
132
+
133
+ def _load_context_paths(path: Path) -> list[str]:
134
+ fm = _frontmatter(path)
135
+ out: list[str] = []
136
+ for key in ("load_context", "load_context_eager"):
137
+ for entry in fm.get(key) or []:
138
+ out.append(str(entry))
139
+ return out
140
+
141
+
142
+ def _src_to_compressed(entry: str) -> Path:
143
+ if entry.startswith(SRC_PREFIX):
144
+ return REPO_ROOT / (COMP_PREFIX + entry[len(SRC_PREFIX):])
145
+ return REPO_ROOT / entry
146
+
147
+
148
+ def _walk_contexts(rule: Path) -> tuple[set[Path], list[tuple[str, str]]]:
149
+ """Return (set of context files counted, list of depth-violation chains)."""
150
+ seen: set[Path] = set()
151
+ violations: list[tuple[str, str]] = []
152
+ stack: list[tuple[Path, int, str]] = [(rule, 0, rule.name)]
153
+ while stack:
154
+ node, depth, chain = stack.pop()
155
+ for entry in _load_context_paths(node):
156
+ comp = _src_to_compressed(entry)
157
+ new_chain = f"{chain} → {entry}"
158
+ if depth + 1 > MAX_DEPTH:
159
+ violations.append((rule.name, new_chain))
160
+ continue
161
+ if not comp.exists():
162
+ continue
163
+ if comp in seen:
164
+ continue
165
+ seen.add(comp)
166
+ stack.append((comp, depth + 1, new_chain))
167
+ return seen, violations
34
168
 
35
169
 
36
170
  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
171
+ return sorted(p for p in RULES_DIR.glob("*.md") if _is_always(p))
172
+
173
+
174
+ def _extended_size(rule: Path) -> tuple[int, list[tuple[str, str]]]:
175
+ raw = rule.stat().st_size
176
+ contexts, violations = _walk_contexts(rule)
177
+ ext = raw + sum(c.stat().st_size for c in contexts)
178
+ return ext, violations
179
+
180
+
181
+ def _concentration_check(
182
+ sizes: list[tuple[str, int, int]],
183
+ total_ext: int,
184
+ ) -> tuple[list[tuple[str, int, float]], tuple[int, float] | None]:
185
+ """Phase 5.2.1 concentration check (non-safety-floor rules only).
186
+
187
+ Returns (single-rule breaches, top-3 breach or None). Q3=A locked
188
+ safety-floor rules are excluded from both numerator and the top-3
189
+ selection — their size is intentional, not drift.
190
+ """
191
+ non_floor = [
192
+ (name, raw, ext) for name, raw, ext in sizes
193
+ if name not in SAFETY_FLOOR_RULES
194
+ ]
195
+ single_cap = total_ext * CONCENTRATION_SINGLE_PCT
196
+ top3_cap = total_ext * CONCENTRATION_TOP3_PCT
197
+
198
+ single_breaches = [
199
+ (name, ext, ext / total_ext)
200
+ for name, _, ext in non_floor
201
+ if ext > single_cap
202
+ ]
203
+ top3_sum = sum(ext for _, _, ext in non_floor[:3])
204
+ top3_breach = (
205
+ (top3_sum, top3_sum / total_ext)
206
+ if top3_sum > top3_cap else None
207
+ )
208
+ return single_breaches, top3_breach
209
+
43
210
 
211
+ def _record_trend(total_ext: int, sizes: list[tuple[str, int, int]]) -> None:
212
+ """Append the current run to the trend log (Phase 5.3)."""
213
+ TREND_LOG.parent.mkdir(parents=True, exist_ok=True)
214
+ record = {
215
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
216
+ "total": total_ext,
217
+ "rules": {name: ext for name, _, ext in sizes},
218
+ }
219
+ lines: list[str] = []
220
+ if TREND_LOG.exists():
221
+ lines = TREND_LOG.read_text(encoding="utf-8").splitlines()
222
+ lines.append(json.dumps(record, separators=(",", ":")))
223
+ if len(lines) > TREND_LOG_MAX_RECORDS:
224
+ lines = lines[-TREND_LOG_MAX_RECORDS:]
225
+ TREND_LOG.write_text("\n".join(lines) + "\n", encoding="utf-8")
44
226
 
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])
227
+
228
+ def _last_trend() -> dict | None:
229
+ """Return the most recent trend record, or None if log is empty."""
230
+ if not TREND_LOG.exists():
231
+ return None
232
+ lines = [
233
+ line for line in TREND_LOG.read_text(encoding="utf-8").splitlines()
234
+ if line.strip()
235
+ ]
236
+ if not lines:
237
+ return None
238
+ try:
239
+ return json.loads(lines[-1])
240
+ except json.JSONDecodeError:
241
+ return None
48
242
 
49
243
 
50
244
  def main() -> int:
@@ -54,6 +248,11 @@ def main() -> int:
54
248
  action="store_true",
55
249
  help="suppress the per-rule breakdown unless threshold is crossed",
56
250
  )
251
+ parser.add_argument(
252
+ "--no-trend",
253
+ action="store_true",
254
+ help="skip writing to .github/budget-trend.jsonl (Phase 5.3)",
255
+ )
57
256
  args = parser.parse_args()
58
257
 
59
258
  if not RULES_DIR.is_dir():
@@ -65,44 +264,110 @@ def main() -> int:
65
264
  print(f"❌ no always-rules found under {RULES_DIR}", file=sys.stderr)
66
265
  return 3
67
266
 
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])
267
+ sizes: list[tuple[str, int, int]] = []
268
+ all_violations: list[tuple[str, str]] = []
269
+ for rule in rules:
270
+ ext, violations = _extended_size(rule)
271
+ sizes.append((rule.name, rule.stat().st_size, ext))
272
+ all_violations.extend(violations)
273
+
274
+ sizes.sort(key=lambda x: -x[2])
275
+ total_ext = sum(s[2] for s in sizes)
276
+ pct = total_ext / TOTAL_CAP
277
+ top3 = sum(s[2] for s in sizes[:3])
72
278
  top3_breach = top3 > TOP3_CAP
73
279
 
74
- if pct >= FAIL_THRESHOLD or over_per_rule or top3_breach:
75
- status = "❌ FAIL"
76
- rc = 1
280
+ over_per_rule: list[tuple[str, int]] = []
281
+ grew_over_ceiling: list[tuple[str, int, int]] = []
282
+ for name, _, ext in sizes:
283
+ if ext <= PER_RULE_CAP:
284
+ continue
285
+ ceiling = KNOWN_PER_RULE_BREACHES.get(name)
286
+ if ceiling is None:
287
+ over_per_rule.append((name, ext))
288
+ elif ext > ceiling:
289
+ grew_over_ceiling.append((name, ext, ceiling))
290
+
291
+ in_tolerance = 1.0 <= pct <= 1.0 + TOLERANCE_BAND
292
+ baseline = _load_baseline() if RECOVERY_BAND_ENABLED else None
293
+ in_recovery_band = (
294
+ baseline is not None
295
+ and FAIL_THRESHOLD <= pct < 1.0
296
+ and total_ext < baseline
297
+ )
298
+ single_breaches, top3_concentration_breach = _concentration_check(
299
+ sizes, total_ext
300
+ )
301
+ failing = (
302
+ (
303
+ pct >= FAIL_THRESHOLD
304
+ and not in_tolerance
305
+ and not in_recovery_band
306
+ and pct < 1.0
307
+ )
308
+ or pct > 1.0 + TOLERANCE_BAND
309
+ or over_per_rule
310
+ or grew_over_ceiling
311
+ or top3_breach
312
+ or all_violations
313
+ or single_breaches
314
+ or top3_concentration_breach is not None
315
+ )
316
+ if failing:
317
+ status, rc = "❌ FAIL", 1
318
+ elif in_tolerance:
319
+ status, rc = "⚠️ WARN (G3 tolerance band)", 0
320
+ elif in_recovery_band:
321
+ status, rc = (
322
+ f"⚠️ WARN (recovery band, baseline {baseline:,})",
323
+ 0,
324
+ )
77
325
  elif pct >= WARN_THRESHOLD:
78
- status = "⚠️ WARN"
79
- rc = 0
326
+ status, rc = "⚠️ WARN", 0
80
327
  else:
81
- status = "✅ OK"
82
- rc = 0
328
+ status, rc = "✅ OK", 0
83
329
 
84
330
  print(
85
- f"{status} always-rule budget: {total:,} / {TOTAL_CAP:,} chars "
86
- f"({pct * 100:.1f}%) across {len(rules)} rule(s)"
331
+ f"{status} always-rule extended budget: {total_ext:,} / "
332
+ f"{TOTAL_CAP:,} chars ({pct * 100:.1f}%) across {len(rules)} rule(s)"
87
333
  )
88
334
  print(
89
335
  f" thresholds: warn {WARN_THRESHOLD * 100:.0f}% · "
90
336
  f"fail {FAIL_THRESHOLD * 100:.0f}% · "
91
- f"per-rule ≤ {PER_RULE_CAP:,} · top-3 ≤ {TOP3_CAP:,}"
337
+ f"per-rule ≤ {PER_RULE_CAP:,} (ext) · top-3 ≤ {TOP3_CAP:,} (ext) · "
338
+ f"depth ≤ {MAX_DEPTH}"
92
339
  )
93
340
 
94
341
  if rc != 0 or pct >= WARN_THRESHOLD or not args.quiet:
95
342
  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 ""
343
+ print(f" breakdown (largest extended first; top-3 sum = {top3:,}):")
344
+ for i, (name, raw, ext) in enumerate(sizes):
99
345
  tag = " (top-3)" if i < 3 else ""
100
- print(f" {size:>5} {name}{tag}{mark}")
346
+ ceiling = KNOWN_PER_RULE_BREACHES.get(name)
347
+ if ceiling is not None:
348
+ marker = f" ⚠️ allowlisted ≤ {ceiling:,}"
349
+ elif ext > PER_RULE_CAP:
350
+ marker = " ❌ per-rule breach"
351
+ else:
352
+ marker = ""
353
+ print(
354
+ f" ext={ext:>5} raw={raw:>5} {name}{tag}{marker}"
355
+ )
101
356
 
102
357
  if over_per_rule:
103
358
  names = ", ".join(f"{n}={s:,}" for n, s in over_per_rule)
104
359
  print(
105
- f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars): {names}"
360
+ f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars, not allowlisted): "
361
+ f"{names}"
362
+ )
363
+
364
+ if grew_over_ceiling:
365
+ details = ", ".join(
366
+ f"{n}={ext:,} > ceiling {ceiling:,}"
367
+ for n, ext, ceiling in grew_over_ceiling
368
+ )
369
+ print(
370
+ f"\n Allowlisted-breach growth (regression): {details}"
106
371
  )
107
372
 
108
373
  if top3_breach:
@@ -111,12 +376,65 @@ def main() -> int:
111
376
  f"(top-3 must stay ≤ 50% of {TOTAL_CAP:,} total budget)."
112
377
  )
113
378
 
379
+ if all_violations:
380
+ print(
381
+ f"\n Depth-{MAX_DEPTH} nesting cap violations:"
382
+ )
383
+ for rule_name, chain in all_violations:
384
+ print(f" {rule_name}: {chain}")
385
+
386
+ if single_breaches:
387
+ details = ", ".join(
388
+ f"{n}={ext:,} ({frac * 100:.1f}%)"
389
+ for n, ext, frac in single_breaches
390
+ )
391
+ print(
392
+ f"\n Concentration breach (single rule > "
393
+ f"{CONCENTRATION_SINGLE_PCT * 100:.0f}% of used budget, "
394
+ f"non-allowlisted): {details}"
395
+ )
396
+
397
+ if top3_concentration_breach is not None:
398
+ sum_, frac = top3_concentration_breach
399
+ print(
400
+ f"\n Concentration breach (top-3 non-allowlisted > "
401
+ f"{CONCENTRATION_TOP3_PCT * 100:.0f}% of used budget): "
402
+ f"{sum_:,} ({frac * 100:.1f}%)"
403
+ )
404
+
405
+ # Phase 5.3 — per-rule trend delta vs. previous run.
406
+ prev = _last_trend()
407
+ if prev is not None and not args.quiet:
408
+ prev_total = prev.get("total")
409
+ prev_rules = prev.get("rules") or {}
410
+ if isinstance(prev_total, int):
411
+ delta_total = total_ext - prev_total
412
+ sign = "+" if delta_total >= 0 else ""
413
+ print(
414
+ f"\n Trend vs. previous run "
415
+ f"({prev.get('ts', '?')}): total {sign}{delta_total:,} chars"
416
+ )
417
+ deltas: list[tuple[str, int, int]] = []
418
+ for name, _, ext in sizes:
419
+ old = prev_rules.get(name)
420
+ if isinstance(old, int) and old != ext:
421
+ deltas.append((name, ext - old, ext))
422
+ if deltas:
423
+ deltas.sort(key=lambda x: -abs(x[1]))
424
+ for name, d, ext in deltas[:5]:
425
+ s = "+" if d >= 0 else ""
426
+ print(f" {name}: {s}{d:,} (now {ext:,})")
427
+
428
+ if not args.no_trend:
429
+ _record_trend(total_ext, sizes)
430
+
114
431
  if rc == 1:
115
432
  print(
116
433
  f"\n Action: trim the offending rule(s) via load_context: "
117
434
  f"extraction (see contexts/execution + contexts/authority) "
118
435
  f"until utilization drops below {FAIL_THRESHOLD * 100:.0f}% "
119
- f"and all per-rule / top-3 caps hold."
436
+ f"and all per-rule / top-3 / depth caps hold. See "
437
+ f"docs/contracts/load-context-budget-model.md."
120
438
  )
121
439
 
122
440
  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())
@@ -53,7 +53,9 @@ def canonical_counts() -> tuple[int, int, int]:
53
53
  print(f"❌ {COMMANDS_DIR.relative_to(ROOT)} not found", file=sys.stderr)
54
54
  sys.exit(1)
55
55
  total = shims = 0
56
- for f in COMMANDS_DIR.glob("*.md"):
56
+ for f in COMMANDS_DIR.rglob("*.md"):
57
+ if f.name == "AGENTS.md":
58
+ continue
57
59
  total += 1
58
60
  m = FM_RE.match(f.read_text(encoding="utf-8"))
59
61
  fm = m.group(1) if m else ""
@@ -82,16 +84,21 @@ def main() -> int:
82
84
  # README.md
83
85
  (README, r"<strong>(\d+) Commands</strong>", active, "hero row"),
84
86
  (README, r"Browse all (\d+) active commands", active, "browse line"),
85
- (README, r"\((\d+) files total ", total, "browse meta · total files"),
86
- (README, r"— (\d+) are deprecation shims", shims, "browse meta · shims"),
87
87
  (README, r"\+ (\d+) native commands\)", active, "tools blurb"),
88
- # AGENTS.md (`commands/ (84 files — 69 active + 15 deprecation shims)`)
89
- (AGENTS, r"commands/\s+\((\d+) files —", total, "tree · total files"),
90
- (AGENTS, r"files — (\d+) active", active, "tree · active"),
91
- (AGENTS, r"active \+ (\d+) deprecation shims", shims, "tree · shims"),
92
88
  # docs/getting-started.md
93
89
  (GETTING_STARTED, r"Browse all (\d+) active commands", active, "browse line"),
94
90
  ]
91
+ # Shim-specific messaging only applies during a deprecation window.
92
+ # When shims == 0 the clauses are dropped from public docs entirely;
93
+ # re-add these patterns when a new deprecation cycle starts.
94
+ if shims > 0:
95
+ checks.extend([
96
+ (README, r"\((\d+) files total ", total, "browse meta · total files"),
97
+ (README, r"— (\d+) are deprecation shims", shims, "browse meta · shims"),
98
+ (AGENTS, r"commands/\s+\((\d+) files —", total, "tree · total files"),
99
+ (AGENTS, r"files — (\d+) active", active, "tree · active"),
100
+ (AGENTS, r"active \+ (\d+) deprecation shims", shims, "tree · shims"),
101
+ ])
95
102
 
96
103
  errors: list[str] = []
97
104
  for path, pattern, expected, label in checks: