@event4u/agent-config 2.18.0 → 2.20.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 (108) hide show
  1. package/.agent-src/commands/agent-status.md +29 -0
  2. package/.agent-src/commands/onboard.md +221 -81
  3. package/.agent-src/commands/refine-ticket.md +3 -0
  4. package/.agent-src/packs/README.md +49 -0
  5. package/.agent-src/packs/agency-delivery.yml +63 -0
  6. package/.agent-src/packs/content-engine.yml +53 -0
  7. package/.agent-src/packs/founder-mvp.yml +51 -0
  8. package/.agent-src/personas/README.md +8 -0
  9. package/.agent-src/presets/README.md +26 -0
  10. package/.agent-src/presets/balanced.yml +34 -0
  11. package/.agent-src/presets/fast.yml +31 -0
  12. package/.agent-src/presets/strict.yml +38 -0
  13. package/.agent-src/profiles/README.md +29 -0
  14. package/.agent-src/profiles/agency.yml +27 -0
  15. package/.agent-src/profiles/content_creator.yml +25 -0
  16. package/.agent-src/profiles/developer.yml +26 -0
  17. package/.agent-src/profiles/finance.yml +24 -0
  18. package/.agent-src/profiles/founder.yml +25 -0
  19. package/.agent-src/profiles/ops.yml +25 -0
  20. package/.agent-src/rules/no-cheap-questions.md +25 -17
  21. package/.agent-src/skills/adr-create/SKILL.md +78 -68
  22. package/.agent-src/skills/refine-ticket/SKILL.md +3 -0
  23. package/.agent-src/skills/subagent-orchestration/SKILL.md +33 -0
  24. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/.agent-src/templates/skill-archive-note.md +101 -0
  26. package/.agent-src/user-types/README.md +124 -0
  27. package/.agent-src/user-types/_template/user-type.md +95 -0
  28. package/.agent-src/user-types/galabau-field-crew.md +100 -0
  29. package/.agent-src/user-types/metalworking-shop.md +105 -0
  30. package/.agent-src/user-types/truck-driver.md +113 -0
  31. package/.claude-plugin/marketplace.json +1 -1
  32. package/CHANGELOG.md +91 -30
  33. package/README.md +68 -72
  34. package/config/agent-settings.template.yml +22 -0
  35. package/docs/adrs/caveman/0001-default-off-until-bench.md +93 -0
  36. package/docs/adrs/caveman/README.md +9 -0
  37. package/docs/adrs/cost/0001-hard-stop-hook.md +114 -0
  38. package/docs/adrs/cost/README.md +9 -0
  39. package/docs/adrs/memory/0001-consumer-side-snapshot.md +111 -0
  40. package/docs/adrs/memory/README.md +9 -0
  41. package/docs/adrs/router/0001-three-tier-routing.md +119 -0
  42. package/docs/adrs/router/README.md +9 -0
  43. package/docs/adrs/schema/0001-json-schema-frontmatter.md +102 -0
  44. package/docs/adrs/schema/README.md +9 -0
  45. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +99 -0
  46. package/docs/adrs/smoke/README.md +9 -0
  47. package/docs/architecture/current-onboard-baseline.md +126 -0
  48. package/docs/architecture/current-safety-behavior.md +137 -0
  49. package/docs/archive/CHANGELOG-pre-2.16.0.md +48 -0
  50. package/docs/contracts/adr-layout.md +108 -0
  51. package/docs/contracts/adr-mcp-runtime.md +128 -0
  52. package/docs/contracts/adr-user-types-axis.md +127 -0
  53. package/docs/contracts/benchmark-corpus-spec.md +97 -0
  54. package/docs/contracts/benchmark-report-schema.md +111 -0
  55. package/docs/contracts/command-clusters.md +1 -0
  56. package/docs/contracts/command-taxonomy.md +137 -0
  57. package/docs/contracts/compression-default-kill-criterion.md +69 -0
  58. package/docs/contracts/config-presets.md +144 -0
  59. package/docs/contracts/cost-dashboard.md +143 -0
  60. package/docs/contracts/cost-enforcement.md +134 -0
  61. package/docs/contracts/file-ownership-matrix.json +0 -7
  62. package/docs/contracts/mcp-tool-inventory.md +53 -0
  63. package/docs/contracts/measurement-baseline.md +102 -0
  64. package/docs/contracts/namespace.md +125 -0
  65. package/docs/contracts/profile-system.md +142 -0
  66. package/docs/contracts/safety-model.md +129 -0
  67. package/docs/contracts/smoke-contracts.md +144 -0
  68. package/docs/contracts/user-type-schema.md +146 -0
  69. package/docs/contracts/workflow-packs.md +121 -0
  70. package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +132 -0
  71. package/docs/decisions/INDEX.md +1 -0
  72. package/docs/featured-commands.md +27 -0
  73. package/docs/parity/bench-ruflo.json +58 -0
  74. package/docs/parity/bench.json +41 -0
  75. package/docs/parity/ruflo.md +46 -0
  76. package/docs/profiles.md +91 -0
  77. package/docs/recruits/_template.md +81 -0
  78. package/package.json +1 -1
  79. package/scripts/_cli/cmd_explain.py +250 -0
  80. package/scripts/_lib/bench_cost.py +138 -0
  81. package/scripts/_lib/bench_quality.py +118 -0
  82. package/scripts/_lib/bench_report.py +150 -0
  83. package/scripts/agent-config +13 -0
  84. package/scripts/audit_adr_coverage.py +175 -0
  85. package/scripts/audit_mcp_tools.py +146 -0
  86. package/scripts/bench_baseline_ready.py +108 -0
  87. package/scripts/bench_drift_check.py +151 -0
  88. package/scripts/bench_per_tool.py +216 -0
  89. package/scripts/bench_run.py +155 -0
  90. package/scripts/compress.py +48 -2
  91. package/scripts/config/__init__.py +9 -0
  92. package/scripts/config/presets.py +206 -0
  93. package/scripts/config/profiles.py +173 -0
  94. package/scripts/cost/budget.mjs +73 -12
  95. package/scripts/cost/preflight.mjs +89 -0
  96. package/scripts/lint_archived_skills.py +143 -0
  97. package/scripts/lint_bench_corpus.py +161 -0
  98. package/scripts/lint_namespace.py +135 -0
  99. package/scripts/schemas/user-type.schema.json +35 -0
  100. package/scripts/skill_linter.py +139 -4
  101. package/scripts/skill_overlap.py +204 -0
  102. package/scripts/skill_tools/audit_user_type_coverage.py +148 -0
  103. package/scripts/skill_usage_collect.py +191 -0
  104. package/scripts/skill_usage_report.py +162 -0
  105. package/scripts/smoke/kernel.sh +101 -0
  106. package/scripts/smoke/router.sh +129 -0
  107. package/scripts/smoke/schema.sh +71 -0
  108. package/scripts/smoke/skills.sh +101 -0
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Block D · D3 — audit_user_type_coverage.
3
+
4
+ Coverage audit for the user-type axis. User-types are **CLI-only** in v1
5
+ (see `docs/contracts/adr-user-types-axis.md` and Phase 4 step 3 of
6
+ `agents/roadmaps/step-6-user-types-axis.md`) — skills do NOT declare a
7
+ `user-types:` frontmatter key, so persona-style citation counting does
8
+ not apply. Instead this script:
9
+
10
+ - Inventories every user-type file in the source directory.
11
+ - Scans skills, commands, and `docs/` for `--user-type=<id>` mentions.
12
+ - Flags **orphan references** (CLI mention to a non-existent id) and
13
+ **never-referenced** user-types (file exists but nobody cites it).
14
+
15
+ Inputs:
16
+ --user-types-dir DIR — directory holding user-type Markdown files
17
+ --search-root DIR — root to recurse for `--user-type=<id>` mentions
18
+ --json — machine-readable output
19
+
20
+ Output: per-user-type reference count + status (ok / never-referenced /
21
+ orphan). Exit code: 0 always (advisory, not a CI gate).
22
+
23
+ Stdlib-only. ≤ 130 LOC. Sibling of `audit_persona_coverage.py`.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Dict, List, Set
33
+
34
+ ROOT = Path(__file__).resolve().parents[2]
35
+ DEFAULT_USER_TYPES = ROOT / ".agent-src.uncompressed" / "user-types"
36
+ DEFAULT_SEARCH_ROOT = ROOT / ".agent-src.uncompressed"
37
+ REFERENCE_THRESHOLD = 1 # user-type with 0 references → flagged.
38
+
39
+ # Matches `--user-type=<id>` in command markdown, skill prose, docs.
40
+ _REFERENCE_RE = re.compile(r"--user-type=([\w-]+)")
41
+
42
+
43
+ def _read_block(path: Path) -> str:
44
+ text = path.read_text(encoding="utf-8", errors="replace")
45
+ if not text.startswith("---"):
46
+ return ""
47
+ end = text.find("\n---", 3)
48
+ return text[3:end] if end != -1 else ""
49
+
50
+
51
+ def _frontmatter_value(block: str, key: str) -> str | None:
52
+ m = re.search(rf"^{re.escape(key)}\s*:\s*(.+)$", block, re.MULTILINE)
53
+ if not m:
54
+ return None
55
+ val = m.group(1).strip()
56
+ if val.startswith('"') and val.endswith('"'):
57
+ val = val[1:-1]
58
+ return val
59
+
60
+
61
+ def _load_user_types(user_types_dir: Path) -> Set[str]:
62
+ ids: Set[str] = set()
63
+ if not user_types_dir.is_dir():
64
+ return ids
65
+ for md in sorted(user_types_dir.glob("*.md")):
66
+ if md.name.lower() == "readme.md":
67
+ continue
68
+ block = _read_block(md)
69
+ slug = _frontmatter_value(block, "id") or md.stem
70
+ ids.add(slug)
71
+ # Walk one level deeper to skip `_template/` etc.
72
+ for md in sorted(user_types_dir.glob("*/*.md")):
73
+ if "_template" in md.parts:
74
+ continue
75
+ block = _read_block(md)
76
+ slug = _frontmatter_value(block, "id") or md.parent.name
77
+ ids.add(slug)
78
+ return ids
79
+
80
+
81
+ def _count_references(search_root: Path, skip_dir: Path) -> Dict[str, int]:
82
+ counts: Dict[str, int] = {}
83
+ if not search_root.is_dir():
84
+ return counts
85
+ skip_resolved = skip_dir.resolve() if skip_dir.is_dir() else None
86
+ for md in search_root.rglob("*.md"):
87
+ # Don't count references inside the user-types dir itself
88
+ # (the README documents the flag in example form).
89
+ if skip_resolved and skip_resolved in md.resolve().parents:
90
+ continue
91
+ text = md.read_text(encoding="utf-8", errors="replace")
92
+ for slug in _REFERENCE_RE.findall(text):
93
+ counts[slug] = counts.get(slug, 0) + 1
94
+ return counts
95
+
96
+
97
+ def audit(user_types_dir: Path, search_root: Path) -> List[Dict[str, object]]:
98
+ ids = _load_user_types(user_types_dir)
99
+ references = _count_references(search_root, user_types_dir)
100
+ rows: List[Dict[str, object]] = []
101
+ for slug in sorted(ids):
102
+ count = references.get(slug, 0)
103
+ status = "ok" if count >= REFERENCE_THRESHOLD else "never-referenced"
104
+ rows.append({"user_type": slug, "references": count,
105
+ "threshold": REFERENCE_THRESHOLD, "status": status})
106
+ for slug in sorted(references.keys()):
107
+ if slug not in ids:
108
+ rows.append({"user_type": slug, "references": references[slug],
109
+ "threshold": REFERENCE_THRESHOLD, "status": "orphan"})
110
+ return rows
111
+
112
+
113
+ def _print_human(rows: List[Dict[str, object]]) -> None:
114
+ if not rows:
115
+ print("(no user-types found)")
116
+ return
117
+ width = max(len(str(r["user_type"])) for r in rows)
118
+ print(f" {'user-type':<{width}} refs status")
119
+ print(f" {'-' * width} ----- ----------------")
120
+ for r in rows:
121
+ print(f" {str(r['user_type']):<{width}} "
122
+ f"{int(r['references']):>5} {r['status']}")
123
+ flagged = [r for r in rows if r["status"] != "ok"]
124
+ if flagged:
125
+ print(f"\n {len(flagged)} user-type(s) flagged "
126
+ f"(never-referenced or orphan).")
127
+
128
+
129
+ def main(argv: List[str] | None = None) -> int:
130
+ parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
131
+ parser.add_argument("--user-types-dir", default=str(DEFAULT_USER_TYPES))
132
+ parser.add_argument("--search-root", default=str(DEFAULT_SEARCH_ROOT))
133
+ parser.add_argument("--json", action="store_true",
134
+ help="emit JSON instead of text")
135
+ args = parser.parse_args(argv)
136
+ rows = audit(Path(args.user_types_dir), Path(args.search_root))
137
+ if args.json:
138
+ json.dump({"rows": rows}, sys.stdout, indent=2)
139
+ sys.stdout.write("\n")
140
+ else:
141
+ _print_human(rows)
142
+ return 0
143
+
144
+
145
+ _SAMPLE = {"threshold": REFERENCE_THRESHOLD}
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Collect skill-activation signal from Claude Code session jsonl.
3
+
4
+ Implements step-2-skill-inventory-rationalization.md Phase 1 Step 2.
5
+ Reads `~/.claude/projects/<project-slug>/*.jsonl` for the current repo,
6
+ parses each turn for two signals:
7
+
8
+ - exposure: the skill slug appeared in an `attachment.type=skill_listing`
9
+ payload (catalog presented to the agent that turn).
10
+ - mention: the assistant-text response in the same or following turn
11
+ referenced the slug in backticks with one of the anchor verbs
12
+ (using, via, per, route, dispatch, invoke, call) OR cited a SKILL.md
13
+ path under `.augment/skills/<slug>/`, `.claude/skills/<slug>/`, or
14
+ `.agent-src/skills/<slug>/`.
15
+
16
+ Emits one JSONL record per (session, turn, slug, kind) to
17
+ `agents/metrics/skill-usage.jsonl` (append-only, deduped on the
18
+ (session_id, turn_idx, slug, kind) tuple).
19
+
20
+ Privacy: `prompt_excerpt_hash` = SHA-256 of the first 200 chars of the
21
+ user prompt that opened the turn. No raw user or assistant bodies are
22
+ persisted. See `agents/audit-2026-05-14-north-star/skill-usage-sources.md`.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import hashlib
28
+ import json
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Iterable, Iterator
33
+
34
+ REPO = Path(__file__).resolve().parent.parent
35
+ OUT = REPO / "agents" / "metrics" / "skill-usage.jsonl"
36
+
37
+ LISTING_LINE_RE = re.compile(r"^-\s+([a-z0-9][a-z0-9_-]+):\s", re.MULTILINE)
38
+ ANCHOR_VERBS = ("using", "via", "per", "route", "routing", "dispatch", "dispatched", "invoke", "call")
39
+ PATH_RE = re.compile(r"\.(?:augment|claude|agent-src)/skills/([a-z0-9][a-z0-9_-]+)/SKILL\.md")
40
+
41
+
42
+ def project_slug(repo: Path) -> str:
43
+ return str(repo).replace("/", "-")
44
+
45
+
46
+ def session_files(slug: str) -> list[Path]:
47
+ base = Path.home() / ".claude" / "projects" / slug
48
+ if not base.is_dir():
49
+ return []
50
+ return sorted(base.glob("*.jsonl"))
51
+
52
+
53
+ def iter_turns(jsonl: Path) -> Iterator[dict]:
54
+ with jsonl.open("r", encoding="utf-8", errors="replace") as fh:
55
+ for line in fh:
56
+ line = line.strip()
57
+ if not line:
58
+ continue
59
+ try:
60
+ yield json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+
64
+
65
+ def extract_listing(entry: dict) -> set[str]:
66
+ att = entry.get("attachment") or {}
67
+ if att.get("type") != "skill_listing":
68
+ return set()
69
+ content = att.get("content", "") or ""
70
+ return set(LISTING_LINE_RE.findall(content))
71
+
72
+
73
+ def extract_text(entry: dict) -> str:
74
+ if entry.get("type") != "assistant":
75
+ return ""
76
+ msg = entry.get("message") or {}
77
+ content = msg.get("content")
78
+ if isinstance(content, str):
79
+ return content
80
+ if isinstance(content, list):
81
+ return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
82
+ return ""
83
+
84
+
85
+ def find_mentions(text: str, known_slugs: Iterable[str]) -> set[str]:
86
+ hits: set[str] = set()
87
+ if not text:
88
+ return hits
89
+ hits.update(PATH_RE.findall(text))
90
+ for slug in known_slugs:
91
+ token = f"`{slug}`"
92
+ if token not in text:
93
+ continue
94
+ lower = text.lower()
95
+ for verb in ANCHOR_VERBS:
96
+ if f"{verb} {token}".lower() in lower or f"{verb} the {token}".lower() in lower:
97
+ hits.add(slug)
98
+ break
99
+ return hits
100
+
101
+
102
+ def hash_prompt(text: str) -> str:
103
+ if not text:
104
+ return ""
105
+ return hashlib.sha256(text[:200].encode("utf-8", errors="replace")).hexdigest()[:16]
106
+
107
+
108
+ def collect_session(jsonl: Path, all_known: set[str]) -> list[dict]:
109
+ session_id = jsonl.stem
110
+ records: list[dict] = []
111
+ last_prompt_hash = ""
112
+ listed: set[str] = set()
113
+ turn_idx = -1
114
+ for entry in iter_turns(jsonl):
115
+ etype = entry.get("type")
116
+ if etype == "user":
117
+ turn_idx += 1
118
+ msg = entry.get("message") or {}
119
+ body = msg.get("content") if isinstance(msg.get("content"), str) else ""
120
+ last_prompt_hash = hash_prompt(body or "")
121
+ continue
122
+ if etype == "attachment":
123
+ listed |= extract_listing(entry)
124
+ continue
125
+ if etype == "assistant":
126
+ text = extract_text(entry)
127
+ mentions = find_mentions(text, listed | all_known)
128
+ ts = entry.get("timestamp") or ""
129
+ for slug in sorted(listed):
130
+ records.append({"session_id": session_id, "turn_idx": turn_idx, "slug": slug,
131
+ "kind": "exposure", "ts": ts, "prompt_excerpt_hash": last_prompt_hash})
132
+ for slug in sorted(mentions):
133
+ records.append({"session_id": session_id, "turn_idx": turn_idx, "slug": slug,
134
+ "kind": "mention", "ts": ts, "prompt_excerpt_hash": last_prompt_hash})
135
+ listed = set()
136
+ return records
137
+
138
+
139
+ def load_known_slugs(repo: Path) -> set[str]:
140
+ slugs: set[str] = set()
141
+ for root in (repo / ".augment" / "skills", repo / ".claude" / "skills", repo / ".agent-src" / "skills"):
142
+ if not root.is_dir():
143
+ continue
144
+ for skill_md in root.glob("*/SKILL.md"):
145
+ slugs.add(skill_md.parent.name)
146
+ return slugs
147
+
148
+
149
+ def dedup_key(rec: dict) -> tuple:
150
+ return (rec["session_id"], rec["turn_idx"], rec["slug"], rec["kind"])
151
+
152
+
153
+ def main() -> int:
154
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
155
+ ap.add_argument("--project-slug", help="Override the ~/.claude/projects slug (defaults to current repo)")
156
+ ap.add_argument("--out", type=Path, default=OUT, help="Output jsonl (default: agents/metrics/skill-usage.jsonl)")
157
+ ap.add_argument("--quiet", action="store_true", help="Suppress non-error output")
158
+ args = ap.parse_args()
159
+
160
+ slug = args.project_slug or project_slug(REPO)
161
+ files = session_files(slug)
162
+ if not files:
163
+ if not args.quiet:
164
+ print(f"no session files for slug {slug}", file=sys.stderr)
165
+ return 0
166
+ known = load_known_slugs(REPO)
167
+ seen: set[tuple] = set()
168
+ args.out.parent.mkdir(parents=True, exist_ok=True)
169
+ if args.out.exists():
170
+ for line in args.out.read_text(encoding="utf-8", errors="replace").splitlines():
171
+ try:
172
+ seen.add(dedup_key(json.loads(line)))
173
+ except (json.JSONDecodeError, KeyError):
174
+ continue
175
+ appended = 0
176
+ with args.out.open("a", encoding="utf-8") as fh:
177
+ for jsonl in files:
178
+ for rec in collect_session(jsonl, known):
179
+ k = dedup_key(rec)
180
+ if k in seen:
181
+ continue
182
+ seen.add(k)
183
+ fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
184
+ appended += 1
185
+ if not args.quiet:
186
+ print(f"✅ Wrote {appended} new record(s) to {args.out.relative_to(REPO)} ({len(seen)} total)")
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Aggregate `agents/metrics/skill-usage.jsonl` into a per-skill report.
3
+
4
+ Implements step-2-skill-inventory-rationalization.md Phase 1 Step 3.
5
+ Groups records by slug; emits `agents/metrics/skill-usage-report.md`
6
+ with columns:
7
+
8
+ slug · exposures_total · mentions_total · exposures_30d · mentions_30d
9
+ · last_seen · status
10
+
11
+ `status` ∈ { active, exposed-only, dead } per:
12
+
13
+ active = mentions_30d ≥ 1
14
+ exposed-only = exposures_30d ≥ 1 ∧ mentions_30d == 0
15
+ dead = exposures_30d == 0
16
+
17
+ The report is **a baseline, not a verdict**. Rationalization decisions
18
+ live in Phase 2 (`skill-rationalization-candidates.md`).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ from collections import defaultdict
25
+ from datetime import datetime, timedelta, timezone
26
+ from pathlib import Path
27
+
28
+ REPO = Path(__file__).resolve().parent.parent
29
+ IN = REPO / "agents" / "metrics" / "skill-usage.jsonl"
30
+ OUT = REPO / "agents" / "metrics" / "skill-usage-report.md"
31
+
32
+
33
+ def parse_ts(raw: str) -> datetime | None:
34
+ if not raw:
35
+ return None
36
+ try:
37
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
38
+ except ValueError:
39
+ return None
40
+
41
+
42
+ def load_records(path: Path) -> list[dict]:
43
+ if not path.exists():
44
+ return []
45
+ records: list[dict] = []
46
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
47
+ if not line.strip():
48
+ continue
49
+ try:
50
+ records.append(json.loads(line))
51
+ except json.JSONDecodeError:
52
+ continue
53
+ return records
54
+
55
+
56
+ def aggregate(records: list[dict], now: datetime, window_days: int = 30) -> dict[str, dict]:
57
+ cutoff = now - timedelta(days=window_days)
58
+ per: dict[str, dict] = defaultdict(lambda: {
59
+ "exposures_total": 0, "mentions_total": 0,
60
+ "exposures_30d": 0, "mentions_30d": 0,
61
+ "last_seen": None,
62
+ })
63
+ for rec in records:
64
+ slug = rec.get("slug")
65
+ kind = rec.get("kind")
66
+ if not slug or kind not in ("exposure", "mention"):
67
+ continue
68
+ ts = parse_ts(rec.get("ts") or "")
69
+ bucket = per[slug]
70
+ bucket[f"{kind}s_total"] += 1
71
+ if ts and ts >= cutoff:
72
+ bucket[f"{kind}s_30d"] += 1
73
+ if ts and (bucket["last_seen"] is None or ts > bucket["last_seen"]):
74
+ bucket["last_seen"] = ts
75
+ return per
76
+
77
+
78
+ def status_for(row: dict) -> str:
79
+ if row["mentions_30d"] >= 1:
80
+ return "active"
81
+ if row["exposures_30d"] >= 1:
82
+ return "exposed-only"
83
+ return "dead"
84
+
85
+
86
+ def all_known_slugs(repo: Path) -> set[str]:
87
+ slugs: set[str] = set()
88
+ for root in (repo / ".augment" / "skills", repo / ".claude" / "skills", repo / ".agent-src" / "skills"):
89
+ if not root.is_dir():
90
+ continue
91
+ for skill_md in root.glob("*/SKILL.md"):
92
+ slugs.add(skill_md.parent.name)
93
+ return slugs
94
+
95
+
96
+ def render(per: dict[str, dict], known: set[str]) -> str:
97
+ rows = []
98
+ for slug in sorted(known | set(per)):
99
+ data = per.get(slug, {
100
+ "exposures_total": 0, "mentions_total": 0,
101
+ "exposures_30d": 0, "mentions_30d": 0, "last_seen": None,
102
+ })
103
+ rows.append({"slug": slug, **data, "status": status_for(data)})
104
+ rows.sort(key=lambda r: (r["status"] != "dead", -r["exposures_total"], r["slug"]))
105
+
106
+ counts = {"active": 0, "exposed-only": 0, "dead": 0}
107
+ for r in rows:
108
+ counts[r["status"]] += 1
109
+ total = len(rows)
110
+
111
+ lines = [
112
+ "# Skill Usage Report (baseline)",
113
+ "",
114
+ "> Generated by `scripts/skill_usage_report.py`. Source:",
115
+ "> `agents/metrics/skill-usage.jsonl` (collector emits per-turn",
116
+ "> exposure/mention records). See",
117
+ "> [`step-2-skill-inventory-rationalization.md`](../roadmaps/step-2-skill-inventory-rationalization.md)",
118
+ "> Phase 1.",
119
+ "",
120
+ f"**Window:** 30-day rolling \u00b7 **Skills tracked:** {total} \u00b7 "
121
+ f"**Active:** {counts['active']} \u00b7 **Exposed-only:** {counts['exposed-only']} \u00b7 "
122
+ f"**Dead:** {counts['dead']}",
123
+ "",
124
+ "| # | slug | status | exposures_30d | mentions_30d | exposures_total | mentions_total | last_seen |",
125
+ "|---|---|---|---|---|---|---|---|",
126
+ ]
127
+ for i, r in enumerate(rows, 1):
128
+ last = r["last_seen"].date().isoformat() if r["last_seen"] else "\u2014"
129
+ lines.append(
130
+ f"| {i} | `{r['slug']}` | {r['status']} | {r['exposures_30d']} | "
131
+ f"{r['mentions_30d']} | {r['exposures_total']} | {r['mentions_total']} | {last} |"
132
+ )
133
+ lines.append("")
134
+ lines.append("**Read-out:** rows tagged `dead` are first-cut archive candidates; "
135
+ "rows tagged `exposed-only` are first-cut merge / rename candidates "
136
+ "(catalog noise, agent never invokes). Phase 2 confirms with "
137
+ "structural overlap before any deletion.")
138
+ lines.append("")
139
+ return "\n".join(lines)
140
+
141
+
142
+ def main() -> int:
143
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
144
+ ap.add_argument("--in", dest="inp", type=Path, default=IN)
145
+ ap.add_argument("--out", type=Path, default=OUT)
146
+ ap.add_argument("--window", type=int, default=30, help="Rolling window in days")
147
+ ap.add_argument("--quiet", action="store_true")
148
+ args = ap.parse_args()
149
+
150
+ records = load_records(args.inp)
151
+ now = datetime.now(timezone.utc)
152
+ per = aggregate(records, now, args.window)
153
+ known = all_known_slugs(REPO)
154
+ args.out.parent.mkdir(parents=True, exist_ok=True)
155
+ args.out.write_text(render(per, known), encoding="utf-8")
156
+ if not args.quiet:
157
+ print(f"\u2705 Wrote {args.out.relative_to(REPO)} ({len(known | set(per))} skill(s))")
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/smoke/kernel.sh — kernel-tier smoke (step-11 Phase 3 Step 2).
3
+ #
4
+ # Asserts:
5
+ # 1. router.json lists exactly 9 kernel rules.
6
+ # 2. Every kernel rule file exists at .agent-src/rules/<id>.md.
7
+ # 3. 8 of 9 carry at least one Iron-Law fenced block.
8
+ # agent-authority is the dispatch index, exempt from the fence
9
+ # requirement (docs/contracts/smoke-contracts.md § 3.1).
10
+ # 4. Kernel-bucket char budget breaches ≤ EXPECTED_BREACHES.
11
+ #
12
+ # Runtime ceiling: 30 s.
13
+ # Output: table by default, baseline line on stdout last; SMOKE_QUIET=1
14
+ # suppresses the table.
15
+ # Contract: docs/contracts/smoke-contracts.md
16
+
17
+ set -euo pipefail
18
+
19
+ EXPECTED_KERNEL_COUNT=9
20
+ EXPECTED_FENCE_CARRIERS=8
21
+ EXPECTED_BREACHES=2
22
+ EXEMPT_FROM_FENCE="agent-authority"
23
+
24
+ quiet="${SMOKE_QUIET:-0}"
25
+ fail=0
26
+
27
+ log() { [ "$quiet" = "1" ] || printf '%s\n' "$*"; }
28
+
29
+ # 1. kernel ids from router.json
30
+ kernel_ids=$(python3 -c '
31
+ import json
32
+ d = json.load(open("router.json"))
33
+ print("\n".join(d.get("kernel", [])))
34
+ ')
35
+ kernel_count=$(printf '%s\n' "$kernel_ids" | grep -c .)
36
+
37
+ log "## Kernel smoke"
38
+ log ""
39
+ log "| Check | Value |"
40
+ log "|---|---:|"
41
+ log "| router.json kernel count | $kernel_count |"
42
+
43
+ if [ "$kernel_count" -ne "$EXPECTED_KERNEL_COUNT" ]; then
44
+ echo "❌ kernel count: $kernel_count (expected $EXPECTED_KERNEL_COUNT)"
45
+ fail=1
46
+ fi
47
+
48
+ # 2. every kernel rule has a file
49
+ missing=0
50
+ for id in $kernel_ids; do
51
+ if [ ! -f ".agent-src/rules/$id.md" ]; then
52
+ echo "❌ missing rule file: .agent-src/rules/$id.md"
53
+ missing=$((missing + 1))
54
+ fi
55
+ done
56
+ log "| Rule files present | $((kernel_count - missing))/$kernel_count |"
57
+ if [ "$missing" -gt 0 ]; then fail=1; fi
58
+
59
+ # 3. count Iron-Law fences per rule
60
+ fence_carriers=0
61
+ for id in $kernel_ids; do
62
+ if printf ' %s ' "$EXEMPT_FROM_FENCE" | grep -q " $id "; then
63
+ continue
64
+ fi
65
+ if [ -f ".agent-src/rules/$id.md" ]; then
66
+ fences=$(awk 'BEGIN{c=0;open=0} /^```/{ if(open==0){c++;open=1}else{open=0} } END{print c}' ".agent-src/rules/$id.md")
67
+ if [ "$fences" -ge 1 ]; then
68
+ fence_carriers=$((fence_carriers + 1))
69
+ else
70
+ echo "❌ no Iron-Law fence in .agent-src/rules/$id.md"
71
+ fail=1
72
+ fi
73
+ fi
74
+ done
75
+ log "| Iron-Law fence carriers | $fence_carriers/$((kernel_count - 1)) |"
76
+
77
+ if [ "$fence_carriers" -lt "$EXPECTED_FENCE_CARRIERS" ]; then
78
+ echo "❌ fence carriers: $fence_carriers (expected $EXPECTED_FENCE_CARRIERS)"
79
+ fail=1
80
+ fi
81
+
82
+ # 4. kernel char-budget breach count (advisory: locked at current measured)
83
+ breach_count=0
84
+ if python3 scripts/measure_rule_budget.py --kernel-budget-check >/tmp/kernel-budget.$$ 2>&1; then
85
+ breach_count=0
86
+ else
87
+ breach_count=$(grep -c "^ - " /tmp/kernel-budget.$$ || true)
88
+ fi
89
+ rm -f /tmp/kernel-budget.$$
90
+ log "| Kernel-budget breaches | $breach_count (locked ≤ $EXPECTED_BREACHES) |"
91
+
92
+ if [ "$breach_count" -gt "$EXPECTED_BREACHES" ]; then
93
+ echo "❌ kernel budget breaches: $breach_count > $EXPECTED_BREACHES (regression)"
94
+ fail=1
95
+ fi
96
+
97
+ # Baseline line — last line of stdout for CI summary parsing.
98
+ log ""
99
+ echo "BASELINE: $kernel_count kernel rules · $fence_carriers carry Iron-Law fences · 1 dispatch index · $breach_count budget breach(es)"
100
+
101
+ exit $fail