@event4u/agent-config 4.8.0 → 4.9.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "4.8.0",
9
+ "version": "4.9.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -802,6 +802,19 @@ our recommendation order, not its support status.
802
802
  > that forces a new era split (`# Era: 4.6.x`, etc.) — see
803
803
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
804
804
 
805
+ ## [4.9.0](https://github.com/event4u-app/agent-config/compare/4.8.0...4.9.0) (2026-05-28)
806
+
807
+ ### Features
808
+
809
+ * **scripts:** inventory abstraction-budget classes via grep-backed audit ([bf4de06](https://github.com/event4u-app/agent-config/commit/bf4de06d12908281e7a657cab8783c3cdae39a2e))
810
+
811
+ ### Documentation
812
+
813
+ * **roadmaps:** close discovery, charter scoped reduction follow-up ([f749c77](https://github.com/event4u-app/agent-config/commit/f749c778ae02f6718c9d499213c8781392e95b3e))
814
+ * **evidence:** abstraction-budget Phase-1 inventory + frontmatter audit ([178c0b6](https://github.com/event4u-app/agent-config/commit/178c0b605085801282c7f61c2b01d6d8dc83396e))
815
+
816
+ Tests: 5078 (+0 since 4.8.0)
817
+
805
818
  ## [4.8.0](https://github.com/event4u-app/agent-config/compare/4.7.2...4.8.0) (2026-05-28)
806
819
 
807
820
  ### Features
@@ -1,6 +1,6 @@
1
1
  # Discovery — Deprecation Report
2
2
 
3
- - Generated: `2026-05-28T11:41:20Z`
3
+ - Generated: `2026-05-28T13:27:08Z`
4
4
  - Deprecated artefacts: **0**
5
5
 
6
6
  _None. Tree is clean._
@@ -9543,7 +9543,7 @@
9543
9543
  "reason": "scaffold for new SKILL.md authoring"
9544
9544
  }
9545
9545
  ],
9546
- "generated_at": "2026-05-28T11:41:20Z",
9546
+ "generated_at": "2026-05-28T13:27:08Z",
9547
9547
  "packs": [
9548
9548
  {
9549
9549
  "artefact_count": 84,
@@ -1 +1 @@
1
- ebdbb29d37f2509c6098a29419ece7ba11030a5709468b1be49bcd3ad42e90e6 discovery-manifest.json
1
+ 02075520b8ccb3025ec9d54d7ec0585f26f09c3c947d3c696f3ef4328aa04e74 discovery-manifest.json
@@ -1,6 +1,6 @@
1
1
  # Discovery Manifest — Summary
2
2
 
3
- - Generated: `2026-05-28T11:41:20Z`
3
+ - Generated: `2026-05-28T13:27:08Z`
4
4
  - Scanner: `d75eba636abb`
5
5
  - Artefacts: **432**
6
6
  - Unassigned: **0**
@@ -1,6 +1,6 @@
1
1
  # Discovery — Orphan Report
2
2
 
3
- - Generated: `2026-05-28T11:41:20Z`
3
+ - Generated: `2026-05-28T13:27:08Z`
4
4
  - Orphan artefacts: **0**
5
5
 
6
6
  > An orphan is an artefact whose declared pack has no other members.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:eb7494e53428947d792e0377cb59e043457819ad3fff0b7ff6bc1011134be766",
3
- "generated_at": "2026-05-28T11:41:20Z",
3
+ "generated_at": "2026-05-28T13:27:08Z",
4
4
  "packs": [
5
5
  {
6
6
  "artefact_count": 84,
@@ -1,6 +1,6 @@
1
1
  # Discovery — Trust Report
2
2
 
3
- - Generated: `2026-05-28T11:41:20Z`
3
+ - Generated: `2026-05-28T13:27:08Z`
4
4
  - Workspaces tracked: **8**
5
5
  - Human-review-required artefacts: **2**
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "checksum": "sha256:eb7494e53428947d792e0377cb59e043457819ad3fff0b7ff6bc1011134be766",
3
- "generated_at": "2026-05-28T11:41:20Z",
3
+ "generated_at": "2026-05-28T13:27:08Z",
4
4
  "scanner_version": "d75eba636abb",
5
5
  "workspaces": [
6
6
  {
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://github.com/event4u-app/agent-config#readme",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "4.8.0"
12
+ "version": "4.9.0"
13
13
  },
14
14
  "registries": [
15
15
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "description": "Universal AI Agent OS \u2014 audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,616 @@
1
+ """Abstraction-budget inventory — read-only discovery pass.
2
+
3
+ Drives Phase 1 of `agents/roadmaps/road-to-abstraction-budget-discovery.md`.
4
+
5
+ For each abstraction class (packs, roles, directives, council-members,
6
+ trust-levels, flows, commands, skills, rules, personas) emits a row
7
+ with name, class, reference count, last-modified date, and a
8
+ `bloat_candidate` flag (Y if usage_count == 0 OR purpose overlap).
9
+
10
+ Also runs a frontmatter field-bloat sub-audit: tabulates every
11
+ frontmatter field across artefacts that carry one, and flags fields
12
+ with a single dominant value in >95% of artefacts as
13
+ lean-contract candidates.
14
+
15
+ Outputs to:
16
+ - agents/evidence/analysis/abstraction-budget-inventory.md
17
+ - agents/evidence/analysis/abstraction-budget-inventory.csv
18
+ - agents/evidence/analysis/abstraction-budget-frontmatter.csv
19
+
20
+ Read-only. Touches no abstraction file. Reference counts are
21
+ grep-backed (ripgrep with python fallback) — not estimates.
22
+
23
+ Usage:
24
+ python3 scripts/inventory_abstraction_budget.py [--quiet]
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import csv
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ from collections import Counter, defaultdict
36
+ from dataclasses import dataclass, field
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+
40
+ REPO_ROOT = Path(__file__).resolve().parent.parent
41
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
42
+
43
+ try:
44
+ from _lib import script_output # type: ignore[import-not-found]
45
+ except ImportError:
46
+ script_output = None # graceful fallback when running outside repo
47
+
48
+ CORE_SRC = REPO_ROOT / "packages" / "core" / ".agent-src.uncondensed"
49
+ DIRECTIVES_ROOT = CORE_SRC / "templates" / "scripts" / "work_engine" / "directives"
50
+ EVIDENCE_DIR = REPO_ROOT / "agents" / "evidence" / "analysis"
51
+
52
+ EXCLUDE_DIRS = {
53
+ ".git",
54
+ "node_modules",
55
+ "dist",
56
+ ".claude/worktrees",
57
+ ".cursor",
58
+ ".windsurf",
59
+ ".clinerules",
60
+ ".augment",
61
+ ".agent-src", # condensed output (counts already covered by .uncondensed)
62
+ ".claude/skills",
63
+ ".claude/commands",
64
+ ".claude/personas",
65
+ "agents/evidence", # don't count our own outputs
66
+ "agents/runtime",
67
+ }
68
+
69
+ EXCLUDE_PATH_FRAGMENTS = tuple(EXCLUDE_DIRS)
70
+
71
+ ROLES_ENUM = ("developer", "reviewer", "tester", "po", "incident", "planner")
72
+ TRUST_LEVELS_ENUM = ("core", "professional", "advisory", "restricted", "experimental")
73
+
74
+
75
+ @dataclass
76
+ class InventoryRow:
77
+ name: str
78
+ cls: str
79
+ ref_count: int
80
+ last_modified: str
81
+ bloat_candidate: bool
82
+ notes: str = ""
83
+
84
+ def to_row(self) -> list[str]:
85
+ return [
86
+ self.cls,
87
+ self.name,
88
+ str(self.ref_count),
89
+ self.last_modified,
90
+ "Y" if self.bloat_candidate else "N",
91
+ self.notes,
92
+ ]
93
+
94
+
95
+ @dataclass
96
+ class FrontmatterAudit:
97
+ field: str
98
+ cls: str
99
+ total: int
100
+ distinct: int
101
+ dominant_value: str
102
+ dominant_share: float
103
+ bloat_candidate: bool
104
+
105
+ def to_row(self) -> list[str]:
106
+ return [
107
+ self.cls,
108
+ self.field,
109
+ str(self.total),
110
+ str(self.distinct),
111
+ self.dominant_value,
112
+ f"{self.dominant_share:.2%}",
113
+ "Y" if self.bloat_candidate else "N",
114
+ ]
115
+
116
+
117
+ @dataclass
118
+ class Stats:
119
+ rows: list[InventoryRow] = field(default_factory=list)
120
+ fm_rows: list[FrontmatterAudit] = field(default_factory=list)
121
+ overlap_notes: list[str] = field(default_factory=list)
122
+
123
+
124
+ def _log(level: str, msg: str) -> None:
125
+ if script_output is None:
126
+ if level == "error":
127
+ print(msg, file=sys.stderr)
128
+ return
129
+ getattr(script_output, level)(msg)
130
+
131
+
132
+ def has_rg() -> bool:
133
+ return shutil.which("rg") is not None
134
+
135
+
136
+ def grep_count(pattern: str, *, regex: bool = False, exclude_dir: Path | None = None) -> int:
137
+ """Count matches across repo, excluding generated trees and optionally a self-dir."""
138
+ if has_rg():
139
+ cmd = ["rg", "--count-matches", "--no-heading"]
140
+ if not regex:
141
+ cmd.append("--fixed-strings")
142
+ for frag in EXCLUDE_PATH_FRAGMENTS:
143
+ cmd.extend(["-g", f"!{frag}/**"])
144
+ if exclude_dir is not None:
145
+ try:
146
+ rel = exclude_dir.relative_to(REPO_ROOT)
147
+ cmd.extend(["-g", f"!{rel}/**"])
148
+ except ValueError:
149
+ pass
150
+ cmd.extend([pattern, str(REPO_ROOT)])
151
+ try:
152
+ out = subprocess.run(
153
+ cmd,
154
+ capture_output=True,
155
+ text=True,
156
+ check=False,
157
+ )
158
+ except OSError:
159
+ return _python_grep(pattern, regex=regex, exclude_dir=exclude_dir)
160
+ total = 0
161
+ for line in out.stdout.splitlines():
162
+ # format: <path>:<count>
163
+ parts = line.rsplit(":", 1)
164
+ if len(parts) == 2 and parts[1].isdigit():
165
+ total += int(parts[1])
166
+ return total
167
+ return _python_grep(pattern, regex=regex, exclude_dir=exclude_dir)
168
+
169
+
170
+ def _python_grep(pattern: str, *, regex: bool = False, exclude_dir: Path | None = None) -> int:
171
+ rx = re.compile(pattern) if regex else None
172
+ total = 0
173
+ excl_str = str(exclude_dir) if exclude_dir is not None else None
174
+ for root, dirs, files in os.walk(REPO_ROOT):
175
+ rel = os.path.relpath(root, REPO_ROOT)
176
+ if any(rel == frag or rel.startswith(frag + os.sep) for frag in EXCLUDE_PATH_FRAGMENTS):
177
+ dirs[:] = []
178
+ continue
179
+ if excl_str is not None and root.startswith(excl_str):
180
+ dirs[:] = []
181
+ continue
182
+ for fn in files:
183
+ if not fn.endswith((".md", ".py", ".yml", ".yaml", ".json", ".sh", ".ts", ".js")):
184
+ continue
185
+ p = Path(root) / fn
186
+ try:
187
+ text = p.read_text(encoding="utf-8", errors="replace")
188
+ except OSError:
189
+ continue
190
+ if regex and rx is not None:
191
+ total += len(rx.findall(text))
192
+ else:
193
+ total += text.count(pattern)
194
+ return total
195
+
196
+
197
+ def last_modified(path: Path) -> str:
198
+ """Last git commit date for path; falls back to mtime."""
199
+ try:
200
+ out = subprocess.run(
201
+ ["git", "log", "-1", "--format=%cs", "--", str(path)],
202
+ capture_output=True,
203
+ text=True,
204
+ cwd=REPO_ROOT,
205
+ check=False,
206
+ )
207
+ date = out.stdout.strip()
208
+ if date:
209
+ return date
210
+ except OSError:
211
+ pass
212
+ try:
213
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).date().isoformat()
214
+ except OSError:
215
+ return "unknown"
216
+
217
+
218
+ def parse_frontmatter(path: Path) -> dict[str, str]:
219
+ """Return frontmatter as flat dict[str, str]. Returns empty dict if absent."""
220
+ try:
221
+ text = path.read_text(encoding="utf-8", errors="replace")
222
+ except OSError:
223
+ return {}
224
+ if not text.startswith("---\n"):
225
+ return {}
226
+ end = text.find("\n---\n", 4)
227
+ if end < 0:
228
+ return {}
229
+ block = text[4:end]
230
+ out: dict[str, str] = {}
231
+ indent_path: list[str] = []
232
+ for raw in block.splitlines():
233
+ if not raw.strip() or raw.lstrip().startswith("#"):
234
+ continue
235
+ indent = len(raw) - len(raw.lstrip(" "))
236
+ depth = indent // 2
237
+ if ":" not in raw:
238
+ continue
239
+ key_part, _, value = raw.lstrip().partition(":")
240
+ key = key_part.strip()
241
+ value = value.strip()
242
+ indent_path = indent_path[:depth]
243
+ indent_path.append(key)
244
+ if value:
245
+ full_key = ".".join(indent_path)
246
+ out[full_key] = value
247
+ return out
248
+
249
+
250
+ def inventory_packs(stats: Stats) -> None:
251
+ packs_dir = REPO_ROOT / "packages"
252
+ for child in sorted(packs_dir.iterdir()):
253
+ if not child.is_dir() or not child.name.startswith("pack-"):
254
+ continue
255
+ ref = grep_count(child.name)
256
+ # subtract self-references inside the pack's own directory
257
+ self_refs = 0
258
+ if has_rg():
259
+ try:
260
+ out = subprocess.run(
261
+ ["rg", "--count-matches", "--no-heading", "--fixed-strings", child.name, str(child)],
262
+ capture_output=True,
263
+ text=True,
264
+ check=False,
265
+ )
266
+ for line in out.stdout.splitlines():
267
+ parts = line.rsplit(":", 1)
268
+ if len(parts) == 2 and parts[1].isdigit():
269
+ self_refs += int(parts[1])
270
+ except OSError:
271
+ pass
272
+ external = max(ref - self_refs, 0)
273
+ stats.rows.append(InventoryRow(
274
+ name=child.name,
275
+ cls="pack",
276
+ ref_count=external,
277
+ last_modified=last_modified(child),
278
+ bloat_candidate=(external == 0),
279
+ notes=f"total={ref}, internal={self_refs}",
280
+ ))
281
+
282
+
283
+ def inventory_roles(stats: Stats) -> None:
284
+ for role in ROLES_ENUM:
285
+ # role names are common English words; restrict to active_role context
286
+ ref = grep_count(f'active_role: {role}')
287
+ ref += grep_count(f'active_role: "{role}"')
288
+ ref += grep_count(f'active_role: \'{role}\'')
289
+ # mention in role-contracts table
290
+ contract = REPO_ROOT / "docs" / "guidelines" / "agent-infra" / "role-contracts.md"
291
+ stats.rows.append(InventoryRow(
292
+ name=role,
293
+ cls="role",
294
+ ref_count=ref,
295
+ last_modified=last_modified(contract),
296
+ bloat_candidate=(ref == 0),
297
+ notes="enum role-contracts.md",
298
+ ))
299
+
300
+
301
+ def inventory_directives(stats: Stats) -> None:
302
+ if not DIRECTIVES_ROOT.is_dir():
303
+ return
304
+ for child in sorted(DIRECTIVES_ROOT.iterdir()):
305
+ if not child.is_dir() or child.name.startswith("_") or child.name.startswith("."):
306
+ continue
307
+ ref = grep_count(f'directive_set: {child.name}') + grep_count(f'directive_set="{child.name}"')
308
+ ref += grep_count(f'"{child.name}"') # broad
309
+ stats.rows.append(InventoryRow(
310
+ name=child.name,
311
+ cls="directive_set",
312
+ ref_count=ref,
313
+ last_modified=last_modified(child),
314
+ bloat_candidate=(ref < 2),
315
+ notes="work_engine directive set",
316
+ ))
317
+
318
+
319
+ def inventory_council_members(stats: Stats) -> None:
320
+ # Council members per ai-council-config.md members block
321
+ for member in ("anthropic", "openai", "gemini"):
322
+ ref = grep_count(f' {member}:')
323
+ cfg = REPO_ROOT / "docs" / "contracts" / "ai-council-config.md"
324
+ stats.rows.append(InventoryRow(
325
+ name=member,
326
+ cls="council_member",
327
+ ref_count=ref,
328
+ last_modified=last_modified(cfg),
329
+ bloat_candidate=(ref == 0),
330
+ notes="ai-council provider slot",
331
+ ))
332
+
333
+
334
+ def inventory_trust_levels(stats: Stats) -> None:
335
+ cfg = REPO_ROOT / "docs" / "contracts" / "trust-and-safety.md"
336
+ for level in TRUST_LEVELS_ENUM:
337
+ ref = grep_count(f'trust.level: {level}') + grep_count(f'level: {level}')
338
+ ref += grep_count(f'`{level}`')
339
+ stats.rows.append(InventoryRow(
340
+ name=level,
341
+ cls="trust_level",
342
+ ref_count=ref,
343
+ last_modified=last_modified(cfg),
344
+ bloat_candidate=(ref < 2),
345
+ notes="trust enum value",
346
+ ))
347
+
348
+
349
+ def inventory_flows(stats: Stats) -> None:
350
+ contracts = REPO_ROOT / "docs" / "contracts"
351
+ if not contracts.is_dir():
352
+ return
353
+ for p in sorted(contracts.glob("*flow*.md")):
354
+ ref = grep_count(p.stem)
355
+ stats.rows.append(InventoryRow(
356
+ name=p.stem,
357
+ cls="flow",
358
+ ref_count=ref,
359
+ last_modified=last_modified(p),
360
+ bloat_candidate=(ref < 3),
361
+ notes=str(p.relative_to(REPO_ROOT)),
362
+ ))
363
+
364
+
365
+ def inventory_artefacts(stats: Stats, *, subdir: str, cls: str) -> None:
366
+ """Inventory skill/rule/command/persona artefacts with broad-match + self-ref subtraction."""
367
+ root = CORE_SRC / subdir
368
+ if not root.is_dir():
369
+ return
370
+ for child in sorted(root.iterdir()):
371
+ if child.is_dir():
372
+ md = child / "SKILL.md" if cls == "skill" else None
373
+ if md and md.is_file():
374
+ _record_artefact(stats, child.name, cls, md, exclude_dir=child)
375
+ elif cls == "command":
376
+ for cmd_file in child.rglob("*.md"):
377
+ name = str(cmd_file.relative_to(root)).removesuffix(".md").replace("/", ":")
378
+ _record_artefact(stats, name, cls, cmd_file, exclude_dir=None)
379
+ elif cls == "persona":
380
+ if child.name.startswith("_"):
381
+ continue
382
+ for persona_file in child.rglob("*.md"):
383
+ name = persona_file.stem
384
+ if name.startswith("_"):
385
+ continue
386
+ _record_artefact(stats, name, cls, persona_file, exclude_dir=None)
387
+ elif child.suffix == ".md":
388
+ name = child.stem
389
+ if name.startswith("_") or name.upper() == "README":
390
+ continue
391
+ _record_artefact(stats, name, cls, child, exclude_dir=None)
392
+
393
+
394
+ def _record_artefact(stats: Stats, name: str, cls: str, path: Path, *, exclude_dir: Path | None) -> None:
395
+ """Count *external* references to the artefact name (broad match, self-ref subtracted)."""
396
+ # Broad: count bare name across the tree, exclude the artefact's own dir/file.
397
+ # The artefact name is kebab-case (commands use `:` separators) and is
398
+ # treated as a fixed string — so the only false-positive risk is a generic
399
+ # English word colliding with an artefact name, which the audit notes.
400
+ if exclude_dir is not None:
401
+ external = grep_count(name, exclude_dir=exclude_dir)
402
+ else:
403
+ # No own-dir to exclude: count whole-tree then subtract the file's own refs.
404
+ total = grep_count(name)
405
+ try:
406
+ self_text = path.read_text(encoding="utf-8", errors="replace")
407
+ self_refs = self_text.count(name)
408
+ except OSError:
409
+ self_refs = 0
410
+ external = max(total - self_refs, 0)
411
+ # Heuristic threshold: <3 external references signals a candidate
412
+ # (not a verdict — Phase 2 gate decides).
413
+ bloat = external < 3
414
+ stats.rows.append(InventoryRow(
415
+ name=name,
416
+ cls=cls,
417
+ ref_count=external,
418
+ last_modified=last_modified(path),
419
+ bloat_candidate=bloat,
420
+ notes=str(path.relative_to(REPO_ROOT)),
421
+ ))
422
+
423
+
424
+ def overlap_audit(stats: Stats) -> None:
425
+ """Surface obvious purpose overlaps within the same class via name overlap."""
426
+ by_class: dict[str, list[str]] = defaultdict(list)
427
+ for row in stats.rows:
428
+ by_class[row.cls].append(row.name)
429
+ for cls, names in by_class.items():
430
+ if cls not in ("skill", "rule", "command", "persona"):
431
+ continue
432
+ # naive overlap signal: same stemmed prefix family
433
+ families: dict[str, list[str]] = defaultdict(list)
434
+ for n in names:
435
+ stem = re.split(r"[:_-]", n, maxsplit=1)[0]
436
+ families[stem].append(n)
437
+ for stem, group in families.items():
438
+ if len(group) >= 4:
439
+ stats.overlap_notes.append(
440
+ f"{cls} family '{stem}' has {len(group)} members: {', '.join(sorted(group))}",
441
+ )
442
+
443
+
444
+ def frontmatter_audit(stats: Stats) -> None:
445
+ """Per-class frontmatter field-bloat audit."""
446
+ classes = {
447
+ "skill": list(CORE_SRC.glob("skills/*/SKILL.md")),
448
+ "rule": list(CORE_SRC.glob("rules/*.md")),
449
+ "command": list(CORE_SRC.glob("commands/**/*.md")),
450
+ "persona": list(CORE_SRC.glob("personas/**/*.md")),
451
+ }
452
+ for cls, paths in classes.items():
453
+ field_values: dict[str, list[str]] = defaultdict(list)
454
+ for p in paths:
455
+ if p.name.startswith("_") or p.name.upper() == "README.MD":
456
+ continue
457
+ fm = parse_frontmatter(p)
458
+ for k, v in fm.items():
459
+ field_values[k].append(v)
460
+ for fkey, values in field_values.items():
461
+ counter = Counter(values)
462
+ dominant_value, dominant_count = counter.most_common(1)[0]
463
+ total = len(values)
464
+ distinct = len(counter)
465
+ share = dominant_count / total if total else 0
466
+ bloat = share > 0.95 and total >= 10
467
+ stats.fm_rows.append(FrontmatterAudit(
468
+ field=fkey,
469
+ cls=cls,
470
+ total=total,
471
+ distinct=distinct,
472
+ dominant_value=(dominant_value[:60] + "…") if len(dominant_value) > 60 else dominant_value,
473
+ dominant_share=share,
474
+ bloat_candidate=bloat,
475
+ ))
476
+
477
+
478
+ def write_csv(path: Path, header: list[str], rows: list[list[str]]) -> None:
479
+ path.parent.mkdir(parents=True, exist_ok=True)
480
+ with path.open("w", encoding="utf-8", newline="") as fh:
481
+ w = csv.writer(fh)
482
+ w.writerow(header)
483
+ w.writerows(rows)
484
+
485
+
486
+ def write_markdown(path: Path, stats: Stats) -> None:
487
+ path.parent.mkdir(parents=True, exist_ok=True)
488
+ bloat_rows = [r for r in stats.rows if r.bloat_candidate]
489
+ bloat_fm = [r for r in stats.fm_rows if r.bloat_candidate]
490
+ by_class = Counter(r.cls for r in stats.rows)
491
+ bloat_by_class = Counter(r.cls for r in bloat_rows)
492
+
493
+ lines: list[str] = []
494
+ lines.append("# Abstraction-Budget Inventory\n")
495
+ lines.append(
496
+ "> Read-only discovery output for "
497
+ "`agents/roadmaps/road-to-abstraction-budget-discovery.md`. "
498
+ "Counts are grep-backed via the inventory script "
499
+ "`scripts/inventory_abstraction_budget.py`. "
500
+ "`bloat_candidate = Y` means usage-count threshold not met "
501
+ "(typically zero external references) OR purpose overlap.\n",
502
+ )
503
+ lines.append(f"_Generated: {datetime.now(timezone.utc).date().isoformat()}_\n")
504
+
505
+ lines.append("\n## Summary\n")
506
+ lines.append("| Class | Total | Bloat candidates |\n|---|---:|---:|")
507
+ for cls in sorted(by_class):
508
+ lines.append(f"| {cls} | {by_class[cls]} | {bloat_by_class.get(cls, 0)} |")
509
+ lines.append("")
510
+
511
+ lines.append("\n## Phase 2 gate signals\n")
512
+ zero_usage = [r for r in stats.rows if r.ref_count == 0]
513
+ lines.append(f"- **Abstractions with usage_count == 0:** {len(zero_usage)}")
514
+ lines.append(f"- **Frontmatter fields >95% boilerplate:** {len(bloat_fm)}")
515
+ lines.append(f"- **Overlap notes surfaced:** {len(stats.overlap_notes)}")
516
+ lines.append("")
517
+ if zero_usage:
518
+ lines.append("Zero-usage list:\n")
519
+ for r in zero_usage:
520
+ lines.append(f"- `{r.cls}/{r.name}` (last modified {r.last_modified})")
521
+ lines.append("")
522
+ if bloat_fm:
523
+ lines.append("\nFrontmatter boilerplate candidates:\n")
524
+ for r in bloat_fm:
525
+ lines.append(
526
+ f"- `{r.cls}.{r.field}` — dominant `{r.dominant_value}` "
527
+ f"in {r.dominant_share:.0%} of {r.total} artefacts",
528
+ )
529
+ lines.append("")
530
+ if stats.overlap_notes:
531
+ lines.append("\nOverlap notes:\n")
532
+ for note in stats.overlap_notes:
533
+ lines.append(f"- {note}")
534
+ lines.append("")
535
+
536
+ lines.append("\n## Full inventory\n")
537
+ lines.append("| Class | Name | Refs | Last modified | Bloat? | Notes |")
538
+ lines.append("|---|---|---:|---|:---:|---|")
539
+ for r in sorted(stats.rows, key=lambda x: (x.cls, x.name)):
540
+ lines.append(
541
+ f"| {r.cls} | `{r.name}` | {r.ref_count} | "
542
+ f"{r.last_modified} | {'Y' if r.bloat_candidate else 'N'} | {r.notes} |",
543
+ )
544
+
545
+ lines.append("\n## Frontmatter field audit\n")
546
+ lines.append("| Class | Field | Total | Distinct | Dominant value | Share | Bloat? |")
547
+ lines.append("|---|---|---:|---:|---|---:|:---:|")
548
+ for r in sorted(stats.fm_rows, key=lambda x: (x.cls, -x.dominant_share)):
549
+ lines.append(
550
+ f"| {r.cls} | `{r.field}` | {r.total} | {r.distinct} | "
551
+ f"`{r.dominant_value}` | {r.dominant_share:.0%} | "
552
+ f"{'Y' if r.bloat_candidate else 'N'} |",
553
+ )
554
+
555
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
556
+
557
+
558
+ def main() -> int:
559
+ parser = argparse.ArgumentParser(description=__doc__)
560
+ parser.add_argument("--quiet", action="store_true", help="suppress info-level output")
561
+ args = parser.parse_args()
562
+ if args.quiet:
563
+ os.environ.setdefault("AGENT_SCRIPT_VERBOSITY", "silent")
564
+
565
+ _log("info", "[inventory] scanning packs…")
566
+ stats = Stats()
567
+ inventory_packs(stats)
568
+ _log("info", "[inventory] scanning roles…")
569
+ inventory_roles(stats)
570
+ _log("info", "[inventory] scanning directives…")
571
+ inventory_directives(stats)
572
+ _log("info", "[inventory] scanning council members…")
573
+ inventory_council_members(stats)
574
+ _log("info", "[inventory] scanning trust levels…")
575
+ inventory_trust_levels(stats)
576
+ _log("info", "[inventory] scanning flows…")
577
+ inventory_flows(stats)
578
+ _log("info", "[inventory] scanning skills…")
579
+ inventory_artefacts(stats, subdir="skills", cls="skill")
580
+ _log("info", "[inventory] scanning rules…")
581
+ inventory_artefacts(stats, subdir="rules", cls="rule")
582
+ _log("info", "[inventory] scanning commands…")
583
+ inventory_artefacts(stats, subdir="commands", cls="command")
584
+ _log("info", "[inventory] scanning personas…")
585
+ inventory_artefacts(stats, subdir="personas", cls="persona")
586
+ _log("info", "[inventory] overlap audit…")
587
+ overlap_audit(stats)
588
+ _log("info", "[inventory] frontmatter audit…")
589
+ frontmatter_audit(stats)
590
+
591
+ out_md = EVIDENCE_DIR / "abstraction-budget-inventory.md"
592
+ out_csv = EVIDENCE_DIR / "abstraction-budget-inventory.csv"
593
+ out_fm_csv = EVIDENCE_DIR / "abstraction-budget-frontmatter.csv"
594
+
595
+ write_markdown(out_md, stats)
596
+ write_csv(
597
+ out_csv,
598
+ header=["class", "name", "ref_count", "last_modified", "bloat_candidate", "notes"],
599
+ rows=[r.to_row() for r in sorted(stats.rows, key=lambda x: (x.cls, x.name))],
600
+ )
601
+ write_csv(
602
+ out_fm_csv,
603
+ header=["class", "field", "total", "distinct", "dominant_value", "dominant_share", "bloat_candidate"],
604
+ rows=[r.to_row() for r in sorted(stats.fm_rows, key=lambda x: (x.cls, -x.dominant_share))],
605
+ )
606
+
607
+ _log("success", f"[inventory] wrote {out_md.relative_to(REPO_ROOT)}")
608
+ _log("success", f"[inventory] wrote {out_csv.relative_to(REPO_ROOT)}")
609
+ _log("success", f"[inventory] wrote {out_fm_csv.relative_to(REPO_ROOT)}")
610
+ if script_output is not None:
611
+ script_output.flush_summary("[inventory] inventory written")
612
+ return 0
613
+
614
+
615
+ if __name__ == "__main__":
616
+ sys.exit(main())