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,539 @@
1
+ #!/usr/bin/env python3
2
+ """Plan and report Code Mower builder-side calibration experiments."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import sys
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Any, Iterable, Mapping, Sequence
14
+
15
+ BUILDER_PLAN_MODE = "code-mower-builder-experiment-plan"
16
+ BUILDER_PLAN_SCHEMA = "code_mower.builderExperimentPlan.v1"
17
+ BUILDER_REPORT_MODE = "code-mower-builder-experiment-report"
18
+ BUILDER_REPORT_SCHEMA = "code_mower.builderExperimentReport.v1"
19
+ SAFE_SLUG_RE = re.compile(r"[^A-Za-z0-9_.-]+")
20
+ DEFAULT_METRICS = (
21
+ "elapsed_seconds",
22
+ "cost_usd",
23
+ "user_interventions",
24
+ "audit_blockers",
25
+ "resolved_blockers",
26
+ "tests_passed",
27
+ "post_merge_health",
28
+ )
29
+
30
+
31
+ def _safe_slug(value: Any, fallback: str = "item") -> str:
32
+ text = SAFE_SLUG_RE.sub("-", str(value or "").strip()).strip("._-")
33
+ while ".." in text:
34
+ text = text.replace("..", ".")
35
+ return text or fallback
36
+
37
+
38
+ def _stable_suffix(value: Any, length: int = 8) -> str:
39
+ return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest()[:length]
40
+
41
+
42
+ def _identity_slug(value: Any, fallback: str) -> str:
43
+ return f"{_safe_slug(value, fallback)}-{_stable_suffix(value)}"
44
+
45
+
46
+ def _load_json(path: Path) -> Mapping[str, Any]:
47
+ try:
48
+ payload = json.loads(path.read_text(encoding="utf-8"))
49
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
50
+ raise ValueError(f"unable to read {path}: {exc}") from exc
51
+ if not isinstance(payload, Mapping):
52
+ raise ValueError(f"{path} must contain a JSON object")
53
+ return payload
54
+
55
+
56
+ def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
59
+
60
+
61
+ def _as_list(value: Any) -> list[Any]:
62
+ if value is None:
63
+ return []
64
+ if isinstance(value, list):
65
+ return value
66
+ return [value]
67
+
68
+
69
+ def _str_list(value: Any) -> list[str]:
70
+ return [str(item) for item in _as_list(value) if str(item).strip()]
71
+
72
+
73
+ def normalize_spec(payload: Mapping[str, Any]) -> dict[str, Any]:
74
+ payload = dict(payload)
75
+ if payload.get("version", 1) not in {1, "1"}:
76
+ raise ValueError("builder experiment spec version must be 1")
77
+ tasks = payload.get("tasks")
78
+ builders = payload.get("builders")
79
+ if not isinstance(tasks, list) or not tasks:
80
+ raise ValueError("builder experiment spec must include a non-empty tasks list")
81
+ if not isinstance(builders, list) or not builders:
82
+ raise ValueError("builder experiment spec must include a non-empty builders list")
83
+
84
+ normalized_tasks: list[dict[str, Any]] = []
85
+ seen_tasks: set[str] = set()
86
+ for index, task in enumerate(tasks):
87
+ if not isinstance(task, Mapping):
88
+ raise ValueError(f"tasks[{index}] must be a JSON object")
89
+ task_id = str(task.get("task_id") or task.get("id") or "").strip()
90
+ if not task_id:
91
+ raise ValueError(f"tasks[{index}].task_id is required")
92
+ if task_id in seen_tasks:
93
+ raise ValueError(f"duplicate task_id: {task_id}")
94
+ seen_tasks.add(task_id)
95
+ repo = str(task.get("repo") or "").strip()
96
+ if "/" not in repo:
97
+ raise ValueError(f"tasks[{index}].repo must be an owner/repo slug")
98
+ normalized_tasks.append(
99
+ {
100
+ "task_id": task_id,
101
+ "repo": repo,
102
+ "base_ref": str(task.get("base_ref") or "origin/main"),
103
+ "task_class": str(task.get("task_class") or "general"),
104
+ "prompt": str(task.get("prompt") or ""),
105
+ "success_criteria": _str_list(task.get("success_criteria")),
106
+ "context_packs": _str_list(task.get("context_packs")),
107
+ "review_classes": _str_list(task.get("review_classes")),
108
+ "notes": str(task.get("notes") or ""),
109
+ }
110
+ )
111
+
112
+ normalized_builders: list[dict[str, Any]] = []
113
+ seen_builders: set[str] = set()
114
+ for index, builder in enumerate(builders):
115
+ if not isinstance(builder, Mapping):
116
+ raise ValueError(f"builders[{index}] must be a JSON object")
117
+ builder_id = str(builder.get("builder_id") or builder.get("id") or "").strip()
118
+ if not builder_id:
119
+ raise ValueError(f"builders[{index}].builder_id is required")
120
+ if builder_id in seen_builders:
121
+ raise ValueError(f"duplicate builder_id: {builder_id}")
122
+ seen_builders.add(builder_id)
123
+ normalized_builders.append(
124
+ {
125
+ "builder_id": builder_id,
126
+ "provider": str(builder.get("provider") or "unknown"),
127
+ "tool": str(builder.get("tool") or ""),
128
+ "model": str(builder.get("model") or ""),
129
+ "prompt_lenses": _str_list(builder.get("prompt_lenses")),
130
+ "context_packs": _str_list(builder.get("context_packs")),
131
+ "command_template": _str_list(builder.get("command_template")),
132
+ "cost_policy": str(builder.get("cost_policy") or "unknown"),
133
+ "notes": str(builder.get("notes") or ""),
134
+ }
135
+ )
136
+
137
+ return {
138
+ "version": 1,
139
+ "name": str(payload.get("name") or ""),
140
+ "description": str(payload.get("description") or ""),
141
+ "tasks": normalized_tasks,
142
+ "builders": normalized_builders,
143
+ "metrics": _str_list(payload.get("metrics")) or list(DEFAULT_METRICS),
144
+ "review_protocol": dict(payload.get("review_protocol") or {}),
145
+ }
146
+
147
+
148
+ def load_spec(path: Path) -> dict[str, Any]:
149
+ payload = dict(_load_json(path))
150
+ normalized = normalize_spec(payload)
151
+ if not str(payload.get("name") or "").strip():
152
+ normalized["name"] = path.stem
153
+ return normalized
154
+
155
+
156
+ def _experiment_id(spec: Mapping[str, Any]) -> str:
157
+ raw = "\n".join(
158
+ [
159
+ str(spec.get("name") or ""),
160
+ json.dumps(spec.get("tasks", []), sort_keys=True),
161
+ json.dumps(spec.get("builders", []), sort_keys=True),
162
+ ]
163
+ )
164
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
165
+ return f"{_safe_slug(spec.get('name'), 'builder-experiment')}-{digest}"
166
+
167
+
168
+ def _run_id(
169
+ experiment_id: str,
170
+ task_id: str,
171
+ builder_id: str,
172
+ replicate: int,
173
+ ) -> str:
174
+ return (
175
+ f"{_safe_slug(experiment_id, 'experiment')}-"
176
+ f"{_identity_slug(task_id, 'task')}-"
177
+ f"{_identity_slug(builder_id, 'builder')}-r{replicate}"
178
+ )
179
+
180
+
181
+ def build_plan(
182
+ spec: Mapping[str, Any],
183
+ *,
184
+ output_dir: Path = Path(".code-mower/builder-experiments"),
185
+ replicates: int = 1,
186
+ ) -> dict[str, Any]:
187
+ spec = normalize_spec(spec)
188
+ if replicates <= 0:
189
+ raise ValueError("replicates must be greater than zero")
190
+ experiment_id = _experiment_id(spec)
191
+ runs: list[dict[str, Any]] = []
192
+ metrics = _str_list(spec.get("metrics")) or list(DEFAULT_METRICS)
193
+ review_protocol = dict(spec.get("review_protocol") or {})
194
+
195
+ for task in spec.get("tasks", []) or []:
196
+ if not isinstance(task, Mapping):
197
+ continue
198
+ for builder in spec.get("builders", []) or []:
199
+ if not isinstance(builder, Mapping):
200
+ continue
201
+ for replicate in range(1, replicates + 1):
202
+ task_id = str(task["task_id"])
203
+ builder_id = str(builder["builder_id"])
204
+ run_id = _run_id(experiment_id, task_id, builder_id, replicate)
205
+ run_dir = (
206
+ output_dir
207
+ / _safe_slug(experiment_id, "experiment")
208
+ / _identity_slug(task_id, "task")
209
+ / _identity_slug(builder_id, "builder")
210
+ / f"r{replicate}"
211
+ )
212
+ runs.append(
213
+ {
214
+ "run_id": run_id,
215
+ "experiment_id": experiment_id,
216
+ "task_id": task_id,
217
+ "task_class": task.get("task_class", "general"),
218
+ "repo": task["repo"],
219
+ "base_ref": task.get("base_ref", "origin/main"),
220
+ "builder_id": builder_id,
221
+ "provider": builder.get("provider", ""),
222
+ "tool": builder.get("tool", ""),
223
+ "model": builder.get("model", ""),
224
+ "prompt_lenses": list(builder.get("prompt_lenses", [])),
225
+ "context_packs": sorted(
226
+ set(task.get("context_packs", []))
227
+ | set(builder.get("context_packs", []))
228
+ ),
229
+ "success_criteria": list(task.get("success_criteria", [])),
230
+ "review_classes": list(task.get("review_classes", [])),
231
+ "command_template": list(builder.get("command_template", [])),
232
+ "output_dir": str(run_dir),
233
+ "replicate": replicate,
234
+ "metrics_required": metrics,
235
+ "blind_review_required": True,
236
+ "merge_authority": False,
237
+ "review_protocol": review_protocol
238
+ or {
239
+ "required_before_merge": [
240
+ "structured audits pass",
241
+ "Code Mower merge bar is clean",
242
+ "post-merge health is verified",
243
+ ],
244
+ },
245
+ }
246
+ )
247
+
248
+ return {
249
+ "mode": BUILDER_PLAN_MODE,
250
+ "schema": BUILDER_PLAN_SCHEMA,
251
+ "experiment_id": experiment_id,
252
+ "name": str(spec.get("name") or ""),
253
+ "description": str(spec.get("description") or ""),
254
+ "replicates": replicates,
255
+ "output_dir": str(output_dir),
256
+ "task_count": len(spec.get("tasks", []) or []),
257
+ "builder_count": len(spec.get("builders", []) or []),
258
+ "run_count": len(runs),
259
+ "tasks": list(spec.get("tasks", []) or []),
260
+ "builders": list(spec.get("builders", []) or []),
261
+ "runs": runs,
262
+ "guardrails": [
263
+ "Use a fresh worktree for every builder run.",
264
+ "Keep reviewer output hidden until the builder declares the run complete.",
265
+ "Record user interventions and audit blocker iterations explicitly.",
266
+ "Do not treat builder experiments as merge authority by themselves.",
267
+ "Only count a delivery as verified after merge and post-merge health checks.",
268
+ ],
269
+ }
270
+
271
+
272
+ def load_run_results(paths: Iterable[Path]) -> list[dict[str, Any]]:
273
+ records: list[dict[str, Any]] = []
274
+ for path in paths:
275
+ payload = _load_json(path)
276
+ raw_runs = payload.get("runs", payload.get("run_results"))
277
+ if raw_runs is None and payload.get("run_id"):
278
+ raw_runs = [payload]
279
+ if not isinstance(raw_runs, list):
280
+ raise ValueError(f"{path} must include a runs list or one run_id object")
281
+ for index, run in enumerate(raw_runs):
282
+ if not isinstance(run, Mapping):
283
+ raise ValueError(f"{path}: runs[{index}] must be a JSON object")
284
+ records.append(dict(run))
285
+ return records
286
+
287
+
288
+ def _float(value: Any) -> float:
289
+ if isinstance(value, bool):
290
+ return 0.0
291
+ if isinstance(value, (int, float)):
292
+ return float(value)
293
+ if isinstance(value, str):
294
+ try:
295
+ return float(value)
296
+ except ValueError:
297
+ return 0.0
298
+ return 0.0
299
+
300
+
301
+ def _int(value: Any) -> int:
302
+ if isinstance(value, bool):
303
+ return 0
304
+ if isinstance(value, int):
305
+ return value
306
+ if isinstance(value, float):
307
+ return int(value)
308
+ if isinstance(value, str):
309
+ try:
310
+ return int(float(value))
311
+ except ValueError:
312
+ return 0
313
+ return 0
314
+
315
+
316
+ def _status_category(value: Any) -> str:
317
+ status = str(value or "").strip().lower().replace("-", "_")
318
+ if status in {"merged", "verified", "success", "succeeded"}:
319
+ return "verified" if status == "verified" else "merged"
320
+ if status in {"blocked", "failed", "failure", "abandoned"}:
321
+ return "failed"
322
+ if status in {"running", "started", "in_progress"}:
323
+ return "running"
324
+ if status in {"planned", "pending"}:
325
+ return "planned"
326
+ return status or "unknown"
327
+
328
+
329
+ def build_report(
330
+ plan: Mapping[str, Any],
331
+ run_results: Sequence[Mapping[str, Any]] = (),
332
+ ) -> dict[str, Any]:
333
+ planned_runs = {
334
+ str(run.get("run_id")): run
335
+ for run in plan.get("runs", []) or []
336
+ if isinstance(run, Mapping) and run.get("run_id")
337
+ }
338
+ result_by_run_id: dict[str, Mapping[str, Any]] = {}
339
+ for result in run_results:
340
+ run_id = str(result.get("run_id") or "").strip()
341
+ if run_id:
342
+ result_by_run_id[run_id] = result
343
+ unmatched_result_run_ids = sorted(set(result_by_run_id) - set(planned_runs))
344
+
345
+ builder_stats: dict[str, dict[str, Any]] = {}
346
+ run_rows: list[dict[str, Any]] = []
347
+ matched_reported_run_count = 0
348
+ for run_id, planned in planned_runs.items():
349
+ result = result_by_run_id.get(run_id, {})
350
+ builder_id = str(planned.get("builder_id") or "unknown-builder")
351
+ stats = builder_stats.setdefault(
352
+ builder_id,
353
+ {
354
+ "builder_id": builder_id,
355
+ "planned_runs": 0,
356
+ "reported_runs": 0,
357
+ "merged_runs": 0,
358
+ "verified_runs": 0,
359
+ "failed_runs": 0,
360
+ "running_runs": 0,
361
+ "total_elapsed_seconds": 0.0,
362
+ "total_cost_usd": 0.0,
363
+ "total_user_interventions": 0,
364
+ "total_audit_blockers": 0,
365
+ "total_resolved_blockers": 0,
366
+ },
367
+ )
368
+ stats["planned_runs"] += 1
369
+ status = _status_category(result.get("status") or result.get("merge_result"))
370
+ if result:
371
+ matched_reported_run_count += 1
372
+ stats["reported_runs"] += 1
373
+ stats["total_elapsed_seconds"] += _float(result.get("elapsed_seconds"))
374
+ stats["total_cost_usd"] += _float(result.get("cost_usd"))
375
+ stats["total_user_interventions"] += _int(result.get("user_interventions"))
376
+ stats["total_audit_blockers"] += _int(result.get("audit_blockers"))
377
+ stats["total_resolved_blockers"] += _int(result.get("resolved_blockers"))
378
+ post_merge_health = str(result.get("post_merge_health") or "").strip().lower()
379
+ if status in {"merged", "verified"}:
380
+ stats["merged_runs"] += 1
381
+ if status == "failed":
382
+ stats["failed_runs"] += 1
383
+ elif status == "running":
384
+ stats["running_runs"] += 1
385
+ elif status == "verified" or (
386
+ status == "merged" and post_merge_health == "verified"
387
+ ):
388
+ stats["verified_runs"] += 1
389
+ run_rows.append(
390
+ {
391
+ "run_id": run_id,
392
+ "task_id": planned.get("task_id"),
393
+ "builder_id": builder_id,
394
+ "status": status,
395
+ "reported": bool(result),
396
+ "elapsed_seconds": result.get("elapsed_seconds"),
397
+ "cost_usd": result.get("cost_usd"),
398
+ "user_interventions": result.get("user_interventions"),
399
+ "audit_blockers": result.get("audit_blockers"),
400
+ "resolved_blockers": result.get("resolved_blockers"),
401
+ "post_merge_health": result.get("post_merge_health", ""),
402
+ }
403
+ )
404
+
405
+ for stats in builder_stats.values():
406
+ reported = int(stats["reported_runs"])
407
+ verified = int(stats["verified_runs"])
408
+ stats["avg_elapsed_seconds"] = (
409
+ round(float(stats["total_elapsed_seconds"]) / reported, 3)
410
+ if reported
411
+ else None
412
+ )
413
+ stats["verified_rate"] = (
414
+ round(verified / int(stats["reported_runs"]), 4)
415
+ if int(stats["reported_runs"])
416
+ else None
417
+ )
418
+ stats["cost_per_verified_run"] = (
419
+ round(float(stats["total_cost_usd"]) / verified, 4) if verified else None
420
+ )
421
+ stats["total_elapsed_seconds"] = round(float(stats["total_elapsed_seconds"]), 3)
422
+ stats["total_cost_usd"] = round(float(stats["total_cost_usd"]), 4)
423
+
424
+ return {
425
+ "mode": BUILDER_REPORT_MODE,
426
+ "schema": BUILDER_REPORT_SCHEMA,
427
+ "report_id": uuid.uuid4().hex,
428
+ "experiment_id": plan.get("experiment_id", ""),
429
+ "name": plan.get("name", ""),
430
+ "planned_run_count": len(planned_runs),
431
+ "reported_run_count": matched_reported_run_count,
432
+ "unmatched_reported_run_count": len(unmatched_result_run_ids),
433
+ "unmatched_result_run_ids": unmatched_result_run_ids,
434
+ "builders": dict(sorted(builder_stats.items())),
435
+ "runs": run_rows,
436
+ "caveat": (
437
+ "Builder experiments measure delivery loops on a real codebase. "
438
+ "Treat early results as directional until each task has matched "
439
+ "clean worktrees, hidden reviewer output, and verified post-merge health."
440
+ ),
441
+ }
442
+
443
+
444
+ def render_plan_text(plan: Mapping[str, Any]) -> str:
445
+ lines = [
446
+ "Code Mower builder experiment plan",
447
+ f"Experiment: {plan.get('experiment_id', '')}",
448
+ f"Tasks: {plan.get('task_count', 0)}",
449
+ f"Builders: {plan.get('builder_count', 0)}",
450
+ f"Runs: {plan.get('run_count', 0)}",
451
+ "",
452
+ "First runs:",
453
+ ]
454
+ for run in (plan.get("runs", []) or [])[:5]:
455
+ if isinstance(run, Mapping):
456
+ lines.append(
457
+ f"- {run.get('run_id')}: {run.get('builder_id')} "
458
+ f"on {run.get('repo')} task={run.get('task_id')}"
459
+ )
460
+ if not plan.get("runs"):
461
+ lines.append("- none")
462
+ lines.extend(["", "Guardrails:"])
463
+ lines.extend(f"- {item}" for item in plan.get("guardrails", []) or [])
464
+ return "\n".join(lines) + "\n"
465
+
466
+
467
+ def render_report_text(report: Mapping[str, Any]) -> str:
468
+ lines = [
469
+ "# Code Mower Builder Experiment Report",
470
+ "",
471
+ f"Experiment: `{report.get('experiment_id', '')}`",
472
+ f"Planned runs: {report.get('planned_run_count', 0)}",
473
+ f"Reported runs: {report.get('reported_run_count', 0)}",
474
+ "",
475
+ "| Builder | Planned | Reported | Merged | Verified | Failed | User interventions | Audit blockers | Cost | Avg sec | Cost/verified |",
476
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
477
+ ]
478
+ builders = report.get("builders", {})
479
+ if isinstance(builders, Mapping):
480
+ for builder_id, stats in sorted(builders.items()):
481
+ if not isinstance(stats, Mapping):
482
+ continue
483
+ lines.append(
484
+ f"| `{builder_id}` | {stats.get('planned_runs', 0)} | "
485
+ f"{stats.get('reported_runs', 0)} | {stats.get('merged_runs', 0)} | "
486
+ f"{stats.get('verified_runs', 0)} | {stats.get('failed_runs', 0)} | "
487
+ f"{stats.get('total_user_interventions', 0)} | "
488
+ f"{stats.get('total_audit_blockers', 0)} | "
489
+ f"{stats.get('total_cost_usd', 0)} | "
490
+ f"{stats.get('avg_elapsed_seconds', 0)} | "
491
+ f"{stats.get('cost_per_verified_run')} |"
492
+ )
493
+ lines.extend(["", f"Caveat: {report.get('caveat', '')}"])
494
+ return "\n".join(lines) + "\n"
495
+
496
+
497
+ def main(argv: list[str] | None = None) -> int:
498
+ parser = argparse.ArgumentParser(prog="code-mower builder-experiment")
499
+ subparsers = parser.add_subparsers(dest="command", required=True)
500
+ plan_parser = subparsers.add_parser("plan")
501
+ plan_parser.add_argument("spec", type=Path)
502
+ plan_parser.add_argument("--output-dir", type=Path, default=Path(".code-mower/builder-experiments"))
503
+ plan_parser.add_argument("--replicates", type=int, default=1)
504
+ plan_parser.add_argument("--output", type=Path)
505
+ plan_parser.add_argument("--json", action="store_true")
506
+ report_parser = subparsers.add_parser("report")
507
+ report_parser.add_argument("spec", type=Path)
508
+ report_parser.add_argument("--runs", type=Path, action="append", default=[])
509
+ report_parser.add_argument("--output-dir", type=Path, default=Path(".code-mower/builder-experiments"))
510
+ report_parser.add_argument("--replicates", type=int, default=1)
511
+ report_parser.add_argument("--output", type=Path)
512
+ report_parser.add_argument("--json", action="store_true")
513
+ args = parser.parse_args(argv)
514
+
515
+ try:
516
+ spec = load_spec(args.spec)
517
+ plan = build_plan(spec, output_dir=args.output_dir, replicates=args.replicates)
518
+ if args.command == "plan":
519
+ if args.output:
520
+ _write_json(args.output, plan)
521
+ print(json.dumps(plan, indent=2, sort_keys=True) if args.json else render_plan_text(plan))
522
+ return 0
523
+ run_results = load_run_results(args.runs)
524
+ report = build_report(plan, run_results)
525
+ if args.output:
526
+ args.output.parent.mkdir(parents=True, exist_ok=True)
527
+ if args.output.suffix.lower() == ".json":
528
+ _write_json(args.output, report)
529
+ else:
530
+ args.output.write_text(render_report_text(report), encoding="utf-8")
531
+ print(json.dumps(report, indent=2, sort_keys=True) if args.json else render_report_text(report))
532
+ return 0
533
+ except ValueError as exc:
534
+ print(f"error: {exc}", file=sys.stderr)
535
+ return 1
536
+
537
+
538
+ if __name__ == "__main__":
539
+ raise SystemExit(main())