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
syncade/logging.py ADDED
@@ -0,0 +1,289 @@
1
+ """Small stdout/stderr progress logger for syncade runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ from syncade.billing import from_usages as _billing_from_usages
10
+ from syncade.findings import ReviewerOutputError
11
+ from syncade.retry import is_usage_limit_error
12
+ from syncade.synthesis import SynthesizerOutputError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterator
16
+
17
+ from syncade.orchestrator import RunResult
18
+
19
+ LogLevel = Literal["quiet", "normal"]
20
+
21
+
22
+ def _timestamp() -> str:
23
+ return datetime.now().strftime("[%H:%M:%S]")
24
+
25
+
26
+ def _is_budget_stop(run_result: RunResult) -> bool:
27
+ return getattr(run_result, "termination_reason", None) == "budget_exceeded"
28
+
29
+
30
+ def _budget_stop_line(run_result: RunResult) -> str:
31
+ """The budget-abort notice (PR-v2-11), shown even under ``--quiet``.
32
+
33
+ Rewritten in PR-h-field-06 to answer the four questions the operator actually has, the
34
+ same shape :func:`_usage_limit_stop_line` uses: what was spent against what ceiling, that
35
+ the completed work survived, and the exact command to continue. It previously pointed at
36
+ a file for the tally, which is one indirection too many for a terminal condition — and it
37
+ matters more now that a ceiling exists BY DEFAULT, so the first time most operators meet
38
+ this message they will not have chosen the number it names.
39
+
40
+ Tokens are translated to their API-equivalent dollar figure, flagged as a VALUATION: the
41
+ numbers come from the enforcement tally carried on the result, so this line, the
42
+ loop-summary Budget section and ``--metrics`` cannot disagree.
43
+ """
44
+ tokens = sum(u.total_tokens for u in run_result.budget_usages)
45
+ # Use billing.from_usages() — the SAME rule as loop-summary.md and --metrics — so that
46
+ # the lower-bound warning fires whenever cost_incomplete_tokens > 0 (which includes any
47
+ # Usage with cost_source="unknown", even when cost_usd is non-None). Checking
48
+ # `cost_usd is not None` alone misses that case and contradicts the artifact.
49
+ b = _billing_from_usages(run_result.budget_usages)
50
+ run_id = run_result.artifacts.run_dir.name
51
+
52
+ if run_result.budget_ceiling == "budget_usd":
53
+ limit = run_result.budget_usd
54
+ crossed = f"cost ceiling (budget_usd = ${limit:,.2f})" if limit else "cost ceiling"
55
+ else:
56
+ tokens_limit = run_result.budget_tokens
57
+ crossed = (
58
+ f"token ceiling (budget_tokens = {tokens_limit:,})" if tokens_limit else "token ceiling"
59
+ )
60
+
61
+ if b.total_unpriced_tokens:
62
+ valuation = (
63
+ f"${b.api_equiv:,.2f} (LOWER BOUND — {b.total_unpriced_tokens:,} tokens unpriced)"
64
+ )
65
+ elif b.api_equiv > 0:
66
+ valuation = f"${b.api_equiv:,.2f}"
67
+ else:
68
+ valuation = "unpriced"
69
+ if run_result.budget_ceiling == "budget_usd":
70
+ remedy = "Raise the ceiling (or set `[loop] budget_usd = 0` for none)"
71
+ else:
72
+ remedy = "Raise the ceiling (or set `[loop] budget_tokens = 0` for none)"
73
+ return (
74
+ f"[syncade] stopped early: this run crossed your {crossed}.\n"
75
+ f" Spent: {tokens:,} tokens (API-equivalent {valuation} — a valuation, not "
76
+ f"billed money).\n"
77
+ f" Completed rounds and their findings are preserved; nothing was interrupted "
78
+ f"mid-call.\n"
79
+ f" {remedy}, then: "
80
+ f"syncade --resume {run_id}"
81
+ )
82
+
83
+
84
+ def _approaching_budget_line(ceiling: str, usages: list, loop: object) -> str:
85
+ """The 80%-of-ceiling heads-up. Advisory: it never changes a verdict or an exit code.
86
+
87
+ Says what the remaining headroom IS rather than only that a limit is near, because the
88
+ decision it supports is "let this run finish or stop it myself", and that needs a number.
89
+ """
90
+ if ceiling == "budget_usd":
91
+ spent = sum(u.cost_usd for u in usages if u.cost_usd is not None)
92
+ limit = loop.budget_usd
93
+ return (
94
+ f"[syncade] approaching your cost ceiling: ${spent:,.2f} of ${limit:,.2f} "
95
+ f"(API-equivalent, a valuation not billed money). The run stops at the ceiling and "
96
+ f"is resumable."
97
+ )
98
+ spent = sum(u.total_tokens for u in usages)
99
+ limit = loop.budget_tokens
100
+ return (
101
+ f"[syncade] approaching your token ceiling: {spent:,} of {limit:,} tokens "
102
+ f"({limit - spent:,} left). The run stops at the ceiling and is resumable; raise "
103
+ f"`[loop] budget_tokens` or set it to 0 for none."
104
+ )
105
+
106
+
107
+ def _quota_candidates(
108
+ run_result: RunResult,
109
+ ) -> Iterator[tuple[BaseException | None, str | None]]:
110
+ """Every actor that could have been the one refused, with its provider.
111
+
112
+ A quota is ACCOUNT-wide, so whichever actor reaches the provider first discovers it — and
113
+ after the routing fix that is as often the judge or the producer as a reviewer. Scanning
114
+ only the reviewers left both of those printing a bare "the provider", which is the one
115
+ thing this line exists to avoid saying.
116
+ """
117
+ for r in run_result.dispatch_result.results:
118
+ yield r.error, r.provider
119
+ synth = run_result.synth_result
120
+ if synth is not None:
121
+ yield synth.error, synth.provider
122
+ for rnd in reversed(run_result.rounds):
123
+ producer = rnd.producer_result
124
+ if producer is not None:
125
+ yield producer.error, producer.provider
126
+
127
+
128
+ def _usage_limit_stop_line(run_result: RunResult) -> str:
129
+ """The provider-quota abort notice (PR-h-field-02), shown even under ``--quiet``.
130
+
131
+ Three field runs hit this and the operator saw NOTHING — no verdict, no error, no exit code.
132
+ A terminal condition the operator can act on is worth four lines: what happened, that their
133
+ completed work survived, the remedy, and the exact command to continue.
134
+
135
+ Names the PROVIDER because a mixed panel makes "which one ran out" the first question, and
136
+ names the run id because the resume command is useless without it.
137
+ """
138
+ provider = next(
139
+ (p for err, p in _quota_candidates(run_result) if p and is_usage_limit_error(err)),
140
+ "the provider",
141
+ )
142
+ run_id = run_result.artifacts.run_dir.name
143
+ return (
144
+ f"[syncade] stopped early: {provider}'s usage limit reached — not your configured "
145
+ f"budget, "
146
+ f"and not a fault in the code under review.\n"
147
+ f" Completed rounds and their findings are preserved; nothing was interrupted "
148
+ f"mid-call.\n"
149
+ f" Remedy: consume a reset (run `codex`, then /usage) or wait for the window — "
150
+ f"retrying now fails the same way.\n"
151
+ f" Resume with: syncade --resume {run_id}"
152
+ )
153
+
154
+
155
+ def _is_usage_limit_stop(run_result: RunResult) -> bool:
156
+ return getattr(run_result, "termination_reason", None) == "provider_usage_limit"
157
+
158
+
159
+ class Logger:
160
+ """Timestamped stdout/stderr logger with quiet-mode suppression."""
161
+
162
+ def __init__(self, level: LogLevel = "normal") -> None:
163
+ self.level = level
164
+
165
+ def _emit(self, line: str) -> None:
166
+ if self.level != "quiet":
167
+ print(f"{_timestamp()} {line}", flush=True)
168
+
169
+ def _emit_err(self, line: str) -> None:
170
+ print(f"{_timestamp()} {line}", file=sys.stderr, flush=True)
171
+
172
+ def _emit_warning(self, line: str) -> None:
173
+ if self.level != "quiet":
174
+ print(f"{_timestamp()} {line}", file=sys.stderr, flush=True)
175
+
176
+ def warning(self, message: str) -> None:
177
+ self._emit_warning(f"warning: {message}")
178
+
179
+ def safety(self, message: str) -> None:
180
+ """A disclosure about the DISPOSITION OF COMMITTED WORK — never suppressed (PR-h-13).
181
+
182
+ `--quiet` is for progress, not for safety. The class this exists for is narrow and
183
+ answerable in one sentence: *where did the producer's commits go, and is my tree
184
+ consistent with that?* A branch that did or did not advance, a commit stranded on a
185
+ detached HEAD, a non-descendant ending SHA, a failed `update-ref` — and above all the
186
+ working tree left holding a fully STAGED REVERT of the producer's work, where the
187
+ operator's next `git commit` silently undoes the run.
188
+
189
+ That last one is not hypothetical: it happened twice in one session on 2026-08-10, with
190
+ the warning visible and an experienced operator driving. Under `--quiet` there would
191
+ have been nothing at all. The auth block and the default-branch disclosure already
192
+ bypass quiet for the same reason; this gives that rule a name instead of a hand-rolled
193
+ `print` at each site.
194
+
195
+ Deliberately NOT this class: dirty-tree and untracked-file notes, resume-drift notes,
196
+ auto-prune failures. They describe the run, not the fate of work already committed.
197
+ """
198
+ self._emit_err(f"warning: {message}")
199
+
200
+ def event(self, message: str, *, error: bool = False) -> None:
201
+ if error:
202
+ self._emit_err(message)
203
+ else:
204
+ self._emit(message)
205
+
206
+ def summary(self, run_result: RunResult) -> None:
207
+ """Print the final run summary, including parse-error artifact pointers."""
208
+ artifacts = run_result.artifacts
209
+ round_dir = artifacts.round_dir
210
+ output_error_reviewers = [
211
+ r
212
+ for r in run_result.dispatch_result.results
213
+ if isinstance(r.error, ReviewerOutputError)
214
+ ]
215
+ synth_result = run_result.synth_result
216
+ synth_output_error = synth_result is not None and isinstance(
217
+ synth_result.error, SynthesizerOutputError
218
+ )
219
+
220
+ budget_note = _budget_stop_line(run_result) if _is_budget_stop(run_result) else None
221
+ if budget_note is None and _is_usage_limit_stop(run_result):
222
+ budget_note = _usage_limit_stop_line(run_result)
223
+
224
+ if self.level == "quiet":
225
+ print(
226
+ f"[syncade] run complete — exit {run_result.exit_code}, "
227
+ f"summary at {artifacts.summary_path}",
228
+ flush=True,
229
+ )
230
+ if budget_note is not None:
231
+ print(budget_note, flush=True)
232
+ for r in output_error_reviewers:
233
+ print(
234
+ f"[syncade] {r.reviewer_name} raw response: "
235
+ f"{round_dir / f'{r.reviewer_name}.stdout'}; parse exception: "
236
+ f"{round_dir / f'{r.reviewer_name}.error.txt'}",
237
+ flush=True,
238
+ )
239
+ if synth_output_error:
240
+ print(
241
+ f"[syncade] synthesizer raw response: "
242
+ f"{round_dir / 'synthesizer.stdout'}; parse exception: "
243
+ f"{round_dir / 'synthesizer.error.txt'}",
244
+ flush=True,
245
+ )
246
+ return
247
+
248
+ lines = [
249
+ f"{_timestamp()} run complete",
250
+ f" run id: {artifacts.run_dir.name}",
251
+ f" exit code: {run_result.exit_code}",
252
+ ]
253
+ for r in run_result.dispatch_result.results:
254
+ if r.output is not None:
255
+ lines.append(f" - {r.reviewer_name} ({r.provider}): {r.output.verdict}")
256
+ else:
257
+ err_cls = type(r.error).__name__ if r.error else "Unknown"
258
+ lines.append(f" - {r.reviewer_name} ({r.provider}): FAILED ({err_cls})")
259
+ lines.append(f" artifacts: {artifacts.run_dir}")
260
+ lines.append(f" summary: {artifacts.summary_path}")
261
+ if artifacts.findings_md_path is not None:
262
+ lines.append(f" findings: {artifacts.findings_md_path}")
263
+ if budget_note is not None:
264
+ lines.append(budget_note)
265
+
266
+ for r in output_error_reviewers:
267
+ lines.append(
268
+ f" For {r.reviewer_name}: raw response preserved at "
269
+ f"{round_dir / f'{r.reviewer_name}.stdout'}; parse exception at "
270
+ f"{round_dir / f'{r.reviewer_name}.error.txt'}."
271
+ )
272
+ lines.append(
273
+ " The reviewer ran successfully but its output didn't parse — the verdict "
274
+ "and findings may still be readable in the raw .stdout file."
275
+ )
276
+
277
+ if synth_output_error:
278
+ lines.append(
279
+ f" For synthesizer: raw response preserved at "
280
+ f"{round_dir / 'synthesizer.stdout'}; parse exception at "
281
+ f"{round_dir / 'synthesizer.error.txt'}."
282
+ )
283
+ lines.append(
284
+ " The synthesizer ran successfully but its output didn't parse — common "
285
+ "shapes are invented findings, unanimous-blocker deactivation attempts, or "
286
+ "missing required fields."
287
+ )
288
+
289
+ print("\n".join(lines), flush=True)
@@ -0,0 +1,32 @@
1
+ """Metrics sink for syncade runs (PR-v2-03).
2
+
3
+ A derived, rebuildable sqlite view over the ``.syncade/runs/`` artifact corpus.
4
+ Read-only over run artifacts; stdlib ``sqlite3`` only (no new dependency).
5
+
6
+ Public surface is re-exported here, but monkeypatches/tests should target the
7
+ concrete submodule that owns the lookup (CLAUDE.md Decomposition Rule).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from syncade.metrics.schema import (
13
+ ActorStatRow,
14
+ ReviewerStatRow,
15
+ RunRow,
16
+ fetch_runs,
17
+ open_db,
18
+ upsert_actor_stat,
19
+ upsert_reviewer_stat,
20
+ upsert_run,
21
+ )
22
+
23
+ __all__ = [
24
+ "ActorStatRow",
25
+ "ReviewerStatRow",
26
+ "RunRow",
27
+ "fetch_runs",
28
+ "open_db",
29
+ "upsert_actor_stat",
30
+ "upsert_reviewer_stat",
31
+ "upsert_run",
32
+ ]