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,198 @@
1
+ """Reviewer subprocess persistence.
2
+
3
+ Writes ``<round_dir>/<reviewer_name>.{stdout,stderr,parsed.json,error.txt}``
4
+ and the matching round-manifest entry. Called once per reviewer in the
5
+ fan-out (multiple per round).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ import traceback
15
+ from pathlib import Path
16
+
17
+ from syncade.dispatcher import ReviewerRunResult
18
+ from syncade.process import SubprocessResult
19
+ from syncade.usage import usage_fields
20
+
21
+ from ._atomic import atomic_write_text
22
+ from ._validation import _validate_reviewer_filename_basename
23
+
24
+
25
+ def persist_reviewer_result(
26
+ round_dir: Path,
27
+ run_result: ReviewerRunResult,
28
+ raw_subprocess_result: SubprocessResult | None,
29
+ ) -> None:
30
+ """Write one reviewer's outputs to ``<round_dir>/<name>.*``.
31
+
32
+ Files written:
33
+
34
+ - ``<name>.stdout`` and ``<name>.stderr`` — always created.
35
+ Contain the captured subprocess streams when available;
36
+ empty when ``raw_subprocess_result`` is ``None`` (the failure
37
+ happened before the subprocess actually ran — e.g. unknown
38
+ provider, auth fail-fast, or build_invocation raising).
39
+ - ``<name>.parsed.json`` — written only when
40
+ ``run_result.output is not None``. Uses
41
+ :meth:`pydantic.BaseModel.model_dump_json(indent=2)` so the
42
+ file diffs cleanly across runs.
43
+ - ``<name>.error.txt`` — written only when
44
+ ``run_result.error is not None``. Contains the exception's
45
+ class name, message, and full traceback (when available).
46
+
47
+ Args:
48
+ round_dir: The round directory to write into. Must already
49
+ exist (the orchestrator creates it during run setup).
50
+ run_result: One :class:`ReviewerRunResult` from a
51
+ :class:`DispatchResult`. The ``reviewer_name`` field is
52
+ used as the filename basename and validated against
53
+ path traversal.
54
+ raw_subprocess_result: The :class:`SubprocessResult` from
55
+ the reviewer's subprocess, if one ran. ``None`` when
56
+ the failure happened before any subprocess launched.
57
+
58
+ Raises:
59
+ ValueError: If ``run_result.reviewer_name`` is not a safe
60
+ basename.
61
+ FileNotFoundError: If ``round_dir`` does not exist (caller
62
+ bug — orchestrator is responsible for creating it).
63
+ """
64
+ _validate_reviewer_filename_basename(run_result.reviewer_name)
65
+ if not round_dir.is_dir():
66
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
67
+
68
+ # Use name-append, not with_suffix: a reviewer named "team.a" must produce
69
+ # "team.a.stdout", not "team.stdout" (which would collide with "team.b").
70
+ base_name = run_result.reviewer_name
71
+
72
+ stdout_text = raw_subprocess_result.stdout if raw_subprocess_result is not None else ""
73
+ stderr_text = raw_subprocess_result.stderr if raw_subprocess_result is not None else ""
74
+ atomic_write_text(round_dir / (base_name + ".stdout"), stdout_text)
75
+ atomic_write_text(round_dir / (base_name + ".stderr"), stderr_text)
76
+
77
+ if run_result.output is not None:
78
+ atomic_write_text(
79
+ round_dir / (base_name + ".parsed.json"),
80
+ run_result.output.model_dump_json(indent=2),
81
+ )
82
+
83
+ if run_result.error is not None:
84
+ # Compose: class name, message, traceback. The class name is
85
+ # essential for downstream tooling that wants to bucket
86
+ # failures by type without parsing the message.
87
+ exc = run_result.error
88
+ lines = [
89
+ f"{type(exc).__name__}: {exc}",
90
+ "",
91
+ ]
92
+ # __traceback__ may be None when the exception was constructed
93
+ # (not raised) — e.g. ReviewerInvocationError that the adapter
94
+ # built defensively. Surface that distinction in the file.
95
+ tb = exc.__traceback__
96
+ if tb is not None:
97
+ lines.extend(traceback.format_exception(type(exc), exc, tb))
98
+ else:
99
+ lines.append("(no traceback available — exception was constructed, not raised)")
100
+ atomic_write_text(round_dir / (base_name + ".error.txt"), "\n".join(lines))
101
+
102
+
103
+ def _reviewer_manifest_entry(run_result: ReviewerRunResult) -> dict[str, object]:
104
+ """Build the per-reviewer entry for the manifest's ``reviewers``
105
+ array. Pulls out the fields tooling cares about while keeping the
106
+ full raw output behind the .stdout / .parsed.json files."""
107
+ model = run_result.model or (run_result.usage.model if run_result.usage is not None else "")
108
+ if run_result.output is not None:
109
+ return {
110
+ "name": run_result.reviewer_name,
111
+ "provider": run_result.provider,
112
+ "model": model,
113
+ "verdict": run_result.output.verdict,
114
+ "finding_count": len(run_result.output.findings),
115
+ "duration_seconds": run_result.duration_seconds,
116
+ **usage_fields(run_result.usage),
117
+ "outcome": "success",
118
+ "error_type": None,
119
+ }
120
+ error_type = type(run_result.error).__name__ if run_result.error is not None else None
121
+ return {
122
+ "name": run_result.reviewer_name,
123
+ "provider": run_result.provider,
124
+ "model": model,
125
+ "verdict": None,
126
+ "finding_count": None,
127
+ "duration_seconds": run_result.duration_seconds,
128
+ **usage_fields(run_result.usage),
129
+ "outcome": "failure",
130
+ "error_type": error_type,
131
+ }
132
+
133
+
134
+ def persist_dispatch_record(
135
+ round_dir: Path,
136
+ *,
137
+ round_index: int,
138
+ reviewers: list,
139
+ timeout_seconds: float,
140
+ ) -> None:
141
+ """Record that reviewers were DISPATCHED, before any of them returns (PR-h-field-02).
142
+
143
+ Every other artifact in a round is written after the panel completes, so a run that dies
144
+ mid-dispatch leaves the round directory EMPTY. Three field runs did exactly that — killed in
145
+ ``round-2: reviewing`` with nothing on disk — and the post-mortem had only a log line in a
146
+ terminal and a ``status.json`` phase to work from. The provider error, the child pids, even
147
+ the fact that dispatch had begun, were all unrecoverable.
148
+
149
+ This is the cheapest possible fix for that: one small file, written before the panel starts,
150
+ saying what was in flight and since when. It never changes a verdict and is never read by the
151
+ loop — it exists purely so the NEXT unexplained death is diagnosable.
152
+
153
+ Deliberately records the parent pid: cross-referenced with ``status.json``'s pid it tells a
154
+ reader whether the parent survived its children, which is the difference between "the
155
+ provider rejected us" and "something killed the process".
156
+ """
157
+ payload = {
158
+ "round": round_index,
159
+ "dispatched_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
160
+ "parent_pid": os.getpid(),
161
+ "timeout_seconds": timeout_seconds,
162
+ "reviewers": [
163
+ {"name": r.name, "provider": r.provider, "model": r.model} for r in reviewers
164
+ ],
165
+ }
166
+ atomic_write_text(round_dir / "dispatch.json", json.dumps(payload, indent=2) + "\n")
167
+
168
+
169
+ # Reviewers spawn in parallel threads, so two children can reach record_child_pid at once and
170
+ # a plain read-modify-write would drop one of the pids. One process writes this file, so a
171
+ # threading lock is the whole requirement — no file locking needed.
172
+ _dispatch_record_lock = threading.Lock()
173
+
174
+
175
+ def record_child_pid(round_dir: Path, reviewer_name: str, pid: int) -> None:
176
+ """Add a reviewer's child pid to ``dispatch.json``, once that child EXISTS.
177
+
178
+ The record is written before the panel starts, when no pid is knowable yet; this fills them
179
+ in as each ``Popen`` returns. That answers the question the PR-h-field-02 post-mortem could
180
+ not: whether the children outlived the parent — "something killed syncade and orphaned
181
+ them" — or died first and it followed. A reader can ``ps`` these, or observe their absence.
182
+
183
+ A RETRY overwrites: the previous attempt's child is already dead by construction (retry only
184
+ follows a failure), and the diagnostic question is about what was alive at the time of death.
185
+
186
+ Never raises. Every failure mode here — the file missing, half-written, unparseable, a name
187
+ that is not in it — costs a breadcrumb, and a breadcrumb is never worth failing a review for.
188
+ """
189
+ path = round_dir / "dispatch.json"
190
+ with _dispatch_record_lock:
191
+ try:
192
+ payload = json.loads(path.read_text(encoding="utf-8"))
193
+ for entry in payload["reviewers"]:
194
+ if entry.get("name") == reviewer_name:
195
+ entry["pid"] = pid
196
+ atomic_write_text(path, json.dumps(payload, indent=2) + "\n")
197
+ except (OSError, ValueError, KeyError, TypeError):
198
+ return
@@ -0,0 +1,238 @@
1
+ """Per-round manifest.json persistence.
2
+
3
+ Writes ``<round_dir>/manifest.json`` — the round-level entry point for
4
+ tooling that wants to know what happened without reading every
5
+ reviewer's output. The schema includes reviewer, synthesizer, test,
6
+ producer, and check sections, with ``round_exit_code`` aligned to
7
+ ``loop-manifest.json``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from syncade import __version__
16
+ from syncade.dispatcher import DispatchResult
17
+ from syncade.producer import ProducerResult
18
+ from syncade.snapshot import Snapshot
19
+ from syncade.synthesizer import SynthesizerResult
20
+ from syncade.test_runner import TestRunResult
21
+
22
+ from ._atomic import atomic_write_json
23
+ from .checks import _check_manifest_entry
24
+ from .producer import _producer_manifest_entry
25
+ from .reviewer import _reviewer_manifest_entry
26
+ from .synth import _synthesizer_manifest_entry
27
+ from .test_run import _test_run_manifest_entry
28
+
29
+
30
+ def persist_round_manifest(
31
+ round_dir: Path,
32
+ snapshot: Snapshot,
33
+ dispatch_result: DispatchResult,
34
+ exit_code: int,
35
+ started_at: datetime,
36
+ synth_result: SynthesizerResult | None = None,
37
+ test_result: TestRunResult | None = None,
38
+ test_skip_reason: str | None = None,
39
+ *,
40
+ round_idx: int = 0,
41
+ producer_result: ProducerResult | None = None,
42
+ producer_provider: str | None = None,
43
+ producer_model: str | None = None,
44
+ check_results: list[TestRunResult] | None = None,
45
+ diff_filter_refusal_headers: list[str] | None = None,
46
+ filtered_diff_bytes: int | None = None,
47
+ raw_diff_bytes: int | None = None,
48
+ oversize_prompt_chars: int | None = None,
49
+ refusal_reason: str | None = None,
50
+ ) -> Path:
51
+ """Write ``<round_dir>/manifest.json`` summarizing the round.
52
+
53
+ The manifest is the round-level entry point for tooling that wants
54
+ to know what happened without reading every reviewer's output.
55
+ The loop and skill bridge read it to surface findings counts and round
56
+ status to the user.
57
+
58
+ Schema (matches PRD Appendix C usage):
59
+
60
+ .. code-block:: json
61
+
62
+ {
63
+ "syncade_version": "0.X.0",
64
+ "run_id": "2026-05-12T15-30-04",
65
+ "round": 0,
66
+ "started_at_utc": "2026-05-12T15:30:04Z",
67
+ "snapshot": {
68
+ "commit_sha": "...",
69
+ "branch": "main",
70
+ "base_ref": null,
71
+ "base_oid": null,
72
+ "diff_present": false
73
+ },
74
+ "reviewers": [
75
+ {
76
+ "name": "claude-reviewer",
77
+ "provider": "anthropic",
78
+ "verdict": "SHIP",
79
+ "finding_count": 0,
80
+ "duration_seconds": 12.4,
81
+ "outcome": "success",
82
+ "error_type": null
83
+ }
84
+ ],
85
+ "synthesizer": {
86
+ "outcome": "success",
87
+ "stdout_path": "synthesizer.stdout",
88
+ "stderr_path": "synthesizer.stderr",
89
+ "parsed_path": "synthesizer.parsed.json",
90
+ "error_path": null,
91
+ "duration_seconds": 12.4,
92
+ "dismissed_count": 1,
93
+ "active_blocker_count": 0,
94
+ "active_minor_count": 2,
95
+ "active_nit_count": 1
96
+ },
97
+ "test_run": {
98
+ "outcome": "passed",
99
+ "exit_code": 0,
100
+ "command": "pytest -q",
101
+ "duration_seconds": 8.3,
102
+ "stdout_path": "test-run.stdout",
103
+ "stderr_path": "test-run.stderr",
104
+ "exit_code_path": "test-run.exit-code.txt",
105
+ "error_type": null
106
+ },
107
+ "test_skip_reason": null,
108
+ "producer": null,
109
+ "retried": 0,
110
+ "round_exit_code": 0
111
+ }
112
+
113
+ The ``synthesizer`` section is ``null`` when the phase was
114
+ skipped (any reviewer failed). On synthesizer success the
115
+ consolidation counts populate. On synthesizer failure the
116
+ counts are ``null`` and ``error_path`` points at the
117
+ ``synthesizer.error.txt`` artifact.
118
+
119
+ The ``test_run`` section is ``null`` when the leg was skipped
120
+ (``[loop] test_command`` is not configured OR a prior phase
121
+ failed/produced blockers). When the leg ran, it carries the
122
+ ``outcome``, the operator-configured ``command`` echoed back
123
+ verbatim, and pointers to the three artifact files. On
124
+ ``subprocess_error``, ``error_type`` names the failure shape
125
+ (``SubprocessTimeoutError``, ``SubprocessNotFoundError``, or
126
+ other :class:`~syncade.process.SubprocessError` subclasses).
127
+
128
+ ``started_at`` is the run-start instant captured once by the
129
+ orchestrator and shared with :func:`persist_run_summary`, so the
130
+ two files agree on when the run *began* — not when each file
131
+ happened to be written (a long run finishes minutes after it
132
+ starts).
133
+
134
+ Returns the path of the written manifest.
135
+ """
136
+ if not round_dir.is_dir():
137
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
138
+
139
+ # Run id is the parent directory's name (orchestrator's layout
140
+ # convention). Decoupled here rather than passed in, so a future
141
+ # caller that reorganizes the run hierarchy doesn't have to remember
142
+ # to update this argument list — they only have to keep the
143
+ # parent.name convention.
144
+ run_id = round_dir.parent.name
145
+
146
+ manifest = {
147
+ "syncade_version": __version__,
148
+ "run_id": run_id,
149
+ # ``round`` is the per-round index (0-indexed). Top-level loop
150
+ # aggregation lives in <run-id>/loop-manifest.json.
151
+ "round": round_idx,
152
+ "started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
153
+ "snapshot": {
154
+ "commit_sha": snapshot.commit_sha,
155
+ "branch": snapshot.branch,
156
+ "base_ref": snapshot.base_ref,
157
+ "base_oid": snapshot.base_oid,
158
+ "diff_present": bool(snapshot.diff_text),
159
+ # Size history for a future diff cap. There is no cap today, and picking a
160
+ # threshold without history would be a guess; recording costs nothing and cannot
161
+ # be backfilled, so every run from here on is a datapoint.
162
+ #
163
+ # Two numbers because they answer different questions: `diff_bytes` is what the
164
+ # repo produced, `diff_bytes_reviewed` is what a reviewer was actually handed
165
+ # AFTER repo-context stripping — and a cap must threshold on the latter, since
166
+ # that is what reaches the model's context.
167
+ #
168
+ # BOTH are supplied by the caller that MEASURED them, and this writer never
169
+ # infers one. Deriving `diff_bytes` from `snapshot.diff_text` looked free and was
170
+ # not: a resumed round's snapshot carries a SENTINEL rather than the real diff, so
171
+ # the derivation fabricated a byte count for a diff nobody had. `null` means "not
172
+ # measured on this path" and is honest; a fabricated integer is not.
173
+ "diff_bytes": raw_diff_bytes,
174
+ "diff_bytes_reviewed": filtered_diff_bytes,
175
+ },
176
+ "reviewers": [_reviewer_manifest_entry(r) for r in dispatch_result.results],
177
+ "synthesizer": _synthesizer_manifest_entry(synth_result),
178
+ "test_run": _test_run_manifest_entry(test_result),
179
+ # persist the test_skip_reason alongside test_run.
180
+ # Tooling that wants to know WHY the test leg didn't fire
181
+ # would otherwise have to infer from dispatch + synth
182
+ # state. Surfacing the explicit reason in the
183
+ # machine-readable manifest closes the loop: the CLI,
184
+ # the future update loop, and any external tooling all
185
+ # get the same signal the Logger emitted at run time.
186
+ # ``None`` when test_result is not None (the leg ran).
187
+ "test_skip_reason": test_skip_reason if test_result is None else None,
188
+ # per-round producer section. ``None`` when this round
189
+ # didn't run a producer (the round that SHIPped, or the
190
+ # final round under max-rounds-reached). Populated when the
191
+ # producer ran with outcome / starting_sha / ending_sha /
192
+ # provider + model echo.
193
+ "producer": _producer_manifest_entry(
194
+ producer_result,
195
+ producer_config_provider=producer_provider,
196
+ producer_config_model=producer_model,
197
+ ),
198
+ # Total EXTRA subprocess attempts consumed this round riding out
199
+ # transient provider errors (H5). A 429/5xx/dropped-socket blip in a
200
+ # reviewer, the synthesizer, OR the producer subprocess is retried with
201
+ # jittered backoff instead of aborting the loop at exit 40; surfacing the
202
+ # count makes a flaky run visible in the artifact. Sums the reviewer-dispatch
203
+ # retries, the synthesizer's own (SynthesizerResult.retries), and the
204
+ # producer's (ProducerResult.retries, PR-v2-22).
205
+ "retried": sum(r.retries for r in dispatch_result.results)
206
+ + (synth_result.retries if synth_result is not None else 0)
207
+ + (producer_result.retries if producer_result is not None else 0),
208
+ # Use ``round_exit_code`` for cross-surface consistency with
209
+ # ``loop-manifest.json``'s ``rounds[].round_exit_code``.
210
+ "round_exit_code": exit_code,
211
+ }
212
+
213
+ # append the checks array ONLY when checks ran, so a zero-config
214
+ # round's manifest stays byte-identical (no 'checks' key at all).
215
+ if check_results:
216
+ manifest["checks"] = [_check_manifest_entry(c) for c in check_results]
217
+
218
+ # present ONLY on a fail-closed diff-filter refusal (D2, PR-h-02d).
219
+ # Named "diff_filter_refusal_headers" so tooling can distinguish
220
+ # "reviewer worktree failed" from "diff section(s) were unidentifiable".
221
+ if diff_filter_refusal_headers is not None:
222
+ manifest["diff_filter_refusal_headers"] = diff_filter_refusal_headers
223
+
224
+ # present ONLY on a before-dispatch size refusal. Machine-readable discriminator
225
+ # for the resume planner and tooling: "diff_malformed", "diff_too_large", or
226
+ # "prompt_too_large". Distinct from diff_filter_refusal_headers (legacy field for
227
+ # diff_malformed only) so all three refusal reasons are uniformly detectable.
228
+ if refusal_reason is not None:
229
+ manifest["refusal_reason"] = refusal_reason
230
+
231
+ # present ONLY on a prompt_too_large refusal. Carries the measured assembled prompt
232
+ # size so tooling can tell the operator by how much and for which reviewer.
233
+ if oversize_prompt_chars is not None:
234
+ manifest["oversize_prompt_chars"] = oversize_prompt_chars
235
+
236
+ manifest_path = round_dir / "manifest.json"
237
+ atomic_write_json(manifest_path, manifest, sort_keys=False)
238
+ return manifest_path
@@ -0,0 +1,153 @@
1
+ """Run-init persistence.
2
+
3
+ Writes ``<run_dir>/run-init.json`` — the top-level "what was this run
4
+ about" record, captured at the very start of every fresh ``syncade
5
+ <pr-doc>`` invocation (before round 0's directory exists).
6
+
7
+ The file exists so ``--resume <run-id>`` can
8
+ reconstruct enough of the original invocation's context to decide
9
+ eligibility (was the operator on this branch? did the original run
10
+ start from this SHA?) and to validate against tree drift between
11
+ abort and resume.
12
+
13
+ Schema:
14
+
15
+ .. code-block:: json
16
+
17
+ {
18
+ "syncade_version": "0.1.0",
19
+ "started_at_utc": "2026-05-28T16:43:59Z",
20
+ "pr_doc_path": "design docs",
21
+ "base_ref": "1dbbca3",
22
+ "base_oid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
23
+ "starting_sha": "3442e72ab60b43153cdbee83f3187203b289f14a",
24
+ "operator_branch": "main",
25
+ "max_rounds": 3,
26
+ "config_snapshot": { ... }
27
+ }
28
+
29
+ The ``config_snapshot`` is informational only — the resumed loop
30
+ reads the CURRENT ``.syncade/config.toml`` from disk; the snapshot
31
+ exists so the operator (or a future agent) inspecting an aborted run
32
+ knows what config produced it.
33
+
34
+ It is a ``model_dump`` of :class:`SyncadeConfig` echoing every field at
35
+ its serialized value, with ONE carve-out: a
36
+ default-empty ``checks`` list is omitted, so a zero-config run-init.json does
37
+ not carry an empty optional section. A configured ``[[checks]]`` list is still
38
+ echoed; only the empty default is dropped.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from datetime import datetime
44
+ from pathlib import Path
45
+
46
+ from syncade.config import SyncadeConfig
47
+
48
+ from ._atomic import atomic_write_json
49
+
50
+ RUN_INIT_FILENAME: str = "run-init.json"
51
+ """Filename used for the run-init artifact. Single source of
52
+ truth so the persistence writer, the resume detector, and any future
53
+ tooling all agree on the path."""
54
+
55
+
56
+ def _config_snapshot(config: SyncadeConfig) -> dict:
57
+ """Serialize the config for the diagnostic ``config_snapshot`` block.
58
+
59
+ A default-empty ``checks`` list is omitted so zero-config runs do not carry
60
+ an empty optional section. A configured ``[[checks]]`` list is still echoed
61
+ for diagnostics. Every other field is echoed verbatim.
62
+ """
63
+ snapshot = config.model_dump(mode="json")
64
+ if snapshot.get("checks") == []:
65
+ del snapshot["checks"]
66
+ return snapshot
67
+
68
+
69
+ def persist_run_init(
70
+ run_dir: Path,
71
+ *,
72
+ syncade_version: str,
73
+ started_at: datetime,
74
+ pr_doc_path: Path,
75
+ base_ref: str | None,
76
+ base_oid: str | None,
77
+ starting_sha: str,
78
+ operator_branch: str | None,
79
+ max_rounds: int,
80
+ config: SyncadeConfig,
81
+ ) -> Path:
82
+ """Write ``<run_dir>/run-init.json`` capturing the run's initial
83
+ state.
84
+
85
+ Called ONCE per run, at the very start of
86
+ :func:`syncade.orchestrator.loop.run_review` (after the run directory
87
+ exists, before round 0's directory is created). A
88
+ resumed run does NOT rewrite this file — the snapshot captured
89
+ at the original run start is the authoritative record across
90
+ resume boundaries.
91
+
92
+ Args:
93
+ run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
94
+ Must already exist; the run-id mkdir happens before this
95
+ call in ``run_review``.
96
+ syncade_version: The :data:`syncade.__version__` at run start.
97
+ Recorded so a resume across a syncade upgrade can emit a
98
+ stderr warning.
99
+ started_at: The :func:`datetime.now(tz=UTC)` instant captured
100
+ at run start. Same value threaded into manifest /
101
+ loop-manifest / summary so they all agree on when the
102
+ run began.
103
+ pr_doc_path: Path to the PR doc the run was invoked against.
104
+ Serialized as the ``str(...)`` of the path so the file
105
+ stays JSON-safe.
106
+ base_ref: The ``--base`` value the CLI received, or ``None``
107
+ if no diff was requested. Distinct from ``starting_sha``
108
+ (the resolved HEAD SHA at run start).
109
+ base_oid: The resolved full object ID of ``base_ref`` at
110
+ snapshot time, or ``None`` when ``base_ref`` is ``None``.
111
+ Unlike ``base_ref``, this is immutable even if the ref
112
+ moves after the run starts.
113
+ starting_sha: The full HEAD object ID the orchestrator
114
+ snapshotted at round 0. The authoritative tree state
115
+ the run began against.
116
+ operator_branch: The branch the operator was on at run
117
+ start (``Snapshot.branch``). ``None`` for detached
118
+ HEAD. Captured so resume can validate the operator
119
+ hasn't switched branches between abort and resume.
120
+ max_rounds: The ``config.loop.max_rounds`` cap the run was
121
+ launched with. Captured because a future ``--resume
122
+ --max-rounds N`` may override the original cap; the
123
+ original is preserved here for diagnostic comparison.
124
+ config: The :class:`SyncadeConfig` instance loaded at run
125
+ start. Serialized via :meth:`pydantic.BaseModel.model_dump`
126
+ with ``mode="json"`` so enums + paths flatten to
127
+ JSON-safe primitives.
128
+
129
+ Returns:
130
+ Path of the written ``run-init.json``.
131
+
132
+ Raises:
133
+ FileNotFoundError: If ``run_dir`` does not exist.
134
+ """
135
+ if not run_dir.is_dir():
136
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
137
+
138
+ record = {
139
+ "syncade_version": syncade_version,
140
+ "started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
141
+ "pr_doc_path": str(pr_doc_path),
142
+ "base_ref": base_ref,
143
+ "base_oid": base_oid,
144
+ "starting_sha": starting_sha,
145
+ "operator_branch": operator_branch,
146
+ "max_rounds": max_rounds,
147
+ # Full config echo. See module docstring.
148
+ "config_snapshot": _config_snapshot(config),
149
+ }
150
+
151
+ run_init_path = run_dir / RUN_INIT_FILENAME
152
+ atomic_write_json(run_init_path, record, sort_keys=False)
153
+ return run_init_path