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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- 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())
|