@event4u/agent-config 2.19.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/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/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/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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +52 -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/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/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/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/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/skill_overlap.py +204 -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,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint archive notes under agents/archived-skills/.
|
|
3
|
+
|
|
4
|
+
Enforces the contract from
|
|
5
|
+
.agent-src.uncompressed/templates/skill-archive-note.md:
|
|
6
|
+
|
|
7
|
+
1. Every <slug>.md under agents/archived-skills/ has the six required
|
|
8
|
+
frontmatter fields with valid values.
|
|
9
|
+
2. `reason` is one of {unused, merged, superseded, deprecated}.
|
|
10
|
+
3. When `reason ∈ {merged, superseded}` the `replacement` slug exists
|
|
11
|
+
under .agent-src.uncompressed/skills/.
|
|
12
|
+
4. No archived slug still has a live SKILL.md (no zombies).
|
|
13
|
+
5. No live SKILL.md cites an archived slug as a router target in
|
|
14
|
+
its frontmatter `replaced_by:` field.
|
|
15
|
+
|
|
16
|
+
Hooked into `task ci` via `task lint-archived-skills`. Passes cleanly
|
|
17
|
+
against an empty agents/archived-skills/ (only README.md present).
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 contract holds
|
|
21
|
+
1 one or more violations
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
QUIET = "--quiet" in sys.argv
|
|
30
|
+
|
|
31
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
32
|
+
ARCHIVE_DIR = REPO / "agents" / "archived-skills"
|
|
33
|
+
SKILLS_DIR = REPO / ".agent-src.uncompressed" / "skills"
|
|
34
|
+
|
|
35
|
+
REQUIRED_FIELDS = ("slug", "archived_on", "last_seen_count", "reason", "replacement", "last_known_callers")
|
|
36
|
+
VALID_REASONS = frozenset({"unused", "merged", "superseded", "deprecated"})
|
|
37
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_frontmatter(text: str) -> dict[str, str] | None:
|
|
41
|
+
if not text.startswith("---\n"):
|
|
42
|
+
return None
|
|
43
|
+
end = text.find("\n---\n", 4)
|
|
44
|
+
if end == -1:
|
|
45
|
+
return None
|
|
46
|
+
fields: dict[str, str] = {}
|
|
47
|
+
for line in text[4:end].splitlines():
|
|
48
|
+
if ":" not in line or line.startswith(" ") or line.startswith("-"):
|
|
49
|
+
continue
|
|
50
|
+
k, _, v = line.partition(":")
|
|
51
|
+
fields[k.strip()] = v.strip().strip('"').strip("'")
|
|
52
|
+
return fields
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def archived_slugs() -> list[Path]:
|
|
56
|
+
return sorted(p for p in ARCHIVE_DIR.glob("*.md") if p.name != "README.md")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def live_skill_slugs() -> set[str]:
|
|
60
|
+
return {p.name for p in SKILLS_DIR.iterdir() if p.is_dir() and (p / "SKILL.md").exists()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
if not ARCHIVE_DIR.exists():
|
|
65
|
+
print(f"❌ lint_archived_skills: {ARCHIVE_DIR} missing", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
notes = archived_slugs()
|
|
69
|
+
live = live_skill_slugs()
|
|
70
|
+
errors: list[str] = []
|
|
71
|
+
|
|
72
|
+
archived_keys: set[str] = set()
|
|
73
|
+
for note in notes:
|
|
74
|
+
text = note.read_text(encoding="utf-8")
|
|
75
|
+
fm = parse_frontmatter(text)
|
|
76
|
+
slug_from_name = note.stem
|
|
77
|
+
|
|
78
|
+
if fm is None:
|
|
79
|
+
errors.append(f"{note.name}: missing or malformed frontmatter")
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
missing = [f for f in REQUIRED_FIELDS if f not in fm]
|
|
83
|
+
if missing:
|
|
84
|
+
errors.append(f"{note.name}: missing required fields: {', '.join(missing)}")
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if fm["slug"] != slug_from_name:
|
|
88
|
+
errors.append(f"{note.name}: slug field '{fm['slug']}' != filename stem '{slug_from_name}'")
|
|
89
|
+
|
|
90
|
+
if not DATE_RE.match(fm["archived_on"]):
|
|
91
|
+
errors.append(f"{note.name}: archived_on '{fm['archived_on']}' is not YYYY-MM-DD")
|
|
92
|
+
|
|
93
|
+
if fm["reason"] not in VALID_REASONS:
|
|
94
|
+
errors.append(f"{note.name}: reason '{fm['reason']}' not in {sorted(VALID_REASONS)}")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
int(fm["last_seen_count"])
|
|
98
|
+
except ValueError:
|
|
99
|
+
errors.append(f"{note.name}: last_seen_count '{fm['last_seen_count']}' is not an integer")
|
|
100
|
+
|
|
101
|
+
replacement = fm["replacement"]
|
|
102
|
+
reason = fm["reason"]
|
|
103
|
+
if reason in {"merged", "superseded"}:
|
|
104
|
+
if replacement == "none" or not replacement:
|
|
105
|
+
errors.append(f"{note.name}: reason={reason} requires a replacement slug, got 'none'")
|
|
106
|
+
elif replacement not in live:
|
|
107
|
+
errors.append(f"{note.name}: replacement '{replacement}' not found under {SKILLS_DIR}")
|
|
108
|
+
elif reason in {"unused", "deprecated"}:
|
|
109
|
+
if replacement not in {"none", ""}:
|
|
110
|
+
if replacement not in live:
|
|
111
|
+
errors.append(f"{note.name}: replacement '{replacement}' not found under {SKILLS_DIR}")
|
|
112
|
+
|
|
113
|
+
if fm["slug"] in live:
|
|
114
|
+
errors.append(f"{note.name}: slug '{fm['slug']}' still has a live SKILL.md (zombie)")
|
|
115
|
+
|
|
116
|
+
archived_keys.add(fm["slug"])
|
|
117
|
+
|
|
118
|
+
# Cross-check: live skills must not list an archived slug as replaced_by.
|
|
119
|
+
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
120
|
+
skill_md = skill_dir / "SKILL.md"
|
|
121
|
+
if not skill_md.exists():
|
|
122
|
+
continue
|
|
123
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
124
|
+
fm = parse_frontmatter(text)
|
|
125
|
+
if fm is None:
|
|
126
|
+
continue
|
|
127
|
+
rb = fm.get("replaced_by", "").strip()
|
|
128
|
+
if rb and rb in archived_keys:
|
|
129
|
+
errors.append(f"{skill_dir.name}/SKILL.md: replaced_by '{rb}' points at an archived slug")
|
|
130
|
+
|
|
131
|
+
if errors:
|
|
132
|
+
print(f"❌ lint_archived_skills: {len(errors)} violation(s) across {len(notes)} note(s)", file=sys.stderr)
|
|
133
|
+
for e in errors:
|
|
134
|
+
print(f" {e}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
if not QUIET:
|
|
138
|
+
print(f"✅ lint_archived_skills: {len(notes)} archive note(s), contract holds")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint benchmark corpora under tests/eval/corpus-*.yaml.
|
|
3
|
+
|
|
4
|
+
Enforces the contract from docs/contracts/benchmark-corpus-spec.md:
|
|
5
|
+
- Required top-level keys (version, corpus_id, prompts) present.
|
|
6
|
+
- version == 1.
|
|
7
|
+
- selection_accuracy_target in [0.0, 1.0].
|
|
8
|
+
- Per-prompt schema (id format, category enum, language enum,
|
|
9
|
+
expected_skills non-empty + referencing real skills, destructive
|
|
10
|
+
prompts carry expected_carve_outs, prompt text non-empty).
|
|
11
|
+
- No duplicate ids within a corpus.
|
|
12
|
+
|
|
13
|
+
Hooked into `task ci` via `task lint-bench`. Step-4 Phase 1 Step 3.
|
|
14
|
+
|
|
15
|
+
Exit codes:
|
|
16
|
+
0 contract holds across every corpus
|
|
17
|
+
1 one or more violations
|
|
18
|
+
2 invocation error (missing PyYAML, no corpora found)
|
|
19
|
+
|
|
20
|
+
Flags:
|
|
21
|
+
--quiet suppress per-file OK lines
|
|
22
|
+
--require-full also enforce 25-prompt composition (10/8/5/2)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import yaml
|
|
32
|
+
except ImportError:
|
|
33
|
+
sys.stderr.write("error: PyYAML required (pip install pyyaml)\n")
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
|
|
36
|
+
QUIET = "--quiet" in sys.argv
|
|
37
|
+
REQUIRE_FULL = "--require-full" in sys.argv
|
|
38
|
+
|
|
39
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
40
|
+
CORPUS_DIR = REPO / "tests" / "eval"
|
|
41
|
+
SKILLS_DIR = REPO / ".agent-src.uncompressed" / "skills"
|
|
42
|
+
|
|
43
|
+
VALID_CATEGORIES = frozenset({"canonical", "ambiguous", "destructive", "long-context"})
|
|
44
|
+
# Non-dev corpus (pre-spec) uses legacy categories — accept them so the
|
|
45
|
+
# new linter does not break that file. Migration is a follow-up.
|
|
46
|
+
LEGACY_CATEGORIES = frozenset({"content", "consulting", "finance", "ops", "safety"})
|
|
47
|
+
VALID_LANGUAGES = frozenset({"en", "de"})
|
|
48
|
+
VALID_VERSIONS = frozenset({1})
|
|
49
|
+
ID_RE = re.compile(r"^[a-z][a-z0-9-]*-\d{2}$")
|
|
50
|
+
FULL_COUNTS = {"canonical": 10, "ambiguous": 8, "destructive": 5, "long-context": 2}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def live_skills() -> set[str]:
|
|
54
|
+
return {p.name for p in SKILLS_DIR.iterdir() if p.is_dir() and (p / "SKILL.md").exists()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lint_corpus(path: Path, skills: set[str]) -> list[str]:
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
try:
|
|
60
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
61
|
+
except yaml.YAMLError as exc:
|
|
62
|
+
return [f"{path.name}: yaml_parse_error: {exc}"]
|
|
63
|
+
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
return [f"{path.name}: missing_top_level: corpus must be a mapping"]
|
|
66
|
+
|
|
67
|
+
for key in ("version", "corpus_id", "prompts"):
|
|
68
|
+
if key not in data:
|
|
69
|
+
errors.append(f"{path.name}: missing_top_level: {key}")
|
|
70
|
+
|
|
71
|
+
if data.get("version") not in VALID_VERSIONS:
|
|
72
|
+
errors.append(f"{path.name}: unsupported_version: {data.get('version')!r}")
|
|
73
|
+
|
|
74
|
+
target = data.get("selection_accuracy_target")
|
|
75
|
+
if target is not None and not (isinstance(target, (int, float)) and 0.0 <= target <= 1.0):
|
|
76
|
+
errors.append(f"{path.name}: target_out_of_range: {target!r}")
|
|
77
|
+
|
|
78
|
+
prompts = data.get("prompts") or []
|
|
79
|
+
if not isinstance(prompts, list):
|
|
80
|
+
return errors + [f"{path.name}: missing_top_level: prompts must be a list"]
|
|
81
|
+
|
|
82
|
+
seen_ids: set[str] = set()
|
|
83
|
+
bucket_counts: dict[str, int] = {}
|
|
84
|
+
is_legacy = data.get("corpus_id") == "non-dev"
|
|
85
|
+
for idx, p in enumerate(prompts):
|
|
86
|
+
loc = f"{path.name}:#{idx}"
|
|
87
|
+
if not isinstance(p, dict):
|
|
88
|
+
errors.append(f"{loc}: bad_prompt_shape")
|
|
89
|
+
continue
|
|
90
|
+
pid = p.get("id")
|
|
91
|
+
if not isinstance(pid, str) or not ID_RE.match(pid):
|
|
92
|
+
errors.append(f"{loc}: bad_id_format: {pid!r}")
|
|
93
|
+
elif pid in seen_ids:
|
|
94
|
+
errors.append(f"{loc}: duplicate_id: {pid}")
|
|
95
|
+
else:
|
|
96
|
+
seen_ids.add(pid)
|
|
97
|
+
|
|
98
|
+
cat = p.get("category")
|
|
99
|
+
if cat not in VALID_CATEGORIES and not (is_legacy and cat in LEGACY_CATEGORIES):
|
|
100
|
+
errors.append(f"{loc}: bad_category: {cat!r}")
|
|
101
|
+
bucket_counts[cat] = bucket_counts.get(cat, 0) + 1
|
|
102
|
+
|
|
103
|
+
lang = p.get("language", "en")
|
|
104
|
+
if lang not in VALID_LANGUAGES:
|
|
105
|
+
errors.append(f"{loc}: bad_language: {lang!r}")
|
|
106
|
+
|
|
107
|
+
prompt_text = p.get("prompt", "")
|
|
108
|
+
if not isinstance(prompt_text, str) or not prompt_text.strip():
|
|
109
|
+
errors.append(f"{loc}: empty_prompt")
|
|
110
|
+
|
|
111
|
+
expected = p.get("expected_skills") or []
|
|
112
|
+
if not isinstance(expected, list) or not expected:
|
|
113
|
+
errors.append(f"{loc}: empty_expected")
|
|
114
|
+
else:
|
|
115
|
+
for slug in expected:
|
|
116
|
+
if slug not in skills:
|
|
117
|
+
errors.append(f"{loc}: unknown_skill: {slug}")
|
|
118
|
+
|
|
119
|
+
if cat == "destructive":
|
|
120
|
+
carve = p.get("expected_carve_outs") or []
|
|
121
|
+
if not isinstance(carve, list) or not carve:
|
|
122
|
+
errors.append(f"{loc}: missing_carve_out")
|
|
123
|
+
|
|
124
|
+
if REQUIRE_FULL and not is_legacy:
|
|
125
|
+
for bucket, want in FULL_COUNTS.items():
|
|
126
|
+
have = bucket_counts.get(bucket, 0)
|
|
127
|
+
if have != want:
|
|
128
|
+
errors.append(f"{path.name}: composition_drift: {bucket} have={have} want={want}")
|
|
129
|
+
|
|
130
|
+
return errors
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> int:
|
|
134
|
+
if not CORPUS_DIR.is_dir():
|
|
135
|
+
sys.stderr.write(f"error: corpus dir missing: {CORPUS_DIR}\n")
|
|
136
|
+
return 2
|
|
137
|
+
corpora = sorted(CORPUS_DIR.glob("corpus-*.yaml"))
|
|
138
|
+
if not corpora:
|
|
139
|
+
sys.stderr.write("error: no corpora found\n")
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
skills = live_skills()
|
|
143
|
+
all_errors: list[str] = []
|
|
144
|
+
for path in corpora:
|
|
145
|
+
errs = lint_corpus(path, skills)
|
|
146
|
+
if errs:
|
|
147
|
+
all_errors.extend(errs)
|
|
148
|
+
elif not QUIET:
|
|
149
|
+
print(f"✅ {path.name}: contract OK")
|
|
150
|
+
|
|
151
|
+
if all_errors:
|
|
152
|
+
for err in all_errors:
|
|
153
|
+
print(f"❌ {err}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
if not QUIET:
|
|
156
|
+
print(f"✅ lint-bench: {len(corpora)} corpora clean")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Namespace linter. Enforces `<stem>-<intent>` kebab-case + reserved
|
|
3
|
+
names list across skills / rules / commands / personas.
|
|
4
|
+
|
|
5
|
+
Contract: docs/contracts/namespace.md.
|
|
6
|
+
Wired into: `task lint-skills` (taskfiles/ci-fast.yml).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse, re, sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
SRC = ROOT / ".agent-src.uncompressed"
|
|
14
|
+
|
|
15
|
+
# Source-of-truth regex; mirrored in docs/contracts/namespace.md § 1.
|
|
16
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
|
|
17
|
+
|
|
18
|
+
MIN_LEN = 2
|
|
19
|
+
MIN_LEN_SKILL = 3
|
|
20
|
+
MAX_LEN = 64
|
|
21
|
+
|
|
22
|
+
RESERVED = {"pattern", "claude-memories", "default", "index", "router"}
|
|
23
|
+
|
|
24
|
+
# Filenames that are documentation, not artefacts.
|
|
25
|
+
NON_ARTEFACTS = {"README.md", "INDEX.md"}
|
|
26
|
+
|
|
27
|
+
# (kind, root, glob, depth, sub_verb) — depth tells us how to extract
|
|
28
|
+
# the name. depth=0 → file stem; depth=1 → first directory under root.
|
|
29
|
+
# sub_verb=True → the path is a `<group>/<verb>.md` form; reserved-name
|
|
30
|
+
# check is skipped because the verb is namespaced under the group.
|
|
31
|
+
TARGETS = [
|
|
32
|
+
("skill", SRC / "skills", "*/SKILL.md", 1, False),
|
|
33
|
+
("rule", SRC / "rules", "*.md", 0, False),
|
|
34
|
+
("command", SRC / "commands", "*.md", 0, False),
|
|
35
|
+
("command", SRC / "commands", "*/*.md", 0, True),
|
|
36
|
+
("persona", SRC / "personas", "*.md", 0, False),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _name_for(path: Path, root: Path, depth: int) -> str:
|
|
41
|
+
if depth == 0:
|
|
42
|
+
return path.stem
|
|
43
|
+
rel = path.relative_to(root)
|
|
44
|
+
return rel.parts[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _shape_errors(name: str, *, sub_verb: bool = False,
|
|
48
|
+
kind: str = "command") -> list[str]:
|
|
49
|
+
errs = []
|
|
50
|
+
floor = MIN_LEN_SKILL if kind == "skill" else MIN_LEN
|
|
51
|
+
if not (floor <= len(name) <= MAX_LEN):
|
|
52
|
+
errs.append(f"length — {len(name)} chars (must be {floor}–{MAX_LEN})")
|
|
53
|
+
if not NAME_RE.match(name):
|
|
54
|
+
errs.append("regex — must match ^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
|
|
55
|
+
if name in RESERVED and not sub_verb:
|
|
56
|
+
errs.append(f"reserved — '{name}' in reserved-names list")
|
|
57
|
+
return errs
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _skill_name_field(path: Path) -> str | None:
|
|
61
|
+
"""Read `name:` from skill frontmatter. None on missing / unparseable."""
|
|
62
|
+
try:
|
|
63
|
+
text = path.read_text(encoding="utf-8")
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
if not text.startswith("---"):
|
|
67
|
+
return None
|
|
68
|
+
end = text.find("\n---", 3)
|
|
69
|
+
if end < 0:
|
|
70
|
+
return None
|
|
71
|
+
fm = text[3:end]
|
|
72
|
+
for line in fm.splitlines():
|
|
73
|
+
m = re.match(r"^name:\s*['\"]?([^'\"]+)['\"]?\s*$", line.strip())
|
|
74
|
+
if m:
|
|
75
|
+
return m.group(1).strip()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scan() -> tuple[int, int]:
|
|
80
|
+
issues = 0
|
|
81
|
+
checked = 0
|
|
82
|
+
seen: set[tuple[str, str]] = set()
|
|
83
|
+
for kind, root, glob, depth, sub_verb in TARGETS:
|
|
84
|
+
if not root.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
for path in sorted(root.glob(glob)):
|
|
87
|
+
if path.name in NON_ARTEFACTS:
|
|
88
|
+
continue
|
|
89
|
+
name = _name_for(path, root, depth)
|
|
90
|
+
key = (kind, str(path.relative_to(root)))
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
checked += 1
|
|
95
|
+
errs = _shape_errors(name, sub_verb=sub_verb, kind=kind)
|
|
96
|
+
if kind == "skill":
|
|
97
|
+
fm_name = _skill_name_field(path)
|
|
98
|
+
if fm_name and fm_name != name:
|
|
99
|
+
errs.append(f"skill — frontmatter name='{fm_name}' != dir '{name}'")
|
|
100
|
+
for e in errs:
|
|
101
|
+
rel = path.relative_to(ROOT)
|
|
102
|
+
print(f"❌ {rel}: {e}", file=sys.stderr)
|
|
103
|
+
issues += 1
|
|
104
|
+
return checked, issues
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_single(name: str) -> int:
|
|
108
|
+
errs = _shape_errors(name)
|
|
109
|
+
if not errs:
|
|
110
|
+
print(f"✅ '{name}' is a valid artefact name")
|
|
111
|
+
return 0
|
|
112
|
+
for e in errs:
|
|
113
|
+
print(f"❌ '{name}': {e}", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> int:
|
|
118
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
119
|
+
ap.add_argument("--name", help="Check a single candidate name and exit.")
|
|
120
|
+
ap.add_argument("--quiet", action="store_true",
|
|
121
|
+
help="Suppress the summary line on success.")
|
|
122
|
+
args = ap.parse_args()
|
|
123
|
+
if args.name:
|
|
124
|
+
return check_single(args.name)
|
|
125
|
+
checked, issues = scan()
|
|
126
|
+
if issues:
|
|
127
|
+
print(f"BASELINE: {issues} issue(s) across {checked} name(s)", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
if not args.quiet:
|
|
130
|
+
print(f"BASELINE: 0 issues · {checked} name(s) checked")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural overlap detection across skills (description + triggers).
|
|
3
|
+
|
|
4
|
+
Implements step-2-skill-inventory-rationalization.md Phase 2 Step 2.
|
|
5
|
+
Mirrors `scripts/audit_overlap.py` (the rule-side analog) but reads
|
|
6
|
+
`.agent-src.uncompressed/skills/<slug>/SKILL.md` frontmatter directly
|
|
7
|
+
and emits `agents/metrics/skill-overlap.md` listing pairs scoring
|
|
8
|
+
≥ 0.6 on either:
|
|
9
|
+
|
|
10
|
+
- description-trigger Jaccard (tokenized union of `description` +
|
|
11
|
+
any `triggers:` / `keywords:` / `intents:` frontmatter values);
|
|
12
|
+
- symbol-set overlap (paths cited inside the SKILL.md body —
|
|
13
|
+
`.agent-src.uncompressed/...`, `agents/...`, `scripts/...`).
|
|
14
|
+
|
|
15
|
+
The 0.6 threshold matches the roadmap; the rule-side script uses
|
|
16
|
+
lower thresholds because rules have richer trigger metadata. Skills
|
|
17
|
+
encode most signal in prose, so we raise the bar.
|
|
18
|
+
|
|
19
|
+
Output is **a baseline, not a verdict**. Phase 2 Step 3 combines this
|
|
20
|
+
report with the 30-day activation counts before any action.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
from itertools import combinations
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
31
|
+
SKILLS = REPO / ".agent-src.uncompressed" / "skills"
|
|
32
|
+
OUT = REPO / "agents" / "metrics" / "skill-overlap.md"
|
|
33
|
+
|
|
34
|
+
# Roadmap target. Empirical calibration (210 skills, 2026-05-16) shows
|
|
35
|
+
# this threshold catches structural carbon-copies only — known-similar
|
|
36
|
+
# pairs like blade-ui / flux land around 0.35 token-jaccard because
|
|
37
|
+
# skill descriptions encode distinct trigger language by design.
|
|
38
|
+
STRONG_TOKEN = 0.6
|
|
39
|
+
STRONG_SYMBOL = 0.6
|
|
40
|
+
# Calibrated review threshold — flags pairs worth a Phase 2 Step 3
|
|
41
|
+
# review without exceeding signal-to-noise. Below this, descriptions
|
|
42
|
+
# diverge enough that overlap is coincidental.
|
|
43
|
+
CANDIDATE_TOKEN = 0.30
|
|
44
|
+
CANDIDATE_SYMBOL = 0.50
|
|
45
|
+
# Symbol-jaccard is noisy below this floor — two skills sharing a single
|
|
46
|
+
# context-spine reference produce 1.0 with no signal. Require a non-trivial
|
|
47
|
+
# symbol set on both sides before the symbol axis counts as evidence.
|
|
48
|
+
SYMBOL_MIN_SET = 4
|
|
49
|
+
|
|
50
|
+
STOPWORDS = {
|
|
51
|
+
"the", "and", "for", "with", "when", "use", "or", "of", "to", "a", "an",
|
|
52
|
+
"is", "in", "on", "by", "be", "at", "as", "it", "if", "are", "this",
|
|
53
|
+
"that", "from", "but", "not", "can", "any", "all", "no", "after",
|
|
54
|
+
"before", "during", "user", "agent", "code", "project", "via", "into",
|
|
55
|
+
"onto", "even", "without", "naming", "skill", "skills", "rule", "rules",
|
|
56
|
+
"command", "commands", "guideline", "guidelines",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
PATH_RE = re.compile(r"`?(?:\.agent-src(?:\.uncompressed)?|agents|scripts|docs|tests|\.augment|\.claude)/[A-Za-z0-9_./-]+`?")
|
|
60
|
+
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{2,}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
64
|
+
if not text.startswith("---"):
|
|
65
|
+
return {}, text
|
|
66
|
+
parts = text.split("---", 2)
|
|
67
|
+
if len(parts) < 3:
|
|
68
|
+
return {}, text
|
|
69
|
+
fm_raw, body = parts[1], parts[2]
|
|
70
|
+
fm: dict[str, str] = {}
|
|
71
|
+
current_key: str | None = None
|
|
72
|
+
buf: list[str] = []
|
|
73
|
+
for line in fm_raw.splitlines():
|
|
74
|
+
if not line.strip():
|
|
75
|
+
continue
|
|
76
|
+
if line.startswith(" ") and current_key is not None:
|
|
77
|
+
buf.append(line.strip())
|
|
78
|
+
continue
|
|
79
|
+
if current_key is not None:
|
|
80
|
+
fm[current_key] = " ".join(buf) if buf else fm.get(current_key, "")
|
|
81
|
+
if ":" in line:
|
|
82
|
+
k, v = line.split(":", 1)
|
|
83
|
+
current_key, buf = k.strip(), []
|
|
84
|
+
v = v.strip()
|
|
85
|
+
if v:
|
|
86
|
+
fm[current_key] = v.strip().strip('"').strip("'")
|
|
87
|
+
current_key = None
|
|
88
|
+
if current_key is not None and buf:
|
|
89
|
+
fm[current_key] = " ".join(buf)
|
|
90
|
+
return fm, body
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def tokenize(text: str) -> set[str]:
|
|
94
|
+
return {t.lower() for t in TOKEN_RE.findall(text or "")
|
|
95
|
+
if t.lower() not in STOPWORDS and not t.isdigit() and len(t) > 2}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def symbol_set(body: str) -> set[str]:
|
|
99
|
+
return {m.strip("`") for m in PATH_RE.findall(body or "")}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def jaccard(a: set, b: set) -> float:
|
|
103
|
+
if not a and not b:
|
|
104
|
+
return 0.0
|
|
105
|
+
return len(a & b) / len(a | b)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_skills(root: Path) -> list[dict]:
|
|
109
|
+
skills: list[dict] = []
|
|
110
|
+
if not root.is_dir():
|
|
111
|
+
return skills
|
|
112
|
+
for skill_md in sorted(root.glob("*/SKILL.md")):
|
|
113
|
+
slug = skill_md.parent.name
|
|
114
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
fm, body = parse_frontmatter(text)
|
|
116
|
+
desc = fm.get("description", "")
|
|
117
|
+
trig = " ".join(fm.get(k, "") for k in ("triggers", "keywords", "intents", "domain"))
|
|
118
|
+
skills.append({
|
|
119
|
+
"slug": slug,
|
|
120
|
+
"tokens": tokenize(desc + " " + trig),
|
|
121
|
+
"symbols": symbol_set(body),
|
|
122
|
+
})
|
|
123
|
+
return skills
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def analyse(skills: list[dict]) -> list[dict]:
|
|
127
|
+
pairs: list[dict] = []
|
|
128
|
+
for a, b in combinations(skills, 2):
|
|
129
|
+
j = jaccard(a["tokens"], b["tokens"])
|
|
130
|
+
if min(len(a["symbols"]), len(b["symbols"])) >= SYMBOL_MIN_SET:
|
|
131
|
+
s = jaccard(a["symbols"], b["symbols"])
|
|
132
|
+
else:
|
|
133
|
+
s = 0.0
|
|
134
|
+
if j >= STRONG_TOKEN or s >= STRONG_SYMBOL:
|
|
135
|
+
tier = "strong"
|
|
136
|
+
elif j >= CANDIDATE_TOKEN or s >= CANDIDATE_SYMBOL:
|
|
137
|
+
tier = "candidate"
|
|
138
|
+
else:
|
|
139
|
+
continue
|
|
140
|
+
pairs.append({
|
|
141
|
+
"skill_a": a["slug"], "skill_b": b["slug"],
|
|
142
|
+
"tier": tier,
|
|
143
|
+
"description_jaccard": round(j, 3),
|
|
144
|
+
"symbol_jaccard": round(s, 3),
|
|
145
|
+
})
|
|
146
|
+
pairs.sort(key=lambda p: (p["tier"] != "strong",
|
|
147
|
+
-max(p["description_jaccard"], p["symbol_jaccard"])))
|
|
148
|
+
return pairs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render(pairs: list[dict], total: int) -> str:
|
|
152
|
+
strong = [p for p in pairs if p["tier"] == "strong"]
|
|
153
|
+
candidate = [p for p in pairs if p["tier"] == "candidate"]
|
|
154
|
+
lines = [
|
|
155
|
+
"# Skill Structural Overlap (baseline)",
|
|
156
|
+
"",
|
|
157
|
+
"> Generated by `scripts/skill_overlap.py`. Scans",
|
|
158
|
+
"> `.agent-src.uncompressed/skills/*/SKILL.md` frontmatter (description +",
|
|
159
|
+
"> trigger metadata) and body symbol references. Reports pairs in two",
|
|
160
|
+
f"> tiers: **strong** ≥ {STRONG_TOKEN} description-token Jaccard or ≥ {STRONG_SYMBOL}",
|
|
161
|
+
f"> symbol-set Jaccard (roadmap floor); **candidate** ≥ {CANDIDATE_TOKEN} / ≥ {CANDIDATE_SYMBOL}",
|
|
162
|
+
"> (empirical calibration — skill descriptions encode distinct trigger",
|
|
163
|
+
"> language by design, so the roadmap floor catches structural carbon-",
|
|
164
|
+
"> copies only). See [`step-2-skill-inventory-rationalization.md`](../roadmaps/step-2-skill-inventory-rationalization.md)",
|
|
165
|
+
"> Phase 2 Step 2.",
|
|
166
|
+
"",
|
|
167
|
+
f"**Skills scanned:** {total} · **Strong pairs:** {len(strong)} · "
|
|
168
|
+
f"**Candidate pairs:** {len(candidate)}",
|
|
169
|
+
"",
|
|
170
|
+
"| # | skill_a | skill_b | tier | desc_jaccard | symbol_jaccard |",
|
|
171
|
+
"|---|---|---|---|---|---|",
|
|
172
|
+
]
|
|
173
|
+
for i, p in enumerate(pairs, 1):
|
|
174
|
+
lines.append(f"| {i} | `{p['skill_a']}` | `{p['skill_b']}` | {p['tier']} | "
|
|
175
|
+
f"{p['description_jaccard']} | {p['symbol_jaccard']} |")
|
|
176
|
+
lines.append("")
|
|
177
|
+
lines.append("**Read-out:** `strong` pairs are first-cut merge / supersede candidates. "
|
|
178
|
+
"`candidate` pairs are worth a Phase 2 Step 3 review but the description "
|
|
179
|
+
"signal is faint — usage data (30-day activation report) is the deciding "
|
|
180
|
+
"input, not this report. Structural overlap alone is evidence, not a verdict.")
|
|
181
|
+
lines.append("")
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main() -> int:
|
|
186
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
187
|
+
ap.add_argument("--out", type=Path, default=OUT)
|
|
188
|
+
ap.add_argument("--quiet", action="store_true")
|
|
189
|
+
args = ap.parse_args()
|
|
190
|
+
skills = load_skills(SKILLS)
|
|
191
|
+
if not skills:
|
|
192
|
+
print(f"no skills under {SKILLS}", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
pairs = analyse(skills)
|
|
195
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
args.out.write_text(render(pairs, len(skills)), encoding="utf-8")
|
|
197
|
+
if not args.quiet:
|
|
198
|
+
print(f"✅ Wrote {args.out.relative_to(REPO)} "
|
|
199
|
+
f"({len(skills)} skills, {len(pairs)} pair(s) flagged)")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main())
|