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,441 @@
1
+ #!/usr/bin/env python3
2
+ """Render calibration reports from local LLM bakeoff summaries."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import sys
10
+ from collections import Counter
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Iterable, Mapping
14
+
15
+
16
+ KNOWN_DISPOSITIONS = {
17
+ "true_positive",
18
+ "false_positive",
19
+ "useful",
20
+ "noise",
21
+ "unknown",
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class ProfileStats:
27
+ profile_id: str
28
+ model: str = ""
29
+ runs: int = 0
30
+ files_reviewed: int = 0
31
+ blocker_file_count: int = 0
32
+ concern_file_count: int = 0
33
+ parse_failure_count: int = 0
34
+ json_repair_used_count: int = 0
35
+ parse_attempts_total: int = 0
36
+ duration_seconds_total: float = 0.0
37
+ verdicts: Counter[str] = field(default_factory=Counter)
38
+ dispositions: Counter[str] = field(default_factory=Counter)
39
+
40
+ def record_run(self, run: Mapping[str, Any]) -> None:
41
+ self.runs += 1
42
+ self.model = str(run.get("model") or self.model)
43
+ self.files_reviewed += _int(run.get("files_reviewed"))
44
+ self.blocker_file_count += _int(run.get("blocker_file_count"))
45
+ self.concern_file_count += _int(run.get("concern_file_count"))
46
+ self.parse_failure_count += _int(run.get("parse_failure_count"))
47
+ self.json_repair_used_count += _int(run.get("json_repair_used_count"))
48
+ self.parse_attempts_total += _int(run.get("parse_attempts_total"))
49
+ self.duration_seconds_total += _float(run.get("duration_seconds"))
50
+ verdict = str(run.get("verdict") or "UNKNOWN")
51
+ self.verdicts[verdict] += 1
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ average_duration = (
55
+ self.duration_seconds_total / self.runs
56
+ if self.runs
57
+ else 0.0
58
+ )
59
+ parse_failure_rate = (
60
+ self.parse_failure_count / self.files_reviewed
61
+ if self.files_reviewed
62
+ else 0.0
63
+ )
64
+ return {
65
+ "profile_id": self.profile_id,
66
+ "model": self.model,
67
+ "runs": self.runs,
68
+ "files_reviewed": self.files_reviewed,
69
+ "verdicts": dict(sorted(self.verdicts.items())),
70
+ "blocker_file_count": self.blocker_file_count,
71
+ "concern_file_count": self.concern_file_count,
72
+ "parse_failure_count": self.parse_failure_count,
73
+ "json_repair_used_count": self.json_repair_used_count,
74
+ "parse_attempts_total": self.parse_attempts_total,
75
+ "duration_seconds_total": round(self.duration_seconds_total, 3),
76
+ "duration_seconds_average": round(average_duration, 3),
77
+ "parse_failure_rate": round(parse_failure_rate, 4),
78
+ "dispositions": dict(sorted(self.dispositions.items())),
79
+ }
80
+
81
+
82
+ def _int(value: Any) -> int:
83
+ if isinstance(value, bool):
84
+ return int(value)
85
+ if isinstance(value, int):
86
+ return value
87
+ if isinstance(value, float):
88
+ return int(value)
89
+ if isinstance(value, str) and value.strip().isdigit():
90
+ return int(value)
91
+ return 0
92
+
93
+
94
+ def _float(value: Any) -> float:
95
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
96
+ return float(value)
97
+ if isinstance(value, str):
98
+ try:
99
+ return float(value)
100
+ except ValueError:
101
+ return 0.0
102
+ return 0.0
103
+
104
+
105
+ def _load_json(path: Path) -> Mapping[str, Any]:
106
+ try:
107
+ payload = json.loads(path.read_text(encoding="utf-8"))
108
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
109
+ raise ValueError(f"unable to read {path}: {exc}") from exc
110
+ if not isinstance(payload, Mapping):
111
+ raise ValueError(f"{path} must contain a JSON object")
112
+ return payload
113
+
114
+
115
+ def load_dispositions(path: Path | None) -> dict[str, str]:
116
+ if path is None:
117
+ return {}
118
+ payload = _load_json(path)
119
+ dispositions: dict[str, str] = {}
120
+ for finding_id, value in payload.items():
121
+ if isinstance(value, Mapping):
122
+ disposition = str(value.get("disposition", "unknown"))
123
+ else:
124
+ disposition = str(value)
125
+ disposition = disposition.strip().lower()
126
+ if disposition not in KNOWN_DISPOSITIONS:
127
+ raise ValueError(
128
+ f"unknown disposition for finding {finding_id}: {disposition!r}"
129
+ )
130
+ dispositions[str(finding_id)] = disposition
131
+ return dispositions
132
+
133
+
134
+ def _finding_id(
135
+ *,
136
+ repo: str,
137
+ pr_number: Any,
138
+ head_sha: str,
139
+ profile_id: str,
140
+ severity: str,
141
+ path: str,
142
+ index: int,
143
+ text: str,
144
+ ) -> str:
145
+ raw = "\n".join(
146
+ [
147
+ repo,
148
+ str(pr_number),
149
+ head_sha,
150
+ profile_id,
151
+ severity,
152
+ path,
153
+ str(index),
154
+ text,
155
+ ]
156
+ )
157
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
158
+
159
+
160
+ def iter_run_findings(
161
+ payload: Mapping[str, Any],
162
+ run: Mapping[str, Any],
163
+ ) -> Iterable[dict[str, Any]]:
164
+ repo = str(payload.get("repo") or run.get("repo") or "")
165
+ pr_number = payload.get("pr_number", run.get("pr_number", ""))
166
+ head_sha = str(payload.get("head_sha") or run.get("head_sha_end") or "")
167
+ profile_id = str(run.get("profile_id") or "")
168
+
169
+ for finding_group in run.get("blocker_findings", []) or []:
170
+ if not isinstance(finding_group, Mapping):
171
+ continue
172
+ path = str(finding_group.get("path") or "")
173
+ for index, text in enumerate(finding_group.get("blockers", []) or []):
174
+ text = str(text)
175
+ yield {
176
+ "id": _finding_id(
177
+ repo=repo,
178
+ pr_number=pr_number,
179
+ head_sha=head_sha,
180
+ profile_id=profile_id,
181
+ severity="BLOCKER",
182
+ path=path,
183
+ index=index,
184
+ text=text,
185
+ ),
186
+ "profile_id": profile_id,
187
+ "repo": repo,
188
+ "pr_number": pr_number,
189
+ "head_sha": head_sha,
190
+ "severity": "BLOCKER",
191
+ "path": path,
192
+ "text": text,
193
+ }
194
+
195
+ for index, text in enumerate(run.get("pr_level_blockers", []) or []):
196
+ text = str(text)
197
+ yield {
198
+ "id": _finding_id(
199
+ repo=repo,
200
+ pr_number=pr_number,
201
+ head_sha=head_sha,
202
+ profile_id=profile_id,
203
+ severity="BLOCKER",
204
+ path="__pr__",
205
+ index=index,
206
+ text=text,
207
+ ),
208
+ "profile_id": profile_id,
209
+ "repo": repo,
210
+ "pr_number": pr_number,
211
+ "head_sha": head_sha,
212
+ "severity": "BLOCKER",
213
+ "path": "__pr__",
214
+ "text": text,
215
+ }
216
+
217
+ for finding_group in run.get("concern_findings", []) or []:
218
+ if not isinstance(finding_group, Mapping):
219
+ continue
220
+ path = str(finding_group.get("path") or "")
221
+ for index, text in enumerate(finding_group.get("concerns", []) or []):
222
+ text = str(text)
223
+ yield {
224
+ "id": _finding_id(
225
+ repo=repo,
226
+ pr_number=pr_number,
227
+ head_sha=head_sha,
228
+ profile_id=profile_id,
229
+ severity="CONCERN",
230
+ path=path,
231
+ index=index,
232
+ text=text,
233
+ ),
234
+ "profile_id": profile_id,
235
+ "repo": repo,
236
+ "pr_number": pr_number,
237
+ "head_sha": head_sha,
238
+ "severity": "CONCERN",
239
+ "path": path,
240
+ "text": text,
241
+ }
242
+
243
+
244
+ def build_calibration_report(
245
+ summaries: Iterable[Path],
246
+ *,
247
+ dispositions: Mapping[str, str] | None = None,
248
+ ) -> dict[str, Any]:
249
+ dispositions = dispositions or {}
250
+ profiles: dict[str, ProfileStats] = {}
251
+ findings: list[dict[str, Any]] = []
252
+ sources: list[str] = []
253
+
254
+ for summary_path in summaries:
255
+ payload = _load_json(summary_path)
256
+ sources.append(str(summary_path))
257
+ if payload.get("mode") != "local-llm-bakeoff":
258
+ raise ValueError(f"{summary_path} is not a local-llm-bakeoff summary")
259
+ runs = payload.get("runs")
260
+ if not isinstance(runs, list):
261
+ raise ValueError(f"{summary_path} must include a runs list")
262
+ for run in runs:
263
+ if not isinstance(run, Mapping):
264
+ continue
265
+ profile_id = str(run.get("profile_id") or "")
266
+ if not profile_id:
267
+ continue
268
+ stats = profiles.setdefault(profile_id, ProfileStats(profile_id))
269
+ stats.record_run(run)
270
+ for finding in iter_run_findings(payload, run):
271
+ disposition = dispositions.get(finding["id"], "unknown")
272
+ finding["disposition"] = disposition
273
+ stats.dispositions[disposition] += 1
274
+ findings.append(finding)
275
+
276
+ data = {
277
+ "mode": "local-llm-calibration",
278
+ "sources": sources,
279
+ "profiles": {
280
+ profile_id: profiles[profile_id].to_dict()
281
+ for profile_id in sorted(profiles)
282
+ },
283
+ "finding_count": len(findings),
284
+ "findings": findings,
285
+ "recommendations": _recommendations(profiles.values()),
286
+ }
287
+ return data
288
+
289
+
290
+ def build_disposition_template(report: Mapping[str, Any]) -> dict[str, Any]:
291
+ """Return a stable human-adjudication template for report findings."""
292
+
293
+ template: dict[str, Any] = {}
294
+ findings = report.get("findings", [])
295
+ if not isinstance(findings, list):
296
+ return template
297
+ for finding in findings:
298
+ if not isinstance(finding, Mapping):
299
+ continue
300
+ finding_id = str(finding.get("id") or "")
301
+ if not finding_id:
302
+ continue
303
+ template[finding_id] = {
304
+ "disposition": str(finding.get("disposition") or "unknown"),
305
+ "profile_id": str(finding.get("profile_id") or ""),
306
+ "repo": str(finding.get("repo") or ""),
307
+ "pr_number": finding.get("pr_number", ""),
308
+ "head_sha": str(finding.get("head_sha") or ""),
309
+ "severity": str(finding.get("severity") or ""),
310
+ "path": str(finding.get("path") or ""),
311
+ "text": str(finding.get("text") or ""),
312
+ }
313
+ return template
314
+
315
+
316
+ def write_disposition_template(
317
+ path: Path,
318
+ report: Mapping[str, Any],
319
+ *,
320
+ force: bool = False,
321
+ ) -> None:
322
+ if path.exists() and not force:
323
+ raise ValueError(f"refusing to overwrite existing disposition file: {path}")
324
+ path.parent.mkdir(parents=True, exist_ok=True)
325
+ payload = build_disposition_template(report)
326
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
327
+
328
+
329
+ def _recommendations(stats: Iterable[ProfileStats]) -> list[str]:
330
+ recommendations: list[str] = []
331
+ for profile in sorted(stats, key=lambda item: item.profile_id):
332
+ if profile.files_reviewed and profile.parse_failure_count:
333
+ rate = profile.parse_failure_count / profile.files_reviewed
334
+ if rate >= 0.2:
335
+ recommendations.append(
336
+ f"{profile.profile_id}: high parse-failure rate ({rate:.0%}); keep informational and tighten prompt/output repair before promotion."
337
+ )
338
+ false_blockers = profile.dispositions.get("false_positive", 0) + profile.dispositions.get("noise", 0)
339
+ true_or_useful = profile.dispositions.get("true_positive", 0) + profile.dispositions.get("useful", 0)
340
+ if false_blockers and false_blockers > true_or_useful:
341
+ recommendations.append(
342
+ f"{profile.profile_id}: more adjudicated noise than useful findings; do not use as merge authority yet."
343
+ )
344
+ if not recommendations:
345
+ recommendations.append(
346
+ "Keep local LLM lanes informational until multiple PRs have human dispositions for blocker and concern findings."
347
+ )
348
+ return recommendations
349
+
350
+
351
+ def render_calibration_text(report: Mapping[str, Any]) -> str:
352
+ lines = [
353
+ "Local LLM calibration report",
354
+ "",
355
+ "Profiles:",
356
+ ]
357
+ profiles = report.get("profiles", {})
358
+ if isinstance(profiles, Mapping):
359
+ for profile_id, stats in profiles.items():
360
+ if not isinstance(stats, Mapping):
361
+ continue
362
+ verdicts = ", ".join(
363
+ f"{key}={value}"
364
+ for key, value in sorted((stats.get("verdicts") or {}).items())
365
+ ) or "none"
366
+ lines.extend(
367
+ [
368
+ f"- {profile_id} ({stats.get('model', '')})",
369
+ f" runs: {stats.get('runs', 0)}, files: {stats.get('files_reviewed', 0)}, verdicts: {verdicts}",
370
+ f" blockers: {stats.get('blocker_file_count', 0)}, concerns: {stats.get('concern_file_count', 0)}",
371
+ f" parse failures: {stats.get('parse_failure_count', 0)}, json repair used: {stats.get('json_repair_used_count', 0)}",
372
+ f" avg runtime: {stats.get('duration_seconds_average', 0)}s",
373
+ ]
374
+ )
375
+ dispositions = stats.get("dispositions") or {}
376
+ if dispositions:
377
+ lines.append(
378
+ " dispositions: "
379
+ + ", ".join(f"{key}={value}" for key, value in sorted(dispositions.items()))
380
+ )
381
+
382
+ lines.extend(["", "Recommendations:"])
383
+ for recommendation in report.get("recommendations", []) or []:
384
+ lines.append(f"- {recommendation}")
385
+
386
+ finding_count = report.get("finding_count", 0)
387
+ lines.extend(["", f"Findings indexed for adjudication: {finding_count}"])
388
+ return "\n".join(lines) + "\n"
389
+
390
+
391
+ def main(argv: list[str] | None = None) -> int:
392
+ parser = argparse.ArgumentParser(description=__doc__)
393
+ parser.add_argument("summaries", nargs="+", type=Path)
394
+ parser.add_argument(
395
+ "--dispositions",
396
+ type=Path,
397
+ default=None,
398
+ help="Optional JSON mapping of finding id to true_positive/false_positive/useful/noise.",
399
+ )
400
+ parser.add_argument(
401
+ "--write-disposition-template",
402
+ type=Path,
403
+ default=None,
404
+ help=(
405
+ "Write a JSON adjudication template keyed by finding id. Fill in "
406
+ "disposition values, then pass it back via --dispositions."
407
+ ),
408
+ )
409
+ parser.add_argument(
410
+ "--force",
411
+ action="store_true",
412
+ help="Allow --write-disposition-template to overwrite an existing file.",
413
+ )
414
+ parser.add_argument("--json", action="store_true")
415
+ args = parser.parse_args(argv)
416
+
417
+ try:
418
+ report = build_calibration_report(
419
+ args.summaries,
420
+ dispositions=load_dispositions(args.dispositions),
421
+ )
422
+ if args.write_disposition_template is not None:
423
+ write_disposition_template(
424
+ args.write_disposition_template,
425
+ report,
426
+ force=args.force,
427
+ )
428
+ report["disposition_template_path"] = str(args.write_disposition_template)
429
+ except ValueError as exc:
430
+ print(f"error: {exc}", file=sys.stderr)
431
+ return 1
432
+
433
+ if args.json:
434
+ print(json.dumps(report, indent=2, sort_keys=True))
435
+ else:
436
+ print(render_calibration_text(report), end="")
437
+ return 0
438
+
439
+
440
+ if __name__ == "__main__":
441
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Named local/private LLM runtime profiles for Code Mower.
3
+
4
+ Profiles are intentionally plain data. Provider-specific behavior still lives
5
+ in the caller and adapter layers; a profile only answers "where is the model
6
+ endpoint, what model id should be sent, and what budgets are reasonable?"
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class LocalLlmProfile:
17
+ profile_id: str
18
+ description: str
19
+ api_base: str
20
+ model: str
21
+ endpoint: str = ""
22
+ api_key: str = "EMPTY"
23
+ context_window: int = 128_000
24
+ max_files: int = 25
25
+ max_file_bytes: int = 60_000
26
+ http_timeout: int = 900
27
+ informational: bool = True
28
+
29
+ def as_dict(self) -> dict[str, Any]:
30
+ return asdict(self)
31
+
32
+
33
+ LOCAL_LLM_PROFILES: dict[str, LocalLlmProfile] = {
34
+ "qwen3-coder-next-lmstudio": LocalLlmProfile(
35
+ profile_id="qwen3-coder-next-lmstudio",
36
+ description=(
37
+ "Qwen3 Coder Next through LM Studio's OpenAI-compatible local endpoint."
38
+ ),
39
+ api_base="http://localhost:1234/v1",
40
+ model="qwen/qwen3-coder-next",
41
+ endpoint="lmstudio",
42
+ ),
43
+ "gemma4-ollama": LocalLlmProfile(
44
+ profile_id="gemma4-ollama",
45
+ description="Gemma 4 through Ollama's OpenAI-compatible local endpoint.",
46
+ api_base="http://localhost:11434/v1",
47
+ model="gemma4:e4b-mlx",
48
+ endpoint="ollama",
49
+ ),
50
+ }
51
+
52
+
53
+ def list_profiles() -> list[LocalLlmProfile]:
54
+ return [LOCAL_LLM_PROFILES[key] for key in sorted(LOCAL_LLM_PROFILES)]
55
+
56
+
57
+ def profile_ids() -> tuple[str, ...]:
58
+ return tuple(sorted(LOCAL_LLM_PROFILES))
59
+
60
+
61
+ def get_profile(profile_id: str) -> LocalLlmProfile:
62
+ try:
63
+ return LOCAL_LLM_PROFILES[profile_id]
64
+ except KeyError as exc:
65
+ known = ", ".join(profile_ids())
66
+ raise KeyError(f"unknown local LLM profile {profile_id!r}; known profiles: {known}") from exc