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,242 @@
1
+ """Synthesizer subprocess persistence.
2
+
3
+ Writes ``<round_dir>/synthesizer.{stdout,stderr,parsed.json[,error.txt]}``
4
+ and the matching round-manifest entry. There is exactly one
5
+ synthesizer per round, so the basename is hardcoded (``SYNTHESIZER_NAME``
6
+ from :mod:`syncade.synthesizer`).
7
+
8
+ The module is named ``synth.py`` (not ``synthesizer.py``) to
9
+ disambiguate from :mod:`syncade.synthesizer`, which contains the actual
10
+ synthesizer driver.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import traceback
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from syncade.synthesizer import (
20
+ SYNTHESIZER_MODEL,
21
+ SYNTHESIZER_NAME,
22
+ SYNTHESIZER_PROVIDER,
23
+ SynthesizerResult,
24
+ )
25
+ from syncade.usage import usage_fields
26
+
27
+ from ._atomic import atomic_write_text
28
+ from ._validation import _validate_reviewer_filename_basename
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SynthesizerArtifactPaths:
33
+ """Where the synthesizer subprocess's artifacts land on disk.
34
+
35
+ All four paths are absolute and rooted at ``<round_dir>``.
36
+ ``parsed`` is ``None`` when the synthesizer failed (no
37
+ :class:`SynthesizerOutput` to serialize); ``error`` is ``None``
38
+ when it succeeded (no exception to record).
39
+
40
+ Returned by :func:`persist_synthesizer_result` and attached to
41
+ :class:`~syncade.orchestrator.RunArtifacts` so the CLI / future
42
+ loop can address the files without re-deriving the layout
43
+ convention.
44
+ """
45
+
46
+ stdout: Path
47
+ stderr: Path
48
+ parsed: Path | None
49
+ error: Path | None
50
+
51
+
52
+ def persist_synthesizer_result(
53
+ round_dir: Path, synth_result: SynthesizerResult
54
+ ) -> SynthesizerArtifactPaths:
55
+ """Write the synthesizer subprocess's outputs to
56
+ ``<round_dir>/synthesizer.*``.
57
+
58
+ Mirrors :func:`persist_reviewer_result`'s file-layout convention
59
+ so a tool inspecting the round directory sees the synthesizer
60
+ artifacts in the same shape as the per-reviewer artifacts. The
61
+ only difference is the fixed ``"synthesizer"`` basename — there
62
+ is exactly one synthesizer per round, so no per-reviewer-name
63
+ collision risk.
64
+
65
+ Files written:
66
+
67
+ - ``synthesizer.stdout`` and ``synthesizer.stderr`` — always
68
+ created. Carry the captured codex subprocess streams when
69
+ :attr:`SynthesizerResult.raw_subprocess_result` is present;
70
+ empty when the failure happened before any subprocess output
71
+ (binary missing).
72
+ - ``synthesizer.parsed.json`` — written only when
73
+ ``synth_result.output is not None``. Pretty-printed via
74
+ :meth:`pydantic.BaseModel.model_dump_json(indent=2)` so the
75
+ file diffs cleanly across runs.
76
+ - ``synthesizer.error.txt`` — written only when
77
+ ``synth_result.error is not None``. Class name, message, and
78
+ traceback (when available).
79
+
80
+ Args:
81
+ round_dir: The round directory to write into. Must already
82
+ exist.
83
+ synth_result: The :class:`SynthesizerResult` from
84
+ :func:`syncade.synthesizer.run_synthesizer`.
85
+
86
+ Returns:
87
+ :class:`SynthesizerArtifactPaths` naming all written files.
88
+
89
+ Raises:
90
+ FileNotFoundError: If ``round_dir`` does not exist (caller
91
+ bug — the orchestrator creates it during run setup).
92
+ """
93
+ _validate_reviewer_filename_basename(SYNTHESIZER_NAME)
94
+ if not round_dir.is_dir():
95
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
96
+
97
+ base = round_dir / SYNTHESIZER_NAME
98
+
99
+ raw_result = synth_result.raw_subprocess_result
100
+ stdout_text = raw_result.stdout if raw_result is not None else ""
101
+ stderr_text = raw_result.stderr if raw_result is not None else ""
102
+ stdout_path = base.with_suffix(".stdout")
103
+ stderr_path = base.with_suffix(".stderr")
104
+ atomic_write_text(stdout_path, stdout_text)
105
+ atomic_write_text(stderr_path, stderr_text)
106
+
107
+ parsed_path: Path | None = None
108
+ if synth_result.output is not None:
109
+ parsed_path = base.with_suffix(".parsed.json")
110
+ atomic_write_text(parsed_path, synth_result.output.model_dump_json(indent=2))
111
+
112
+ error_path: Path | None = None
113
+ if synth_result.error is not None:
114
+ exc = synth_result.error
115
+ lines = [
116
+ f"{type(exc).__name__}: {exc}",
117
+ "",
118
+ ]
119
+ tb = exc.__traceback__
120
+ if tb is not None:
121
+ lines.extend(traceback.format_exception(type(exc), exc, tb))
122
+ else:
123
+ lines.append("(no traceback available — exception was constructed, not raised)")
124
+ error_path = base.with_suffix(".error.txt")
125
+ atomic_write_text(error_path, "\n".join(lines))
126
+
127
+ return SynthesizerArtifactPaths(
128
+ stdout=stdout_path,
129
+ stderr=stderr_path,
130
+ parsed=parsed_path,
131
+ error=error_path,
132
+ )
133
+
134
+
135
+ def _synthesizer_manifest_entry(synth_result: SynthesizerResult | None) -> dict[str, object] | None:
136
+ """Build the ``synthesizer`` section of the round manifest.
137
+
138
+ Returns ``None`` when the synthesizer phase was skipped (any
139
+ reviewer failed); a dict otherwise.
140
+
141
+ Schema:
142
+
143
+ .. code-block:: json
144
+
145
+ {
146
+ "outcome": "success" | "failure",
147
+ "stdout_path": "synthesizer.stdout",
148
+ "stderr_path": "synthesizer.stderr",
149
+ "parsed_path": "synthesizer.parsed.json" | null,
150
+ "error_path": "synthesizer.error.txt" | null,
151
+ "duration_seconds": float,
152
+ "error_type": null | "ExceptionClassName",
153
+ "dismissed_count": int | null,
154
+ "active_blocker_count": int | null,
155
+ "active_minor_count": int | null,
156
+ "active_nit_count": int | null,
157
+ "provenance_repairs": [ {reviewer_name, original_index,
158
+ synthesizer_text, reviewer_text} ] | []
159
+ }
160
+
161
+ On success: counts populated, ``error_path`` AND ``error_type``
162
+ are null (no .error.txt was written; no exception class to
163
+ record).
164
+
165
+ On failure: counts are null, ``error_path`` names the .error.txt
166
+ artifact IFF persistence will actually write one — i.e. when
167
+ ``synth_result.error is not None``. ``SynthesizerResult`` enforces
168
+ exactly one of output/error at construction, so the null-error
169
+ fallback is defensive only.
170
+ """
171
+ if synth_result is None:
172
+ return None
173
+
174
+ paths = {
175
+ "provider": synth_result.provider or SYNTHESIZER_PROVIDER,
176
+ "model": synth_result.model
177
+ or (synth_result.usage.model if synth_result.usage is not None else SYNTHESIZER_MODEL),
178
+ "stdout_path": f"{SYNTHESIZER_NAME}.stdout",
179
+ "stderr_path": f"{SYNTHESIZER_NAME}.stderr",
180
+ }
181
+
182
+ if synth_result.output is not None:
183
+ consolidated = synth_result.output.consolidated_findings
184
+ dismissed = sum(1 for f in consolidated if f.dismissed)
185
+ active_by_sev = {"blocker": 0, "minor": 0, "nit": 0}
186
+ for f in consolidated:
187
+ if not f.dismissed:
188
+ active_by_sev[f.severity] += 1
189
+ return {
190
+ "outcome": "success",
191
+ **paths,
192
+ "parsed_path": f"{SYNTHESIZER_NAME}.parsed.json",
193
+ "error_path": None,
194
+ "duration_seconds": synth_result.duration_seconds,
195
+ **usage_fields(synth_result.usage),
196
+ # Include error_type on success (null) for schema symmetry;
197
+ # downstream tools don't have to KeyError-guard or treat absence as
198
+ # a separate signal.
199
+ "error_type": None,
200
+ "dismissed_count": dismissed,
201
+ "active_blocker_count": active_by_sev["blocker"],
202
+ "active_minor_count": active_by_sev["minor"],
203
+ "active_nit_count": active_by_sev["nit"],
204
+ # Non-empty means the synthesizer miscopied a source it had correctly
205
+ # attributed, and syncade corrected the quotation from the reviewer's own
206
+ # text (PR-h-field-01 item 5). Recorded rather than silent: it is a signal about
207
+ # that model's fidelity, and rewriting a model's output without saying so
208
+ # is not something to hide. Both strings are kept so the operator can judge.
209
+ "provenance_repairs": [
210
+ {
211
+ "reviewer_name": r.reviewer_name,
212
+ "original_index": r.original_index,
213
+ "consolidated_index": r.consolidated_index,
214
+ "provenance_index": r.provenance_index,
215
+ "synthesizer_text": r.synthesizer_text,
216
+ "reviewer_text": r.reviewer_text,
217
+ }
218
+ for r in synth_result.provenance_repairs
219
+ ],
220
+ }
221
+
222
+ # Failure path. error_path is null when no .error.txt will be
223
+ # written — that's the contract-violation case (output=None AND
224
+ # error=None) where persist_synthesizer_result intentionally
225
+ # skips the error.txt write. Without this guard the manifest
226
+ # would point at a file that doesn't exist on disk.
227
+ error_type = type(synth_result.error).__name__ if synth_result.error is not None else None
228
+ error_path = f"{SYNTHESIZER_NAME}.error.txt" if synth_result.error is not None else None
229
+ return {
230
+ "outcome": "failure",
231
+ **paths,
232
+ "parsed_path": None,
233
+ "error_path": error_path,
234
+ "duration_seconds": synth_result.duration_seconds,
235
+ **usage_fields(synth_result.usage),
236
+ "error_type": error_type,
237
+ "dismissed_count": None,
238
+ "active_blocker_count": None,
239
+ "active_minor_count": None,
240
+ "active_nit_count": None,
241
+ "provenance_repairs": [],
242
+ }
@@ -0,0 +1,152 @@
1
+ """Test re-run leg persistence.
2
+
3
+ Writes ``<round_dir>/test-run.{stdout,stderr,exit-code.txt}`` and the
4
+ matching round-manifest entry. The orchestrator only calls these on
5
+ rounds where the operator configured ``[loop] test_command`` AND every
6
+ prior phase succeeded AND the synthesizer was clean.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from syncade.test_runner import TestRunResult
15
+
16
+ from ._atomic import atomic_write_text
17
+ from ._validation import _validate_reviewer_filename_basename
18
+
19
+ # Hardcoded basename for the test-leg artifacts. There is exactly one
20
+ # test run per round, so no per-test-command collision risk. The
21
+ # hardcoded basename also keeps the operator's ``test_command`` string
22
+ # (which can be anything) entirely out of the filesystem layer —
23
+ # manifest.json echoes the command but no file is ever named from it.
24
+ TEST_RUN_NAME = "test-run"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TestRunArtifactPaths:
29
+ """Where the test re-run leg's artifacts land on disk.
30
+
31
+ Returned by :func:`persist_test_run_result` and attached to
32
+ :class:`~syncade.orchestrator.RunArtifacts` so the CLI / future
33
+ loop can address the files without re-deriving the layout
34
+ convention.
35
+
36
+ All three paths are absolute and rooted at ``<round_dir>``. They
37
+ are always written when the test leg ran (the orchestrator only
38
+ calls :func:`persist_test_run_result` on a non-None
39
+ ``test_result``); the unconfigured-leg path keeps the field
40
+ ``None`` on :class:`~syncade.orchestrator.RunArtifacts` so the
41
+ no-files-written-on-disk signal stays unambiguous.
42
+ """
43
+
44
+ stdout: Path
45
+ stderr: Path
46
+ exit_code: Path
47
+
48
+
49
+ def persist_test_run_result(
50
+ round_dir: Path,
51
+ test_result: TestRunResult,
52
+ ) -> TestRunArtifactPaths:
53
+ """Write the test re-run leg's outputs to
54
+ ``<round_dir>/test-run.{stdout,stderr,exit-code.txt}``.
55
+
56
+ Mirrors :func:`persist_reviewer_result` and
57
+ :func:`persist_synthesizer_result`'s file-layout convention so a
58
+ tool inspecting the round directory sees the test artifacts in
59
+ the same shape as the per-reviewer and synthesizer artifacts.
60
+
61
+ Files written:
62
+
63
+ - ``test-run.stdout`` — captured test command stdout (whatever
64
+ reached the pipe buffer before the subprocess exited or was
65
+ killed). Empty on the subprocess-error path where the
66
+ subprocess never started (binary missing, bad cwd).
67
+ - ``test-run.stderr`` — captured test command stderr.
68
+ - ``test-run.exit-code.txt`` — one-line integer terminated by
69
+ ``\\n`` (``0`` for passed, the actual exit code for failed,
70
+ ``-1`` for subprocess error). Stored as a hardcoded basename
71
+ file rather than embedded only in ``manifest.json`` so a
72
+ shell-script consumer (CI integration, ``grep`` against the
73
+ round directory) can pull it without parsing JSON.
74
+
75
+ Args:
76
+ round_dir: The round directory to write into. Must already
77
+ exist (the orchestrator creates it during run setup).
78
+ test_result: The :class:`TestRunResult` from
79
+ :func:`syncade.test_runner.run_tests`. The caller (the
80
+ orchestrator) only calls this on a non-None test_result;
81
+ the unconfigured-leg path never reaches here.
82
+
83
+ Returns:
84
+ :class:`TestRunArtifactPaths` naming all three written files.
85
+
86
+ Raises:
87
+ FileNotFoundError: If ``round_dir`` does not exist (caller
88
+ bug — the orchestrator creates it during run setup).
89
+ """
90
+ _validate_reviewer_filename_basename(TEST_RUN_NAME)
91
+ if not round_dir.is_dir():
92
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
93
+
94
+ base = round_dir / TEST_RUN_NAME
95
+ stdout_path = base.with_suffix(".stdout")
96
+ stderr_path = base.with_suffix(".stderr")
97
+ # ``test-run.exit-code.txt`` — Path.with_suffix would replace the
98
+ # ``.stdout`` suffix wholesale, leaving us with
99
+ # ``test-run.exit-code.txt`` only if we hand-stitch. Build the
100
+ # filename directly rather than relying on with_suffix; the
101
+ # hardcoded basename keeps the layout self-explanatory.
102
+ exit_code_path = round_dir / f"{TEST_RUN_NAME}.exit-code.txt"
103
+
104
+ atomic_write_text(stdout_path, test_result.stdout)
105
+ atomic_write_text(stderr_path, test_result.stderr)
106
+ atomic_write_text(exit_code_path, f"{test_result.exit_code}\n")
107
+
108
+ return TestRunArtifactPaths(
109
+ stdout=stdout_path,
110
+ stderr=stderr_path,
111
+ exit_code=exit_code_path,
112
+ )
113
+
114
+
115
+ def _test_run_manifest_entry(test_result: TestRunResult | None) -> dict[str, object] | None:
116
+ """Build the ``test_run`` section of the round manifest.
117
+
118
+ Returns ``None`` when the leg was skipped (either ``test_command``
119
+ is not configured OR a prior phase failed/produced blockers).
120
+ Returns a dict with the documented synthesizer schema otherwise.
121
+
122
+ Schema:
123
+
124
+ .. code-block:: json
125
+
126
+ {
127
+ "outcome": "passed" | "failed" | "subprocess_error",
128
+ "exit_code": 0 | int | -1,
129
+ "command": "<operator-configured string>",
130
+ "duration_seconds": float,
131
+ "stdout_path": "test-run.stdout",
132
+ "stderr_path": "test-run.stderr",
133
+ "exit_code_path": "test-run.exit-code.txt",
134
+ "error_type": null | "SubprocessTimeoutError" | ...
135
+ }
136
+
137
+ ``command`` is echoed verbatim from the operator's config so a
138
+ consumer reading manifest.json knows what was run without
139
+ cross-referencing back to ``.syncade/config.toml``.
140
+ """
141
+ if test_result is None:
142
+ return None
143
+ return {
144
+ "outcome": test_result.outcome,
145
+ "exit_code": test_result.exit_code,
146
+ "command": test_result.command,
147
+ "duration_seconds": test_result.duration_seconds,
148
+ "stdout_path": f"{TEST_RUN_NAME}.stdout",
149
+ "stderr_path": f"{TEST_RUN_NAME}.stderr",
150
+ "exit_code_path": f"{TEST_RUN_NAME}.exit-code.txt",
151
+ "error_type": (type(test_result.error).__name__ if test_result.error is not None else None),
152
+ }
syncade/presets.py ADDED
@@ -0,0 +1,36 @@
1
+ """Bundled config presets (PR-v2-9): ``--preset cheap|balanced|thorough``.
2
+
3
+ Loads a packaged TOML preset as a plain ``dict`` — stdlib + importlib only, NO ``config`` import, so
4
+ :func:`syncade.config_loader.load_config` can deep-merge it without a cycle. The preset is the run's
5
+ BASE config; the user's ``.syncade/config.toml`` layers on top (user wins).
6
+
7
+ Presets vary ONLY safe loop dimensions (rounds / timeout) and NEVER the reviewer model or effort
8
+ tier — a cheaper panel that audits leniently is the pr-29 hazard. ``tests/config/test_presets.py``
9
+ pins that: every preset yields the SAME reviewer roster as the shipped defaults, and ``balanced`` is
10
+ behaviourally the defaults.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import tomllib
16
+ from importlib.resources import files
17
+
18
+ # The bundled presets. Explicit (not directory-discovered) so ``--preset``'s argparse choices and
19
+ # this tuple cannot drift, and an unrelated ``.toml`` dropped in the dir can't become a preset.
20
+ PRESET_NAMES: tuple[str, ...] = ("cheap", "balanced", "thorough")
21
+
22
+
23
+ class PresetError(Exception):
24
+ """An unknown ``--preset`` name (the CLI's argparse ``choices`` normally catches this first)."""
25
+
26
+
27
+ def load_preset(name: str) -> dict:
28
+ """Return the bundled preset ``name`` as a parsed TOML dict.
29
+
30
+ Read via :func:`importlib.resources.files` so it resolves in both a source checkout and an
31
+ installed wheel. Raises :class:`PresetError` for an unknown name.
32
+ """
33
+ if name not in PRESET_NAMES:
34
+ raise PresetError(f"unknown preset {name!r}; choose from {list(PRESET_NAMES)}")
35
+ text = (files("syncade") / "templates" / "presets" / f"{name}.toml").read_text(encoding="utf-8")
36
+ return tomllib.loads(text)
@@ -0,0 +1,72 @@
1
+ """Config-driven per-model pricing (PR-v2-04).
2
+
3
+ Codex reports tokens but no cost, so cost is derived here. Ships a conservative
4
+ default table so zero-config runs still estimate cost (labeled ``"estimated"``);
5
+ operators override via a ``[pricing]`` table in ``.syncade/config.toml``. Prices
6
+ are list prices as of 2026-07 (USD per 1M tokens) and go stale — override for
7
+ accuracy. Split into its own module (not ``config.py``, which is at its LOC cap).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+
15
+ class ModelPrice(BaseModel):
16
+ """USD per 1M tokens for one model."""
17
+
18
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
19
+
20
+ input_per_mtok: float = Field(ge=0)
21
+ output_per_mtok: float = Field(ge=0)
22
+ cached_input_per_mtok: float | None = Field(
23
+ default=None, ge=0
24
+ ) # unset → priced at the input rate
25
+
26
+
27
+ # List prices as of 2026-07 (USD / 1M tokens). Estimates — override via [pricing].
28
+ DEFAULT_PRICES: dict[str, ModelPrice] = {
29
+ "claude-opus-4-8": ModelPrice(
30
+ input_per_mtok=15.0, output_per_mtok=75.0, cached_input_per_mtok=1.5
31
+ ),
32
+ "claude-sonnet-4-6": ModelPrice(
33
+ input_per_mtok=3.0, output_per_mtok=15.0, cached_input_per_mtok=0.3
34
+ ),
35
+ # No default uses gpt-5.6-sol any more (PR-28 put the reviewers + judge on it;
36
+ # PR-29 moved both back to gpt-5.5). It STAYS priced: runs 2026-07-11T15-36-39
37
+ # and 2026-07-12T10-04-35 ran on it, and `syncade --metrics` reads the whole
38
+ # historical corpus — dropping the entry would silently turn those runs' cost
39
+ # into "unknown". Prices are append-mostly for exactly this reason.
40
+ "gpt-5.6-sol": ModelPrice(input_per_mtok=5.0, output_per_mtok=30.0, cached_input_per_mtok=0.5),
41
+ # Codex-harness producer default.
42
+ "gpt-5.6-terra": ModelPrice(
43
+ input_per_mtok=2.5, output_per_mtok=15.0, cached_input_per_mtok=0.25
44
+ ),
45
+ "gpt-5.5": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
46
+ "gpt-5.4": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
47
+ "gpt-5.4-mini": ModelPrice(
48
+ input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
49
+ ),
50
+ "gpt-5.3-codex": ModelPrice(
51
+ input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
52
+ ),
53
+ "gpt-5.3-codex-spark": ModelPrice(
54
+ input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
55
+ ),
56
+ "gpt-5.2": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
57
+ "gpt-5-codex": ModelPrice(
58
+ input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
59
+ ),
60
+ }
61
+
62
+
63
+ class PricingConfig(BaseModel):
64
+ """The ``[pricing]`` config section. A user-supplied ``models`` table replaces
65
+ the packaged default (provide the full table to override)."""
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ models: dict[str, ModelPrice] = Field(default_factory=lambda: dict(DEFAULT_PRICES))
70
+
71
+ def price_for(self, model: str) -> ModelPrice | None:
72
+ return self.models.get(model)