syncade 0.6.2__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 (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,550 @@
1
+ """Aggregate the ``.syncade/runs/`` artifact corpus into metrics rows.
2
+
3
+ Read-only over the artifacts: reads ``loop-manifest.json`` + ``run-init.json``
4
+ and the presence of ``handoff.md`` / ``decision-needed.md``, mapping each run to
5
+ one :class:`RunRow` plus per-reviewer :class:`ReviewerStatRow`s. Legacy/partial
6
+ runs (missing fields or whole files) degrade to defaults rather than crashing —
7
+ older runs in a real corpus predate current fields.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import sqlite3
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from syncade.metrics.schema import (
19
+ ActorStatRow,
20
+ ReviewerStatRow,
21
+ RunRow,
22
+ upsert_actor_stat,
23
+ upsert_reviewer_stat,
24
+ upsert_run,
25
+ )
26
+ from syncade.synthesizer.constants import (
27
+ SYNTHESIZER_NAME,
28
+ SYNTHESIZER_PROVIDER,
29
+ )
30
+
31
+ # Reasons a coarse exit code cannot distinguish, so the reason wins where one is recorded.
32
+ #
33
+ # no_changes_to_review / producer_emptied_diff exit 0 but are NOT SHIPs — the final round
34
+ # dispatched no reviewers. producer_emptied_diff additionally had prior rounds spend model
35
+ # work, but the final verdict is still no-review.
36
+ #
37
+ # provider_usage_limit shares exit 25 with the operator's OWN budget ceiling. Reporting it as
38
+ # "BUDGET" blames their config for someone else's quota window — and the two want opposite
39
+ # responses: raise the cap, versus wait for the window to reset.
40
+ _VERDICT_BY_REASON = {
41
+ "no_changes_to_review": "NO-CHANGE",
42
+ "producer_emptied_diff": "NO-CHANGE",
43
+ "provider_usage_limit": "QUOTA",
44
+ }
45
+
46
+ # syncade exit code -> coarse verdict label (see CLAUDE.md "Exit Codes").
47
+ _VERDICT_BY_EXIT = {
48
+ 0: "SHIP",
49
+ 10: "DECISION-NEEDED",
50
+ 20: "MAX-ROUNDS",
51
+ 25: "BUDGET",
52
+ 30: "NO-SHIP",
53
+ 40: "ERROR",
54
+ 50: "CONFIG-ERROR",
55
+ 60: "ENV-ERROR",
56
+ 70: "PARSE-ERROR",
57
+ }
58
+
59
+
60
+ def _int(value: object) -> int:
61
+ """Coerce to int, defaulting non-ints (bool/str/list/…) to 0 — a malformed
62
+ historical artifact must not crash arithmetic or sqlite binding."""
63
+ return value if type(value) is int else 0
64
+
65
+
66
+ def _float(value: object) -> float:
67
+ return float(value) if type(value) in (int, float) else 0.0
68
+
69
+
70
+ def _float_or_none(value: object) -> float | None:
71
+ return float(value) if type(value) in (int, float) else None
72
+
73
+
74
+ def _int_or_none(value: object) -> int | None:
75
+ return value if type(value) is int else None
76
+
77
+
78
+ def _usage_int_or_none(value: object) -> int | None:
79
+ return value if type(value) is int and value >= 0 else None
80
+
81
+
82
+ def _usage_float_or_none(value: object) -> float | None:
83
+ if type(value) not in (int, float):
84
+ return None
85
+ cost = float(value)
86
+ return cost if math.isfinite(cost) and cost >= 0 else None
87
+
88
+
89
+ def _usage_pair(tokens_value: object, cost_value: object) -> tuple[int | None, float | None]:
90
+ """Validate persisted usage fields from manifests.
91
+
92
+ Tokens are the anchor for a usage block. If tokens are present but malformed
93
+ (wrong type or negative), ignore the whole usage entry; if tokens are valid
94
+ but cost is malformed, keep tokens and mark cost unknown downstream.
95
+ """
96
+ if tokens_value is None and cost_value is None:
97
+ return None, None
98
+ tokens = _usage_int_or_none(tokens_value)
99
+ if tokens is None:
100
+ return None, None
101
+ return tokens, _usage_float_or_none(cost_value)
102
+
103
+
104
+ def _text_or_none(value: object) -> str | None:
105
+ return value if type(value) is str else None
106
+
107
+
108
+ def _reviewer_models(init: dict) -> dict[str, str]:
109
+ config = init.get("config_snapshot")
110
+ roster = config.get("reviewers") if isinstance(config, dict) else None
111
+ models: dict[str, str] = {}
112
+ if isinstance(roster, list):
113
+ for r in roster:
114
+ if isinstance(r, dict) and isinstance(r.get("name"), str):
115
+ models[r["name"]] = _text_or_none(r.get("model")) or ""
116
+ return models
117
+
118
+
119
+ def _load_init(run_dir: Path) -> dict:
120
+ """Load ``run-init.json`` as a dict; warn (don't silently blank) when it is
121
+ present but unreadable — a broken model-join source should be visible, not
122
+ quietly yield blank-model reviewer stats. A *missing* run-init is normal for
123
+ partial/legacy runs and returns ``{}`` silently."""
124
+ path = run_dir / "run-init.json"
125
+ if not path.exists():
126
+ return {}
127
+ try:
128
+ data = json.loads(path.read_text(encoding="utf-8"))
129
+ except (OSError, ValueError):
130
+ data = None
131
+ if not isinstance(data, dict):
132
+ print(
133
+ f"[syncade] metrics: {run_dir.name} has an unreadable run-init.json "
134
+ "— model join skipped for this run",
135
+ file=sys.stderr,
136
+ )
137
+ return {}
138
+ return data
139
+
140
+
141
+ def read_run(run_dir: Path | str) -> tuple[RunRow, list[ReviewerStatRow]] | None:
142
+ """Map one run directory to a :class:`RunRow` + per-reviewer stats.
143
+
144
+ Returns ``None`` when ``loop-manifest.json`` is present but unparseable — a
145
+ *corrupt* run, which the caller skips with a warning rather than counting it as
146
+ a bogus UNKNOWN run. A *missing* or *parseable-but-partial* manifest is still
147
+ tolerated — scalar fields are coerced and non-list rounds/reviewers ignored, so
148
+ a single malformed historical artifact never crashes the whole aggregation.
149
+ """
150
+ run_dir = Path(run_dir)
151
+ try:
152
+ manifest = json.loads((run_dir / "loop-manifest.json").read_text(encoding="utf-8"))
153
+ except FileNotFoundError:
154
+ manifest = {}
155
+ except (OSError, ValueError):
156
+ return None
157
+ if not isinstance(manifest, dict):
158
+ return None
159
+ init = _load_init(run_dir)
160
+ rounds = manifest.get("rounds")
161
+ rounds = rounds if isinstance(rounds, list) else []
162
+ exit_code = _int_or_none(manifest.get("final_exit_code"))
163
+
164
+ def _sum(key: str) -> int:
165
+ total = 0
166
+ for r in rounds:
167
+ synth = r.get("synthesizer") if isinstance(r, dict) else None
168
+ if isinstance(synth, dict):
169
+ total += _int(synth.get(key, 0))
170
+ return total
171
+
172
+ run_id = manifest.get("run_id")
173
+ run_id = run_id if isinstance(run_id, str) and run_id else run_dir.name
174
+
175
+ def _producer_outcome_count(outcome: str) -> int:
176
+ return sum(
177
+ 1
178
+ for r in rounds
179
+ if isinstance(r, dict)
180
+ and isinstance(r.get("producer"), dict)
181
+ and r["producer"].get("outcome") == outcome
182
+ )
183
+
184
+ def _sum_usage() -> tuple[int | None, float | None]:
185
+ # Sum usage across every actor — reviewers, synthesizer, producer — in
186
+ # every round. None when NO actor reported valid usage, so legacy runs
187
+ # stay NULL rather than showing a misleading 0.
188
+ tokens_total = 0
189
+ cost_total = 0.0
190
+ tokens_present = False
191
+ cost_present = False
192
+ for r in rounds:
193
+ if not isinstance(r, dict):
194
+ continue
195
+ reviewers = r.get("reviewers")
196
+ actors = list(reviewers) if isinstance(reviewers, list) else []
197
+ for key in ("synthesizer", "producer"):
198
+ a = r.get(key)
199
+ if isinstance(a, dict):
200
+ actors.append(a)
201
+ for a in actors:
202
+ if not isinstance(a, dict):
203
+ continue
204
+ tokens, cost_usd = _usage_pair(a.get("tokens"), a.get("cost_usd"))
205
+ if tokens is not None:
206
+ tokens_total += tokens
207
+ tokens_present = True
208
+ if cost_usd is not None:
209
+ cost_total += cost_usd
210
+ cost_present = True
211
+ return (
212
+ tokens_total if tokens_present else None,
213
+ cost_total if cost_present else None,
214
+ )
215
+
216
+ _tok, _cost = _sum_usage()
217
+ _termination_reason = _text_or_none(manifest.get("termination_reason"))
218
+ _verdict = _VERDICT_BY_REASON.get(
219
+ _termination_reason or "", _VERDICT_BY_EXIT.get(exit_code, "UNKNOWN")
220
+ )
221
+
222
+ row = RunRow(
223
+ run_id=run_id,
224
+ verdict=_verdict,
225
+ rounds_executed=len(rounds),
226
+ blockers=_sum("active_blocker_count"),
227
+ minors=_sum("active_minor_count"),
228
+ nits=_sum("active_nit_count"),
229
+ dismissed=_sum("dismissed_count"),
230
+ final_exit_code=exit_code,
231
+ termination_reason=_termination_reason,
232
+ operator_branch=_text_or_none(init.get("operator_branch")),
233
+ handoff=1 if (run_dir / "handoff.md").exists() else 0,
234
+ decision_needed=1 if (run_dir / "decision-needed.md").exists() else 0,
235
+ producer_commits=_producer_outcome_count("committed"),
236
+ producer_stalled=_producer_outcome_count("stalled"),
237
+ producer_errors=_producer_outcome_count("subprocess_error"),
238
+ producer_escalated=_producer_outcome_count("escalated"),
239
+ tokens=int(_tok) if _tok is not None else None,
240
+ cost_usd=_cost,
241
+ )
242
+ return row, _reviewer_stats(run_id, rounds, init)
243
+
244
+
245
+ def read_actor_stats(run_dir: Path | str) -> list[ActorStatRow]:
246
+ """Return per-actor usage stats for one run directory.
247
+
248
+ This is intentionally separate from :func:`read_run` so the long-standing
249
+ ``read_run() -> (RunRow, reviewer_stats)`` contract remains stable.
250
+ """
251
+ run_dir = Path(run_dir)
252
+ try:
253
+ manifest = json.loads((run_dir / "loop-manifest.json").read_text(encoding="utf-8"))
254
+ except FileNotFoundError:
255
+ manifest = {}
256
+ except (OSError, ValueError):
257
+ return []
258
+ if not isinstance(manifest, dict):
259
+ return []
260
+ rounds = manifest.get("rounds")
261
+ rounds = rounds if isinstance(rounds, list) else []
262
+ run_id = manifest.get("run_id")
263
+ run_id = run_id if isinstance(run_id, str) and run_id else run_dir.name
264
+ return _actor_stats(run_id, rounds, _load_init(run_dir))
265
+
266
+
267
+ def _merge_cost_source(
268
+ current: str,
269
+ incoming: str | None,
270
+ *,
271
+ tokens: int | None,
272
+ cost_usd: float | None,
273
+ ) -> str:
274
+ source = incoming or ""
275
+ if tokens is not None and (cost_usd is None or source == "unknown"):
276
+ return "unknown"
277
+ if not source:
278
+ return current
279
+ if current in ("", source):
280
+ return source
281
+ if current == "unknown":
282
+ return "unknown"
283
+ return "estimated"
284
+
285
+
286
+ def _merge_sources(current: str, incoming: str) -> str:
287
+ if not incoming:
288
+ return current
289
+ if current == "unknown" or incoming == "unknown":
290
+ return "unknown"
291
+ if current in ("", incoming):
292
+ return incoming
293
+ return "estimated"
294
+
295
+
296
+ def _actor_stats(run_id: str, rounds: list, init: dict) -> list[ActorStatRow]:
297
+ """Aggregate usage across reviewers, synthesizer, and producer."""
298
+ models = _reviewer_models(init)
299
+ agg: dict[tuple[str, str, str, str], dict[str, object]] = {}
300
+
301
+ def remember(
302
+ *,
303
+ role: str,
304
+ name: str,
305
+ provider: str,
306
+ model: str,
307
+ tokens_value: object,
308
+ cost_value: object,
309
+ cost_source_value: object,
310
+ auth_mode_value: object = None,
311
+ ) -> None:
312
+ tokens, cost_usd = _usage_pair(tokens_value, cost_value)
313
+ cost_source = _text_or_none(cost_source_value)
314
+ auth_mode = _text_or_none(auth_mode_value)
315
+ if tokens is None and cost_usd is None:
316
+ return
317
+ entry = agg.setdefault(
318
+ (role, name, provider, model, auth_mode or ""),
319
+ {
320
+ "provider": provider,
321
+ "model": model,
322
+ "tokens": None,
323
+ "cost_usd": None,
324
+ "cost_incomplete_tokens": 0,
325
+ "cost_source": "",
326
+ "auth_mode": "",
327
+ "rounds_with_usage": 0,
328
+ },
329
+ )
330
+ # No merging: auth_mode is part of the aggregation key, so two modes for one actor
331
+ # produce two rows and each keeps its own dollars. Merging them to "mixed" made
332
+ # real API spend unclassified.
333
+ entry["auth_mode"] = auth_mode or ""
334
+ entry["rounds_with_usage"] = int(entry["rounds_with_usage"]) + 1
335
+ if tokens is not None:
336
+ entry["tokens"] = (entry["tokens"] or 0) + tokens
337
+ if cost_usd is not None:
338
+ entry["cost_usd"] = (entry["cost_usd"] or 0.0) + cost_usd
339
+ if tokens is not None and (cost_usd is None or cost_source == "unknown"):
340
+ entry["cost_incomplete_tokens"] = int(entry["cost_incomplete_tokens"]) + tokens
341
+ entry["cost_source"] = _merge_cost_source(
342
+ str(entry["cost_source"]),
343
+ cost_source,
344
+ tokens=tokens,
345
+ cost_usd=cost_usd,
346
+ )
347
+
348
+ for rnd in rounds:
349
+ if not isinstance(rnd, dict):
350
+ continue
351
+ reviewers = rnd.get("reviewers")
352
+ if isinstance(reviewers, list):
353
+ for rev in reviewers:
354
+ if not isinstance(rev, dict):
355
+ continue
356
+ name = _text_or_none(rev.get("name"))
357
+ if not name:
358
+ continue
359
+ remember(
360
+ role="reviewer",
361
+ name=name,
362
+ provider=_text_or_none(rev.get("provider")) or "",
363
+ model=_text_or_none(rev.get("model")) or models.get(name, ""),
364
+ tokens_value=rev.get("tokens"),
365
+ cost_value=rev.get("cost_usd"),
366
+ cost_source_value=rev.get("cost_source"),
367
+ auth_mode_value=rev.get("auth_mode"),
368
+ )
369
+
370
+ synth = rnd.get("synthesizer")
371
+ if isinstance(synth, dict):
372
+ remember(
373
+ role="synthesizer",
374
+ name=SYNTHESIZER_NAME,
375
+ provider=_text_or_none(synth.get("provider")) or SYNTHESIZER_PROVIDER,
376
+ # "" (unknown), NOT the current SYNTHESIZER_MODEL: a synth
377
+ # artifact that recorded no model predates model provenance, and
378
+ # attributing it to today's pin would silently rewrite historical
379
+ # spend-by-model every time the pin moves.
380
+ model=_text_or_none(synth.get("model")) or "",
381
+ tokens_value=synth.get("tokens"),
382
+ cost_value=synth.get("cost_usd"),
383
+ cost_source_value=synth.get("cost_source"),
384
+ auth_mode_value=synth.get("auth_mode"),
385
+ )
386
+
387
+ producer = rnd.get("producer")
388
+ if isinstance(producer, dict):
389
+ remember(
390
+ role="producer",
391
+ name="producer",
392
+ provider=_text_or_none(producer.get("provider")) or "",
393
+ model=_text_or_none(producer.get("model")) or "",
394
+ tokens_value=producer.get("tokens"),
395
+ cost_value=producer.get("cost_usd"),
396
+ cost_source_value=producer.get("cost_source"),
397
+ auth_mode_value=producer.get("auth_mode"),
398
+ )
399
+
400
+ return [
401
+ ActorStatRow(
402
+ run_id=run_id,
403
+ role=role,
404
+ name=name,
405
+ provider=str(e["provider"]),
406
+ model=str(e["model"]),
407
+ tokens=e["tokens"] if type(e["tokens"]) is int else None,
408
+ cost_usd=e["cost_usd"] if type(e["cost_usd"]) in (int, float) else None,
409
+ cost_incomplete_tokens=int(e["cost_incomplete_tokens"]),
410
+ cost_source=str(e["cost_source"]),
411
+ auth_mode=str(e["auth_mode"]),
412
+ rounds_with_usage=int(e["rounds_with_usage"]),
413
+ )
414
+ for (role, name, _provider, _model, _auth), e in agg.items()
415
+ ]
416
+
417
+
418
+ def _reviewer_stats(run_id: str, rounds: list, init: dict) -> list[ReviewerStatRow]:
419
+ """Aggregate per-reviewer finding counts across rounds by artifact model.
420
+
421
+ New manifests carry ``model`` beside reviewer usage. Legacy manifests fall
422
+ back to the run-init config snapshot keyed by reviewer name.
423
+ """
424
+ models = _reviewer_models(init)
425
+ agg: dict[tuple[str, str, str], dict] = {}
426
+ for rnd in rounds:
427
+ if not isinstance(rnd, dict):
428
+ continue
429
+ reviewers = rnd.get("reviewers")
430
+ if not isinstance(reviewers, list):
431
+ continue
432
+ for rev in reviewers:
433
+ if not isinstance(rev, dict):
434
+ continue
435
+ name = rev.get("name")
436
+ if not isinstance(name, str) or not name:
437
+ continue
438
+ provider = _text_or_none(rev.get("provider")) or ""
439
+ model = _text_or_none(rev.get("model")) or models.get(name, "")
440
+ entry = agg.setdefault(
441
+ (name, provider, model),
442
+ {
443
+ "provider": provider,
444
+ "model": model,
445
+ "finding_count": 0,
446
+ "duration_s": 0.0,
447
+ "tokens": None,
448
+ "cost_usd": None,
449
+ "cost_source": "",
450
+ },
451
+ )
452
+ entry["finding_count"] += _int(rev.get("finding_count", 0))
453
+ entry["duration_s"] += _float(rev.get("duration_seconds", 0))
454
+ tokens, cost_usd = _usage_pair(rev.get("tokens"), rev.get("cost_usd"))
455
+ if tokens is not None:
456
+ entry["tokens"] = (entry["tokens"] or 0) + tokens
457
+ if cost_usd is not None:
458
+ entry["cost_usd"] = (entry["cost_usd"] or 0.0) + cost_usd
459
+ if tokens is not None or cost_usd is not None:
460
+ if tokens is not None and cost_usd is None:
461
+ source = "unknown"
462
+ else:
463
+ source = _text_or_none(rev.get("cost_source")) or ""
464
+ entry["cost_source"] = _merge_sources(str(entry["cost_source"]), source)
465
+ return [
466
+ ReviewerStatRow(
467
+ run_id=run_id,
468
+ name=name,
469
+ provider=e["provider"],
470
+ model=e["model"],
471
+ finding_count=e["finding_count"],
472
+ duration_s=e["duration_s"],
473
+ tokens=e["tokens"],
474
+ cost_usd=e["cost_usd"],
475
+ cost_source=e["cost_source"],
476
+ )
477
+ for (name, _provider, _model), e in agg.items()
478
+ ]
479
+
480
+
481
+ def _candidate_run_dirs(runs_root: Path | str) -> list[Path]:
482
+ runs_root = Path(runs_root)
483
+ if not runs_root.is_dir():
484
+ return []
485
+ return [
486
+ d
487
+ for d in sorted(runs_root.iterdir())
488
+ if d.is_dir() and ((d / "loop-manifest.json").exists() or (d / "run-init.json").exists())
489
+ ]
490
+
491
+
492
+ def read_runs(runs_root: Path | str) -> list[tuple[RunRow, list[ReviewerStatRow]]]:
493
+ """Read every run directory that looks like a syncade run.
494
+
495
+ A directory qualifies if it has ``loop-manifest.json`` OR ``run-init.json``;
496
+ partial/interrupted runs with only ``run-init.json`` degrade to UNKNOWN rows
497
+ rather than being silently omitted. Corrupt runs (manifest present but
498
+ unparseable) are skipped with a stderr warning.
499
+ """
500
+ results: list[tuple[RunRow, list[ReviewerStatRow]]] = []
501
+ for d in _candidate_run_dirs(runs_root):
502
+ parsed = read_run(d)
503
+ if parsed is None:
504
+ print(
505
+ f"[syncade] metrics: skipping {d.name} — unparseable loop-manifest.json",
506
+ file=sys.stderr,
507
+ )
508
+ continue
509
+ results.append(parsed)
510
+ return results
511
+
512
+
513
+ def backfill(conn: sqlite3.Connection, runs_root: Path | str) -> None:
514
+ """Sync the DB to the current corpus (idempotent).
515
+
516
+ Upserts every present run, refreshes each run's reviewer set (so a changed
517
+ roster drops stale rows), and prunes runs no longer in ``.syncade/runs/`` — so
518
+ the DB stays a faithful derived view even after ``--gc`` slims runs.
519
+ """
520
+ present: list[str] = []
521
+ for d in _candidate_run_dirs(runs_root):
522
+ parsed = read_run(d)
523
+ if parsed is None:
524
+ print(
525
+ f"[syncade] metrics: skipping {d.name} — unparseable loop-manifest.json",
526
+ file=sys.stderr,
527
+ )
528
+ continue
529
+ row, stats = parsed
530
+ upsert_run(conn, row)
531
+ conn.execute("DELETE FROM reviewer_stats WHERE run_id = ?", (row.run_id,))
532
+ conn.execute("DELETE FROM actor_stats WHERE run_id = ?", (row.run_id,))
533
+ for stat in stats:
534
+ upsert_reviewer_stat(conn, stat)
535
+ for stat in read_actor_stats(d):
536
+ upsert_actor_stat(conn, stat)
537
+ present.append(row.run_id)
538
+ _prune_absent(conn, present)
539
+ conn.commit()
540
+
541
+
542
+ def _prune_absent(conn: sqlite3.Connection, present_ids: list[str]) -> None:
543
+ """Delete rows for runs no longer present in the corpus (row-at-a-time, so
544
+ there is no SQL parameter-count ceiling on corpus size)."""
545
+ present = set(present_ids)
546
+ for (run_id,) in conn.execute("SELECT run_id FROM runs").fetchall():
547
+ if run_id not in present:
548
+ conn.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
549
+ conn.execute("DELETE FROM reviewer_stats WHERE run_id = ?", (run_id,))
550
+ conn.execute("DELETE FROM actor_stats WHERE run_id = ?", (run_id,))