arbiter-dev 0.2.0__py3-none-any.whl

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.
arbiter/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Arbiter — Agent-aware code quality system."""
2
+
3
+ __version__ = "0.1.0"
arbiter/__main__.py ADDED
@@ -0,0 +1,570 @@
1
+ """Arbiter CLI entry point.
2
+
3
+ Usage:
4
+ python -m arbiter analyze /path/to/repo Full analysis with persistence
5
+ python -m arbiter score /path/to/repo Quick score (no persistence)
6
+ python -m arbiter agents Agent leaderboard
7
+ python -m arbiter trend [--days 30] Quality trend over time
8
+ python -m arbiter worst [--limit 20] Worst files by quality
9
+ python -m arbiter commits [--agent NAME] Recent commits with scores
10
+ python -m arbiter serve [--port 8080] Start API + dashboard
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from arbiter.agent_registry import AgentRegistry
21
+ from arbiter.analyzers.base import Analyzer, Finding
22
+ from arbiter.analyzers.ruff_analyzer import RuffAnalyzer
23
+ from arbiter.analyzers.complexity_analyzer import ComplexityAnalyzer
24
+ from arbiter.analyzers.security_analyzer import SecurityAnalyzer
25
+ from arbiter.analyzers.dead_code_analyzer import DeadCodeAnalyzer
26
+ from arbiter.analyzers.duplication_analyzer import DuplicationAnalyzer
27
+ from arbiter.diff_analyzer import score_commit, score_diff
28
+ from arbiter.git_historian import count_loc, walk_commits
29
+ from arbiter.scoring import score_findings
30
+ from arbiter.store import Store
31
+
32
+
33
+ def _get_analyzers() -> list[Analyzer]:
34
+ """Get all available analyzers."""
35
+ all_analyzers = [
36
+ RuffAnalyzer(),
37
+ ComplexityAnalyzer(),
38
+ SecurityAnalyzer(),
39
+ DeadCodeAnalyzer(),
40
+ DuplicationAnalyzer(),
41
+ ]
42
+ available = []
43
+ for a in all_analyzers:
44
+ if a.is_available():
45
+ available.append(a)
46
+ else:
47
+ print(f" [skip] {a.name} not installed", file=sys.stderr)
48
+ return available
49
+
50
+
51
+ def _run_analysis(repo_path: Path, analyzers: list[Analyzer], exclude_paths: list[str] | None = None) -> list[Finding]:
52
+ """Run all analyzers against a repo."""
53
+ all_findings: list[Finding] = []
54
+ for analyzer in analyzers:
55
+ try:
56
+ findings = analyzer.analyze_repo(repo_path, exclude_paths=exclude_paths)
57
+ all_findings.extend(findings)
58
+ print(f" [{analyzer.name}] {len(findings)} findings", file=sys.stderr)
59
+ except Exception as e:
60
+ print(f" [{analyzer.name}] ERROR: {e}", file=sys.stderr)
61
+ return all_findings
62
+
63
+
64
+ def _find_git_root(path: Path) -> Path | None:
65
+ """Walk up from path to find the nearest .git directory."""
66
+ current = path
67
+ while current != current.parent:
68
+ if (current / ".git").exists():
69
+ return current
70
+ current = current.parent
71
+ return None
72
+
73
+
74
+ def cmd_analyze(args: argparse.Namespace) -> None:
75
+ """Full analysis: run analyzers, score, persist to store."""
76
+ repo_path = Path(args.repo).resolve()
77
+ git_root = _find_git_root(repo_path)
78
+ if not git_root:
79
+ print(f"Warning: {repo_path} is not inside a git repository. Skipping commit analysis.", file=sys.stderr)
80
+
81
+ db_path = Path(args.db) if args.db else Path("arbiter_data.db")
82
+ store = Store(db_path)
83
+ registry = AgentRegistry()
84
+
85
+ exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
86
+ if exclude_paths:
87
+ print(f"Analyzing {repo_path} (excluding: {', '.join(exclude_paths)})...", file=sys.stderr)
88
+ else:
89
+ print(f"Analyzing {repo_path}...", file=sys.stderr)
90
+
91
+ # Run analyzers
92
+ analyzers = _get_analyzers()
93
+ findings = _run_analysis(repo_path, analyzers, exclude_paths=exclude_paths)
94
+ loc = count_loc(repo_path)
95
+
96
+ repo_name = repo_path.name
97
+
98
+ # Score
99
+ score = score_findings(findings, loc)
100
+ store.record_snapshot(score, loc, repo_name=repo_name)
101
+
102
+ # Update file-level quality
103
+ file_findings: dict[str, list[Finding]] = {}
104
+ for f in findings:
105
+ file_findings.setdefault(f.file_path, []).append(f)
106
+ for fp, ff in file_findings.items():
107
+ worst = max((f.severity for f in ff), key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0))
108
+ store.update_file_quality(fp, len(ff), worst, "analysis", repo_name=repo_name)
109
+
110
+ # Walk recent commits and score per-agent (requires git root)
111
+ commits = []
112
+ if git_root:
113
+ commits = walk_commits(git_root, max_count=args.commits, registry=registry)
114
+ for commit in commits:
115
+ # Per-commit scoring: score only the files this commit touched
116
+ commit_score = score_commit(git_root, commit, analyzers, exclude_paths=exclude_paths)
117
+ store.record_commit(
118
+ commit.hash, commit.timestamp, commit.agent,
119
+ commit.files_changed, commit.loc_added, commit.loc_removed, commit_score,
120
+ repo_name=repo_name,
121
+ )
122
+
123
+ # Print summary
124
+ print(f"\n{'='*60}")
125
+ print(f" Arbiter Score: {score.overall} ({score.grade})")
126
+ print(f"{'='*60}")
127
+ print(f" Lint: {score.lint_score:5.1f}")
128
+ print(f" Security: {score.security_score:5.1f}")
129
+ print(f" Complexity: {score.complexity_score:5.1f}")
130
+ print(f" Findings: {score.total_findings} ({', '.join(f'{k}:{v}' for k, v in sorted(score.findings_by_severity.items()))})")
131
+ print(f" LOC: {loc:,}")
132
+ print(f" Commits: {len(commits)} analyzed")
133
+ print(f" Tools: {', '.join(score.findings_by_tool.keys()) or 'none'}")
134
+
135
+ # Agent leaderboard
136
+ board = store.get_agent_leaderboard()
137
+ if board:
138
+ print("\n Agent Leaderboard:")
139
+ for agent in board:
140
+ print(f" {agent.agent_name:12s} avg={agent.avg_score:5.1f} commits={agent.commit_count} loc={agent.total_loc:,}")
141
+
142
+ print(f"\n Data stored in {db_path}")
143
+
144
+
145
+ def cmd_score(args: argparse.Namespace) -> None:
146
+ """Quick score without persistence."""
147
+ repo_path = Path(args.repo).resolve()
148
+ exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
149
+ analyzers = _get_analyzers()
150
+ findings = _run_analysis(repo_path, analyzers, exclude_paths=exclude_paths)
151
+ loc = count_loc(repo_path)
152
+ score = score_findings(findings, loc)
153
+
154
+ if args.json:
155
+ print(json.dumps({
156
+ "overall": score.overall,
157
+ "grade": score.grade,
158
+ "lint": score.lint_score,
159
+ "security": score.security_score,
160
+ "complexity": score.complexity_score,
161
+ "findings": score.total_findings,
162
+ "loc": loc,
163
+ "by_severity": score.findings_by_severity,
164
+ "by_tool": score.findings_by_tool,
165
+ }, indent=2))
166
+ else:
167
+ print(f"Score: {score.overall} ({score.grade}) | Lint: {score.lint_score} | Security: {score.security_score} | Complexity: {score.complexity_score} | Findings: {score.total_findings} | LOC: {loc:,}")
168
+
169
+
170
+ def cmd_agents(args: argparse.Namespace) -> None:
171
+ """Print agent leaderboard."""
172
+ db_path = Path(args.db) if args.db else Path("arbiter_data.db")
173
+ store = Store(db_path)
174
+ board = store.get_agent_leaderboard()
175
+ if not board:
176
+ print("No data. Run 'arbiter analyze' first.")
177
+ return
178
+ print(f"{'Agent':15s} {'Avg Score':>10s} {'Commits':>8s} {'LOC':>10s}")
179
+ print("-" * 47)
180
+ for agent in board:
181
+ print(f"{agent.agent_name:15s} {agent.avg_score:10.1f} {agent.commit_count:8d} {agent.total_loc:10,}")
182
+
183
+
184
+ def cmd_trend(args: argparse.Namespace) -> None:
185
+ """Print quality trend."""
186
+ db_path = Path(args.db) if args.db else Path("arbiter_data.db")
187
+ store = Store(db_path)
188
+ trend = store.get_trend(days=args.days)
189
+ if not trend:
190
+ print("No trend data. Run 'arbiter analyze' first.")
191
+ return
192
+ for point in trend:
193
+ print(f"{point['timestamp']} score={point['overall_score']:5.1f} "
194
+ f"lint={point['lint_score']:5.1f} sec={point['security_score']:5.1f} "
195
+ f"cx={point['complexity_score']:5.1f} findings={point['total_findings']}")
196
+
197
+
198
+ def cmd_worst(args: argparse.Namespace) -> None:
199
+ """Print worst files by quality."""
200
+ db_path = Path(args.db) if args.db else Path("arbiter_data.db")
201
+ store = Store(db_path)
202
+ worst = store.get_worst_files(limit=args.limit)
203
+ if not worst:
204
+ print("No file data. Run 'arbiter analyze' first.")
205
+ return
206
+ print(f"{'File':60s} {'Findings':>8s} {'Severity':>10s} {'Agent':>10s}")
207
+ print("-" * 92)
208
+ for f in worst:
209
+ print(f"{f.file_path:60s} {f.finding_count:8d} {f.worst_severity:>10s} {f.last_agent:>10s}")
210
+
211
+
212
+ def cmd_audit_fleet(args: argparse.Namespace) -> None:
213
+ """Audit all repos in a directory."""
214
+ fleet_dir = Path(args.directory).resolve()
215
+ if not fleet_dir.is_dir():
216
+ print(f"Error: {fleet_dir} is not a directory", file=sys.stderr)
217
+ sys.exit(1)
218
+
219
+ db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
220
+ store = Store(db_path)
221
+ registry = AgentRegistry()
222
+ analyzers = _get_analyzers()
223
+
224
+ repos = sorted([d for d in fleet_dir.iterdir() if d.is_dir() and (d / ".git").exists()])
225
+ print(f"Found {len(repos)} git repos in {fleet_dir}", file=sys.stderr)
226
+
227
+ for i, repo_path in enumerate(repos, 1):
228
+ repo_name = repo_path.name
229
+ print(f"\n[{i}/{len(repos)}] {repo_name}", file=sys.stderr)
230
+
231
+ findings = _run_analysis(repo_path, analyzers)
232
+ loc = count_loc(repo_path)
233
+ score = score_findings(findings, loc)
234
+ store.record_snapshot(score, loc, repo_name=repo_name)
235
+
236
+ # File-level quality
237
+ file_findings: dict[str, list[Finding]] = {}
238
+ for f in findings:
239
+ file_findings.setdefault(f.file_path, []).append(f)
240
+ for fp, ff in file_findings.items():
241
+ worst = max((f.severity for f in ff), key=lambda s: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(s, 0))
242
+ store.update_file_quality(fp, len(ff), worst, "analysis", repo_name=repo_name)
243
+
244
+ # Commits
245
+ commits = walk_commits(repo_path, max_count=args.commits, registry=registry)
246
+ for commit in commits:
247
+ store.record_commit(
248
+ commit.hash, commit.timestamp, commit.agent,
249
+ commit.files_changed, commit.loc_added, commit.loc_removed, score,
250
+ repo_name=repo_name,
251
+ )
252
+
253
+ grade = score.grade
254
+ print(f" Score: {score.overall} ({grade}) | {score.total_findings} findings | {loc:,} LOC", file=sys.stderr)
255
+
256
+ # Print fleet summary
257
+ print(f"\n{'='*70}", file=sys.stderr)
258
+ report = store.get_fleet_report()
259
+ _print_fleet_report(report)
260
+ print(f"\nData stored in {db_path}")
261
+
262
+
263
+ def cmd_fleet_report(args: argparse.Namespace) -> None:
264
+ """Print fleet quality report from existing data."""
265
+ db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
266
+ store = Store(db_path)
267
+ report = store.get_fleet_report()
268
+ if not report:
269
+ print("No fleet data. Run 'arbiter audit-fleet <dir>' first.")
270
+ return
271
+ _print_fleet_report(report)
272
+
273
+
274
+ def _print_fleet_report(report: list[dict]) -> None:
275
+ """Format and print the fleet report."""
276
+ def _grade(score: float) -> str:
277
+ if score >= 90: return "A"
278
+ if score >= 80: return "B"
279
+ if score >= 70: return "C"
280
+ if score >= 60: return "D"
281
+ return "F"
282
+
283
+ print(f"\n{'Repo':30s} {'Score':>6s} {'Grade':>6s} {'Findings':>9s} {'LOC':>8s}")
284
+ print("-" * 63)
285
+ grades = {"A": 0, "B": 0, "C": 0, "D": 0, "F": 0}
286
+ for r in report:
287
+ score = r.get("overall_score", 0) or 0
288
+ g = _grade(score)
289
+ grades[g] += 1
290
+ loc = r.get("total_loc", 0) or 0
291
+ findings = r.get("total_findings", 0) or 0
292
+ name = (r.get("repo_name") or "?")[:30]
293
+ print(f"{name:30s} {score:6.1f} {g:>6s} {findings:9d} {loc:8,}")
294
+
295
+ print(f"\nFleet: {len(report)} repos | ", end="")
296
+ print(" | ".join(f"{g}:{c}" for g, c in sorted(grades.items()) if c > 0))
297
+
298
+
299
+ def cmd_triage(args: argparse.Namespace) -> None:
300
+ """Auto-classify repos and output actionable recommendations."""
301
+ db_path = Path(args.db) if args.db else Path("arbiter_fleet.db")
302
+ store = Store(db_path)
303
+ report = store.get_fleet_report()
304
+ if not report:
305
+ print("No fleet data. Run 'arbiter audit-fleet <dir>' first.")
306
+ return
307
+
308
+ green, yellow, red, archive = [], [], [], []
309
+
310
+ for r in report:
311
+ score = r.get("overall_score", 0) or 0
312
+ loc = r.get("total_loc", 0) or 0
313
+ findings = r.get("total_findings", 0) or 0
314
+ name = r.get("repo_name", "?")
315
+
316
+ entry = {"name": name, "score": score, "loc": loc, "findings": findings}
317
+
318
+ if loc == 0 and findings == 0:
319
+ archive.append(entry)
320
+ elif score >= 80:
321
+ green.append(entry)
322
+ elif score >= 60:
323
+ if findings > 100:
324
+ red.append(entry)
325
+ else:
326
+ yellow.append(entry)
327
+ else:
328
+ red.append(entry)
329
+
330
+ print("=" * 70)
331
+ print(" ARBITER FLEET TRIAGE")
332
+ print("=" * 70)
333
+
334
+ print(f"\n GREEN ({len(green)} repos) — no action needed")
335
+ for r in sorted(green, key=lambda x: -x["score"]):
336
+ print(f" {r['name']:30s} {r['score']:5.1f} {r['loc']:>8,} LOC")
337
+
338
+ print(f"\n YELLOW ({len(yellow)} repos) — minor cleanup")
339
+ for r in sorted(yellow, key=lambda x: x["score"]):
340
+ print(f" {r['name']:30s} {r['score']:5.1f} {r['findings']:>5d} findings {r['loc']:>8,} LOC")
341
+ print(f" ACTION: ruff check --fix {r['name']}/")
342
+
343
+ print(f"\n RED ({len(red)} repos) — needs remediation or archival decision")
344
+ for r in sorted(red, key=lambda x: x["score"]):
345
+ print(f" {r['name']:30s} {r['score']:5.1f} {r['findings']:>5d} findings {r['loc']:>8,} LOC")
346
+ if r["findings"] > 500:
347
+ print(f" ACTION: ruff check --fix + manual review (high finding count)")
348
+ else:
349
+ print(f" ACTION: ruff check --fix, then re-score")
350
+
351
+ print(f"\n ARCHIVE CANDIDATES ({len(archive)} repos) — 0 LOC, no Python code")
352
+ for r in sorted(archive, key=lambda x: x["name"]):
353
+ print(f" {r['name']}")
354
+ print(f" ACTION: review for archival → gh repo archive hummbl-dev/<name>")
355
+
356
+ print(f"\n{'='*70}")
357
+ print(f" Summary: {len(green)} green | {len(yellow)} yellow | {len(red)} red | {len(archive)} archive candidates")
358
+ print(f"{'='*70}")
359
+
360
+
361
+ def cmd_fix(args: argparse.Namespace) -> None:
362
+ """Run ruff --fix on a repo and show before/after score."""
363
+ repo_path = Path(args.repo).resolve()
364
+ if not (repo_path / ".git").exists():
365
+ print(f"Error: {repo_path} is not a git repository", file=sys.stderr)
366
+ sys.exit(1)
367
+
368
+ analyzers = _get_analyzers()
369
+ ruff_only = [a for a in analyzers if a.name == "ruff"]
370
+ if not ruff_only:
371
+ print("Error: ruff not available", file=sys.stderr)
372
+ sys.exit(1)
373
+
374
+ # Before score
375
+ findings_before = _run_analysis(repo_path, ruff_only)
376
+ loc = count_loc(repo_path)
377
+ score_before = score_findings(findings_before, loc)
378
+
379
+ print(f"\nBEFORE: {score_before.overall} ({score_before.grade}) | {score_before.total_findings} findings")
380
+
381
+ if args.dry_run:
382
+ # Show what would be fixed
383
+ import subprocess
384
+ result = subprocess.run(
385
+ ["ruff", "check", "--fix", "--diff", str(repo_path)],
386
+ capture_output=True, text=True, timeout=120,
387
+ )
388
+ if result.stdout:
389
+ lines = result.stdout.strip().split("\n")
390
+ print(f"\nWould fix {len([l for l in lines if l.startswith('---')])} files")
391
+ print("(use without --dry-run to apply)")
392
+ else:
393
+ print("\nNothing to fix automatically.")
394
+ return
395
+
396
+ # Apply fixes
397
+ import subprocess
398
+ result = subprocess.run(
399
+ ["ruff", "check", "--fix", "--unsafe-fixes", str(repo_path)],
400
+ capture_output=True, text=True, timeout=120,
401
+ )
402
+ print(f"\nruff --fix output: {result.stdout.strip()}" if result.stdout.strip() else "")
403
+
404
+ # After score
405
+ findings_after = _run_analysis(repo_path, ruff_only)
406
+ score_after = score_findings(findings_after, loc)
407
+
408
+ delta = score_after.overall - score_before.overall
409
+ print(f"AFTER: {score_after.overall} ({score_after.grade}) | {score_after.total_findings} findings")
410
+ print(f"DELTA: {'+' if delta >= 0 else ''}{delta:.1f} points | "
411
+ f"{score_before.total_findings - score_after.total_findings} findings fixed")
412
+
413
+ if score_after.total_findings > 0:
414
+ print(f"\nRemaining findings ({score_after.total_findings}):")
415
+ for f in findings_after[:10]:
416
+ print(f" {f.file_path}:{f.line} [{f.rule_id}] {f.message[:60]}")
417
+ if len(findings_after) > 10:
418
+ print(f" ... and {len(findings_after) - 10} more")
419
+
420
+ if not args.no_commit and delta > 0:
421
+ print(f"\nTo commit: cd {repo_path} && git add -A && git commit -m 'fix: auto-remediate ruff findings (Arbiter)'")
422
+
423
+
424
+ def cmd_diff(args: argparse.Namespace) -> None:
425
+ """Score only files changed since base branch."""
426
+ repo_path = Path(args.repo).resolve()
427
+ exclude_paths = [p.strip() for p in args.exclude.split(",") if p.strip()] if args.exclude else None
428
+ analyzers = _get_analyzers()
429
+
430
+ base = args.base
431
+ print(f"Analyzing diff against {base}...", file=sys.stderr)
432
+
433
+ diff_score, findings, changed_files = score_diff(
434
+ repo_path, analyzers, base_branch=base, exclude_paths=exclude_paths,
435
+ )
436
+
437
+ python_changed = [f for f in changed_files if f.endswith(".py")]
438
+
439
+ if args.json:
440
+ print(json.dumps({
441
+ "overall": diff_score.overall,
442
+ "grade": diff_score.grade,
443
+ "lint": diff_score.lint_score,
444
+ "security": diff_score.security_score,
445
+ "complexity": diff_score.complexity_score,
446
+ "findings": diff_score.total_findings,
447
+ "files_changed": len(changed_files),
448
+ "python_files_changed": len(python_changed),
449
+ "by_severity": diff_score.findings_by_severity,
450
+ "by_tool": diff_score.findings_by_tool,
451
+ "changed_files": changed_files,
452
+ }, indent=2))
453
+ else:
454
+ print(f"\nDiff vs {base}: {len(changed_files)} files changed ({len(python_changed)} Python)")
455
+ print(f"Score: {diff_score.overall} ({diff_score.grade}) | "
456
+ f"Lint: {diff_score.lint_score} | Security: {diff_score.security_score} | "
457
+ f"Complexity: {diff_score.complexity_score} | "
458
+ f"Findings: {diff_score.total_findings}")
459
+
460
+ if findings:
461
+ print(f"\nTop findings ({min(len(findings), 10)} of {len(findings)}):")
462
+ for f in findings[:10]:
463
+ print(f" {f.file_path}:{f.line} [{f.severity}] {f.rule_id}: {f.message[:60]}")
464
+ if len(findings) > 10:
465
+ print(f" ... and {len(findings) - 10} more")
466
+
467
+
468
+ def cmd_commits(args: argparse.Namespace) -> None:
469
+ """Print recent commits with scores."""
470
+ db_path = Path(args.db) if args.db else Path("arbiter_data.db")
471
+ store = Store(db_path)
472
+ commits = store.get_recent_commits(agent=args.agent, limit=args.limit)
473
+ if not commits:
474
+ print("No commit data. Run 'arbiter analyze' first.")
475
+ return
476
+ for c in commits:
477
+ print(f"{c['commit_hash'][:8]} {c['agent']:12s} score={c['overall_score']:5.1f} "
478
+ f"+{c['loc_added']}/-{c['loc_removed']} {c['timestamp'][:16]}")
479
+
480
+
481
+ def main() -> None:
482
+ parser = argparse.ArgumentParser(description="Arbiter — Agent-aware code quality system")
483
+ parser.add_argument("--db", help="Path to SQLite database (default: arbiter_data.db)")
484
+ subparsers = parser.add_subparsers(dest="command", required=True)
485
+
486
+ # analyze
487
+ p_analyze = subparsers.add_parser("analyze", help="Full analysis with persistence")
488
+ p_analyze.add_argument("repo", help="Path to git repository")
489
+ p_analyze.add_argument("--commits", type=int, default=100, help="Max commits to analyze")
490
+ p_analyze.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude (e.g. dashboard,agents/factorio_system)")
491
+
492
+ # score
493
+ p_score = subparsers.add_parser("score", help="Quick score (no persistence)")
494
+ p_score.add_argument("repo", help="Path to directory or git repository")
495
+ p_score.add_argument("--json", action="store_true", help="JSON output")
496
+ p_score.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude")
497
+
498
+ # diff
499
+ p_diff = subparsers.add_parser("diff", help="Score only files changed since base branch")
500
+ p_diff.add_argument("repo", help="Path to git repository")
501
+ p_diff.add_argument("--base", default="main", help="Base branch (default: main)")
502
+ p_diff.add_argument("--json", action="store_true", help="JSON output")
503
+ p_diff.add_argument("--exclude", type=str, default="", help="Comma-separated paths to exclude")
504
+
505
+ # agents
506
+ subparsers.add_parser("agents", help="Agent leaderboard")
507
+
508
+ # trend
509
+ p_trend = subparsers.add_parser("trend", help="Quality trend")
510
+ p_trend.add_argument("--days", type=int, default=30, help="Days of history")
511
+
512
+ # worst
513
+ p_worst = subparsers.add_parser("worst", help="Worst files")
514
+ p_worst.add_argument("--limit", type=int, default=20, help="Number of files")
515
+
516
+ # commits
517
+ p_commits = subparsers.add_parser("commits", help="Recent commits with scores")
518
+ p_commits.add_argument("--agent", help="Filter by agent")
519
+ p_commits.add_argument("--limit", type=int, default=50, help="Number of commits")
520
+
521
+ # audit-fleet
522
+ p_fleet = subparsers.add_parser("audit-fleet", help="Audit all repos in a directory")
523
+ p_fleet.add_argument("directory", help="Directory containing git repos")
524
+ p_fleet.add_argument("--commits", type=int, default=20, help="Max commits per repo")
525
+
526
+ # triage
527
+ subparsers.add_parser("triage", help="Auto-classify repos with actionable recommendations")
528
+
529
+ # fix
530
+ p_fix = subparsers.add_parser("fix", help="Auto-fix ruff findings and show before/after")
531
+ p_fix.add_argument("repo", help="Path to git repository")
532
+ p_fix.add_argument("--dry-run", action="store_true", help="Show what would be fixed without applying")
533
+ p_fix.add_argument("--no-commit", action="store_true", help="Don't suggest commit command")
534
+
535
+ # fleet-report
536
+ subparsers.add_parser("fleet-report", help="Print fleet quality report")
537
+
538
+ # serve
539
+ p_serve = subparsers.add_parser("serve", help="Start API + dashboard")
540
+ p_serve.add_argument("--port", type=int, default=8080, help="Port")
541
+ p_serve.add_argument("--host", default="0.0.0.0", help="Host")
542
+
543
+ args = parser.parse_args()
544
+
545
+ commands = {
546
+ "analyze": cmd_analyze,
547
+ "score": cmd_score,
548
+ "diff": cmd_diff,
549
+ "agents": cmd_agents,
550
+ "trend": cmd_trend,
551
+ "worst": cmd_worst,
552
+ "commits": cmd_commits,
553
+ "audit-fleet": cmd_audit_fleet,
554
+ "fleet-report": cmd_fleet_report,
555
+ "triage": cmd_triage,
556
+ "fix": cmd_fix,
557
+ }
558
+
559
+ handler = commands.get(args.command)
560
+ if handler:
561
+ handler(args)
562
+ elif args.command == "serve":
563
+ from arbiter.api import run_server
564
+ run_server(host=args.host, port=args.port, db_path=args.db)
565
+ else:
566
+ parser.print_help()
567
+
568
+
569
+ if __name__ == "__main__":
570
+ main()
@@ -0,0 +1,120 @@
1
+ """Agent Registry — Maps commit authors to AI agent identities.
2
+
3
+ Provides agent attribution for commits based on author email,
4
+ Co-Authored-By trailers, and configurable patterns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class AgentProfile:
17
+ """Profile for a known agent or human contributor."""
18
+
19
+ name: str
20
+ emails: list[str] = field(default_factory=list)
21
+ co_author_patterns: list[str] = field(default_factory=list)
22
+ trust_tier: str = "verified" # verified, probation, unknown
23
+ quality_threshold: float = 70.0 # minimum passing score
24
+
25
+
26
+ # Default registry covering common AI agent signatures
27
+ _DEFAULT_AGENTS = [
28
+ AgentProfile(
29
+ name="claude",
30
+ emails=["noreply@anthropic.com"],
31
+ co_author_patterns=[r"Claude\s+(Opus|Sonnet|Haiku|Code)"],
32
+ trust_tier="verified",
33
+ quality_threshold=70.0,
34
+ ),
35
+ AgentProfile(
36
+ name="codex",
37
+ emails=["codex@openai.com", "noreply@github.com"],
38
+ co_author_patterns=[r"Codex"],
39
+ trust_tier="verified",
40
+ quality_threshold=70.0,
41
+ ),
42
+ AgentProfile(
43
+ name="gemini",
44
+ emails=["gemini@google.com"],
45
+ co_author_patterns=[r"Gemini"],
46
+ trust_tier="probation",
47
+ quality_threshold=80.0,
48
+ ),
49
+ AgentProfile(
50
+ name="copilot",
51
+ emails=[],
52
+ co_author_patterns=[r"GitHub\s+Copilot"],
53
+ trust_tier="verified",
54
+ quality_threshold=70.0,
55
+ ),
56
+ ]
57
+
58
+
59
+ class AgentRegistry:
60
+ """Registry of known agents and human contributors."""
61
+
62
+ def __init__(self, agents: list[AgentProfile] | None = None):
63
+ self._agents = agents if agents is not None else list(_DEFAULT_AGENTS)
64
+ self._email_index: dict[str, str] = {}
65
+ self._rebuild_index()
66
+
67
+ def _rebuild_index(self) -> None:
68
+ self._email_index.clear()
69
+ for agent in self._agents:
70
+ for email in agent.emails:
71
+ self._email_index[email.lower()] = agent.name
72
+
73
+ def add_agent(self, agent: AgentProfile) -> None:
74
+ self._agents.append(agent)
75
+ self._rebuild_index()
76
+
77
+ def identify(self, author_email: str, commit_message: str = "") -> str:
78
+ """Identify the agent from commit metadata.
79
+
80
+ Checks in order:
81
+ 1. Co-Authored-By trailer in commit message
82
+ 2. Author email match
83
+ 3. Default: "human"
84
+ """
85
+ # Check Co-Authored-By patterns
86
+ for agent in self._agents:
87
+ for pattern in agent.co_author_patterns:
88
+ if re.search(pattern, commit_message, re.IGNORECASE):
89
+ return agent.name
90
+
91
+ # Check email
92
+ email_lower = author_email.lower()
93
+ if email_lower in self._email_index:
94
+ return self._email_index[email_lower]
95
+
96
+ return "human"
97
+
98
+ def get_profile(self, agent_name: str) -> AgentProfile | None:
99
+ for agent in self._agents:
100
+ if agent.name == agent_name:
101
+ return agent
102
+ return None
103
+
104
+ def all_agents(self) -> list[AgentProfile]:
105
+ return list(self._agents)
106
+
107
+ @classmethod
108
+ def from_json(cls, path: Path) -> "AgentRegistry":
109
+ """Load registry from a JSON config file."""
110
+ data = json.loads(path.read_text())
111
+ agents = []
112
+ for entry in data.get("agents", []):
113
+ agents.append(AgentProfile(
114
+ name=entry["name"],
115
+ emails=entry.get("emails", []),
116
+ co_author_patterns=entry.get("co_author_patterns", []),
117
+ trust_tier=entry.get("trust_tier", "unknown"),
118
+ quality_threshold=entry.get("quality_threshold", 70.0),
119
+ ))
120
+ return cls(agents)