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,561 @@
1
+ #!/usr/bin/env python3
2
+ """Summarize Code Mower audit/review telemetry from JSONL event logs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+ from typing import Any, Iterable, Mapping, Sequence
15
+
16
+ if __package__ in {None, ""}:
17
+ module_dir = Path(__file__).resolve().parent
18
+ sys.path.insert(0, str(module_dir.parent))
19
+ if module_dir.name == "code_mower": # pragma: no cover - extracted direct CLI.
20
+ from code_mower import audit_handoff_log
21
+ else:
22
+ from tools import audit_handoff_log
23
+ elif __package__ == "tools":
24
+ from tools import audit_handoff_log
25
+ else: # pragma: no cover - exercised after package extraction.
26
+ from . import audit_handoff_log
27
+
28
+
29
+ PASS_VERDICTS = {"completed", "done", "pass", "passed", "success", "succeeded"}
30
+ BLOCKED_VERDICTS = {"block", "blocked", "fail", "failed", "failure"}
31
+ VERDICT_ARTIFACT_SCHEMA = "code_mower.auditVerdictArtifact.v1"
32
+ BENCHMARK_EVENT_SCHEMA = "code_mower.benchmarkEvent.v1"
33
+ VERDICT_ARTIFACT_DIR_ENV = "CODE_MOWER_VERDICT_ARTIFACT_DIR"
34
+ DEFAULT_VERDICT_ARTIFACT_DIR = Path.home() / ".cache" / "code-mower-audits" / "verdicts"
35
+ SEVERITY_RE = re.compile(r"\b(P[0-3])=(\d+)\b")
36
+
37
+
38
+ def default_verdict_artifact_dir() -> Path:
39
+ configured = os.environ.get(VERDICT_ARTIFACT_DIR_ENV, "").strip()
40
+ if configured:
41
+ return Path(configured).expanduser()
42
+ return DEFAULT_VERDICT_ARTIFACT_DIR
43
+
44
+
45
+ def _normalize_repo_slug(value: str) -> str:
46
+ text = value.strip().strip("/")
47
+ parts = [part for part in text.split("/") if part]
48
+ if len(parts) != 2:
49
+ return text
50
+ return f"{parts[0]}/{parts[1]}"
51
+
52
+
53
+ def _repo_slug_from_artifact_path(path: Path) -> str:
54
+ for parent in [path.parent, *path.parents]:
55
+ name = parent.name
56
+ if "__" in name:
57
+ owner, repo = name.split("__", 1)
58
+ if owner and repo:
59
+ return f"{owner}/{repo}"
60
+ return ""
61
+
62
+
63
+ def _artifact_pr_from_path(path: Path) -> int | None:
64
+ for parent in [path.parent, *path.parents]:
65
+ match = re.fullmatch(r"pr-(\d+)", parent.name)
66
+ if match:
67
+ return int(match.group(1))
68
+ return None
69
+
70
+
71
+ def _lane_provider(lane_id: str) -> str:
72
+ if lane_id.endswith("-audit"):
73
+ return lane_id.removesuffix("-audit")
74
+ return lane_id or "unknown"
75
+
76
+
77
+ def _severity_counts(comment_body: str) -> dict[str, int]:
78
+ counts = {"p0": 0, "p1": 0, "p2": 0, "p3": 0}
79
+ for severity, value in SEVERITY_RE.findall(comment_body):
80
+ counts[severity.lower()] += int(value)
81
+ return counts
82
+
83
+
84
+ def _coerce_severity_counts(value: Any) -> dict[str, int] | None:
85
+ if not isinstance(value, Mapping):
86
+ return None
87
+ counts = {"p0": 0, "p1": 0, "p2": 0, "p3": 0}
88
+ found = False
89
+ for key in counts:
90
+ raw = value.get(key) if key in value else value.get(key.upper())
91
+ if raw is None:
92
+ continue
93
+ try:
94
+ counts[key] = int(raw)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ found = True
98
+ return counts if found else None
99
+
100
+
101
+ def _artifact_severity_counts(payload: Mapping[str, Any]) -> tuple[dict[str, int], str]:
102
+ structured_counts = _coerce_severity_counts(payload.get("severity_counts"))
103
+ if structured_counts is not None:
104
+ return structured_counts, "structured"
105
+ finding_count = payload.get("finding_count")
106
+ if isinstance(finding_count, int):
107
+ return {"p0": 0, "p1": 0, "p2": 0, "p3": 0}, "finding_count_only"
108
+ comment_counts = _severity_counts(str(payload.get("comment_body") or ""))
109
+ if any(comment_counts.values()):
110
+ return comment_counts, "severity_summary"
111
+ return comment_counts, "unavailable"
112
+
113
+
114
+ def _normal_artifact_verdict(value: Any) -> str:
115
+ verdict = str(value or "").strip().lower()
116
+ if verdict in PASS_VERDICTS:
117
+ return "pass"
118
+ if verdict in BLOCKED_VERDICTS:
119
+ return "blocked"
120
+ return verdict or "unknown"
121
+
122
+
123
+ def _verdict_event_id(payload: Mapping[str, Any], path: Path) -> str:
124
+ # Keep event IDs stable without encoding private git refs. Git refs are only
125
+ # exported when the operator explicitly passes --include-git-ref.
126
+ seed = "|".join(
127
+ [
128
+ str(payload.get("repo") or _repo_slug_from_artifact_path(path)),
129
+ str(payload.get("pr_number") or _artifact_pr_from_path(path) or ""),
130
+ str(payload.get("lane_id") or ""),
131
+ str(payload.get("verdict") or ""),
132
+ str(payload.get("created_at") or ""),
133
+ path.name,
134
+ ]
135
+ )
136
+ digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:20]
137
+ return f"verdict-artifact-{digest}"
138
+
139
+
140
+ def reviewer_run_event_from_verdict_artifact(
141
+ payload: Mapping[str, Any],
142
+ *,
143
+ path: Path,
144
+ include_git_ref: bool = False,
145
+ ) -> dict[str, Any]:
146
+ if payload.get("schema") != VERDICT_ARTIFACT_SCHEMA:
147
+ raise ValueError(
148
+ f"unsupported verdict artifact schema {payload.get('schema')!r}; "
149
+ f"expected {VERDICT_ARTIFACT_SCHEMA!r}"
150
+ )
151
+ lane_id = str(payload.get("lane_id") or "unknown")
152
+ repo_slug = _normalize_repo_slug(
153
+ str(payload.get("repo") or _repo_slug_from_artifact_path(path))
154
+ )
155
+ pr_number = payload.get("pr_number")
156
+ if pr_number is None:
157
+ pr_number = _artifact_pr_from_path(path)
158
+ try:
159
+ pr_number_value: int | None = int(pr_number) if pr_number is not None else None
160
+ except (TypeError, ValueError):
161
+ pr_number_value = None
162
+ severities, severity_source = _artifact_severity_counts(payload)
163
+ finding_count_raw = payload.get("finding_count")
164
+ finding_count = (
165
+ int(finding_count_raw)
166
+ if isinstance(finding_count_raw, int)
167
+ else sum(severities.values())
168
+ )
169
+ status = _normal_artifact_verdict(payload.get("verdict"))
170
+ dimensions: dict[str, Any] = {
171
+ "lane_id": lane_id,
172
+ "pr_number": pr_number_value,
173
+ "artifact_source": "local-verdict-cache",
174
+ "git_ref_included": include_git_ref,
175
+ "severity_count_source": severity_source,
176
+ }
177
+ if include_git_ref:
178
+ head = str(payload.get("head_sha_end") or payload.get("head_sha_start") or "")
179
+ dimensions["head_sha"] = head
180
+ dimensions["head_sha_short"] = head[:12]
181
+ return {
182
+ "schema": BENCHMARK_EVENT_SCHEMA,
183
+ "event_type": "reviewer_run",
184
+ "event_id": _verdict_event_id(payload, path),
185
+ "created_at": str(payload.get("created_at") or ""),
186
+ "repo_slug": repo_slug,
187
+ "source": "verdict-artifact-export",
188
+ "provider": _lane_provider(lane_id),
189
+ "lens": "base",
190
+ "status": status,
191
+ "metrics": {
192
+ "finding_count": finding_count,
193
+ "p0_count": severities["p0"],
194
+ "p1_count": severities["p1"],
195
+ "p2_count": severities["p2"],
196
+ "p3_count": severities["p3"],
197
+ },
198
+ "dimensions": dimensions,
199
+ }
200
+
201
+
202
+ def _iter_verdict_artifact_paths(root: Path) -> list[Path]:
203
+ if not root.exists():
204
+ return []
205
+ if root.is_file():
206
+ return [root]
207
+ return sorted(root.glob("**/*.json"))
208
+
209
+
210
+ def export_reviewer_run_events_from_verdicts(
211
+ root: Path,
212
+ *,
213
+ repo: str = "",
214
+ limit: int = 0,
215
+ include_git_ref: bool = False,
216
+ ) -> list[dict[str, Any]]:
217
+ repo_filter = _normalize_repo_slug(repo) if repo else ""
218
+ events: list[dict[str, Any]] = []
219
+ for path in _iter_verdict_artifact_paths(root.expanduser()):
220
+ try:
221
+ payload = json.loads(path.read_text(encoding="utf-8"))
222
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
223
+ raise ValueError(f"{path}: invalid verdict artifact: {exc}") from exc
224
+ if not isinstance(payload, dict):
225
+ raise ValueError(f"{path}: verdict artifact must be a JSON object")
226
+ if payload.get("schema") != VERDICT_ARTIFACT_SCHEMA:
227
+ continue
228
+ artifact_repo = _normalize_repo_slug(
229
+ str(payload.get("repo") or _repo_slug_from_artifact_path(path))
230
+ )
231
+ if repo_filter and artifact_repo != repo_filter:
232
+ continue
233
+ events.append(
234
+ reviewer_run_event_from_verdict_artifact(
235
+ payload,
236
+ path=path,
237
+ include_git_ref=include_git_ref,
238
+ )
239
+ )
240
+ events.sort(
241
+ key=lambda event: (str(event.get("created_at") or ""), str(event.get("event_id") or "")),
242
+ reverse=True,
243
+ )
244
+ return events[:limit] if limit else events
245
+
246
+
247
+ def load_jsonl_events(path: Path) -> list[dict[str, Any]]:
248
+ events: list[dict[str, Any]] = []
249
+ if not path.exists():
250
+ return events
251
+ for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
252
+ if not raw.strip():
253
+ continue
254
+ try:
255
+ payload = json.loads(raw)
256
+ except json.JSONDecodeError as exc:
257
+ raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
258
+ if not isinstance(payload, dict):
259
+ raise ValueError(f"{path}:{line_no}: event must be a JSON object")
260
+ events.append(payload)
261
+ return events
262
+
263
+
264
+ def _event_lane(event: Mapping[str, Any]) -> str:
265
+ lane = event.get("lane")
266
+ if lane:
267
+ return str(lane)
268
+ dimensions = event.get("dimensions")
269
+ if isinstance(dimensions, Mapping) and dimensions.get("lane_id"):
270
+ return str(dimensions["lane_id"])
271
+ provider = event.get("provider")
272
+ if provider:
273
+ lens = str(event.get("lens") or "base")
274
+ return f"{provider}:{lens}" if lens else str(provider)
275
+ lock = event.get("lock")
276
+ if isinstance(lock, Mapping) and lock.get("lane"):
277
+ return str(lock["lane"])
278
+ active = event.get("active")
279
+ if isinstance(active, Mapping) and active.get("lane"):
280
+ return str(active["lane"])
281
+ return "unknown"
282
+
283
+
284
+ def _event_repo(event: Mapping[str, Any]) -> str:
285
+ repo_slug = event.get("repo_slug")
286
+ if repo_slug:
287
+ return str(repo_slug)
288
+ repo = event.get("repo")
289
+ if repo:
290
+ return str(repo)
291
+ lock = event.get("lock")
292
+ if isinstance(lock, Mapping) and lock.get("repo"):
293
+ return str(lock["repo"])
294
+ return "unknown"
295
+
296
+
297
+ def _event_pr(event: Mapping[str, Any]) -> str:
298
+ pr = event.get("pr")
299
+ if pr is not None:
300
+ return str(pr)
301
+ dimensions = event.get("dimensions")
302
+ if isinstance(dimensions, Mapping) and dimensions.get("pr_number") is not None:
303
+ return str(dimensions["pr_number"])
304
+ lock = event.get("lock")
305
+ if isinstance(lock, Mapping) and lock.get("pr") is not None:
306
+ return str(lock["pr"])
307
+ return "unknown"
308
+
309
+
310
+ def _event_head(event: Mapping[str, Any]) -> str:
311
+ head = event.get("head")
312
+ if head:
313
+ return str(head)
314
+ lock = event.get("lock")
315
+ if isinstance(lock, Mapping) and lock.get("head"):
316
+ return str(lock["head"])
317
+ return "unknown"
318
+
319
+
320
+ def _normal_verdict(value: Any) -> str:
321
+ text = str(value or "").strip().lower()
322
+ if text in PASS_VERDICTS:
323
+ return "pass"
324
+ if text in BLOCKED_VERDICTS:
325
+ return "blocked"
326
+ return text or "unknown"
327
+
328
+
329
+ def _event_outcome(event: Mapping[str, Any]) -> str:
330
+ if event.get("verdict") is not None:
331
+ return _normal_verdict(event.get("verdict"))
332
+ status = str(event.get("status") or "").strip().lower()
333
+ if status in PASS_VERDICTS or status in BLOCKED_VERDICTS:
334
+ return _normal_verdict(status)
335
+ return "unknown"
336
+
337
+
338
+ def _finding_count(event: Mapping[str, Any]) -> int:
339
+ metrics = event.get("metrics")
340
+ if isinstance(metrics, Mapping):
341
+ for key in ("finding_count", "findings_count", "findings"):
342
+ value = metrics.get(key)
343
+ if isinstance(value, int):
344
+ return value
345
+ if isinstance(value, list):
346
+ return len(value)
347
+ for key in ("finding_count", "findings_count", "findings"):
348
+ value = event.get(key)
349
+ if isinstance(value, int):
350
+ return value
351
+ if isinstance(value, list):
352
+ return len(value)
353
+ return 0
354
+
355
+
356
+ def summarize_events(events: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
357
+ event_list = list(events)
358
+ by_event = Counter(
359
+ str(event.get("event") or event.get("event_type") or "unknown")
360
+ for event in event_list
361
+ )
362
+ by_lane = Counter(_event_lane(event) for event in event_list)
363
+ by_verdict = Counter(_event_outcome(event) for event in event_list)
364
+ by_verdict.pop("unknown", None)
365
+
366
+ lane_stats: dict[str, dict[str, Any]] = {}
367
+ lane_heads: dict[str, set[str]] = defaultdict(set)
368
+ lane_repos: dict[str, set[str]] = defaultdict(set)
369
+ lane_prs: dict[str, set[str]] = defaultdict(set)
370
+ lane_repo_prs: dict[str, set[str]] = defaultdict(set)
371
+ authoring_tools = Counter()
372
+
373
+ for event in event_list:
374
+ lane = _event_lane(event)
375
+ repo = _event_repo(event)
376
+ pr = _event_pr(event)
377
+ stats = lane_stats.setdefault(
378
+ lane,
379
+ {
380
+ "events": 0,
381
+ "started": 0,
382
+ "finished": 0,
383
+ "pass": 0,
384
+ "blocked": 0,
385
+ "failed": 0,
386
+ "findings": 0,
387
+ },
388
+ )
389
+ stats["events"] += 1
390
+ event_name = str(event.get("event") or event.get("event_type") or "unknown")
391
+ if event_name == "started":
392
+ stats["started"] += 1
393
+ if event_name in {"finished", "reviewer_run", "calibration_run"}:
394
+ stats["finished"] += 1
395
+ verdict = _event_outcome(event)
396
+ if verdict in {"pass", "blocked"}:
397
+ stats[verdict] += 1
398
+ if str(event.get("status", "")).lower() == "failed":
399
+ stats["failed"] += 1
400
+ stats["findings"] += _finding_count(event)
401
+ lane_heads[lane].add(_event_head(event))
402
+ lane_repos[lane].add(repo)
403
+ lane_prs[lane].add(pr)
404
+ if repo != "unknown" or pr != "unknown":
405
+ lane_repo_prs[lane].add(f"{repo}#{pr}")
406
+
407
+ authoring_tool = (
408
+ event.get("authoring_tool")
409
+ or event.get("authoringTool")
410
+ or event.get("tool")
411
+ )
412
+ if authoring_tool:
413
+ authoring_tools[str(authoring_tool)] += 1
414
+
415
+ for lane, stats in lane_stats.items():
416
+ heads = sorted(item for item in lane_heads[lane] if item != "unknown")
417
+ repos = sorted(item for item in lane_repos[lane] if item != "unknown")
418
+ prs = sorted(item for item in lane_prs[lane] if item != "unknown")
419
+ repo_prs = sorted(item for item in lane_repo_prs[lane] if item != "unknown#unknown")
420
+ stats["heads"] = heads
421
+ stats["repositories"] = repos
422
+ stats["pull_requests"] = prs
423
+ stats["repo_pull_requests"] = repo_prs
424
+ stats["observed_pr_count"] = len(repo_prs)
425
+ finished = stats["finished"]
426
+ stats["pass_rate"] = round(stats["pass"] / finished, 4) if finished else None
427
+ stats["blocked_rate"] = (
428
+ round(stats["blocked"] / finished, 4) if finished else None
429
+ )
430
+
431
+ return {
432
+ "mode": "telemetry-summary",
433
+ "event_count": len(event_list),
434
+ "events_by_type": dict(sorted(by_event.items())),
435
+ "events_by_lane": dict(sorted(by_lane.items())),
436
+ "verdicts": dict(sorted(by_verdict.items())),
437
+ "lanes": dict(sorted(lane_stats.items())),
438
+ "authoring_intelligence": {
439
+ "observed_authoring_tools": dict(sorted(authoring_tools.items())),
440
+ "caveat": (
441
+ "Observational telemetry can compare findings density and "
442
+ "head-to-head catch differential, but it is not causal proof "
443
+ "of which AI writes better code."
444
+ ),
445
+ },
446
+ }
447
+
448
+
449
+ def render_summary_text(summary: Mapping[str, Any]) -> str:
450
+ lines = [
451
+ "Code Mower telemetry summary",
452
+ f"Events: {summary.get('event_count', 0)}",
453
+ "",
454
+ "Lanes:",
455
+ ]
456
+ lanes = summary.get("lanes", {})
457
+ if isinstance(lanes, Mapping) and lanes:
458
+ for lane, stats in lanes.items():
459
+ if not isinstance(stats, Mapping):
460
+ continue
461
+ lines.append(
462
+ f"- {lane}: finished={stats.get('finished', 0)} "
463
+ f"pass={stats.get('pass', 0)} blocked={stats.get('blocked', 0)} "
464
+ f"findings={stats.get('findings', 0)}"
465
+ )
466
+ else:
467
+ lines.append("- none")
468
+ caveat = summary.get("authoring_intelligence", {})
469
+ if isinstance(caveat, Mapping):
470
+ lines.extend(["", f"Caveat: {caveat.get('caveat', '')}"])
471
+ return "\n".join(lines) + "\n"
472
+
473
+
474
+ def main(argv: Sequence[str] | None = None) -> int:
475
+ parser = argparse.ArgumentParser(description=__doc__)
476
+ subparsers = parser.add_subparsers(dest="command", required=True)
477
+ summarize = subparsers.add_parser("summarize")
478
+ summarize.add_argument(
479
+ "events",
480
+ nargs="?",
481
+ type=Path,
482
+ default=audit_handoff_log.events_path(),
483
+ )
484
+ summarize.add_argument("--json", action="store_true")
485
+ export_verdicts = subparsers.add_parser(
486
+ "export-verdict-events",
487
+ help="Export saved audit verdict artifacts as metadata-only reviewer_run JSONL events.",
488
+ )
489
+ export_verdicts.add_argument(
490
+ "verdicts",
491
+ nargs="?",
492
+ type=Path,
493
+ default=default_verdict_artifact_dir(),
494
+ help="Verdict artifact file or directory.",
495
+ )
496
+ export_verdicts.add_argument("--repo", default="", help="Optional owner/repo filter.")
497
+ export_verdicts.add_argument(
498
+ "--limit",
499
+ type=int,
500
+ default=0,
501
+ help="Maximum events to export; 0 means no limit.",
502
+ )
503
+ export_verdicts.add_argument(
504
+ "--include-git-ref",
505
+ action="store_true",
506
+ help="Include head SHA metadata. Off by default for privacy.",
507
+ )
508
+ export_verdicts.add_argument(
509
+ "--output",
510
+ type=Path,
511
+ help="Write JSONL events to this path instead of stdout.",
512
+ )
513
+ args = parser.parse_args(argv)
514
+
515
+ if args.command == "summarize":
516
+ try:
517
+ summary = summarize_events(load_jsonl_events(args.events))
518
+ except (OSError, ValueError) as exc:
519
+ print(f"error: {exc}", file=sys.stderr)
520
+ return 1
521
+
522
+ if args.json:
523
+ print(json.dumps(summary, indent=2, sort_keys=True))
524
+ else:
525
+ print(render_summary_text(summary), end="")
526
+ return 0
527
+
528
+ if args.command == "export-verdict-events":
529
+ try:
530
+ events = export_reviewer_run_events_from_verdicts(
531
+ args.verdicts,
532
+ repo=args.repo,
533
+ limit=args.limit,
534
+ include_git_ref=args.include_git_ref,
535
+ )
536
+ except (OSError, ValueError) as exc:
537
+ print(f"error: {exc}", file=sys.stderr)
538
+ return 1
539
+ text = "".join(json.dumps(event, sort_keys=True) + "\n" for event in events)
540
+ if not events:
541
+ print(
542
+ "warning: no verdict artifacts matched; no reviewer_run events exported",
543
+ file=sys.stderr,
544
+ )
545
+ return 0
546
+ if args.output:
547
+ try:
548
+ args.output.parent.mkdir(parents=True, exist_ok=True)
549
+ args.output.write_text(text, encoding="utf-8")
550
+ except OSError as exc:
551
+ print(f"error: failed to write {args.output}: {exc}", file=sys.stderr)
552
+ return 1
553
+ else:
554
+ print(text, end="")
555
+ return 0
556
+
557
+ return 0
558
+
559
+
560
+ if __name__ == "__main__":
561
+ raise SystemExit(main())