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,363 @@
1
+ """``syncade --metrics`` mode handler.
2
+
3
+ A one-shot, read-only pass: aggregate the ``.syncade/runs/`` artifact corpus
4
+ into ``.syncade/metrics.db`` (idempotent) and print a cumulative report. Split
5
+ into its own module so :mod:`syncade.cli.modes` stays under the blocking
6
+ file-length cap; re-exported from ``modes`` so the public import path is
7
+ unchanged. Never mutates run artifacts and never touches the review path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sqlite3
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from syncade import billing
17
+ from syncade.billing import Billing
18
+ from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
19
+ from syncade.metrics.aggregate import backfill
20
+ from syncade.metrics.schema import open_db
21
+ from syncade.snapshot import SnapshotError, discover_repo_root
22
+
23
+
24
+ def _spend_lines(
25
+ tokens: object,
26
+ cost: object,
27
+ incomplete_tokens: object,
28
+ totals: Billing,
29
+ ) -> list[str]:
30
+ """The honest spend block.
31
+
32
+ ``billed`` is money. ``API-equiv`` is a VALUATION of the same traffic at API list
33
+ price -- worth showing (it is what a subscription is saving you) but it is NOT spend
34
+ and must never be labelled as such. Whatever cannot be classified is named, never
35
+ quietly counted as free.
36
+
37
+ Every pre-existing honesty signal is preserved deliberately: "usage unavailable" must
38
+ NOT collapse to "0 tokens" (unknown is not zero), and cost-incomplete tokens are still
39
+ flagged "not free". Replacing one lie with another would be a poor trade.
40
+ """
41
+ if tokens is None and cost is None and not incomplete_tokens:
42
+ return [" usage: unavailable (legacy/no usage recorded)"]
43
+
44
+ b = totals
45
+ tok = int(tokens or 0)
46
+ incomplete = int(incomplete_tokens or 0)
47
+ tok_line = f" tokens: {tok}"
48
+ if incomplete:
49
+ tok_line += f" ({incomplete} cost-incomplete tokens — priced at $0, but NOT free)"
50
+ lines = [tok_line]
51
+ lines.extend(billing.render(b, indent=" "))
52
+ return lines
53
+
54
+
55
+ def _usd(value: float) -> str:
56
+ if 0 < value < 0.005:
57
+ return "<$0.01"
58
+ return f"${value:.2f}"
59
+
60
+
61
+ def _merge_auth_mode(current: str, incoming: str) -> str:
62
+ """Aggregate auth modes for one actor/model. Two different modes -> "mixed"."""
63
+ if not incoming:
64
+ return current
65
+ if current in ("", incoming):
66
+ return incoming
67
+ return "mixed"
68
+
69
+
70
+ def _billing_totals(conn: sqlite3.Connection, run_ids: list[str] | None = None) -> Billing:
71
+ """``(billed, api_equivalent, unclassified)`` in USD.
72
+
73
+ THE distinction this whole PR exists for. ``cost_usd`` is an API-EQUIVALENT
74
+ VALUATION, not money: it is fiction whenever the call rode a subscription. Before
75
+ this, ``--metrics`` summed it and called the result "spend" — reporting $71.18
76
+ across a corpus whose real marginal cost was ~$0, because every call ran on a
77
+ ChatGPT plan or a claude.ai plan.
78
+
79
+ - ``billed`` — auth_mode == "api". Money that actually left the account.
80
+ - ``api_equiv`` — every priced token, regardless of how it was paid for. Still
81
+ useful: it is what the traffic WOULD cost on API pricing, and
82
+ it is the number that tells a subscription user what their plan
83
+ is worth.
84
+ - ``unclassified`` — auth mode not recorded (runs from before this PR). Reported
85
+ separately and NEVER folded into either: we do not know, and
86
+ guessing is what got us here.
87
+
88
+ **Computed from the RAW table, never from display rows.** The first version summed
89
+ ``_actor_spend_rows()``, which groups by ``(provider, model)`` for the per-model
90
+ lines and merges differing auth modes into ``"mixed"`` on the way. That grouping
91
+ destroyed the api/subscription split BEFORE billing was computed, so a corpus with
92
+ genuine API spend alongside subscription traffic on the same model reported
93
+ **$0.00 billed** — real money, silently unreported. Caught by syncade's own panel,
94
+ unanimously; it is the exact case the PR brief dared them to find.
95
+
96
+ Display aggregation and money must never share a code path. SQL, grouped by
97
+ auth_mode, no collapsing.
98
+ """
99
+ where = ""
100
+ params: list[str] = []
101
+ if run_ids:
102
+ where = f"WHERE run_id IN ({','.join('?' * len(run_ids))})"
103
+ params = run_ids
104
+ rows = conn.execute(
105
+ # cost_incomplete_tokens, NOT `CASE WHEN cost_usd IS NULL`. A single actor_stats
106
+ # row aggregates several rounds, so it can carry BOTH a real cost AND unpriced
107
+ # tokens -- one priced round plus one unpriced round. The NULL test then sees a
108
+ # non-null cost and reports `billed` as complete when it is not. The schema has
109
+ # carried cost_incomplete_tokens for exactly this since PR-v2-04; I invented a
110
+ # worse proxy instead of reading it.
111
+ "SELECT auth_mode, COALESCE(SUM(cost_usd), 0), "
112
+ "COALESCE(SUM(cost_incomplete_tokens), 0) "
113
+ f"FROM actor_stats {where} GROUP BY auth_mode",
114
+ params,
115
+ ).fetchall()
116
+ b = billing.from_rows(rows)
117
+
118
+ # Reconcile against `runs`, the authoritative run-level total. actor_stats is DERIVED
119
+ # from the same manifests, so normally it sums to runs and the delta is 0. But a run can
120
+ # persist a `runs` row with NO actor_stats rows (legacy runs; a run whose per-actor
121
+ # aggregation produced nothing) -- and then its usage vanished from billing entirely.
122
+ # Whatever `runs` has that actor_stats does not represent has no auth_mode, so its honest
123
+ # home is `unclassified`: unknown-but-real, never dropped.
124
+ #
125
+ # BOTH axes must be reconciled. Round 13 reconciled COST and left TOKENS -- a run with
126
+ # 1M tokens and no cost then still read as free. Same bug, one column over. `runs`
127
+ # carries both; both are matched here.
128
+ # Reconcile ONLY runs with NO actor_stats row at all. A represented run is trusted
129
+ # entirely to actor_stats -- crucially, its cost_incomplete_tokens are finer-grained than
130
+ # runs (a run can have a KNOWN total cost yet some cost-incomplete tokens), so an
131
+ # aggregate `runs - actor_stats` delta subtracts across different populations and is
132
+ # wrong. Per-unrepresented-run is the clean unit: cost known-but-unauthed -> unclassified
133
+ # cost; tokens with no cost -> unclassified unpriced. (Found by mutating my own round-13
134
+ # reconciliation, which used the aggregate delta.)
135
+ unrep = f"{'AND' if where else 'WHERE'} run_id NOT IN (SELECT run_id FROM actor_stats)"
136
+ unrep_cost, unrep_unpriced_tokens = conn.execute(
137
+ "SELECT COALESCE(SUM(cost_usd), 0), "
138
+ "COALESCE(SUM(CASE WHEN cost_usd IS NULL THEN tokens ELSE 0 END), 0) "
139
+ f"FROM runs {where} {unrep}",
140
+ params,
141
+ ).fetchone()
142
+ if unrep_cost or unrep_unpriced_tokens:
143
+ b = b._replace(
144
+ api_equiv=b.api_equiv + unrep_cost,
145
+ unclassified=b.unclassified + unrep_cost,
146
+ unclassified_unpriced_tokens=b.unclassified_unpriced_tokens + unrep_unpriced_tokens,
147
+ total_unpriced_tokens=b.total_unpriced_tokens + unrep_unpriced_tokens,
148
+ )
149
+ return b
150
+
151
+
152
+ def _merge_cost_source(current: str, incoming: str) -> str:
153
+ if not incoming:
154
+ return current
155
+ if incoming == "unknown" or current == "unknown":
156
+ return "unknown"
157
+ if current in ("", incoming):
158
+ return incoming
159
+ return "estimated"
160
+
161
+
162
+ def _actor_spend_rows(
163
+ conn: sqlite3.Connection, run_ids: list[str] | None = None
164
+ ) -> list[tuple[str, str, int, float, int, str, str]]:
165
+ where = ""
166
+ params: list[str] = []
167
+ if run_ids:
168
+ where = f"WHERE run_id IN ({','.join('?' * len(run_ids))})"
169
+ params = run_ids
170
+ rows = conn.execute(
171
+ "SELECT provider, model, role, tokens, cost_usd, cost_incomplete_tokens, cost_source, "
172
+ f"auth_mode FROM actor_stats {where}",
173
+ params,
174
+ ).fetchall()
175
+ grouped: dict[tuple[str, str], dict[str, object]] = {}
176
+ for provider, model, role, tokens, cost, incomplete_tokens, source, auth in rows:
177
+ key = (provider or "", model or "")
178
+ entry = grouped.setdefault(
179
+ key,
180
+ {
181
+ "tokens": 0,
182
+ "cost": 0.0,
183
+ "incomplete": 0,
184
+ "source": "",
185
+ "auth": "",
186
+ "roles": set(),
187
+ },
188
+ )
189
+ tok = int(tokens or 0)
190
+ entry["tokens"] = int(entry["tokens"]) + tok
191
+ entry["cost"] = float(entry["cost"]) + float(cost or 0.0)
192
+ entry["incomplete"] = int(entry["incomplete"]) + int(incomplete_tokens or 0)
193
+ entry["source"] = _merge_cost_source(str(entry["source"]), source or "")
194
+ entry["auth"] = _merge_auth_mode(str(entry["auth"]), auth or "")
195
+ if role:
196
+ entry["roles"].add(role)
197
+ out = []
198
+ for (provider, model), entry in grouped.items():
199
+ roles = ",".join(sorted(entry["roles"]))
200
+ out.append(
201
+ (
202
+ provider,
203
+ model,
204
+ int(entry["tokens"]),
205
+ float(entry["cost"]),
206
+ int(entry["incomplete"]),
207
+ str(entry["source"]),
208
+ roles,
209
+ str(entry["auth"]),
210
+ )
211
+ )
212
+ return sorted(out, key=lambda r: (r[0], r[1]))
213
+
214
+
215
+ def _cost_incomplete_tokens(conn: sqlite3.Connection, run_ids: list[str] | None = None) -> int:
216
+ """Total tokens whose cost we could not establish, for the top ``tokens:`` line.
217
+
218
+ Derived from :func:`_billing_totals` -- the SAME reconciliation the billing block uses --
219
+ so the two CANNOT disagree. The old version summed only ``actor_stats`` whenever any
220
+ actor_stats row existed, so a mixed corpus (current rows + a legacy run-level-only row)
221
+ undercounted the top line while the billing block reported the full count. No money was
222
+ misstated, but two lines in one report disagreeing is the same two-sources bug; the fix
223
+ is the same -- one computation.
224
+ """
225
+ return _billing_totals(conn, run_ids).total_unpriced_tokens
226
+
227
+
228
+ def render_report(conn: sqlite3.Connection, last_n: int | None = None) -> str:
229
+ """Render the cumulative metrics report from the DB (pure). When ``last_n`` is
230
+ set, append a billed / API-equivalent / unclassified breakdown scoped to the
231
+ ``last_n`` most recent runs (see :mod:`syncade.billing`)."""
232
+ runs = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
233
+ if not runs:
234
+ return "syncade metrics — no runs recorded yet"
235
+
236
+ ship = conn.execute("SELECT COUNT(*) FROM runs WHERE verdict = 'SHIP'").fetchone()[0]
237
+ blockers, minors, nits, dismissed = conn.execute(
238
+ "SELECT COALESCE(SUM(blockers),0), COALESCE(SUM(minors),0), COALESCE(SUM(nits),0), "
239
+ "COALESCE(SUM(dismissed),0) FROM runs"
240
+ ).fetchone()
241
+ rounds = conn.execute("SELECT COALESCE(SUM(rounds_executed),0) FROM runs").fetchone()[0]
242
+ handoffs, decisions = conn.execute(
243
+ "SELECT COALESCE(SUM(handoff),0), COALESCE(SUM(decision_needed),0) FROM runs"
244
+ ).fetchone()
245
+ p_committed, p_stalled, p_errors, p_escalated = conn.execute(
246
+ "SELECT COALESCE(SUM(producer_commits),0), COALESCE(SUM(producer_stalled),0), "
247
+ "COALESCE(SUM(producer_errors),0), COALESCE(SUM(producer_escalated),0) FROM runs"
248
+ ).fetchone()
249
+ total_tokens, total_cost = conn.execute(
250
+ "SELECT SUM(tokens), SUM(cost_usd) FROM runs"
251
+ ).fetchone()
252
+ actor_rows = _actor_spend_rows(conn)
253
+ incomplete_tokens = _cost_incomplete_tokens(conn)
254
+
255
+ lines = [
256
+ f"syncade metrics — {runs} runs",
257
+ f" ship-rate: {ship}/{runs} ({round(100 * ship / runs)}%)",
258
+ f" findings: {blockers} blockers, {minors} minors, {nits} nits, {dismissed} dismissed",
259
+ f" rounds: {rounds} total",
260
+ f" handoffs: {handoffs} decisions: {decisions}",
261
+ f" producer: committed={p_committed} stalled={p_stalled}"
262
+ f" errors={p_errors} escalated={p_escalated}",
263
+ ]
264
+ lines.extend(_spend_lines(total_tokens, total_cost, incomplete_tokens, _billing_totals(conn)))
265
+ lines.append(" verdicts:")
266
+ # group by exit code too, so distinct unrecognized codes are not merged under one label
267
+ for verdict, code, count in conn.execute(
268
+ "SELECT verdict, final_exit_code, COUNT(*) FROM runs "
269
+ "GROUP BY verdict, final_exit_code ORDER BY COUNT(*) DESC, verdict"
270
+ ):
271
+ label = f"{verdict or '?'} ({code if code is not None else '?'})"
272
+ lines.append(f" {label:20} {count}")
273
+
274
+ if actor_rows:
275
+ lines.append(" by model (cost = API-equivalent; see `billed` above for money):")
276
+ for provider, model, tok, cost, incomplete, source, roles, auth in actor_rows:
277
+ label = f"{provider}/{model}" if model else (provider or "?")
278
+ src = f" ({source})" if source else ""
279
+ # The auth mode is what says whether this line is money or a valuation, so it
280
+ # is rendered right next to the dollars rather than buried in a footnote.
281
+ auth_label = f" auth={auth}" if auth else " auth=unrecorded"
282
+ cost_label = "known_cost" if incomplete else "cost"
283
+ incomplete_text = f" cost-incomplete-tok={int(incomplete)}" if incomplete else ""
284
+ lines.append(
285
+ f" {label:24} {cost_label}={_usd(cost)}{src}{auth_label} "
286
+ f"tok={int(tok)} roles={roles}{incomplete_text}"
287
+ )
288
+
289
+ reviewer_rows = conn.execute(
290
+ "SELECT provider, model, COALESCE(SUM(finding_count),0), COUNT(DISTINCT run_id), "
291
+ "COALESCE(SUM(duration_s),0), COALESCE(SUM(tokens),0) "
292
+ "FROM reviewer_stats GROUP BY provider, model ORDER BY provider, model"
293
+ ).fetchall()
294
+ if reviewer_rows:
295
+ lines.append(" reviewers by model (findings/wall-clock):")
296
+ for provider, model, findings, run_count, wall, tok in reviewer_rows:
297
+ label = f"{provider}/{model}" if model else (provider or "?")
298
+ lines.append(
299
+ f" {label:24} findings={findings} runs={run_count} "
300
+ f"wall={int(wall)}s tok={int(tok)}"
301
+ )
302
+
303
+ if last_n:
304
+ ids = [
305
+ r[0]
306
+ for r in conn.execute(
307
+ "SELECT run_id FROM runs ORDER BY run_id DESC LIMIT ?", (last_n,)
308
+ ).fetchall()
309
+ ]
310
+ if ids:
311
+ ph = ",".join("?" * len(ids))
312
+ w_tok, w_cost = conn.execute(
313
+ f"SELECT SUM(tokens), SUM(cost_usd) FROM runs WHERE run_id IN ({ph})",
314
+ ids,
315
+ ).fetchone()
316
+ window_actor_rows = _actor_spend_rows(conn, ids)
317
+ window_incomplete = _cost_incomplete_tokens(conn, ids)
318
+ # The windowed section gets the SAME honest block as the totals. Fixing the
319
+ # top line and leaving this one summing valuations as "spend" would just move
320
+ # the lie down the page.
321
+ lines.append(f" last {len(ids)} run(s):")
322
+ lines.extend(_spend_lines(w_tok, w_cost, window_incomplete, _billing_totals(conn, ids)))
323
+ for provider, model, tok, cost, incomplete, source, roles, auth in window_actor_rows:
324
+ label = f"{provider}/{model}" if model else (provider or "?")
325
+ src = f" ({source})" if source else ""
326
+ auth_label = f" auth={auth}" if auth else " auth=unrecorded"
327
+ cost_label = "known_cost" if incomplete else "cost"
328
+ incomplete_text = f" cost-incomplete-tok={int(incomplete)}" if incomplete else ""
329
+ lines.append(
330
+ f" {label:24} {cost_label}={_usd(cost)}{src}{auth_label} "
331
+ f"tok={int(tok)} roles={roles}{incomplete_text}"
332
+ )
333
+ return "\n".join(lines)
334
+
335
+
336
+ def _run_metrics(args) -> int:
337
+ """Dispatch ``syncade --metrics``: aggregate the corpus + print the report.
338
+
339
+ Read-only over ``.syncade/runs/``; the DB is a derived, rebuildable view.
340
+ Exit codes follow the CLI mode-handler convention (CLAUDE.md): success → 0;
341
+ repo-discovery failure → 60 (WORKTREE_ERROR).
342
+ """
343
+ repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
344
+ try:
345
+ repo_root = discover_repo_root(repo_root_hint)
346
+ except SnapshotError as exc:
347
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
348
+ return WORKTREE_ERROR
349
+
350
+ try:
351
+ conn = open_db(repo_root / ".syncade" / "metrics.db")
352
+ except (sqlite3.Error, OSError) as exc:
353
+ print(f"[syncade] metrics: cannot open metrics DB: {exc}", file=sys.stderr)
354
+ return WORKTREE_ERROR
355
+ try:
356
+ backfill(conn, repo_root / ".syncade" / "runs")
357
+ print(render_report(conn, getattr(args, "metrics_last", None)))
358
+ except (sqlite3.Error, OSError) as exc:
359
+ print(f"[syncade] metrics: aggregation failed: {exc}", file=sys.stderr)
360
+ return WORKTREE_ERROR
361
+ finally:
362
+ conn.close()
363
+ return SUCCESS