@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/inventory_abstraction_budget.py +616 -0
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 +1 @@
|
|
|
1
|
-
|
|
1
|
+
02075520b8ccb3025ec9d54d7ec0585f26f09c3c947d3c696f3ef4328aa04e74 discovery-manifest.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
|
-
"version": "4.
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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())
|