@event4u/agent-config 2.12.0 → 2.13.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 (64) hide show
  1. package/.agent-src/commands/council/analysis.md +142 -0
  2. package/.agent-src/commands/council/debate.md +129 -0
  3. package/.agent-src/commands/council/default.md +8 -0
  4. package/.agent-src/commands/council/design.md +16 -12
  5. package/.agent-src/commands/council/optimize.md +16 -15
  6. package/.agent-src/commands/council/pr.md +12 -12
  7. package/.agent-src/commands/council.md +48 -2
  8. package/.agent-src/personas/advisors/contrarian.md +95 -0
  9. package/.agent-src/personas/advisors/executor.md +99 -0
  10. package/.agent-src/personas/advisors/expansionist.md +98 -0
  11. package/.agent-src/personas/advisors/first-principles.md +98 -0
  12. package/.agent-src/personas/advisors/outsider.md +102 -0
  13. package/.agent-src/rules/copilot-routing.md +19 -0
  14. package/.agent-src/rules/devcontainer-routing.md +20 -0
  15. package/.agent-src/rules/laravel-routing.md +20 -0
  16. package/.agent-src/rules/symfony-routing.md +20 -0
  17. package/.agent-src/skills/ai-council/SKILL.md +180 -2
  18. package/.agent-src/skills/copilot-config/SKILL.md +1 -1
  19. package/.agent-src/skills/devcontainer/SKILL.md +1 -1
  20. package/.agent-src/skills/laravel/SKILL.md +1 -1
  21. package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
  22. package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
  23. package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
  24. package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  25. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/.claude-plugin/marketplace.json +3 -1
  27. package/AGENTS.md +1 -1
  28. package/CHANGELOG.md +47 -0
  29. package/CONTRIBUTING.md +5 -0
  30. package/README.md +3 -3
  31. package/config/agent-settings.template.yml +5 -93
  32. package/docs/architecture/multi-tool-projection.md +53 -0
  33. package/docs/architecture/{compression.md → source-projection.md} +21 -3
  34. package/docs/architecture.md +5 -5
  35. package/docs/catalog.md +21 -11
  36. package/docs/contracts/adr-architectural-consensus-mechanism.md +67 -0
  37. package/docs/contracts/ai-council-config.md +186 -0
  38. package/docs/contracts/command-clusters.md +57 -1
  39. package/docs/contracts/multi-tool-projection-fidelity.md +109 -0
  40. package/docs/getting-started.md +2 -2
  41. package/package.json +1 -1
  42. package/scripts/_archive/README.md +59 -0
  43. package/scripts/ai_council/_default_prices.py +10 -1
  44. package/scripts/ai_council/advisors.py +148 -0
  45. package/scripts/ai_council/clients.py +172 -0
  46. package/scripts/ai_council/config.py +368 -0
  47. package/scripts/ai_council/consensus.py +290 -0
  48. package/scripts/ai_council/orchestrator.py +628 -14
  49. package/scripts/ai_council/prompts.py +335 -0
  50. package/scripts/check_compressed_paths.py +6 -1
  51. package/scripts/ci_time_ratio.py +168 -0
  52. package/scripts/council_cli.py +973 -29
  53. package/scripts/measure_projection_bytes.py +159 -0
  54. package/scripts/measure_roadmap_trajectory.py +112 -0
  55. package/scripts/probe_projection_fidelity.py +202 -0
  56. package/scripts/score_skill_selection.py +198 -0
  57. package/scripts/skill_collision_clusters.py +162 -0
  58. /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
  59. /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
  60. /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
  61. /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
  62. /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
  63. /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
  64. /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -0
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """Measure per-tool projection bytes.
3
+
4
+ Phase 2.1 deliverable for `agents/roadmaps/step-1-v2-feedback-followup.md`
5
+ (council finding U1 — the 0.45 % source/dist headline metric measures the
6
+ wrong boundary). Replaces the single headline figure with per-tool numbers
7
+ and an explicit projection-method label.
8
+
9
+ Usage:
10
+ python3 scripts/measure_projection_bytes.py # human-readable
11
+ python3 scripts/measure_projection_bytes.py --json # machine-readable
12
+ python3 scripts/measure_projection_bytes.py --regenerate
13
+ # runs `task clean-tools && task generate-tools` with *all* tools
14
+ # enabled (via temporary .agent-tools.yml override) before measuring,
15
+ # then restores the original `.agent-tools.yml`. Use this to produce
16
+ # a complete table when the local repo only enables a subset.
17
+
18
+ Output is intentionally non-cached and read fresh from disk every run.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import yaml
31
+
32
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
+
34
+ # (surface, kind, projection-method). Surface paths are relative to the repo
35
+ # root. `kind` is "dir" (walk recursively) or "file" (single file size).
36
+ SURFACES: list[tuple[str, str, str]] = [
37
+ (".agent-src.uncompressed", "dir", "verbose source (input)"),
38
+ (".agent-src", "dir", "source projection (path-rewrite + .npmignore)"),
39
+ (".augment", "dir", "Augment Code — copies (rules) + symlinks (skills/cmds)"),
40
+ (".claude", "dir", "Claude Code — pure symlinks"),
41
+ (".cursor", "dir", "Cursor — per-rule `.mdc` materialized + symlinks"),
42
+ (".clinerules", "dir", "Cline — pure symlinks"),
43
+ (".windsurf", "dir", "Windsurf — per-rule wave-8 `.md` + symlinks"),
44
+ (".windsurfrules", "file", "Windsurf legacy — concatenated single file"),
45
+ ("GEMINI.md", "file", "Gemini CLI — symlink → AGENTS.md"),
46
+ ]
47
+
48
+
49
+ def _measure_dir(path: Path) -> tuple[int, int, int]:
50
+ """Return (file_count, symlink_count, materialized_bytes) for *path*."""
51
+ if not path.exists():
52
+ return (0, 0, 0)
53
+ files = 0
54
+ links = 0
55
+ size = 0
56
+ for p in path.rglob("*"):
57
+ if p.is_symlink():
58
+ links += 1
59
+ elif p.is_file():
60
+ files += 1
61
+ try:
62
+ size += p.stat().st_size
63
+ except OSError:
64
+ pass
65
+ return (files, links, size)
66
+
67
+
68
+ def _measure_file(path: Path) -> tuple[int, int, int]:
69
+ if path.is_symlink():
70
+ return (0, 1, 0)
71
+ if path.is_file():
72
+ return (1, 0, path.stat().st_size)
73
+ return (0, 0, 0)
74
+
75
+
76
+ def collect() -> list[dict]:
77
+ rows: list[dict] = []
78
+ for surface, kind, method in SURFACES:
79
+ path = PROJECT_ROOT / surface
80
+ files, links, size = (
81
+ _measure_dir(path) if kind == "dir" else _measure_file(path)
82
+ )
83
+ rows.append(
84
+ {
85
+ "surface": surface,
86
+ "kind": kind,
87
+ "method": method,
88
+ "files": files,
89
+ "symlinks": links,
90
+ "bytes_materialized": size,
91
+ "exists": files + links > 0,
92
+ }
93
+ )
94
+ return rows
95
+
96
+
97
+ def _temporarily_enable_all_tools() -> str | None:
98
+ tools_file = PROJECT_ROOT / ".agent-tools.yml"
99
+ if not tools_file.exists():
100
+ return None
101
+ original = tools_file.read_text()
102
+ data = yaml.safe_load(original) or {}
103
+ data["tools"] = [
104
+ "claude-code", "claude-desktop", "augment", "copilot",
105
+ "cursor", "windsurf", "cline", "gemini",
106
+ ]
107
+ tools_file.write_text(
108
+ "# TEMPORARY override by measure_projection_bytes.py — restored on exit\n"
109
+ + yaml.safe_dump(data, sort_keys=False)
110
+ )
111
+ return original
112
+
113
+
114
+ def regenerate_all() -> None:
115
+ backup = _temporarily_enable_all_tools()
116
+ try:
117
+ subprocess.run(["task", "clean-tools"], check=True, capture_output=True)
118
+ subprocess.run(["task", "generate-tools"], check=True, capture_output=True)
119
+ finally:
120
+ if backup is not None:
121
+ (PROJECT_ROOT / ".agent-tools.yml").write_text(backup)
122
+
123
+
124
+ def render_table(rows: list[dict]) -> str:
125
+ width = max(len(r["surface"]) for r in rows)
126
+ lines = [f"{'Surface':<{width}} Files Symlinks Bytes Method"]
127
+ lines.append("-" * (width + 50))
128
+ for r in rows:
129
+ lines.append(
130
+ f"{r['surface']:<{width}} {r['files']:>5} {r['symlinks']:>8} "
131
+ f"{r['bytes_materialized']:>10,} {r['method']}"
132
+ )
133
+ return "\n".join(lines)
134
+
135
+
136
+ def main() -> int:
137
+ parser = argparse.ArgumentParser(description=__doc__)
138
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
139
+ parser.add_argument(
140
+ "--regenerate",
141
+ action="store_true",
142
+ help="regenerate all tool projections before measuring",
143
+ )
144
+ args = parser.parse_args()
145
+ if args.regenerate:
146
+ if not shutil.which("task"):
147
+ print("❌ `task` CLI required for --regenerate", file=sys.stderr)
148
+ return 2
149
+ regenerate_all()
150
+ rows = collect()
151
+ if args.json:
152
+ print(json.dumps({"surfaces": rows}, indent=2))
153
+ else:
154
+ print(render_table(rows))
155
+ return 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 5.1 — Roadmap commitment-history measurement.
3
+
4
+ Walks `agents/roadmaps/archive/` and computes per-roadmap checkbox
5
+ completion ratio at archival time. Output: one-line trajectory metric
6
+ per roadmap, plus an aggregate `agents/reports/roadmap-trajectory.json`.
7
+
8
+ Checkbox grammar (mirrors `scripts/roadmap_progress_check.py`):
9
+ - `[ ]` — open
10
+ - `[x]` — done
11
+ - `[~]` — in-progress
12
+ - `[-]` — cancelled / dropped (counts neither toward open nor closed)
13
+
14
+ Trajectory metric = closed / (open + closed + in-progress); cancelled
15
+ items are excluded from the denominator so a cleanly archived "we
16
+ decided not to do this" doesn't dilute the score.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ ARCHIVE = ROOT / "agents" / "roadmaps" / "archive"
29
+ REPORT = ROOT / "agents" / "reports" / "roadmap-trajectory.json"
30
+
31
+ CHECKBOX = re.compile(r"^\s*[-*]\s*\[(?P<state>[ x~\-])\]", re.MULTILINE)
32
+
33
+
34
+ def measure(path: Path) -> dict:
35
+ text = path.read_text(encoding="utf-8", errors="replace")
36
+ counts = {"open": 0, "done": 0, "wip": 0, "cancelled": 0}
37
+ for m in CHECKBOX.finditer(text):
38
+ state = m.group("state")
39
+ if state == " ":
40
+ counts["open"] += 1
41
+ elif state == "x":
42
+ counts["done"] += 1
43
+ elif state == "~":
44
+ counts["wip"] += 1
45
+ elif state == "-":
46
+ counts["cancelled"] += 1
47
+ denom = counts["open"] + counts["done"] + counts["wip"]
48
+ ratio = (counts["done"] / denom) if denom else None
49
+ return {
50
+ "file": str(path.relative_to(ROOT)),
51
+ "counts": counts,
52
+ "completion_ratio": ratio,
53
+ "total_actionable": denom,
54
+ }
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser()
59
+ ap.add_argument("--archive", default=str(ARCHIVE))
60
+ ap.add_argument("--report", default=str(REPORT))
61
+ ap.add_argument("--print-table", action="store_true")
62
+ args = ap.parse_args()
63
+
64
+ archive = Path(args.archive)
65
+ if not archive.exists():
66
+ print(f"❌ archive not found: {archive}", file=sys.stderr)
67
+ return 2
68
+
69
+ rows = [measure(p) for p in sorted(archive.glob("*.md"))]
70
+
71
+ # Aggregate: mean, median, count above 80%, count zero-completion
72
+ ratios = [r["completion_ratio"] for r in rows if r["completion_ratio"] is not None]
73
+ aggregate = {
74
+ "roadmaps": len(rows),
75
+ "scored": len(ratios),
76
+ "mean": (sum(ratios) / len(ratios)) if ratios else None,
77
+ "median": sorted(ratios)[len(ratios) // 2] if ratios else None,
78
+ "above_80pct": sum(1 for r in ratios if r >= 0.80),
79
+ "below_50pct": sum(1 for r in ratios if r < 0.50),
80
+ "zero_completion": sum(1 for r in ratios if r == 0.0),
81
+ }
82
+
83
+ out = Path(args.report)
84
+ out.parent.mkdir(parents=True, exist_ok=True)
85
+ out.write_text(
86
+ json.dumps({"aggregate": aggregate, "rows": rows}, indent=2) + "\n",
87
+ encoding="utf-8",
88
+ )
89
+
90
+ print(f"✅ Wrote {out.relative_to(ROOT)}")
91
+ print(f" roadmaps={aggregate['roadmaps']} scored={aggregate['scored']}")
92
+ if aggregate["mean"] is not None:
93
+ print(
94
+ f" mean={aggregate['mean']:.1%} median={aggregate['median']:.1%} "
95
+ f"above_80%={aggregate['above_80pct']} below_50%={aggregate['below_50pct']} "
96
+ f"zero={aggregate['zero_completion']}"
97
+ )
98
+ if args.print_table:
99
+ print()
100
+ print(f" {'file':70s} {'ratio':>7s} {'done':>5s} {'open':>5s} {'wip':>5s} {'cx':>5s}")
101
+ for r in sorted(rows, key=lambda x: (x["completion_ratio"] is None, -(x["completion_ratio"] or 0))):
102
+ ratio = "—" if r["completion_ratio"] is None else f"{r['completion_ratio']:.1%}"
103
+ print(
104
+ f" {Path(r['file']).name:70s} {ratio:>7s} "
105
+ f"{r['counts']['done']:>5d} {r['counts']['open']:>5d} "
106
+ f"{r['counts']['wip']:>5d} {r['counts']['cancelled']:>5d}"
107
+ )
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 4.2 — Probe per-tool projection fidelity against the fixture.
3
+
4
+ Reads tests/fixtures/projection_fidelity/fixtures.yml, walks the
5
+ projected trees (.augment/, .claude/, .cursor/, .clinerules/,
6
+ .windsurfrules, .windsurf/), and records pass/fail/partial per check.
7
+
8
+ Output: agents/reports/projection-fidelity.json + stdout summary.
9
+
10
+ Pure stdlib (PyYAML reuse from scripts/_lib if installed; otherwise
11
+ inline minimal YAML loader for the fixture's restricted shape).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ try:
23
+ import yaml # type: ignore
24
+ except ImportError: # pragma: no cover
25
+ print("❌ PyYAML required (already a project dep)", file=sys.stderr)
26
+ sys.exit(2)
27
+
28
+ ROOT = Path(__file__).resolve().parent.parent
29
+
30
+ TREES = {
31
+ "augment": ROOT / ".augment",
32
+ "claude": ROOT / ".claude",
33
+ "cursor_mdc": ROOT / ".cursor" / "rules",
34
+ "cursor_commands": ROOT / ".cursor" / "commands",
35
+ "cline": ROOT / ".clinerules",
36
+ "windsurf": ROOT / ".windsurfrules",
37
+ "windsurf_workflows": ROOT / ".windsurf" / "workflows",
38
+ }
39
+
40
+
41
+ def parse_frontmatter(path: Path) -> tuple[dict, str]:
42
+ if not path.exists():
43
+ return {}, ""
44
+ text = path.read_text(encoding="utf-8")
45
+ if not text.startswith("---"):
46
+ return {}, text
47
+ parts = text.split("---", 2)
48
+ if len(parts) < 3:
49
+ return {}, text
50
+ try:
51
+ fm = yaml.safe_load(parts[1]) or {}
52
+ except yaml.YAMLError:
53
+ fm = {}
54
+ return fm if isinstance(fm, dict) else {}, parts[2]
55
+
56
+
57
+ def locate(tree_key: str, entry_type: str, src: str) -> Path | None:
58
+ """Locate the projected artefact in a given tree."""
59
+ name = Path(src).stem # 'laravel-routing'
60
+ if entry_type == "rule":
61
+ if tree_key in ("augment", "claude"):
62
+ p = TREES[tree_key] / "rules" / Path(src).name
63
+ return p if p.exists() else None
64
+ if tree_key == "cursor_mdc":
65
+ p = TREES[tree_key] / f"{name}.mdc"
66
+ return p if p.exists() else None
67
+ if tree_key == "cline":
68
+ p = TREES[tree_key] / f"{name}.md"
69
+ return p if p.exists() else None
70
+ if tree_key == "windsurf":
71
+ return TREES[tree_key] if TREES[tree_key].exists() else None
72
+ if entry_type == "skill":
73
+ if tree_key in ("augment", "claude"):
74
+ p = TREES[tree_key] / "skills" / Path(src).parent.name / "SKILL.md"
75
+ return p if p.exists() else None
76
+ if entry_type == "command":
77
+ if tree_key == "augment":
78
+ p = TREES[tree_key] / "commands" / Path(src).name
79
+ return p if p.exists() else None
80
+ if tree_key == "claude":
81
+ p = TREES[tree_key] / "skills" / name / "SKILL.md"
82
+ return p if p.exists() else None
83
+ if tree_key == "cursor_commands":
84
+ p = TREES[tree_key] / f"{name}.md"
85
+ return p if p.exists() else None
86
+ if tree_key == "windsurf_workflows":
87
+ p = TREES[tree_key] / f"{name}.md"
88
+ return p if p.exists() else None
89
+ return None
90
+
91
+
92
+ def check_entry(entry: dict) -> dict:
93
+ out = {"id": entry["id"], "type": entry["type"], "tier": entry.get("tier"), "results": {}}
94
+ for tool, spec in (entry.get("checks") or {}).items():
95
+ result = {"status": "pass", "details": []}
96
+ expect_present = spec.get("present", True)
97
+ path = locate(tool, entry["type"], entry["source"])
98
+
99
+ if tool == "windsurf" and spec.get("concatenated_in"):
100
+ fp = ROOT / spec["concatenated_in"]
101
+ if not fp.exists():
102
+ result["status"] = "fail"
103
+ result["details"].append(f"missing concat file {spec['concatenated_in']}")
104
+ else:
105
+ body = fp.read_text(encoding="utf-8")
106
+ needle = spec.get("body_contains")
107
+ if needle and needle not in body:
108
+ result["status"] = "fail"
109
+ result["details"].append(f"body missing '{needle}'")
110
+ if spec.get("routes_to_visible") is False and "routes_to" in body:
111
+ result["details"].append("note: routes_to leaks into concat (info)")
112
+ out["results"][tool] = result
113
+ continue
114
+
115
+ if expect_present and path is None:
116
+ result["status"] = "fail"
117
+ result["details"].append("file not found")
118
+ out["results"][tool] = result
119
+ continue
120
+ if not expect_present:
121
+ if path is not None:
122
+ result["status"] = "fail"
123
+ result["details"].append(f"unexpected file at {path}")
124
+ else:
125
+ result["details"].append(f"absent (ok: {spec.get('rationale', '')})")
126
+ out["results"][tool] = result
127
+ continue
128
+
129
+ fm, body = parse_frontmatter(path)
130
+ for key in spec.get("frontmatter_keys", []) or []:
131
+ if key not in fm:
132
+ result["status"] = "fail"
133
+ result["details"].append(f"frontmatter missing '{key}'")
134
+ for key in spec.get("frontmatter_drops", []) or []:
135
+ if key in fm:
136
+ result["status"] = "fail"
137
+ result["details"].append(f"frontmatter unexpectedly contains '{key}'")
138
+ if spec.get("alwaysApply") is not None and fm.get("alwaysApply") != spec["alwaysApply"]:
139
+ result["status"] = "partial"
140
+ result["details"].append(
141
+ f"alwaysApply={fm.get('alwaysApply')!r} expected {spec['alwaysApply']!r}"
142
+ )
143
+ trig_kw = spec.get("triggers_keyword_contains") or []
144
+ trig_pp = spec.get("triggers_path_prefix_contains") or []
145
+ if trig_kw or trig_pp:
146
+ trigs = fm.get("triggers") or []
147
+ kws = [t.get("keyword") for t in trigs if isinstance(t, dict) and t.get("keyword")]
148
+ pps = [t.get("path_prefix") for t in trigs if isinstance(t, dict) and t.get("path_prefix")]
149
+ for kw in trig_kw:
150
+ if kw not in kws:
151
+ result["status"] = "fail"
152
+ result["details"].append(f"trigger keyword '{kw}' missing")
153
+ for pp in trig_pp:
154
+ if pp not in pps:
155
+ result["status"] = "fail"
156
+ result["details"].append(f"trigger path_prefix '{pp}' missing")
157
+ routes = spec.get("routes_to_contains") or []
158
+ if routes:
159
+ rt = fm.get("routes_to") or []
160
+ for r in routes:
161
+ if r not in rt:
162
+ result["status"] = "fail"
163
+ result["details"].append(f"routes_to missing '{r}'")
164
+ body_needle = spec.get("body_contains")
165
+ if body_needle and body_needle not in body:
166
+ result["status"] = "fail"
167
+ result["details"].append(f"body missing '{body_needle}'")
168
+ out["results"][tool] = result
169
+ return out
170
+
171
+
172
+ def main() -> int:
173
+ ap = argparse.ArgumentParser()
174
+ ap.add_argument("--fixture", default="tests/fixtures/projection_fidelity/fixtures.yml")
175
+ ap.add_argument("--report", default="agents/reports/projection-fidelity.json")
176
+ args = ap.parse_args()
177
+
178
+ fixture = yaml.safe_load((ROOT / args.fixture).read_text(encoding="utf-8"))
179
+ entries = fixture.get("entries", [])
180
+ results = [check_entry(e) for e in entries]
181
+
182
+ summary = {"pass": 0, "partial": 0, "fail": 0}
183
+ for e in results:
184
+ for r in e["results"].values():
185
+ summary[r["status"]] += 1
186
+
187
+ report = {"summary": summary, "entries": results}
188
+ out = ROOT / args.report
189
+ out.parent.mkdir(parents=True, exist_ok=True)
190
+ out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
191
+
192
+ print(f"✅ Wrote {args.report}")
193
+ print(f" pass={summary['pass']} partial={summary['partial']} fail={summary['fail']}")
194
+ for e in results:
195
+ for tool, r in e["results"].items():
196
+ if r["status"] != "pass":
197
+ print(f" {r['status']:7s} {e['id']:40s} {tool:18s} {'; '.join(r['details'])}")
198
+ return 0 if summary["fail"] == 0 else 1
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """Selection-accuracy scorer (council file 05, Phase 2.2).
3
+
4
+ Reads `tests/fixtures/skill_selection/fixtures.yml` and a predictions
5
+ JSON (`{fixture_id: selected_skill_name}`), then computes:
6
+
7
+ - (a) intended-skill hit rate — exact `intended` match
8
+ - (b) correct-cluster hit rate — any member of the same cluster
9
+
10
+ Per-cluster pass/fail uses the Round-3 protocol:
11
+ pass = (a) >= 0.90 OR (b) >= 0.95
12
+ fail = (a) < 0.80 AND (b) < 0.80 → cluster needs `routes_to`
13
+
14
+ Predictions source:
15
+ - `--predictions <path>`: external JSON file (LLM run, eval harness, manual).
16
+ - `--baseline`: built-in TF-IDF-style description-similarity baseline. The
17
+ baseline does NOT speak for any specific host tool; it estimates what
18
+ pure description-matching would do and provides a numeric floor.
19
+
20
+ Output: human-readable summary on stdout + machine JSON to
21
+ `agents/reports/skill-selection-accuracy.json` (or `--out`).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import math
29
+ import re
30
+ import sys
31
+ from collections import Counter, defaultdict
32
+ from pathlib import Path
33
+
34
+ import yaml
35
+
36
+ REPO_ROOT = Path(__file__).resolve().parent.parent
37
+ FIXTURES = REPO_ROOT / "tests" / "fixtures" / "skill_selection" / "fixtures.yml"
38
+ CLUSTERS = REPO_ROOT / "agents" / "reports" / "skill-collision-clusters.json"
39
+ SKILLS_DIR = REPO_ROOT / ".agent-src.uncompressed" / "skills"
40
+ DEFAULT_OUT = REPO_ROOT / "agents" / "reports" / "skill-selection-accuracy.json"
41
+
42
+ PASS_A = 0.90
43
+ PASS_B = 0.95
44
+ FAIL_THRESHOLD = 0.80
45
+
46
+ STOPWORDS = {
47
+ "the", "and", "for", "with", "when", "use", "or", "of", "to", "a", "an",
48
+ "is", "in", "on", "by", "be", "at", "as", "it", "if", "are", "this",
49
+ "that", "from", "but", "not", "can", "any", "all", "no", "after",
50
+ "before", "during", "user", "agent", "code", "project", "via", "into",
51
+ "onto", "even", "without", "naming", "uses", "used", "using", "also",
52
+ "etc", "across", "between",
53
+ }
54
+
55
+
56
+ def tokenize(text: str) -> list[str]:
57
+ tokens = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", text.lower())
58
+ return [t for t in tokens if t not in STOPWORDS and not t.isdigit()]
59
+
60
+
61
+ def load_skills() -> dict[str, str]:
62
+ out = {}
63
+ for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")):
64
+ text = skill_md.read_text()
65
+ if not text.startswith("---"):
66
+ continue
67
+ parts = text.split("---", 2)
68
+ if len(parts) < 3:
69
+ continue
70
+ try:
71
+ fm = yaml.safe_load(parts[1]) or {}
72
+ except yaml.YAMLError:
73
+ continue
74
+ name = fm.get("name") or skill_md.parent.name
75
+ desc = (fm.get("description") or "").strip()
76
+ if desc:
77
+ out[name] = desc
78
+ return out
79
+
80
+
81
+ def tfidf_vectors(docs: dict[str, str]) -> tuple[dict[str, dict[str, float]], dict[str, float]]:
82
+ n_docs = len(docs)
83
+ df: Counter[str] = Counter()
84
+ tokenized = {k: tokenize(v) for k, v in docs.items()}
85
+ for toks in tokenized.values():
86
+ for term in set(toks):
87
+ df[term] += 1
88
+ idf = {term: math.log((n_docs + 1) / (count + 1)) + 1 for term, count in df.items()}
89
+ vectors: dict[str, dict[str, float]] = {}
90
+ for name, toks in tokenized.items():
91
+ tf = Counter(toks)
92
+ vectors[name] = {term: tf[term] * idf.get(term, 0.0) for term in tf}
93
+ return vectors, idf
94
+
95
+
96
+ def cosine(a: dict[str, float], b: dict[str, float]) -> float:
97
+ if not a or not b:
98
+ return 0.0
99
+ common = set(a) & set(b)
100
+ dot = sum(a[t] * b[t] for t in common)
101
+ na = math.sqrt(sum(v * v for v in a.values()))
102
+ nb = math.sqrt(sum(v * v for v in b.values()))
103
+ if na == 0 or nb == 0:
104
+ return 0.0
105
+ return dot / (na * nb)
106
+
107
+
108
+ def baseline_predict(fixtures: list[dict], skills: dict[str, str]) -> dict[str, str]:
109
+ vectors, idf = tfidf_vectors(skills)
110
+ preds: dict[str, str] = {}
111
+ for fx in fixtures:
112
+ prompt_tokens = tokenize(fx["prompt"])
113
+ tf = Counter(prompt_tokens)
114
+ pv = {term: tf[term] * idf.get(term, 0.0) for term in tf}
115
+ best_name, best_score = "", -1.0
116
+ for name, vec in vectors.items():
117
+ score = cosine(pv, vec)
118
+ if score > best_score:
119
+ best_name, best_score = name, score
120
+ preds[fx["id"]] = best_name
121
+ return preds
122
+
123
+
124
+ def score(fixtures: list[dict], clusters: list[dict], preds: dict[str, str]) -> dict:
125
+ # Look up cluster membership by intended-skill (robust to cluster_id renumbering).
126
+ by_member: dict[str, set[str]] = {}
127
+ for c in clusters:
128
+ members = set(c["members"])
129
+ for m in members:
130
+ by_member[m] = members
131
+ per_cluster = defaultdict(lambda: {"total": 0, "hits_a": 0, "hits_b": 0, "misses": [], "label": ""})
132
+ for fx in fixtures:
133
+ intended = fx["intended"]
134
+ members = by_member.get(intended, {intended})
135
+ # Stable label: sorted members joined — survives cluster_id renumbering.
136
+ cid = fx.get("cluster") or "+".join(sorted(members)[:2])
137
+ pred = preds.get(fx["id"], "")
138
+ rec = per_cluster[cid]
139
+ rec["total"] += 1
140
+ rec["label"] = ",".join(sorted(members))
141
+ if pred == intended:
142
+ rec["hits_a"] += 1
143
+ if pred in members:
144
+ rec["hits_b"] += 1
145
+ else:
146
+ rec["misses"].append({"id": fx["id"], "intended": intended, "predicted": pred})
147
+ results = []
148
+ for cid, rec in sorted(per_cluster.items()):
149
+ a = rec["hits_a"] / rec["total"]
150
+ b = rec["hits_b"] / rec["total"]
151
+ if a >= PASS_A or b >= PASS_B:
152
+ verdict = "pass"
153
+ elif a < FAIL_THRESHOLD and b < FAIL_THRESHOLD:
154
+ verdict = "fail-needs-routes_to"
155
+ else:
156
+ verdict = "mixed"
157
+ results.append({"cluster": cid, "n": rec["total"], "hit_a": round(a, 3),
158
+ "hit_b": round(b, 3), "verdict": verdict, "misses": rec["misses"]})
159
+ total = sum(r["n"] for r in results)
160
+ overall_a = sum(r["hit_a"] * r["n"] for r in results) / total if total else 0.0
161
+ overall_b = sum(r["hit_b"] * r["n"] for r in results) / total if total else 0.0
162
+ return {"clusters": results,
163
+ "overall": {"n": total, "hit_a": round(overall_a, 3), "hit_b": round(overall_b, 3)}}
164
+
165
+
166
+ def main() -> int:
167
+ p = argparse.ArgumentParser()
168
+ p.add_argument("--predictions", type=Path, help="JSON file: {fixture_id: skill_name}")
169
+ p.add_argument("--baseline", action="store_true", help="Use built-in TF-IDF baseline")
170
+ p.add_argument("--source", default="external", help="Label recorded in output")
171
+ p.add_argument("--out", type=Path, default=DEFAULT_OUT)
172
+ args = p.parse_args()
173
+
174
+ if not args.predictions and not args.baseline:
175
+ print("❌ Specify --predictions <file> or --baseline", file=sys.stderr)
176
+ return 2
177
+ fixtures = yaml.safe_load(FIXTURES.read_text())["fixtures"]
178
+ clusters = json.loads(CLUSTERS.read_text())["clusters"]
179
+ skills = load_skills()
180
+ if args.baseline:
181
+ preds = baseline_predict(fixtures, skills)
182
+ source = "tfidf-baseline"
183
+ else:
184
+ preds = json.loads(args.predictions.read_text())
185
+ source = args.source
186
+ report = score(fixtures, clusters, preds)
187
+ report["source"] = source
188
+ args.out.parent.mkdir(parents=True, exist_ok=True)
189
+ args.out.write_text(json.dumps(report, indent=2) + "\n")
190
+ print(f"✅ Wrote {args.out.relative_to(REPO_ROOT)} (source={source})")
191
+ print(f" overall: hit_a={report['overall']['hit_a']:.3f} hit_b={report['overall']['hit_b']:.3f} n={report['overall']['n']}")
192
+ for c in report["clusters"]:
193
+ print(f" {c['cluster']:6} n={c['n']:2} hit_a={c['hit_a']:.2f} hit_b={c['hit_b']:.2f} {c['verdict']}")
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())