@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.
- package/.agent-src/commands/agent-status.md +29 -0
- package/.agent-src/commands/onboard.md +221 -81
- package/.agent-src/commands/refine-ticket.md +3 -0
- package/.agent-src/packs/README.md +49 -0
- package/.agent-src/packs/agency-delivery.yml +63 -0
- package/.agent-src/packs/content-engine.yml +53 -0
- package/.agent-src/packs/founder-mvp.yml +51 -0
- package/.agent-src/personas/README.md +8 -0
- package/.agent-src/presets/README.md +26 -0
- package/.agent-src/presets/balanced.yml +34 -0
- package/.agent-src/presets/fast.yml +31 -0
- package/.agent-src/presets/strict.yml +38 -0
- package/.agent-src/profiles/README.md +29 -0
- package/.agent-src/profiles/agency.yml +27 -0
- package/.agent-src/profiles/content_creator.yml +25 -0
- package/.agent-src/profiles/developer.yml +26 -0
- package/.agent-src/profiles/finance.yml +24 -0
- package/.agent-src/profiles/founder.yml +25 -0
- package/.agent-src/profiles/ops.yml +25 -0
- package/.agent-src/rules/no-cheap-questions.md +25 -17
- package/.agent-src/skills/adr-create/SKILL.md +78 -68
- package/.agent-src/skills/refine-ticket/SKILL.md +3 -0
- package/.agent-src/skills/subagent-orchestration/SKILL.md +33 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/skill-archive-note.md +101 -0
- package/.agent-src/user-types/README.md +124 -0
- package/.agent-src/user-types/_template/user-type.md +95 -0
- package/.agent-src/user-types/galabau-field-crew.md +100 -0
- package/.agent-src/user-types/metalworking-shop.md +105 -0
- package/.agent-src/user-types/truck-driver.md +113 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +91 -30
- package/README.md +68 -72
- package/config/agent-settings.template.yml +22 -0
- package/docs/adrs/caveman/0001-default-off-until-bench.md +93 -0
- package/docs/adrs/caveman/README.md +9 -0
- package/docs/adrs/cost/0001-hard-stop-hook.md +114 -0
- package/docs/adrs/cost/README.md +9 -0
- package/docs/adrs/memory/0001-consumer-side-snapshot.md +111 -0
- package/docs/adrs/memory/README.md +9 -0
- package/docs/adrs/router/0001-three-tier-routing.md +119 -0
- package/docs/adrs/router/README.md +9 -0
- package/docs/adrs/schema/0001-json-schema-frontmatter.md +102 -0
- package/docs/adrs/schema/README.md +9 -0
- package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +99 -0
- package/docs/adrs/smoke/README.md +9 -0
- package/docs/architecture/current-onboard-baseline.md +126 -0
- package/docs/architecture/current-safety-behavior.md +137 -0
- package/docs/archive/CHANGELOG-pre-2.16.0.md +48 -0
- package/docs/contracts/adr-layout.md +108 -0
- package/docs/contracts/adr-mcp-runtime.md +128 -0
- package/docs/contracts/adr-user-types-axis.md +127 -0
- package/docs/contracts/benchmark-corpus-spec.md +97 -0
- package/docs/contracts/benchmark-report-schema.md +111 -0
- package/docs/contracts/command-clusters.md +1 -0
- package/docs/contracts/command-taxonomy.md +137 -0
- package/docs/contracts/compression-default-kill-criterion.md +69 -0
- package/docs/contracts/config-presets.md +144 -0
- package/docs/contracts/cost-dashboard.md +143 -0
- package/docs/contracts/cost-enforcement.md +134 -0
- package/docs/contracts/file-ownership-matrix.json +0 -7
- package/docs/contracts/mcp-tool-inventory.md +53 -0
- package/docs/contracts/measurement-baseline.md +102 -0
- package/docs/contracts/namespace.md +125 -0
- package/docs/contracts/profile-system.md +142 -0
- package/docs/contracts/safety-model.md +129 -0
- package/docs/contracts/smoke-contracts.md +144 -0
- package/docs/contracts/user-type-schema.md +146 -0
- package/docs/contracts/workflow-packs.md +121 -0
- package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +132 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/featured-commands.md +27 -0
- package/docs/parity/bench-ruflo.json +58 -0
- package/docs/parity/bench.json +41 -0
- package/docs/parity/ruflo.md +46 -0
- package/docs/profiles.md +91 -0
- package/docs/recruits/_template.md +81 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_explain.py +250 -0
- package/scripts/_lib/bench_cost.py +138 -0
- package/scripts/_lib/bench_quality.py +118 -0
- package/scripts/_lib/bench_report.py +150 -0
- package/scripts/agent-config +13 -0
- package/scripts/audit_adr_coverage.py +175 -0
- package/scripts/audit_mcp_tools.py +146 -0
- package/scripts/bench_baseline_ready.py +108 -0
- package/scripts/bench_drift_check.py +151 -0
- package/scripts/bench_per_tool.py +216 -0
- package/scripts/bench_run.py +155 -0
- package/scripts/compress.py +48 -2
- package/scripts/config/__init__.py +9 -0
- package/scripts/config/presets.py +206 -0
- package/scripts/config/profiles.py +173 -0
- package/scripts/cost/budget.mjs +73 -12
- package/scripts/cost/preflight.mjs +89 -0
- package/scripts/lint_archived_skills.py +143 -0
- package/scripts/lint_bench_corpus.py +161 -0
- package/scripts/lint_namespace.py +135 -0
- package/scripts/schemas/user-type.schema.json +35 -0
- package/scripts/skill_linter.py +139 -4
- package/scripts/skill_overlap.py +204 -0
- package/scripts/skill_tools/audit_user_type_coverage.py +148 -0
- package/scripts/skill_usage_collect.py +191 -0
- package/scripts/skill_usage_report.py +162 -0
- package/scripts/smoke/kernel.sh +101 -0
- package/scripts/smoke/router.sh +129 -0
- package/scripts/smoke/schema.sh +71 -0
- 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
|