@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,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,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/event4u-app/agent-config/scripts/schemas/user-type.schema.json",
|
|
4
|
+
"title": "User-type frontmatter",
|
|
5
|
+
"$comment": "Source: docs/contracts/user-type-schema.md. The runtime review-lens axis seeded under .agent-src.uncompressed/user-types/. Distinct from the install-time user-type-axis YAMLs in user-types/ (root) which carry their own user-type-axis.schema.json.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["id", "kind", "description", "version", "source"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"id": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
13
|
+
"description": "Must match the user-type filename stem."
|
|
14
|
+
},
|
|
15
|
+
"kind": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"const": "user-type",
|
|
18
|
+
"description": "Discriminator — locks this file as a review-lens user-type, distinct from the install-time user-type-axis YAMLs."
|
|
19
|
+
},
|
|
20
|
+
"description": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"maxLength": 240
|
|
24
|
+
},
|
|
25
|
+
"version": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"pattern": "^[0-9]+(\\.[0-9]+){0,2}$"
|
|
28
|
+
},
|
|
29
|
+
"source": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": ["package", "project"],
|
|
32
|
+
"description": "Project-specific the typical case — most end-user simulations are consumer-domain, not package-defaults."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/scripts/skill_linter.py
CHANGED
|
@@ -39,7 +39,7 @@ from validate_frontmatter import ( # noqa: E402
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
Severity = Literal["error", "warning", "info"]
|
|
42
|
-
ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "unknown"]
|
|
42
|
+
ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "user-type", "unknown"]
|
|
43
43
|
|
|
44
44
|
REQUIRED_PERSONA_SECTIONS_CORE = [
|
|
45
45
|
"Focus",
|
|
@@ -57,6 +57,20 @@ REQUIRED_PERSONA_SECTIONS = REQUIRED_PERSONA_SECTIONS_CORE
|
|
|
57
57
|
VALID_PERSONA_TIERS = {"core", "specialist"}
|
|
58
58
|
# Locked in docs/contracts/persona-schema.md § 4: core ≤ 120, specialist ≤ 100.
|
|
59
59
|
PERSONA_LINE_BUDGETS = {"core": 120, "specialist": 100}
|
|
60
|
+
|
|
61
|
+
# User-type spine — locked in docs/contracts/user-type-schema.md § 3.
|
|
62
|
+
# Runtime end-user simulation lens (sister axis to personas — methodology vs
|
|
63
|
+
# end-user). Single tier in v1 (no core/specialist split).
|
|
64
|
+
REQUIRED_USERTYPE_SECTIONS = [
|
|
65
|
+
"Focus",
|
|
66
|
+
"Daily Workflow",
|
|
67
|
+
"Vocabulary",
|
|
68
|
+
"Operational Constraints",
|
|
69
|
+
"Unique Questions",
|
|
70
|
+
"Ticket Red Flags",
|
|
71
|
+
"Anti-Patterns",
|
|
72
|
+
]
|
|
73
|
+
USERTYPE_LINE_BUDGET = 120
|
|
60
74
|
# Wing-scoped overrides — Wing-3 (GTM) and Wing-4 (Money/Strategy/Ops) carry
|
|
61
75
|
# denser cognition (funnel × channel × lifecycle, or finance × org × strategy)
|
|
62
76
|
# than Wing-1/2 specialists, so the line cap rises to keep the seven-section
|
|
@@ -514,6 +528,8 @@ def detect_artifact_type(path: Path, text: str) -> ArtifactType:
|
|
|
514
528
|
return "guideline"
|
|
515
529
|
if "/personas/" in path_str:
|
|
516
530
|
return "persona"
|
|
531
|
+
if "/user-types/" in path_str:
|
|
532
|
+
return "user-type"
|
|
517
533
|
if has_skill_heading:
|
|
518
534
|
return "skill"
|
|
519
535
|
return "unknown"
|
|
@@ -1849,6 +1865,106 @@ def lint_persona(path: Path, text: str) -> LintResult:
|
|
|
1849
1865
|
)
|
|
1850
1866
|
|
|
1851
1867
|
|
|
1868
|
+
def lint_usertype(path: Path, text: str) -> LintResult:
|
|
1869
|
+
"""Lint a user-type .md file (frontmatter schema + required sections + size).
|
|
1870
|
+
|
|
1871
|
+
User-types are the runtime end-user simulation lens (sister axis to
|
|
1872
|
+
personas — methodology vs end-user). Contract:
|
|
1873
|
+
docs/contracts/user-type-schema.md.
|
|
1874
|
+
"""
|
|
1875
|
+
issues: List[Issue] = []
|
|
1876
|
+
|
|
1877
|
+
frontmatter = extract_frontmatter(text)
|
|
1878
|
+
if not frontmatter:
|
|
1879
|
+
issues.append(Issue("error", "missing_frontmatter", "User-type requires YAML frontmatter"))
|
|
1880
|
+
return LintResult(
|
|
1881
|
+
file=str(path),
|
|
1882
|
+
artifact_type="user-type",
|
|
1883
|
+
status="fail",
|
|
1884
|
+
issues=issues,
|
|
1885
|
+
suggestions=[".agent-src.uncompressed/user-types/_template/user-type.md"],
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
# Required keys per docs/contracts/user-type-schema.md § 1.
|
|
1889
|
+
required = {
|
|
1890
|
+
"id": re.compile(r'^id:\s*"?([\w-]+)"?\s*$', re.MULTILINE),
|
|
1891
|
+
"kind": re.compile(r'^kind:\s*"?([\w-]+)"?\s*$', re.MULTILINE),
|
|
1892
|
+
"description": re.compile(r'^description:\s*"?([^"\n]+?)"?\s*$', re.MULTILINE),
|
|
1893
|
+
"version": re.compile(r'^version:\s*"?([\d.]+)"?\s*$', re.MULTILINE),
|
|
1894
|
+
"source": re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE),
|
|
1895
|
+
}
|
|
1896
|
+
parsed: dict = {}
|
|
1897
|
+
for field, pattern in required.items():
|
|
1898
|
+
value = extract_frontmatter_field(frontmatter, pattern)
|
|
1899
|
+
if not value:
|
|
1900
|
+
issues.append(Issue("error", f"missing_{field}", f"User-type frontmatter must declare `{field}`"))
|
|
1901
|
+
else:
|
|
1902
|
+
parsed[field] = value
|
|
1903
|
+
|
|
1904
|
+
if "id" in parsed and parsed["id"] != path.stem:
|
|
1905
|
+
issues.append(Issue(
|
|
1906
|
+
"error",
|
|
1907
|
+
"id_filename_mismatch",
|
|
1908
|
+
f"User-type id `{parsed['id']}` must match filename stem `{path.stem}`",
|
|
1909
|
+
))
|
|
1910
|
+
|
|
1911
|
+
if "kind" in parsed and parsed["kind"] != "user-type":
|
|
1912
|
+
issues.append(Issue(
|
|
1913
|
+
"error",
|
|
1914
|
+
"invalid_kind",
|
|
1915
|
+
f"User-type kind must be `user-type` (got `{parsed['kind']}`)",
|
|
1916
|
+
))
|
|
1917
|
+
|
|
1918
|
+
if "description" in parsed and len(parsed["description"]) > 160:
|
|
1919
|
+
issues.append(Issue(
|
|
1920
|
+
"warning",
|
|
1921
|
+
"long_description",
|
|
1922
|
+
f"User-type description is {len(parsed['description'])} chars (target ≤ 160)",
|
|
1923
|
+
))
|
|
1924
|
+
|
|
1925
|
+
sections = extract_sections(text)
|
|
1926
|
+
for required_section in REQUIRED_USERTYPE_SECTIONS:
|
|
1927
|
+
if required_section not in sections:
|
|
1928
|
+
issues.append(Issue(
|
|
1929
|
+
"error",
|
|
1930
|
+
"missing_section",
|
|
1931
|
+
f"User-type is missing required section `## {required_section}`",
|
|
1932
|
+
))
|
|
1933
|
+
|
|
1934
|
+
# Anti-Generic Quality Bar: ≥ 3 Unique Questions
|
|
1935
|
+
uq_block = extract_section_block(text, "Unique Questions")
|
|
1936
|
+
if uq_block:
|
|
1937
|
+
bullet_count = len(re.findall(r"^\s*[-*]\s+", uq_block, re.MULTILINE))
|
|
1938
|
+
if bullet_count < 3:
|
|
1939
|
+
issues.append(Issue(
|
|
1940
|
+
"warning",
|
|
1941
|
+
"too_few_unique_questions",
|
|
1942
|
+
f"User-type has {bullet_count} unique questions (target ≥ 3)",
|
|
1943
|
+
))
|
|
1944
|
+
|
|
1945
|
+
line_count = len(text.splitlines())
|
|
1946
|
+
if line_count > USERTYPE_LINE_BUDGET:
|
|
1947
|
+
issues.append(Issue(
|
|
1948
|
+
"warning",
|
|
1949
|
+
"size_budget",
|
|
1950
|
+
f"User-type has {line_count} lines (budget ≤ {USERTYPE_LINE_BUDGET})",
|
|
1951
|
+
))
|
|
1952
|
+
|
|
1953
|
+
if not H1_PATTERN.search(text):
|
|
1954
|
+
issues.append(Issue("warning", "missing_h1", "User-type is missing an H1 heading"))
|
|
1955
|
+
|
|
1956
|
+
if not text.endswith("\n"):
|
|
1957
|
+
issues.append(Issue("warning", "no_trailing_newline", "File must end with exactly one newline"))
|
|
1958
|
+
|
|
1959
|
+
return LintResult(
|
|
1960
|
+
file=str(path),
|
|
1961
|
+
artifact_type="user-type",
|
|
1962
|
+
status=classify_status(issues),
|
|
1963
|
+
issues=issues,
|
|
1964
|
+
suggestions=[],
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
|
|
1852
1968
|
def gather_all_candidate_files(root: Path) -> list[Path]:
|
|
1853
1969
|
"""Gather all lintable files. Prefers .agent-src.uncompressed/ (source of truth).
|
|
1854
1970
|
Falls back to .agent-src/ only if uncompressed doesn't exist.
|
|
@@ -1906,6 +2022,18 @@ def gather_all_candidate_files(root: Path) -> list[Path]:
|
|
|
1906
2022
|
if not f.is_symlink():
|
|
1907
2023
|
candidates.append(f)
|
|
1908
2024
|
|
|
2025
|
+
# User-types (sister axis to personas — methodology vs end-user).
|
|
2026
|
+
# Top-level .md only; README and _template/ subtree excluded.
|
|
2027
|
+
uncompressed_usertypes = root / ".agent-src.uncompressed" / "user-types"
|
|
2028
|
+
augment_usertypes = root / ".agent-src" / "user-types"
|
|
2029
|
+
usertypes_base = uncompressed_usertypes if uncompressed_usertypes.exists() else augment_usertypes
|
|
2030
|
+
if usertypes_base.exists():
|
|
2031
|
+
for f in usertypes_base.glob("*.md"):
|
|
2032
|
+
if f.name.lower() == "readme.md":
|
|
2033
|
+
continue
|
|
2034
|
+
if not f.is_symlink():
|
|
2035
|
+
candidates.append(f)
|
|
2036
|
+
|
|
1909
2037
|
# Frugality charter (Phase 0.4 Layer 2). Lives in contexts/, not
|
|
1910
2038
|
# walked by the artifact-type loops above, but still needs the
|
|
1911
2039
|
# index-integrity check.
|
|
@@ -2093,8 +2221,13 @@ def _is_execution_artifact(path: Path, text: str) -> bool:
|
|
|
2093
2221
|
path_lower = str(path).lower()
|
|
2094
2222
|
text_lower = text.lower()
|
|
2095
2223
|
|
|
2096
|
-
# Exclude commands, guidelines,
|
|
2097
|
-
if
|
|
2224
|
+
# Exclude commands, guidelines, personas, user-types — not execution-oriented
|
|
2225
|
+
if (
|
|
2226
|
+
"/commands/" in path_lower
|
|
2227
|
+
or "/guidelines/" in path_lower
|
|
2228
|
+
or "/personas/" in path_lower
|
|
2229
|
+
or "/user-types/" in path_lower
|
|
2230
|
+
):
|
|
2098
2231
|
return False
|
|
2099
2232
|
|
|
2100
2233
|
# File name match — strong signal
|
|
@@ -2779,7 +2912,7 @@ def lint_output_schema(path: Path, text: str) -> List[Issue]:
|
|
|
2779
2912
|
|
|
2780
2913
|
|
|
2781
2914
|
# Artefact types that carry a JSON-Schema contract for their frontmatter.
|
|
2782
|
-
_SCHEMA_ARTEFACT_TYPES = {"skill", "rule", "command", "persona"}
|
|
2915
|
+
_SCHEMA_ARTEFACT_TYPES = {"skill", "rule", "command", "persona", "user-type"}
|
|
2783
2916
|
|
|
2784
2917
|
|
|
2785
2918
|
def lint_frontmatter_schema(path: Path, text: str, artifact_type: str) -> List[Issue]:
|
|
@@ -2840,6 +2973,8 @@ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
|
|
|
2840
2973
|
result = lint_guideline(display_path, text)
|
|
2841
2974
|
elif artifact_type == "persona":
|
|
2842
2975
|
result = lint_persona(display_path, text)
|
|
2976
|
+
elif artifact_type == "user-type":
|
|
2977
|
+
result = lint_usertype(display_path, text)
|
|
2843
2978
|
else:
|
|
2844
2979
|
# Frugality charter lives in contexts/ (artifact_type == unknown)
|
|
2845
2980
|
# but still needs Layer 2 index-integrity validation.
|
|
@@ -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())
|