code-mower 0.5.0b5__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.
Files changed (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env python3
2
+ """Report reviewer accuracy, spend, and value from adjudicated outputs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from collections import Counter, defaultdict
10
+ from pathlib import Path
11
+ from typing import Any, Iterable, Mapping
12
+
13
+ if __package__ in {None, ""}:
14
+ module_dir = Path(__file__).resolve().parent
15
+ sys.path.insert(0, str(module_dir.parent))
16
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
17
+ from code_mower import code_mower_telemetry
18
+ else:
19
+ from tools import code_mower_telemetry
20
+ elif __package__ == "tools":
21
+ from tools import code_mower_telemetry
22
+ else: # pragma: no cover - exercised after package extraction.
23
+ from . import code_mower_telemetry
24
+
25
+
26
+ POSITIVE_DISPOSITIONS = {"true_positive", "useful"}
27
+ NEGATIVE_DISPOSITIONS = {"false_positive", "noise"}
28
+ KNOWN_DISPOSITIONS = POSITIVE_DISPOSITIONS | NEGATIVE_DISPOSITIONS | {"unknown"}
29
+ SUPPORTED_CALIBRATION_REPORT_MODES = {
30
+ "local-llm-calibration",
31
+ "reviewer-evidence-calibration",
32
+ }
33
+
34
+
35
+ def _load_json(path: Path) -> Mapping[str, Any]:
36
+ try:
37
+ payload = json.loads(path.read_text(encoding="utf-8"))
38
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
39
+ raise ValueError(f"unable to read {path}: {exc}") from exc
40
+ if not isinstance(payload, Mapping):
41
+ raise ValueError(f"{path} must contain a JSON object")
42
+ return payload
43
+
44
+
45
+ def _load_event_summary(path: Path) -> Mapping[str, Any]:
46
+ if not path.is_file():
47
+ raise ValueError(f"event log does not exist or is not a file: {path}")
48
+ return code_mower_telemetry.summarize_events(
49
+ code_mower_telemetry.load_jsonl_events(path)
50
+ )
51
+
52
+
53
+ def _float(value: Any) -> float:
54
+ if isinstance(value, bool):
55
+ return 0.0
56
+ if isinstance(value, (int, float)):
57
+ return float(value)
58
+ if isinstance(value, str):
59
+ try:
60
+ return float(value)
61
+ except ValueError:
62
+ return 0.0
63
+ return 0.0
64
+
65
+
66
+ def _spend_by_profile(spend_payload: Mapping[str, Any] | None) -> dict[str, float]:
67
+ if not spend_payload:
68
+ return {}
69
+ raw_profiles = spend_payload.get("profiles", spend_payload)
70
+ if not isinstance(raw_profiles, Mapping):
71
+ raise ValueError("spend file must be a mapping or contain a profiles mapping")
72
+ spend: dict[str, float] = {}
73
+ for profile_id, value in raw_profiles.items():
74
+ if isinstance(value, Mapping):
75
+ cost = _float(value.get("cost_usd", value.get("usd")))
76
+ else:
77
+ cost = _float(value)
78
+ spend[str(profile_id)] = cost
79
+ return spend
80
+
81
+
82
+ def _rate(numerator: float, denominator: float) -> float | None:
83
+ if denominator <= 0:
84
+ return None
85
+ return round(numerator / denominator, 4)
86
+
87
+
88
+ def build_reviewer_metrics(
89
+ calibration_reports: Iterable[Mapping[str, Any]],
90
+ *,
91
+ spend: Mapping[str, Any] | None = None,
92
+ event_summaries: Iterable[Mapping[str, Any]] = (),
93
+ ) -> dict[str, Any]:
94
+ spend_map = _spend_by_profile(spend)
95
+ counts: dict[str, Counter[str]] = defaultdict(Counter)
96
+ durations: dict[str, float] = defaultdict(float)
97
+ runs: dict[str, int] = defaultdict(int)
98
+ known_clean_pass_runs: dict[str, int] = defaultdict(int)
99
+ blocking_false_positive_runs: dict[str, int] = defaultdict(int)
100
+ known_blocked_runs: dict[str, int] = defaultdict(int)
101
+ known_blocked_caught_runs: dict[str, int] = defaultdict(int)
102
+ known_blocked_missed_runs: dict[str, int] = defaultdict(int)
103
+ infra_error_runs: dict[str, int] = defaultdict(int)
104
+ audit_input_insufficient_runs: dict[str, int] = defaultdict(int)
105
+ run_statuses: dict[str, Counter[str]] = defaultdict(Counter)
106
+ review_classes: dict[str, set[str]] = defaultdict(set)
107
+ context_packs: dict[str, set[str]] = defaultdict(set)
108
+ useful_review_classes: dict[str, set[str]] = defaultdict(set)
109
+ useful_context_packs: dict[str, set[str]] = defaultdict(set)
110
+ models: dict[str, str] = {}
111
+ source_count = 0
112
+ event_summary_count = 0
113
+
114
+ for report in calibration_reports:
115
+ source_count += 1
116
+ if report.get("mode") not in SUPPORTED_CALIBRATION_REPORT_MODES:
117
+ raise ValueError(
118
+ "reviewer metrics expects local-llm-calibration or "
119
+ "reviewer-evidence-calibration reports"
120
+ )
121
+ profiles = report.get("profiles", {})
122
+ if isinstance(profiles, Mapping):
123
+ for profile_id, stats in profiles.items():
124
+ if not isinstance(stats, Mapping):
125
+ continue
126
+ profile = str(profile_id)
127
+ models[profile] = str(stats.get("model") or models.get(profile, ""))
128
+ runs[profile] += int(stats.get("runs") or 0)
129
+ durations[profile] += _float(stats.get("duration_seconds_total"))
130
+ known_clean_pass_runs[profile] += int(stats.get("known_clean_pass_runs") or 0)
131
+ blocking_false_positive_runs[profile] += int(
132
+ stats.get("blocking_false_positive_runs") or 0
133
+ )
134
+ known_blocked_runs[profile] += int(stats.get("known_blocked_runs") or 0)
135
+ known_blocked_caught_runs[profile] += int(
136
+ stats.get("known_blocked_caught_runs") or 0
137
+ )
138
+ known_blocked_missed_runs[profile] += int(
139
+ stats.get("known_blocked_missed_runs") or 0
140
+ )
141
+ infra_error_runs[profile] += int(stats.get("infra_error_runs") or 0)
142
+ audit_input_insufficient_runs[profile] += int(
143
+ stats.get("audit_input_insufficient_runs") or 0
144
+ )
145
+ raw_run_statuses = stats.get("run_statuses", {})
146
+ if isinstance(raw_run_statuses, Mapping):
147
+ for status, count in raw_run_statuses.items():
148
+ run_statuses[profile][str(status)] += int(count or 0)
149
+ for review_class in stats.get("review_classes", []) or []:
150
+ text = str(review_class).strip()
151
+ if text:
152
+ review_classes[profile].add(text)
153
+ for context_pack in stats.get("context_packs", []) or []:
154
+ text = str(context_pack).strip()
155
+ if text:
156
+ context_packs[profile].add(text)
157
+ for review_class in stats.get("useful_review_classes", []) or []:
158
+ text = str(review_class).strip()
159
+ if text:
160
+ useful_review_classes[profile].add(text)
161
+ for context_pack in stats.get("useful_context_packs", []) or []:
162
+ text = str(context_pack).strip()
163
+ if text:
164
+ useful_context_packs[profile].add(text)
165
+ dispositions = stats.get("dispositions", {})
166
+ if isinstance(dispositions, Mapping):
167
+ for disposition, count in dispositions.items():
168
+ normalized = str(disposition).strip().lower()
169
+ if normalized not in KNOWN_DISPOSITIONS:
170
+ continue
171
+ counts[profile][normalized] += int(count or 0)
172
+
173
+ lane_events: dict[str, dict[str, Any]] = {}
174
+ for summary in event_summaries:
175
+ event_summary_count += 1
176
+ if summary.get("mode") != "telemetry-summary":
177
+ raise ValueError("event summaries must be telemetry-summary reports")
178
+ lanes = summary.get("lanes", {})
179
+ if not isinstance(lanes, Mapping):
180
+ continue
181
+ for lane_id, lane_stats in lanes.items():
182
+ if not isinstance(lane_stats, Mapping):
183
+ continue
184
+ profile = str(lane_id)
185
+ existing = lane_events.setdefault(
186
+ profile,
187
+ {
188
+ "events": 0,
189
+ "finished": 0,
190
+ "pass": 0,
191
+ "blocked": 0,
192
+ "failed": 0,
193
+ "findings": 0,
194
+ "observed_pr_count": 0,
195
+ },
196
+ )
197
+ for key in ("events", "finished", "pass", "blocked", "failed", "findings", "observed_pr_count"):
198
+ existing[key] += int(lane_stats.get(key) or 0)
199
+
200
+ profiles_out: dict[str, dict[str, Any]] = {}
201
+ for profile_id in sorted(set(counts) | set(runs) | set(spend_map) | set(lane_events)):
202
+ profile_counts = counts[profile_id]
203
+ true_positive = profile_counts["true_positive"]
204
+ false_positive = profile_counts["false_positive"]
205
+ useful = profile_counts["useful"]
206
+ noise = profile_counts["noise"]
207
+ unknown = profile_counts["unknown"]
208
+ known = true_positive + false_positive + useful + noise
209
+ useful_total = true_positive + useful
210
+ negative_total = false_positive + noise
211
+ cost_usd = spend_map.get(profile_id, 0.0)
212
+ run_count = runs.get(profile_id, 0)
213
+ duration_seconds = durations.get(profile_id, 0.0)
214
+ known_blocked_catches = known_blocked_caught_runs.get(profile_id, 0)
215
+ profiles_out[profile_id] = {
216
+ "profile_id": profile_id,
217
+ "model": models.get(profile_id, ""),
218
+ "runs": run_count,
219
+ "duration_seconds_total": round(duration_seconds, 3),
220
+ "cost_usd": round(cost_usd, 4),
221
+ "dispositions": dict(sorted(profile_counts.items())),
222
+ "known_disposition_count": known,
223
+ "unknown_disposition_count": unknown,
224
+ "known_clean_pass_runs": known_clean_pass_runs.get(profile_id, 0),
225
+ "blocking_false_positive_runs": blocking_false_positive_runs.get(profile_id, 0),
226
+ "known_blocked_runs": known_blocked_runs.get(profile_id, 0),
227
+ "known_blocked_caught_runs": known_blocked_caught_runs.get(profile_id, 0),
228
+ "known_blocked_missed_runs": known_blocked_missed_runs.get(profile_id, 0),
229
+ "infra_error_runs": infra_error_runs.get(profile_id, 0),
230
+ "audit_input_insufficient_runs": audit_input_insufficient_runs.get(
231
+ profile_id, 0
232
+ ),
233
+ "run_statuses": dict(sorted(run_statuses.get(profile_id, {}).items())),
234
+ "review_classes": sorted(review_classes.get(profile_id, set())),
235
+ "context_packs": sorted(context_packs.get(profile_id, set())),
236
+ "useful_review_classes": sorted(
237
+ useful_review_classes.get(profile_id, set())
238
+ ),
239
+ "useful_context_packs": sorted(
240
+ useful_context_packs.get(profile_id, set())
241
+ ),
242
+ "useful_findings": useful_total,
243
+ "negative_findings": negative_total,
244
+ "precision": _rate(true_positive, true_positive + false_positive),
245
+ "useful_rate": _rate(useful_total, known),
246
+ "noise_rate": _rate(negative_total, known),
247
+ "useful_findings_per_usd": (
248
+ round(useful_total / cost_usd, 4) if cost_usd > 0 else None
249
+ ),
250
+ "cost_per_run": (
251
+ round(cost_usd / run_count, 4) if cost_usd > 0 and run_count else None
252
+ ),
253
+ "cost_per_useful_finding": (
254
+ round(cost_usd / useful_total, 4) if cost_usd > 0 and useful_total else None
255
+ ),
256
+ "cost_per_known_blocked_catch": (
257
+ round(cost_usd / known_blocked_catches, 4)
258
+ if cost_usd > 0 and known_blocked_catches
259
+ else None
260
+ ),
261
+ "seconds_per_run": (
262
+ round(duration_seconds / run_count, 3) if run_count else None
263
+ ),
264
+ "seconds_per_useful_finding": (
265
+ round(duration_seconds / useful_total, 3)
266
+ if useful_total
267
+ else None
268
+ ),
269
+ "seconds_per_known_blocked_catch": (
270
+ round(duration_seconds / known_blocked_catches, 3)
271
+ if known_blocked_catches
272
+ else None
273
+ ),
274
+ "event_log": lane_events.get(profile_id, {}),
275
+ }
276
+
277
+ return {
278
+ "mode": "reviewer-metrics",
279
+ "source_report_count": source_count,
280
+ "event_summary_count": event_summary_count,
281
+ "profiles": profiles_out,
282
+ "recommendations": _recommendations(profiles_out),
283
+ }
284
+
285
+
286
+ def _recommendations(profiles: Mapping[str, Mapping[str, Any]]) -> list[str]:
287
+ recommendations: list[str] = []
288
+ for profile_id, stats in profiles.items():
289
+ if stats.get("known_disposition_count", 0) == 0:
290
+ recommendations.append(
291
+ f"{profile_id}: collect human dispositions before comparing reviewer accuracy."
292
+ )
293
+ continue
294
+ if (stats.get("useful_rate") or 0) < 0.5:
295
+ recommendations.append(
296
+ f"{profile_id}: low useful-rate; keep informational until prompt or context improves."
297
+ )
298
+ if stats.get("known_blocked_missed_runs", 0):
299
+ recommendations.append(
300
+ f"{profile_id}: missed known-blocked calibration runs; keep informational until catch rate improves."
301
+ )
302
+ if stats.get("audit_input_insufficient_runs", 0):
303
+ recommendations.append(
304
+ f"{profile_id}: audit input was insufficient on some runs; add context packs or larger budgets before promotion."
305
+ )
306
+ if stats.get("cost_usd", 0) and not stats.get("useful_findings", 0):
307
+ recommendations.append(
308
+ f"{profile_id}: spend recorded but no useful findings; pause paid runs or narrow triggers."
309
+ )
310
+ if not recommendations:
311
+ recommendations.append(
312
+ "Use these metrics as calibration evidence, not automatic merge authority."
313
+ )
314
+ return recommendations
315
+
316
+
317
+ def render_reviewer_metrics_text(report: Mapping[str, Any]) -> str:
318
+ lines = [
319
+ "Code Mower reviewer metrics",
320
+ "",
321
+ "Profiles:",
322
+ ]
323
+ profiles = report.get("profiles", {})
324
+ if isinstance(profiles, Mapping) and profiles:
325
+ for profile_id, stats in profiles.items():
326
+ if not isinstance(stats, Mapping):
327
+ continue
328
+ lines.append(
329
+ f"- {profile_id}: useful={stats.get('useful_findings', 0)} "
330
+ f"negative={stats.get('negative_findings', 0)} "
331
+ f"useful_rate={stats.get('useful_rate')} "
332
+ f"cost=${stats.get('cost_usd', 0)} "
333
+ f"sec_per_run={stats.get('seconds_per_run')} "
334
+ f"useful_per_usd={stats.get('useful_findings_per_usd')} "
335
+ f"cost_per_useful={stats.get('cost_per_useful_finding')}"
336
+ )
337
+ event_log = stats.get("event_log", {})
338
+ if isinstance(event_log, Mapping) and event_log:
339
+ lines.append(
340
+ f" events: finished={event_log.get('finished', 0)} "
341
+ f"pass={event_log.get('pass', 0)} blocked={event_log.get('blocked', 0)} "
342
+ f"findings={event_log.get('findings', 0)}"
343
+ )
344
+ else:
345
+ lines.append("- none")
346
+ lines.extend(["", "Recommendations:"])
347
+ for recommendation in report.get("recommendations", []) or []:
348
+ lines.append(f"- {recommendation}")
349
+ return "\n".join(lines) + "\n"
350
+
351
+
352
+ def main(argv: list[str] | None = None) -> int:
353
+ parser = argparse.ArgumentParser(description=__doc__)
354
+ parser.add_argument("calibration_reports", nargs="*", type=Path)
355
+ parser.add_argument("--spend", type=Path, default=None)
356
+ parser.add_argument(
357
+ "--events",
358
+ nargs="+",
359
+ type=Path,
360
+ default=[],
361
+ help="Optional Code Mower audit event JSONL logs to fold into lane value metrics.",
362
+ )
363
+ parser.add_argument("--json", action="store_true")
364
+ args = parser.parse_args(argv)
365
+
366
+ try:
367
+ if not args.calibration_reports and not args.events:
368
+ raise ValueError("pass at least one calibration report or --events log")
369
+ reports = [_load_json(path) for path in args.calibration_reports]
370
+ spend = _load_json(args.spend) if args.spend is not None else None
371
+ event_summaries = [_load_event_summary(path) for path in args.events]
372
+ metrics = build_reviewer_metrics(
373
+ reports,
374
+ spend=spend,
375
+ event_summaries=event_summaries,
376
+ )
377
+ except ValueError as exc:
378
+ print(f"error: {exc}", file=sys.stderr)
379
+ return 1
380
+
381
+ if args.json:
382
+ print(json.dumps(metrics, indent=2, sort_keys=True))
383
+ else:
384
+ print(render_reviewer_metrics_text(metrics), end="")
385
+ return 0
386
+
387
+
388
+ if __name__ == "__main__":
389
+ raise SystemExit(main())