@event4u/agent-config 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.agent-src/commands/agent-status.md +29 -0
  2. package/.agent-src/commands/onboard.md +221 -81
  3. package/.agent-src/commands/refine-ticket.md +3 -0
  4. package/.agent-src/packs/README.md +49 -0
  5. package/.agent-src/packs/agency-delivery.yml +63 -0
  6. package/.agent-src/packs/content-engine.yml +53 -0
  7. package/.agent-src/packs/founder-mvp.yml +51 -0
  8. package/.agent-src/personas/README.md +8 -0
  9. package/.agent-src/presets/README.md +26 -0
  10. package/.agent-src/presets/balanced.yml +34 -0
  11. package/.agent-src/presets/fast.yml +31 -0
  12. package/.agent-src/presets/strict.yml +38 -0
  13. package/.agent-src/profiles/README.md +29 -0
  14. package/.agent-src/profiles/agency.yml +27 -0
  15. package/.agent-src/profiles/content_creator.yml +25 -0
  16. package/.agent-src/profiles/developer.yml +26 -0
  17. package/.agent-src/profiles/finance.yml +24 -0
  18. package/.agent-src/profiles/founder.yml +25 -0
  19. package/.agent-src/profiles/ops.yml +25 -0
  20. package/.agent-src/rules/no-cheap-questions.md +25 -17
  21. package/.agent-src/skills/adr-create/SKILL.md +78 -68
  22. package/.agent-src/skills/refine-ticket/SKILL.md +3 -0
  23. package/.agent-src/skills/subagent-orchestration/SKILL.md +33 -0
  24. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/.agent-src/templates/skill-archive-note.md +101 -0
  26. package/.agent-src/user-types/README.md +124 -0
  27. package/.agent-src/user-types/_template/user-type.md +95 -0
  28. package/.agent-src/user-types/galabau-field-crew.md +100 -0
  29. package/.agent-src/user-types/metalworking-shop.md +105 -0
  30. package/.agent-src/user-types/truck-driver.md +113 -0
  31. package/.claude-plugin/marketplace.json +1 -1
  32. package/CHANGELOG.md +91 -30
  33. package/README.md +68 -72
  34. package/config/agent-settings.template.yml +22 -0
  35. package/docs/adrs/caveman/0001-default-off-until-bench.md +93 -0
  36. package/docs/adrs/caveman/README.md +9 -0
  37. package/docs/adrs/cost/0001-hard-stop-hook.md +114 -0
  38. package/docs/adrs/cost/README.md +9 -0
  39. package/docs/adrs/memory/0001-consumer-side-snapshot.md +111 -0
  40. package/docs/adrs/memory/README.md +9 -0
  41. package/docs/adrs/router/0001-three-tier-routing.md +119 -0
  42. package/docs/adrs/router/README.md +9 -0
  43. package/docs/adrs/schema/0001-json-schema-frontmatter.md +102 -0
  44. package/docs/adrs/schema/README.md +9 -0
  45. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +99 -0
  46. package/docs/adrs/smoke/README.md +9 -0
  47. package/docs/architecture/current-onboard-baseline.md +126 -0
  48. package/docs/architecture/current-safety-behavior.md +137 -0
  49. package/docs/archive/CHANGELOG-pre-2.16.0.md +48 -0
  50. package/docs/contracts/adr-layout.md +108 -0
  51. package/docs/contracts/adr-mcp-runtime.md +128 -0
  52. package/docs/contracts/adr-user-types-axis.md +127 -0
  53. package/docs/contracts/benchmark-corpus-spec.md +97 -0
  54. package/docs/contracts/benchmark-report-schema.md +111 -0
  55. package/docs/contracts/command-clusters.md +1 -0
  56. package/docs/contracts/command-taxonomy.md +137 -0
  57. package/docs/contracts/compression-default-kill-criterion.md +69 -0
  58. package/docs/contracts/config-presets.md +144 -0
  59. package/docs/contracts/cost-dashboard.md +143 -0
  60. package/docs/contracts/cost-enforcement.md +134 -0
  61. package/docs/contracts/file-ownership-matrix.json +0 -7
  62. package/docs/contracts/mcp-tool-inventory.md +53 -0
  63. package/docs/contracts/measurement-baseline.md +102 -0
  64. package/docs/contracts/namespace.md +125 -0
  65. package/docs/contracts/profile-system.md +142 -0
  66. package/docs/contracts/safety-model.md +129 -0
  67. package/docs/contracts/smoke-contracts.md +144 -0
  68. package/docs/contracts/user-type-schema.md +146 -0
  69. package/docs/contracts/workflow-packs.md +121 -0
  70. package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +132 -0
  71. package/docs/decisions/INDEX.md +1 -0
  72. package/docs/featured-commands.md +27 -0
  73. package/docs/parity/bench-ruflo.json +58 -0
  74. package/docs/parity/bench.json +41 -0
  75. package/docs/parity/ruflo.md +46 -0
  76. package/docs/profiles.md +91 -0
  77. package/docs/recruits/_template.md +81 -0
  78. package/package.json +1 -1
  79. package/scripts/_cli/cmd_explain.py +250 -0
  80. package/scripts/_lib/bench_cost.py +138 -0
  81. package/scripts/_lib/bench_quality.py +118 -0
  82. package/scripts/_lib/bench_report.py +150 -0
  83. package/scripts/agent-config +13 -0
  84. package/scripts/audit_adr_coverage.py +175 -0
  85. package/scripts/audit_mcp_tools.py +146 -0
  86. package/scripts/bench_baseline_ready.py +108 -0
  87. package/scripts/bench_drift_check.py +151 -0
  88. package/scripts/bench_per_tool.py +216 -0
  89. package/scripts/bench_run.py +155 -0
  90. package/scripts/compress.py +48 -2
  91. package/scripts/config/__init__.py +9 -0
  92. package/scripts/config/presets.py +206 -0
  93. package/scripts/config/profiles.py +173 -0
  94. package/scripts/cost/budget.mjs +73 -12
  95. package/scripts/cost/preflight.mjs +89 -0
  96. package/scripts/lint_archived_skills.py +143 -0
  97. package/scripts/lint_bench_corpus.py +161 -0
  98. package/scripts/lint_namespace.py +135 -0
  99. package/scripts/schemas/user-type.schema.json +35 -0
  100. package/scripts/skill_linter.py +139 -4
  101. package/scripts/skill_overlap.py +204 -0
  102. package/scripts/skill_tools/audit_user_type_coverage.py +148 -0
  103. package/scripts/skill_usage_collect.py +191 -0
  104. package/scripts/skill_usage_report.py +162 -0
  105. package/scripts/smoke/kernel.sh +101 -0
  106. package/scripts/smoke/router.sh +129 -0
  107. package/scripts/smoke/schema.sh +71 -0
  108. package/scripts/smoke/skills.sh +101 -0
@@ -0,0 +1,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
+ }
@@ -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, and personas — they are not execution-oriented
2097
- if "/commands/" in path_lower or "/guidelines/" in path_lower or "/personas/" in path_lower:
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())