@event4u/agent-config 1.24.0 → 1.26.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 (75) hide show
  1. package/.agent-src/commands/review-routing.md +7 -10
  2. package/.agent-src/contexts/authority/kernel-rule-edits.md +48 -0
  3. package/.agent-src/contexts/authority/scope-mechanics.md +15 -0
  4. package/.agent-src/contexts/contracts/consumer-agents-md-guide.md +127 -0
  5. package/.agent-src/contexts/contracts/emergency-triage-block.md +53 -0
  6. package/.agent-src/rules/analysis-skill-routing.md +1 -1
  7. package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
  8. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  9. package/.agent-src/rules/augment-source-of-truth.md +1 -1
  10. package/.agent-src/rules/autonomous-execution.md +1 -1
  11. package/.agent-src/rules/caveman-speak.md +1 -1
  12. package/.agent-src/rules/cli-output-handling.md +1 -1
  13. package/.agent-src/rules/command-suggestion-policy.md +1 -1
  14. package/.agent-src/rules/docs-sync.md +1 -1
  15. package/.agent-src/rules/guidelines.md +1 -1
  16. package/.agent-src/rules/improve-before-implement.md +1 -1
  17. package/.agent-src/rules/invite-challenge.md +1 -1
  18. package/.agent-src/rules/minimal-safe-diff.md +1 -1
  19. package/.agent-src/rules/model-recommendation.md +1 -1
  20. package/.agent-src/rules/no-attribution-footers.md +1 -1
  21. package/.agent-src/rules/no-roadmap-references.md +56 -20
  22. package/.agent-src/rules/onboarding-gate.md +1 -1
  23. package/.agent-src/rules/package-ci-checks.md +1 -1
  24. package/.agent-src/rules/reviewer-awareness.md +9 -2
  25. package/.agent-src/rules/roadmap-progress-sync.md +1 -1
  26. package/.agent-src/rules/scope-control.md +6 -0
  27. package/.agent-src/rules/security-sensitive-stop.md +1 -1
  28. package/.agent-src/rules/size-enforcement.md +1 -1
  29. package/.agent-src/rules/token-optimizer-maintenance.md +1 -1
  30. package/.agent-src/rules/ui-audit-gate.md +1 -1
  31. package/.agent-src/skills/adr-create/SKILL.md +2 -1
  32. package/.agent-src/skills/agents-md-thin-root/SKILL.md +125 -0
  33. package/.agent-src/skills/ai-council/SKILL.md +9 -7
  34. package/.agent-src/skills/review-routing/SKILL.md +3 -4
  35. package/.agent-src/templates/AGENTS.md +18 -148
  36. package/.agent-src/templates/copilot-instructions.md +41 -17
  37. package/.agent-src/templates/github-workflows/pr-risk-review.yml +1 -1
  38. package/.agent-src/templates/scripts/pr_review_routing.py +1 -1
  39. package/.claude-plugin/marketplace.json +2 -1
  40. package/AGENTS.md +18 -216
  41. package/CHANGELOG.md +58 -0
  42. package/README.md +2 -2
  43. package/docs/architecture.md +13 -7
  44. package/docs/catalog.md +26 -27
  45. package/docs/contracts/agents-md-tech-stack.md +74 -0
  46. package/docs/contracts/linear-ai-rules-inclusion.md +1 -1
  47. package/docs/contracts/linter-structural-model.md +180 -0
  48. package/docs/contracts/package-self-orientation.md +135 -0
  49. package/docs/contracts/rule-classification.md +4 -4
  50. package/docs/decisions/ADR-004-rule-governance-pruning.md +240 -0
  51. package/docs/getting-started.md +1 -1
  52. package/docs/guidelines/agent-infra/review-routing-data-format.md +1 -2
  53. package/docs/guidelines/agent-infra/size-and-scope.md +18 -12
  54. package/package.json +1 -1
  55. package/scripts/_p4_migrate.py +5 -5
  56. package/scripts/audit_auto_rules.py +159 -0
  57. package/scripts/audit_likelihood.py +148 -0
  58. package/scripts/audit_overlap.py +145 -0
  59. package/scripts/build_rule_trigger_matrix.py +3 -5
  60. package/scripts/check_augment_description_cap.py +79 -0
  61. package/scripts/check_council_references.py +3 -3
  62. package/scripts/check_kernel_rule_bundle.py +151 -0
  63. package/scripts/check_references.py +21 -1
  64. package/scripts/compile_router.py +3 -0
  65. package/scripts/install.sh +0 -1
  66. package/scripts/lint_agents_md.py +168 -0
  67. package/scripts/measure_augment_budget.py +208 -0
  68. package/scripts/measure_density.py +232 -0
  69. package/scripts/schemas/rule.schema.json +2 -1
  70. package/scripts/skill_linter.py +166 -31
  71. package/scripts/spotcheck_thin_root.py +134 -0
  72. package/scripts/update_counts.py +6 -10
  73. package/.agent-src/rules/no-council-references.md +0 -76
  74. package/.agent-src/rules/review-routing-awareness.md +0 -19
  75. package/.agent-src/templates/copilot-review-instructions.md +0 -76
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """check_kernel_rule_bundle — Phase 4.2 of road-to-always-budget-relief.
3
+
4
+ Fails when a single PR (or commit range) modifies more than one
5
+ kernel rule under `.agent-src.uncompressed/rules/`. Override via the
6
+ PR label `bundled-always-rules-acknowledged`.
7
+
8
+ Kernel set is the locked 9-rule list in
9
+ `docs/contracts/rule-classification.md` § 3.1, mirrored as
10
+ `KERNEL_RULES` below. The list is short and stable; on kernel-set
11
+ change, update both files in the same PR.
12
+
13
+ Inputs:
14
+ --base-ref REF git ref to diff against (default: origin/main, then main)
15
+ --label NAME PR label that overrides the gate (default:
16
+ bundled-always-rules-acknowledged)
17
+ --event-path P GitHub event JSON (defaults to $GITHUB_EVENT_PATH)
18
+ --files F [F …] override changed-file list (testing only)
19
+
20
+ Exit codes: 0 = pass · 1 = fail (> 1 kernel rule, no override) ·
21
+ 3 = internal error.
22
+
23
+ Source: `agents/contexts/adr-always-budget-relief-strategy.md`.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ KERNEL_RULES = frozenset({
35
+ "agent-authority.md",
36
+ "ask-when-uncertain.md",
37
+ "commit-policy.md",
38
+ "direct-answers.md",
39
+ "language-and-tone.md",
40
+ "no-cheap-questions.md",
41
+ "non-destructive-by-default.md",
42
+ "scope-control.md",
43
+ "verify-before-complete.md",
44
+ })
45
+
46
+ KERNEL_DIR = ".agent-src.uncompressed/rules"
47
+ DEFAULT_LABEL = "bundled-always-rules-acknowledged"
48
+
49
+
50
+ def _git_changed_files(base_ref: str) -> list[str]:
51
+ try:
52
+ out = subprocess.check_output(
53
+ ["git", "diff", "--name-only", f"{base_ref}...HEAD"],
54
+ stderr=subprocess.STDOUT,
55
+ text=True,
56
+ )
57
+ except subprocess.CalledProcessError as exc:
58
+ print(f"❌ git diff failed: {exc.output.strip()}", file=sys.stderr)
59
+ return []
60
+ return [line for line in out.splitlines() if line.strip()]
61
+
62
+
63
+ def _resolve_base_ref(explicit: str | None) -> str:
64
+ if explicit:
65
+ return explicit
66
+ for candidate in ("origin/main", "origin/master", "main", "master"):
67
+ try:
68
+ subprocess.check_output(
69
+ ["git", "rev-parse", "--verify", candidate],
70
+ stderr=subprocess.DEVNULL,
71
+ )
72
+ return candidate
73
+ except subprocess.CalledProcessError:
74
+ continue
75
+ return "HEAD~1"
76
+
77
+
78
+ def _pr_labels(event_path: str | None) -> list[str]:
79
+ path = event_path or os.environ.get("GITHUB_EVENT_PATH")
80
+ if not path or not Path(path).exists():
81
+ return []
82
+ try:
83
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError):
85
+ return []
86
+ pr = data.get("pull_request") or {}
87
+ return [lbl.get("name", "") for lbl in pr.get("labels", []) if lbl.get("name")]
88
+
89
+
90
+ def _kernel_changes(files: list[str]) -> list[str]:
91
+ hits: list[str] = []
92
+ for path in files:
93
+ if not path.startswith(f"{KERNEL_DIR}/"):
94
+ continue
95
+ name = Path(path).name
96
+ if name in KERNEL_RULES:
97
+ hits.append(path)
98
+ return sorted(set(hits))
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
103
+ ap.add_argument("--base-ref", default=None)
104
+ ap.add_argument("--label", default=DEFAULT_LABEL)
105
+ ap.add_argument("--event-path", default=None)
106
+ ap.add_argument("--files", nargs="*", default=None)
107
+ args = ap.parse_args(argv)
108
+
109
+ files = args.files or _git_changed_files(_resolve_base_ref(args.base_ref))
110
+ hits = _kernel_changes(files)
111
+
112
+ if len(hits) <= 1:
113
+ if hits:
114
+ print(f"✅ OK kernel-rule bundle: 1 rule touched ({hits[0]})")
115
+ else:
116
+ print("✅ OK kernel-rule bundle: no kernel rule touched")
117
+ return 0
118
+
119
+ labels = _pr_labels(args.event_path)
120
+ if args.label in labels:
121
+ print(
122
+ f"✅ OK kernel-rule bundle: {len(hits)} rules touched but "
123
+ f"label '{args.label}' present"
124
+ )
125
+ for h in hits:
126
+ print(f" · {h}")
127
+ return 0
128
+
129
+ print(
130
+ f"❌ FAIL kernel-rule bundle: {len(hits)} kernel rules touched in "
131
+ f"one PR — slow-rollout requires one-rule-per-PR.",
132
+ file=sys.stderr,
133
+ )
134
+ print(" Touched:", file=sys.stderr)
135
+ for h in hits:
136
+ print(f" · {h}", file=sys.stderr)
137
+ print(
138
+ f" Override: add the label '{args.label}' on the PR and "
139
+ f"document the bundle rationale in the PR body.",
140
+ file=sys.stderr,
141
+ )
142
+ print(
143
+ " Source: agents/contexts/adr-always-budget-relief-strategy.md "
144
+ "(Phase 4.2).",
145
+ file=sys.stderr,
146
+ )
147
+ return 1
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
@@ -228,6 +228,10 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
228
228
  ))
229
229
 
230
230
  in_code_block = False
231
+ # Track whether we are inside an unchecked-TODO bullet (multi-line
232
+ # roadmap items wrap continuation text under the `- [ ]` line and
233
+ # those continuation lines must inherit the forward-ref exemption).
234
+ in_unchecked_todo = False
231
235
  for i, line in enumerate(text.splitlines(), 1):
232
236
  stripped = line.strip()
233
237
  if stripped.startswith("```"):
@@ -237,9 +241,25 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
237
241
  continue
238
242
 
239
243
  # Unchecked TODO checkboxes document future work — their refs are
240
- # forward-looking and will not resolve yet.
244
+ # forward-looking and will not resolve yet. Track multi-line bullets:
245
+ # any `- [ ]` opens a TODO context; a new top-level bullet, heading,
246
+ # or blank line closes it.
241
247
  if UNCHECKED_TODO_PATTERN.match(line):
248
+ in_unchecked_todo = True
242
249
  continue
250
+ if in_unchecked_todo:
251
+ if not stripped:
252
+ in_unchecked_todo = False
253
+ continue
254
+ # A new bullet (checked or unchecked) or a heading closes the
255
+ # current TODO context. An indented continuation line keeps it.
256
+ if re.match(r"^[-*+]\s+\[", line) or stripped.startswith("#"):
257
+ in_unchecked_todo = False
258
+ elif line[:1] in (" ", "\t"):
259
+ # Indented continuation of the unchecked TODO — skip.
260
+ continue
261
+ else:
262
+ in_unchecked_todo = False
243
263
 
244
264
  # File path references
245
265
  for m in PATH_PATTERN.finditer(line):
@@ -125,6 +125,9 @@ def _collect(rules_dir: Path) -> dict:
125
125
  if not COMPILE_TIME_TOGGLES[rule_id](settings):
126
126
  continue
127
127
  rule_type = str(fm.get("type", "auto"))
128
+ # Manual rules are reference-only (ADR-004) — no router emission.
129
+ if rule_type == "manual":
130
+ continue
128
131
  tier = _resolve_tier(rule_type, fm.get("tier", ""))
129
132
  if tier not in ALLOWED_TIERS:
130
133
  continue
@@ -728,7 +728,6 @@ main() {
728
728
  # into consumer projects.
729
729
  copy_if_missing "$SOURCE_PAYLOAD/templates/AGENTS.md" "$TARGET_DIR/AGENTS.md"
730
730
  copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-instructions.md" "$TARGET_DIR/.github/copilot-instructions.md"
731
- copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-review-instructions.md" "$TARGET_DIR/.github/copilot-review-instructions.md"
732
731
 
733
732
  # 3. Create tool-specific symlinks
734
733
  create_tool_symlinks "$TARGET_DIR"
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Thin-Root contract linter for AGENTS.md files (Phase 7).
3
+
4
+ Enforces caps + pointer-ratio + pointer-anatomy + emergency-triage
5
+ contract from `.agent-src.uncompressed/skills/agents-md-thin-root/SKILL.md`:
6
+
7
+ (a) total char-count under FAIL/WARN budgets per file class
8
+ (b) substantive-pointer ratio >= 0.40
9
+ (c) every pointer's *why* clause >= 60 chars
10
+ (d) every pointer target resolves on disk (anchor validity)
11
+ (e) emergency-triage section present with the five canonical questions
12
+
13
+ Exit non-zero on any (a) FAIL, (b)–(e) error. WARN is informational.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ QUIET = "--quiet" in sys.argv
24
+
25
+ LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
26
+ TRIAGE_KEYWORDS = (
27
+ "what is this repo",
28
+ "what language",
29
+ "where do i edit",
30
+ "lint / test / sync",
31
+ "where do the always",
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class Target:
37
+ path: Path
38
+ label: str
39
+ fail_at: int
40
+ warn_at: int
41
+ template: bool # consumer template — relax pointer-target resolution
42
+
43
+
44
+ TARGETS = [
45
+ Target(ROOT / "AGENTS.md", "package-root", 3000, 2800, template=False),
46
+ Target(
47
+ ROOT / ".agent-src.uncompressed" / "templates" / "AGENTS.md",
48
+ "consumer-template", 2500, 2300, template=True,
49
+ ),
50
+ ]
51
+
52
+
53
+ def _strip_links(line: str) -> str:
54
+ return LINK_RE.sub(lambda m: m.group(1), line)
55
+
56
+
57
+ def _resolve(target_str: str, template: bool) -> bool:
58
+ raw = target_str.split("#", 1)[0].strip()
59
+ if raw.startswith("http://") or raw.startswith("https://"):
60
+ return True
61
+ candidates = [ROOT / raw]
62
+ if template and raw.startswith(".augment/"):
63
+ candidates.append(ROOT / raw.replace(".augment/", ".agent-src.uncompressed/", 1))
64
+ candidates.append(ROOT / raw.replace(".augment/", ".agent-src/", 1))
65
+ if raw.startswith(".agent-src/"):
66
+ candidates.append(ROOT / raw.replace(".agent-src/", ".agent-src.uncompressed/", 1))
67
+ return any(c.exists() for c in candidates)
68
+
69
+
70
+ def lint_file(t: Target) -> tuple[bool, list[str], list[str]]:
71
+ """Return (ok, errors, warnings)."""
72
+ errors: list[str] = []
73
+ warnings: list[str] = []
74
+ if not t.path.exists():
75
+ return False, [f"{t.label}: {t.path} not found"], []
76
+
77
+ text = t.path.read_text(encoding="utf-8")
78
+ size = len(text.encode("utf-8"))
79
+
80
+ # (a) size
81
+ if size > t.fail_at:
82
+ errors.append(f"{t.label}: {size} chars > FAIL cap {t.fail_at}")
83
+ elif size > t.warn_at:
84
+ warnings.append(f"{t.label}: {size} chars > WARN cap {t.warn_at}")
85
+
86
+ # Filter out structural lines that are not "prose" the contract
87
+ # asks us to replace with pointers: headings, code fences + content,
88
+ # HTML comments, and Markdown table rows.
89
+ lines = text.splitlines()
90
+ in_fence = False
91
+ in_comment = False
92
+ prose: list[str] = []
93
+ for ln in lines:
94
+ s = ln.strip()
95
+ if not s:
96
+ continue
97
+ if s.startswith("```"):
98
+ in_fence = not in_fence
99
+ continue
100
+ if in_fence:
101
+ continue
102
+ if "<!--" in s:
103
+ in_comment = True
104
+ if in_comment:
105
+ if "-->" in s:
106
+ in_comment = False
107
+ continue
108
+ if s.startswith("#"): # heading
109
+ continue
110
+ if s.startswith("|"): # markdown table row / separator
111
+ continue
112
+ prose.append(ln)
113
+
114
+ non_blank = prose
115
+ pointer_lines = 0
116
+
117
+ for ln in non_blank:
118
+ m = LINK_RE.search(ln)
119
+ if not m:
120
+ continue
121
+ target = m.group(2)
122
+ # (d) target resolves
123
+ if not _resolve(target, t.template):
124
+ errors.append(f"{t.label}: broken pointer target `{target}` in line: {ln.strip()[:100]}")
125
+ # (c) why-clause length: line minus link syntax
126
+ why = _strip_links(ln).strip()
127
+ if len(why) >= 60:
128
+ pointer_lines += 1
129
+ # else line has a link but no real why-clause — does not count
130
+
131
+ # (b) ratio
132
+ ratio = pointer_lines / max(len(non_blank), 1)
133
+ if ratio < 0.40:
134
+ errors.append(
135
+ f"{t.label}: substantive-pointer ratio {ratio:.2f} < 0.40 "
136
+ f"({pointer_lines}/{len(non_blank)} non-blank lines)"
137
+ )
138
+
139
+ # (e) emergency-triage block
140
+ lower = text.lower()
141
+ missing = [k for k in TRIAGE_KEYWORDS if k not in lower]
142
+ if missing:
143
+ errors.append(f"{t.label}: emergency-triage block missing keywords: {missing}")
144
+ if "emergency triage" not in lower:
145
+ errors.append(f"{t.label}: missing 'Emergency triage' section heading")
146
+
147
+ return not errors, errors, warnings
148
+
149
+
150
+ def main() -> int:
151
+ rc = 0
152
+ for t in TARGETS:
153
+ ok, errors, warnings = lint_file(t)
154
+ if not QUIET or errors or warnings:
155
+ print(f"== {t.label} ({t.path.relative_to(ROOT)}) ==")
156
+ for w in warnings:
157
+ print(f" ⚠️ {w}")
158
+ for e in errors:
159
+ print(f" ❌ {e}")
160
+ if ok and not warnings and not QUIET:
161
+ print(f" ✅ ok ({t.path.stat().st_size} bytes)")
162
+ if not ok:
163
+ rc = 1
164
+ return rc
165
+
166
+
167
+ if __name__ == "__main__":
168
+ raise SystemExit(main())
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Measure the Augment workspace-guidelines budget (Phase 1.1 of
3
+ road-to-augment-limit-fit).
4
+
5
+ Mirrors Augment's accounting model for the workspace prompt:
6
+
7
+ 1. `AGENTS.md` body (full file, including frontmatter) injected verbatim.
8
+ 2. `always`-type rules under `.augment/rules/` — full body injected.
9
+ 3. `auto`-type rules — only a registry stub is injected per rule:
10
+
11
+ If the user prompt matches the description "<desc>", read the
12
+ file located in <path>
13
+
14
+ The body of an `auto` rule is NOT counted; only the stub line is.
15
+
16
+ The 49,512-char ceiling is the empirical limit observed against the
17
+ Augment Code workspace prompt (2026-05-08 baseline). This script emits
18
+ a per-component breakdown plus the total against that ceiling.
19
+
20
+ Output:
21
+ - Default: stdout summary (totals + per-component breakdown).
22
+ - `--json`: deterministic JSON.
23
+ - `--trend-append`: append a snapshot record to
24
+ `agents/.augment-budget-history.jsonl`.
25
+
26
+ Exit codes: 0 = under fail threshold, 1 = at/above fail threshold,
27
+ 3 = internal error.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import datetime as _dt
34
+ import json
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ REPO_ROOT = Path(__file__).resolve().parent.parent
40
+ AGENTS_MD = REPO_ROOT / "AGENTS.md"
41
+ RULES_DIR = REPO_ROOT / ".augment" / "rules"
42
+ TREND_FILE = REPO_ROOT / "agents" / ".augment-budget-history.jsonl"
43
+
44
+ # Augment workspace-guidelines ceiling — empirical 2026-05-08.
45
+ TOTAL_CAP = 49_512
46
+ WARN_THRESHOLD = 0.85
47
+ FAIL_THRESHOLD = 0.95
48
+
49
+ # Stub template Augment injects for `type: auto` rules. Measured by
50
+ # subtracting variable-length fields (description, path) from a real
51
+ # rendered stub in the host system prompt.
52
+ STUB_TEMPLATE = (
53
+ 'If the user prompt matches the description "{desc}", '
54
+ "read the file located in {path}"
55
+ )
56
+
57
+
58
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
59
+ if not text.startswith("---\n"):
60
+ return {}, text
61
+ end = text.find("\n---", 4)
62
+ if end < 0:
63
+ return {}, text
64
+ fm_block = text[4:end]
65
+ body = text[end + 4 :].lstrip("\n")
66
+ fm: dict[str, str] = {}
67
+ for line in fm_block.splitlines():
68
+ m = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line)
69
+ if m:
70
+ fm[m.group(1)] = m.group(2).strip().strip('"').strip("'")
71
+ return fm, body
72
+
73
+
74
+ def measure() -> dict:
75
+ components: dict[str, dict] = {}
76
+
77
+ # 1. AGENTS.md
78
+ agents_text = AGENTS_MD.read_text() if AGENTS_MD.exists() else ""
79
+ components["agents_md"] = {
80
+ "path": str(AGENTS_MD.relative_to(REPO_ROOT)),
81
+ "chars": len(agents_text),
82
+ }
83
+
84
+ # 2 + 3. Rules under .augment/rules/.
85
+ always_total = 0
86
+ always_rules: list[dict] = []
87
+ auto_total = 0
88
+ auto_rules: list[dict] = []
89
+
90
+ for rule_path in sorted(RULES_DIR.glob("*.md")):
91
+ text = rule_path.read_text()
92
+ fm, _body = parse_frontmatter(text)
93
+ rtype = fm.get("type", "")
94
+ rel = str(rule_path.relative_to(REPO_ROOT))
95
+ if rtype == "always":
96
+ chars = len(text)
97
+ always_total += chars
98
+ always_rules.append({"path": rel, "chars": chars})
99
+ elif rtype == "auto":
100
+ desc = fm.get("description", "")
101
+ stub = STUB_TEMPLATE.format(desc=desc, path=rel)
102
+ chars = len(stub)
103
+ auto_total += chars
104
+ auto_rules.append(
105
+ {"path": rel, "desc_chars": len(desc), "stub_chars": chars}
106
+ )
107
+
108
+ components["always_rules"] = {
109
+ "count": len(always_rules),
110
+ "chars": always_total,
111
+ "rules": sorted(always_rules, key=lambda r: -r["chars"]),
112
+ }
113
+ components["auto_rules"] = {
114
+ "count": len(auto_rules),
115
+ "chars": auto_total,
116
+ "rules": sorted(auto_rules, key=lambda r: -r["stub_chars"]),
117
+ }
118
+
119
+ total = (
120
+ components["agents_md"]["chars"]
121
+ + always_total
122
+ + auto_total
123
+ )
124
+ return {
125
+ "ts": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
126
+ "total": total,
127
+ "cap": TOTAL_CAP,
128
+ "utilisation": round(total / TOTAL_CAP, 4),
129
+ "components": components,
130
+ }
131
+
132
+
133
+ def render_text(data: dict) -> str:
134
+ total = data["total"]
135
+ cap = data["cap"]
136
+ util = data["utilisation"]
137
+ a = data["components"]["agents_md"]["chars"]
138
+ ar = data["components"]["always_rules"]
139
+ aur = data["components"]["auto_rules"]
140
+ lines = [
141
+ f"Augment workspace-guidelines budget — cap {cap:,} chars",
142
+ "",
143
+ f" AGENTS.md {a:>6,} chars ({a/cap*100:5.1f}%)",
144
+ f" always-rules ({ar['count']:>2}) {ar['chars']:>6,} chars ({ar['chars']/cap*100:5.1f}%)",
145
+ f" auto-rule stubs ({aur['count']:>2}) {aur['chars']:>6,} chars ({aur['chars']/cap*100:5.1f}%)",
146
+ " " + "-" * 50,
147
+ f" TOTAL {total:>6,} chars ({util*100:5.1f}%)",
148
+ "",
149
+ ]
150
+ if util >= 1.0:
151
+ lines.append(f"❌ OVER CAP by {total - cap:,} chars")
152
+ elif util >= FAIL_THRESHOLD:
153
+ lines.append(f"❌ FAIL — utilisation {util*100:.1f}% ≥ {FAIL_THRESHOLD*100:.0f}%")
154
+ elif util >= WARN_THRESHOLD:
155
+ lines.append(f"⚠️ WARN — utilisation {util*100:.1f}% ≥ {WARN_THRESHOLD*100:.0f}%")
156
+ else:
157
+ lines.append(f"✅ OK — utilisation {util*100:.1f}%")
158
+ return "\n".join(lines)
159
+
160
+
161
+ def main() -> int:
162
+ parser = argparse.ArgumentParser(description=__doc__)
163
+ parser.add_argument("--json", action="store_true", help="Emit JSON")
164
+ parser.add_argument(
165
+ "--trend-append",
166
+ action="store_true",
167
+ help="Append a snapshot record to agents/.augment-budget-history.jsonl",
168
+ )
169
+ parser.add_argument(
170
+ "--check",
171
+ action="store_true",
172
+ help="Exit non-zero when utilisation ≥ FAIL_THRESHOLD or over cap",
173
+ )
174
+ args = parser.parse_args()
175
+
176
+ data = measure()
177
+
178
+ if args.trend_append:
179
+ TREND_FILE.parent.mkdir(parents=True, exist_ok=True)
180
+ rec = {
181
+ "ts": data["ts"],
182
+ "total": data["total"],
183
+ "cap": data["cap"],
184
+ "utilisation": data["utilisation"],
185
+ "agents_md": data["components"]["agents_md"]["chars"],
186
+ "always_rules": data["components"]["always_rules"]["chars"],
187
+ "auto_rules": data["components"]["auto_rules"]["chars"],
188
+ }
189
+ with TREND_FILE.open("a") as fh:
190
+ fh.write(json.dumps(rec, sort_keys=True) + "\n")
191
+
192
+ if args.json:
193
+ print(json.dumps(data, indent=2, sort_keys=True))
194
+ else:
195
+ print(render_text(data))
196
+
197
+ if args.check:
198
+ if data["utilisation"] >= 1.0 or data["utilisation"] >= FAIL_THRESHOLD:
199
+ return 1
200
+ return 0
201
+
202
+
203
+ if __name__ == "__main__":
204
+ try:
205
+ sys.exit(main())
206
+ except Exception as exc: # pragma: no cover - defensive top-level guard
207
+ print(f"❌ measure_augment_budget: internal error: {exc}", file=sys.stderr)
208
+ sys.exit(3)