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/selfcheck.py ADDED
@@ -0,0 +1,471 @@
1
+ """``syncade --selfcheck`` — verify the configured producer can headless-commit.
2
+
3
+ Pre-flight smoke for the producer's commit path: provisions a throwaway git
4
+ repo + stub ``findings.md``, runs the
5
+ configured producer adapter against the stub, and verifies that
6
+ HEAD moved AND the requested marker text is present in the seed
7
+ file.
8
+
9
+ Motivation: sandboxed producer modes cannot headless-commit reliably. The fix
10
+ was to make ``"yolo"`` the only supported producer permission. Provider
11
+ semantics can still evolve, and a real loop
12
+ would only surface that as a stall at the end of round 0. ``syncade
13
+ --selfcheck`` runs in ~30 seconds and surfaces drift before the next loop
14
+ hits it.
15
+
16
+ The selfcheck verifies whatever permission level the operator's
17
+ ``[producer]`` block configures. If ``permissions="yolo"`` is what
18
+ works on their machine, that's what gets verified.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+
30
+ from syncade.adapters.producer import ProducerAdapter
31
+ from syncade.config import SyncadeConfig
32
+ from syncade.exit_codes import (
33
+ FINDINGS_PRESENT,
34
+ SUCCESS,
35
+ WORKTREE_ERROR,
36
+ )
37
+ from syncade.persistence import persist_findings_md, persist_producer_result
38
+ from syncade.process import SubprocessError, SubprocessResult, run_subprocess
39
+ from syncade.producer import run_producer
40
+ from syncade.synthesis import (
41
+ ConsolidatedFinding,
42
+ FindingProvenance,
43
+ SynthesizerOutput,
44
+ )
45
+ from syncade.synthesizer import SynthesizerResult
46
+ from syncade.worktree import WorktreeError, WorktreeManager
47
+
48
+ SELFCHECK_MARKER = "# Selfcheck PASSED"
49
+ """The literal marker text the producer must add to the seed file.
50
+
51
+ Pinned as a module constant so tests and the producer-facing
52
+ instruction stay in lockstep — a refactor that changes the marker
53
+ in the instruction text but not in the verifier (or vice versa)
54
+ would let a broken producer pass selfcheck."""
55
+
56
+ SELFCHECK_SEED_BASENAME = "selfcheck.py"
57
+ """Basename of the seed file the producer edits. A ``.py`` extension
58
+ keeps the file looking like real code (a producer that refuses to
59
+ edit non-source files still treats this as code-shaped)."""
60
+
61
+ SELFCHECK_PR_DOC_BASENAME = "pr-doc.md"
62
+ """Basename of the stub PR doc passed to the producer's
63
+ ``{pr_doc_path}`` placeholder."""
64
+
65
+ _SELFCHECK_GIT_USER_NAME = "Syncade Selfcheck"
66
+ _SELFCHECK_GIT_USER_EMAIL = "selfcheck@syncade.test"
67
+
68
+ _KNOWN_GIT_SETUP_STEPS = ("init", "symbolic-ref", "add", "commit", "rev-parse")
69
+ """Setup-phase git subcommands the selfcheck issues against the
70
+ throwaway repo. Used by :func:`_identify_git_step` to extract a
71
+ human-readable step name from a failed git invocation's ``cmd``
72
+ list when mapping :class:`subprocess.CalledProcessError` to
73
+ ``WORKTREE_ERROR``."""
74
+
75
+
76
+ def _identify_git_step(cmd: list[str]) -> str:
77
+ """Return the git subcommand from a failed git invocation's
78
+ ``cmd`` list (``init`` / ``symbolic-ref`` / ``add`` / ``commit`` /
79
+ ``rev-parse``).
80
+
81
+ Handles both the real :func:`_git` cmd shape
82
+ (``["git", "-c", "user.email=...", "-c", "user.name=...",
83
+ <subcommand>, ...]``) and the test-monkey-patched shape
84
+ (``["git", <subcommand>, ...]``) by scanning for the first
85
+ known subcommand token rather than indexing by position. Falls
86
+ back to ``"<unknown>"`` if no known token is found — the
87
+ surrounding error message still names the failure (the rc and
88
+ stderr are emitted alongside).
89
+ """
90
+ for arg in cmd or []:
91
+ if arg in _KNOWN_GIT_SETUP_STEPS:
92
+ return arg
93
+ return "<unknown>"
94
+
95
+
96
+ _SELFCHECK_GIT_TIMEOUT_SECONDS = 30.0
97
+
98
+
99
+ def _git(cwd: Path, *args: str) -> SubprocessResult:
100
+ """Run a git command with selfcheck-scoped author identity,
101
+ raising :class:`subprocess.CalledProcessError` on non-zero rc.
102
+
103
+ Used only for the throwaway selfcheck repo's setup commits. It
104
+ still routes through :func:`syncade.process.run_subprocess` so git
105
+ setup inherits the same timeout and launch-failure behavior as the
106
+ orchestrator's other critical git calls.
107
+ """
108
+ argv = [
109
+ "git",
110
+ "-c",
111
+ f"user.email={_SELFCHECK_GIT_USER_EMAIL}",
112
+ "-c",
113
+ f"user.name={_SELFCHECK_GIT_USER_NAME}",
114
+ *args,
115
+ ]
116
+ result = run_subprocess(
117
+ argv,
118
+ cwd=cwd,
119
+ timeout=_SELFCHECK_GIT_TIMEOUT_SECONDS,
120
+ )
121
+ if result.returncode != 0:
122
+ raise subprocess.CalledProcessError(
123
+ returncode=result.returncode,
124
+ cmd=argv,
125
+ output=result.stdout,
126
+ stderr=result.stderr,
127
+ )
128
+ return result
129
+
130
+
131
+ def _build_stub_synth_output() -> SynthesizerOutput:
132
+ """Construct a :class:`SynthesizerOutput` with one blocker
133
+ finding instructing the producer to add the marker line.
134
+
135
+ The finding is shaped to look like what a real synthesizer
136
+ would emit for a one-line fix request — a blocker with single-
137
+ reviewer provenance, file-scoped, with an actionable
138
+ description. This way the producer's reading of ``findings.md``
139
+ encounters the same structure it would in a real loop, so a
140
+ drift in the findings.md format (e.g. a new required section)
141
+ breaks selfcheck the same way it would break the real loop.
142
+ """
143
+ return SynthesizerOutput(
144
+ consolidated_findings=[
145
+ ConsolidatedFinding(
146
+ description=(
147
+ f"`{SELFCHECK_SEED_BASENAME}` is missing a "
148
+ f"`{SELFCHECK_MARKER}` comment. Add a single new "
149
+ "line of comment text below the existing comment "
150
+ "with that exact text, then commit. Commit subject: "
151
+ "`selfcheck: add passed marker`. No other changes."
152
+ ),
153
+ file=SELFCHECK_SEED_BASENAME,
154
+ severity="blocker",
155
+ provenance=[
156
+ FindingProvenance(
157
+ reviewer_name="selfcheck",
158
+ original_severity="blocker",
159
+ original_index=0,
160
+ original_description=(
161
+ f"Missing `{SELFCHECK_MARKER}` marker comment in "
162
+ f"`{SELFCHECK_SEED_BASENAME}`."
163
+ ),
164
+ )
165
+ ],
166
+ )
167
+ ],
168
+ synthesis_summary=(
169
+ "Selfcheck stub: producer must add the marker comment "
170
+ f"to {SELFCHECK_SEED_BASENAME} and commit."
171
+ ),
172
+ )
173
+
174
+
175
+ def _resolve_timeout(config: SyncadeConfig, override: float | None) -> float:
176
+ """Resolve the per-producer wall-clock timeout.
177
+
178
+ Same precedence the orchestrator's producer phase uses:
179
+ ``override`` (CLI ``--timeout``) > ``config.producer.timeout_seconds``
180
+ > ``config.loop.timeout_seconds``.
181
+ """
182
+ if override is not None:
183
+ return override
184
+ if config.producer.timeout_seconds is not None:
185
+ return config.producer.timeout_seconds
186
+ return config.loop.timeout_seconds
187
+
188
+
189
+ def run_selfcheck(
190
+ config: SyncadeConfig,
191
+ *,
192
+ timeout_seconds: float | None = None,
193
+ adapter: ProducerAdapter | None = None,
194
+ quiet: bool = False,
195
+ always_cleanup: bool = False,
196
+ ) -> int:
197
+ """Run the producer selfcheck and return a CLI exit code.
198
+
199
+ Steps:
200
+
201
+ 1. Create a tmp_path git repo with one sentinel file
202
+ (``selfcheck.py``) seeded with a single-line comment, and
203
+ commit it.
204
+ 2. Render a stub ``findings.md`` via the real
205
+ :func:`persist_findings_md` renderer so the producer sees a
206
+ structurally-correct input. Hand-rolling the markdown would
207
+ risk diverging from the real findings.md schema and the
208
+ selfcheck would stop catching real producer failures if
209
+ findings.md format ever changes.
210
+ 3. Provision a producer worktree at the seed commit
211
+ (``strip_files=[]`` — same asymmetry as the real producer
212
+ path).
213
+ 4. Call :func:`syncade.producer.run_producer` once with the
214
+ operator's ``[producer]`` config and the stub findings.md.
215
+ 5. Verify ``outcome == "committed"``, HEAD moved, and the marker
216
+ text is present in the worktree's copy of ``selfcheck.py``.
217
+ 6. Persist the producer's artifacts via
218
+ :func:`persist_producer_result` into ``<workspace>/round-0/``
219
+ (``producer.stdout``, ``producer.stderr``,
220
+ ``producer.commit.txt``, and on subprocess_error
221
+ ``producer.error.txt``). Same file layout as a real round.
222
+ 7. Clean up the tmp_path workspace on success; preserve it on
223
+ failure with a printed path so the operator can
224
+ ``cat <workspace>/round-0/producer.stdout`` (or
225
+ ``producer.error.txt`` on the subprocess-error path) to
226
+ inspect the raw producer output. The selfcheck does NOT
227
+ write a manifest / summary.md — it's a one-shot smoke, not
228
+ a review run. ``always_cleanup=True`` removes the workspace
229
+ even on failure (no preserved-path print): ``syncade --doctor``
230
+ reuses this smoke as an inert preflight and must leave nothing
231
+ behind — it points the operator at ``syncade --selfcheck`` (which
232
+ DOES preserve) for the raw output instead.
233
+
234
+ Args:
235
+ config: The operator's loaded :class:`SyncadeConfig`. The
236
+ ``[producer]`` block drives provider / model / thinking
237
+ / permissions. ``[loop] timeout_seconds`` is the
238
+ fallback when ``[producer] timeout_seconds`` is unset
239
+ AND the ``timeout_seconds`` arg is ``None``.
240
+ timeout_seconds: Optional ``--timeout`` override. Same
241
+ resolution as the orchestrator: this >
242
+ ``config.producer.timeout_seconds`` >
243
+ ``config.loop.timeout_seconds``.
244
+ adapter: Optional :class:`ProducerAdapter` injection for
245
+ tests. ``None`` (default) routes through the real
246
+ registry per ``config.producer.provider``.
247
+ quiet: Suppress informational stdout. Stderr (failure
248
+ paths) is unaffected. Wired from ``--quiet``.
249
+
250
+ Returns:
251
+ ``SUCCESS`` (0) when the producer committed AND the marker
252
+ is present.
253
+
254
+ ``FINDINGS_PRESENT`` (30) when the producer committed but
255
+ did not add the marker — producer ran but didn't follow the
256
+ instruction.
257
+
258
+ ``WORKTREE_ERROR`` (60) for producer subprocess error,
259
+ producer stall, or worktree provisioning failure — any
260
+ path where the producer couldn't make a verifiable commit.
261
+ """
262
+
263
+ def _info(msg: str) -> None:
264
+ if not quiet:
265
+ print(msg)
266
+
267
+ def _err(msg: str) -> None:
268
+ print(msg, file=sys.stderr)
269
+
270
+ workspace = Path(tempfile.mkdtemp(prefix="syncade-selfcheck-"))
271
+ preserve_on_failure = False
272
+ try:
273
+ # 1. Create the throwaway repo with one seed commit. The
274
+ # init/add/commit + post-subprocess rev-parse calls are
275
+ # wrapped so a CalledProcessError (git binary missing,
276
+ # workspace permissions, corrupt git config, ...) maps to
277
+ # WORKTREE_ERROR with the preserved-workspace contract
278
+ # instead of leaking a Python traceback through the CLI.
279
+ repo_root = workspace / "repo"
280
+ repo_root.mkdir()
281
+ try:
282
+ # init --quiet suppresses the noisy git-2.30+ "hint:"
283
+ # output about the default branch name. Set the branch
284
+ # with symbolic-ref (git 1.7+) rather than
285
+ # --initial-branch=main (git 2.28+) so selfcheck also
286
+ # runs on older platforms; it does not affect the test
287
+ # surface (the seed commit is what matters).
288
+ _git(repo_root, "init", "--quiet")
289
+ _git(repo_root, "symbolic-ref", "HEAD", "refs/heads/main")
290
+ seed = repo_root / SELFCHECK_SEED_BASENAME
291
+ seed.write_text("# selfcheck seed\n", encoding="utf-8")
292
+ _git(repo_root, "add", SELFCHECK_SEED_BASENAME)
293
+ _git(repo_root, "commit", "--quiet", "-m", "selfcheck: seed commit")
294
+ head_result = _git(repo_root, "rev-parse", "HEAD")
295
+ starting_sha = head_result.stdout.strip()
296
+ except (subprocess.CalledProcessError, SubprocessError) as exc:
297
+ preserve_on_failure = True
298
+ if isinstance(exc, subprocess.CalledProcessError) and exc.cmd:
299
+ step = _identify_git_step(list(exc.cmd))
300
+ else:
301
+ step = "<unknown>"
302
+ _err(
303
+ f"[syncade] selfcheck FAILED: git setup error: "
304
+ f"`git {step}` returned rc={getattr(exc, 'returncode', '<launch-error>')}."
305
+ )
306
+ stderr = getattr(exc, "stderr", None)
307
+ if stderr:
308
+ stderr_text = str(stderr).strip()
309
+ if stderr_text:
310
+ _err(f"[syncade] git stderr: {stderr_text}")
311
+ _err(
312
+ "[syncade] git failed during selfcheck repo setup. "
313
+ "Check git installation and workspace permissions; "
314
+ "the preserved workspace below has the partial repo "
315
+ "state for inspection."
316
+ )
317
+ return WORKTREE_ERROR
318
+
319
+ # 2. Stub PR doc the producer template's {pr_doc_path}
320
+ # points at. The producer reads it for context; making it
321
+ # a real file lets a "where's the spec?" producer
322
+ # implementation see something coherent.
323
+ pr_doc = workspace / SELFCHECK_PR_DOC_BASENAME
324
+ pr_doc.write_text(
325
+ (
326
+ "# Selfcheck PR doc\n\n"
327
+ "This is the `syncade --selfcheck` stub PR. The producer is "
328
+ "expected to read `findings.md`, edit "
329
+ f"`{SELFCHECK_SEED_BASENAME}` to add a "
330
+ f"`{SELFCHECK_MARKER}` comment, and commit.\n"
331
+ ),
332
+ encoding="utf-8",
333
+ )
334
+
335
+ # 3. Render findings.md via persist_findings_md so the
336
+ # producer sees the real artifact format.
337
+ round_dir = workspace / "round-0"
338
+ round_dir.mkdir()
339
+ synth_result = SynthesizerResult(
340
+ output=_build_stub_synth_output(),
341
+ error=None,
342
+ duration_seconds=0.0,
343
+ raw_subprocess_result=None,
344
+ )
345
+ findings_md_path = persist_findings_md(
346
+ round_dir,
347
+ synth_result,
348
+ datetime.now(UTC),
349
+ )
350
+
351
+ # 4. Provision the producer worktree. defer_cleanup=True
352
+ # so the with-exit doesn't try to remove worktrees we'll
353
+ # rmtree along with the workspace anyway; the workspace's
354
+ # finally branch is the single cleanup point.
355
+ worktree_base = workspace / "worktrees"
356
+ producer_manager = WorktreeManager(
357
+ repo_root,
358
+ "selfcheck",
359
+ base_dir=worktree_base,
360
+ defer_cleanup=True,
361
+ )
362
+ resolved_timeout = _resolve_timeout(config, timeout_seconds)
363
+
364
+ _info(
365
+ f"[syncade] selfcheck: provisioning producer worktree "
366
+ f"({config.producer.provider}, model={config.producer.model}, "
367
+ f"permissions={config.producer.permissions})"
368
+ )
369
+
370
+ try:
371
+ with producer_manager as mgr:
372
+ producer_worktree = mgr.create(
373
+ reviewer_name="producer",
374
+ commit_sha=starting_sha,
375
+ strip_files=[],
376
+ )
377
+
378
+ # 5. Run the producer once.
379
+ _info(f"[syncade] selfcheck: dispatching producer (timeout {resolved_timeout:g}s)")
380
+ producer_result = run_producer(
381
+ worktree_path=producer_worktree.path,
382
+ starting_sha=starting_sha,
383
+ pr_doc_path=pr_doc,
384
+ findings_md_path=findings_md_path,
385
+ test_run_stdout_path=None,
386
+ producer_config=config.producer,
387
+ timeout_seconds=resolved_timeout,
388
+ round_number=0,
389
+ max_rounds=1,
390
+ repo_root=repo_root,
391
+ adapter=adapter,
392
+ max_retries=config.retry.max_retries,
393
+ # Same round dir persist_producer_result writes to below. Selfcheck is a
394
+ # DIAGNOSTIC — the run most likely to be interrupted by an operator who is
395
+ # already debugging — so losing its producer output is the worst case, not
396
+ # an acceptable one (dogfood finding, PR-h-field-03).
397
+ capture_dir=round_dir,
398
+ )
399
+ # Persist producer artifacts
400
+ # (producer.stdout / stderr / commit.txt / error.txt)
401
+ # into the round dir on every outcome so the
402
+ # "operator can `cat worktree/producer.stdout`"
403
+ # contract is met. Without this, only the tmp_path workspace's
404
+ # findings.md + repo were
405
+ # preserved; the producer's raw output (the most
406
+ # interesting diagnostic file) was lost.
407
+ persist_producer_result(round_dir, producer_result)
408
+
409
+ # 6. Verify.
410
+ if producer_result.outcome == "subprocess_error":
411
+ preserve_on_failure = True
412
+ err_cls = type(producer_result.error).__name__
413
+ _err(
414
+ f"[syncade] selfcheck FAILED: producer subprocess "
415
+ f"error ({err_cls}): {producer_result.error}"
416
+ )
417
+ _err(
418
+ f"[syncade] your `{config.producer.provider}` "
419
+ "producer is in a state where it cannot run "
420
+ "headlessly. Inspect the preserved worktree below."
421
+ )
422
+ return WORKTREE_ERROR
423
+ if producer_result.outcome == "stalled":
424
+ preserve_on_failure = True
425
+ _err(
426
+ "[syncade] selfcheck FAILED: producer stalled "
427
+ "(no commit made). The producer ran without error "
428
+ "but did not move HEAD."
429
+ )
430
+ _err(
431
+ f"[syncade] your `{config.producer.provider}` "
432
+ f"producer with permissions="
433
+ f"{config.producer.permissions!r} is in a state "
434
+ "where it cannot commit headlessly. Common "
435
+ "causes: claude prompts for bash (use "
436
+ 'permissions="yolo") or codex sandbox blocks '
437
+ '`.git/index.lock` writes (use permissions="yolo").'
438
+ )
439
+ return WORKTREE_ERROR
440
+
441
+ # outcome == "committed" — but did the producer add
442
+ # the marker the instruction asked for?
443
+ seed_in_worktree = producer_worktree.path / SELFCHECK_SEED_BASENAME
444
+ content = seed_in_worktree.read_text(encoding="utf-8")
445
+ if SELFCHECK_MARKER not in content:
446
+ preserve_on_failure = True
447
+ _err(
448
+ "[syncade] selfcheck FAILED: producer committed "
449
+ f"(ending_sha={producer_result.ending_sha[:12]}) "
450
+ f"but `{SELFCHECK_SEED_BASENAME}` does not contain "
451
+ f"{SELFCHECK_MARKER!r}. Producer ran headlessly "
452
+ "but did not follow the instruction."
453
+ )
454
+ return FINDINGS_PRESENT
455
+
456
+ _info(
457
+ f"[syncade] selfcheck OK: producer committed with "
458
+ f"marker ({producer_result.ending_sha[:12]}, "
459
+ f"{producer_result.duration_seconds:.1f}s)"
460
+ )
461
+ return SUCCESS
462
+ except WorktreeError as exc:
463
+ preserve_on_failure = True
464
+ _err(f"[syncade] selfcheck FAILED: worktree error: {exc}")
465
+ return WORKTREE_ERROR
466
+ finally:
467
+ if preserve_on_failure and not always_cleanup:
468
+ _err(f"[syncade] selfcheck workspace preserved at: {workspace}")
469
+ else:
470
+ # Success, or an inert caller (doctor) that must leave nothing behind.
471
+ shutil.rmtree(workspace, ignore_errors=True)