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,456 @@
1
+ """Verdict-block selection for cold-actor output parsing.
2
+
3
+ Every cold actor (reviewer, synthesizer, auditor, drafter) answers with prose
4
+ plus a JSON verdict. This module owns the single question *which bytes ARE the
5
+ verdict* — and answers it with exactly one candidate, never a search for
6
+ whichever block happens to validate.
7
+
8
+ The rule (2026-07-27 audit rank 1 / PR-h-01):
9
+
10
+ 1. **Mask code samples.** The content of every fence with a non-empty,
11
+ non-``json`` label (```` ```python ````, ```` ```js ````, ```` ```json5 ````)
12
+ is blanked out. A labeled fence is an illustration by definition, so it is
13
+ excluded from *all* candidate discovery — not merely from the fence scan.
14
+ 2. **A ``json``-labeled fence is authoritative.** When one exists, the LAST one
15
+ is the verdict and nothing else in the response is considered — not bare
16
+ objects, not unlabeled fences. (Multiple are NOT an error: the anthropic
17
+ adapter joins the text of every result turn, so a normal multi-turn claude
18
+ response legitimately carries more than one.)
19
+ 3. **Only when there is no ``json`` fence** do unlabeled fences and bare
20
+ top-level objects compete, by latest document position.
21
+ 4. **Otherwise the whole masked response.**
22
+ 5. A **duplicate JSON key** in the selected block fails rather than resolving
23
+ last-wins (see :func:`_reject_duplicate_keys`).
24
+ 6. If the selected block does not decode, the parse **fails** (exit 70). There
25
+ is deliberately no fallback to an earlier block that validates.
26
+
27
+ Every clause above is the scar of a reproduced false SHIP, and each is argued at
28
+ the code that enforces it rather than re-argued here. The shape of the history:
29
+ the original parser returned the first of many candidates that *validated*, so
30
+ an invalid intended verdict silently fell back to an earlier example. Fixing
31
+ that by POSITION alone then let three separate things written AFTER the verdict
32
+ replace it. Position cannot distinguish a verdict from an afterthought; a label
33
+ can, which is why rule 2 exists. None of these needed an adversarial model, and
34
+ several punished a reviewer for being helpful.
35
+
36
+ The bare-object scan is *kept* (rule 3) because it is load-bearing for a real
37
+ recorded case:
38
+ ``tests/fixtures/pr-5.6-parser-regression/claude-reviewer-prose-with-jsx.stdout``
39
+ is genuine claude output with no fences at all — narrative containing
40
+ ``style={{ color: 'var(--mm-amber)' }}`` followed by a bare verdict object.
41
+
42
+ Two accepted residuals, both requiring the actor to violate its template, and
43
+ both pinned as ``KNOWN_RESIDUAL`` tests rather than papered over:
44
+
45
+ - an illustration in a TRAILING ``json`` fence still replaces the verdict.
46
+ Failing closed on multiple ``json`` fences was implemented and then reverted
47
+ on evidence: a recorded 2026-05-30 run shows a normal claude response whose
48
+ joined result turns carry two differing ``json`` fences, so refusing them
49
+ would burn real rounds. Every reviewer template now warns against trailing
50
+ illustrations explicitly.
51
+ - an actor that labels an EXAMPLE ``json`` and leaves its real verdict
52
+ unlabeled or bare loses to the example.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import copy
58
+ import json
59
+ import logging
60
+ import re
61
+ from collections.abc import Callable
62
+ from typing import TypeVar
63
+
64
+ from pydantic import ValidationError
65
+
66
+ _log = logging.getLogger(__name__)
67
+
68
+ # Triple-backtick fence: opening ```, an optional language label, optional
69
+ # trailing spaces, newline, the content (non-greedy), then a closing ``` THAT
70
+ # MUST BE PRECEDED BY A NEWLINE.
71
+ #
72
+ # The label class is deliberately `[^\s`]*` rather than `[a-zA-Z]*`. With the
73
+ # alphabetic-only class, `json5`, `my-language`, and `c++` matched NO fence at
74
+ # all — so their contents were invisible to the mask and leaked back in through
75
+ # the bare-object scan. That made the rule arbitrary: a verdict mislabeled
76
+ # ```python failed closed, while the same verdict mislabeled ```json5 was
77
+ # silently accepted, and a trailing ```json5 illustration could still override a
78
+ # real verdict. Every labeled fence is now a code sample; only an empty label or
79
+ # exactly `json` marks a verdict fence.
80
+ #
81
+ # The required newline before the closing ``` is load-bearing, not cosmetic.
82
+ # Without it, a reviewer whose own finding text mentions ``` closed the fence
83
+ # early — mid-string — and the truncated block failed to parse, losing a real
84
+ # verdict to exit 70. Requiring the newline makes that impossible rather than
85
+ # unlikely: JSON forbids a raw newline inside a string value (it must be the
86
+ # two-character `\n` escape), so every newline in the content is necessarily
87
+ # OUTSIDE a string, and therefore a ``` appearing inside a string value can
88
+ # never terminate the fence.
89
+ _FENCE_RE = re.compile(r"```([^\s`]*)[ \t]*\r?\n(.*?)\r?\n```", re.DOTALL)
90
+ # Matches only the opening line of a fence (label + newline), without requiring
91
+ # a closing fence. Used to detect unclosed openers that _FENCE_RE never matches.
92
+ _FENCE_OPEN_RE = re.compile(r"```([^\s`]*)[ \t]*\r?\n")
93
+ _NON_NEWLINE_RE = re.compile(r"[^\n]")
94
+ _JSON_DECODER = json.JSONDecoder()
95
+
96
+ _SNIPPET_LEN = 200
97
+
98
+
99
+ class VerdictBlockError(Exception):
100
+ """No decodable verdict block in a cold actor's response.
101
+
102
+ Carries a human-readable reason (which block was selected, why it failed).
103
+ Each parser catches this and re-raises its own phase-named error type so
104
+ exit 70 can say *which* actor to debug, while the reason text stays
105
+ identical across all four.
106
+ """
107
+
108
+
109
+ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
110
+ """``object_pairs_hook`` that refuses a repeated key instead of last-wins.
111
+
112
+ PR-h-01 increment B. ``json.loads`` silently resolves
113
+ ``{"verdict": "NO-SHIP", "verdict": "SHIP"}`` to the LAST value, so a
114
+ duplicated key was a one-token path from a blocker verdict to a false
115
+ SHIP — and it survives every downstream schema check, because by the time
116
+ pydantic sees the dict there is only one ``verdict``.
117
+
118
+ A model that emits the same key twice has already lost the property we
119
+ need, so this fails the parse rather than picking a winner.
120
+ """
121
+ seen: set[str] = set()
122
+ for key, _ in pairs:
123
+ if key in seen:
124
+ raise VerdictBlockError(
125
+ f"duplicate JSON key {key!r} in the verdict block. json.loads "
126
+ f"would silently keep the last value, so a repeated key could "
127
+ f"flip a field (a duplicated 'verdict' turns NO-SHIP into SHIP). "
128
+ f"Emit each key exactly once."
129
+ )
130
+ seen.add(key)
131
+ return dict(pairs)
132
+
133
+
134
+ def _mask_fence_interiors(raw: str, *, labeled_only: bool) -> str:
135
+ """Blank the CONTENT of fences, preserving length and line structure so
136
+ positions and snippets stay meaningful.
137
+
138
+ ``labeled_only=True`` masks only non-``json``-labeled fences
139
+ (```` ```python ````, ```` ```js ````, ```` ```json5 ````): those are
140
+ illustrations, so nothing inside them may become a verdict candidate.
141
+ Masking rather than skipping at fence level is what stops the bare-object
142
+ scan from reaching back into a code sample — the exact hole that let a
143
+ ```` ```python ````-wrapped example be parsed as a verdict.
144
+
145
+ ``labeled_only=False`` masks every fence, and is used to scope the
146
+ bare-object scan to text OUTSIDE any fence, so a fence's own contents are
147
+ counted once (as a fence candidate) rather than twice.
148
+ """
149
+ parts: list[str] = []
150
+ last = 0
151
+ # Record both the opener start AND the closer start of every matched fence.
152
+ # A closer is the ``` that ends the match; it starts at match.end()-3.
153
+ # The unclosed-opener pass below uses this set to skip those lines so that
154
+ # a closer ``` (which looks like an unlabeled opener) is never mistaken for
155
+ # a new unclosed opener.
156
+ matched_positions: set[int] = set()
157
+ for match in _FENCE_RE.finditer(raw):
158
+ matched_positions.add(match.start()) # opener
159
+ matched_positions.add(match.end() - 3) # closer (last 3 chars are ```)
160
+ label = match.group(1).lower()
161
+ if labeled_only and (not label or label == "json"):
162
+ continue
163
+ parts.append(raw[last : match.start(2)])
164
+ parts.append(_NON_NEWLINE_RE.sub(" ", match.group(2)))
165
+ last = match.end(2)
166
+ parts.append(raw[last:])
167
+ result = "".join(parts)
168
+
169
+ # Mask unclosed fence openers: _FENCE_RE requires a closing ``` line so it
170
+ # never matches a truncated/unclosed fence. Without this pass the interior
171
+ # of an unclosed ```python opener leaks into the bare-object scan.
172
+ # Scan `result` (not `raw`): content inside matched fences is already masked
173
+ # to spaces there, so it cannot produce false inner-fence opener matches.
174
+ # `matched_positions` covers opener AND closer lines of every matched fence.
175
+ for m in _FENCE_OPEN_RE.finditer(result):
176
+ if m.start() in matched_positions:
177
+ continue
178
+ label = m.group(1).lower()
179
+ if labeled_only and (not label or label == "json"):
180
+ continue
181
+ cs = m.end()
182
+ if cs < len(result):
183
+ result = result[:cs] + _NON_NEWLINE_RE.sub(" ", result[cs:])
184
+ return result
185
+
186
+
187
+ def _find_fenced_json_candidates(raw: str) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
188
+ """Return ``(json_labeled, unlabeled)`` fences as ``(start_pos, content)``
189
+ lists, each in document order. Labeled-but-not-``json`` fences are code
190
+ samples and appear in neither.
191
+ """
192
+ labeled: list[tuple[int, str]] = []
193
+ unlabeled: list[tuple[int, str]] = []
194
+ for match in _FENCE_RE.finditer(raw):
195
+ label = match.group(1).lower()
196
+ if label and label != "json":
197
+ continue
198
+ content = match.group(2).rstrip("\r\n")
199
+ (labeled if label == "json" else unlabeled).append((match.start(2), content))
200
+ return labeled, unlabeled
201
+
202
+
203
+ def _find_last_json_object(raw: str) -> tuple[int, str] | None:
204
+ """Return ``(start_pos, source_text)`` of the LAST top-level JSON object in
205
+ ``raw``, or ``None``.
206
+
207
+ Find-then-parse-or-skip scan: when a ``{`` does not start valid JSON the
208
+ scanner advances one character and keeps looking, so an unmatched brace in
209
+ prose (``if (x) { do something``) cannot swallow the rest of the document.
210
+ Only the last object is returned — the caller gets one candidate, never a
211
+ chain to fall back through.
212
+ """
213
+ found: tuple[int, str] | None = None
214
+ pos, n = 0, len(raw)
215
+ while pos < n:
216
+ start = raw.find("{", pos)
217
+ if start == -1:
218
+ break
219
+ try:
220
+ parsed, end = _JSON_DECODER.raw_decode(raw, start)
221
+ except (json.JSONDecodeError, RecursionError):
222
+ # RecursionError: deeply-nested junk in prose is not a verdict; skip
223
+ # it like any other non-JSON rather than aborting the scan.
224
+ pos = start + 1
225
+ continue
226
+ if isinstance(parsed, dict):
227
+ found = (start, raw[start:end])
228
+ pos = max(end, start + 1)
229
+ return found
230
+
231
+
232
+ def _select_verdict_block(raw: str) -> tuple[str | None, str]:
233
+ """Return ``(block, description)`` for the one block that IS the verdict.
234
+
235
+ A ```` ```json ````-labeled fence is AUTHORITATIVE: every template tells the
236
+ actor to put its verdict in exactly one, so when one exists nothing else in
237
+ the response is a candidate. Multiple are NOT an error — the last one wins
238
+ (see the comment at the return site). Only when there is no ``json`` fence at
239
+ all do unlabeled fences and bare objects compete, by latest document position.
240
+
241
+ That precedence — rather than position across all kinds — is what closes
242
+ three separately-reproduced false SHIPs, each of which let something the
243
+ reviewer wrote AFTER its verdict replace the verdict: a trailing bare object,
244
+ a trailing unlabeled fence, and an ordinary JSON snippet in closing prose
245
+ (``Fix: add {"strict": true} to tsconfig.json``), which additionally burned a
246
+ whole round at exit 70. Position alone cannot tell a verdict from a
247
+ afterthought; a label can.
248
+
249
+ The cost is a residual in the mirror direction: an actor that labels an
250
+ EXAMPLE ``json`` and leaves its real verdict unlabeled or bare loses to the
251
+ example. That inverts every template instruction, and unlike the cases above
252
+ it cannot happen to an actor that simply follows the contract.
253
+ """
254
+ masked = _mask_fence_interiors(raw, labeled_only=True)
255
+ labeled, unlabeled = _find_fenced_json_candidates(masked)
256
+
257
+ # Detect unclosed fence openers (```json or unlabeled ```) that appear after
258
+ # the last matched fence of the same kind. An unclosed opener is the intended
259
+ # final verdict block; since it has no closing ```, it cannot be decoded, and
260
+ # the parse must fail rather than fall back to an earlier block.
261
+ # Scan `masked` rather than `raw`: content inside non-json labeled fences is
262
+ # already blanked there, so a ```json comment inside a ```python block cannot
263
+ # produce a false unclosed-opener trigger.
264
+ _matched_positions: set[int] = set()
265
+ _last_json_end = -1
266
+ _last_unlabeled_end = -1
267
+ for _m in _FENCE_RE.finditer(raw):
268
+ _matched_positions.add(_m.start())
269
+ _matched_positions.add(_m.end() - 3)
270
+ _lbl = _m.group(1).lower()
271
+ if _lbl == "json":
272
+ _last_json_end = _m.end()
273
+ elif not _lbl:
274
+ _last_unlabeled_end = _m.end()
275
+ for _m in _FENCE_OPEN_RE.finditer(masked):
276
+ if _m.start() in _matched_positions:
277
+ continue
278
+ _lbl = _m.group(1).lower()
279
+ if _lbl == "json" and _m.start() > _last_json_end:
280
+ return None, "the last ```json fence (unclosed — no closing ``` found)"
281
+ # Only fail closed on an unclosed unlabeled opener when there are no
282
+ # closed json fences — if a json fence exists, it wins regardless.
283
+ if not _lbl and not labeled and _m.start() > _last_unlabeled_end:
284
+ return None, "the last unlabeled fence (unclosed — no closing ``` found)"
285
+
286
+ if labeled:
287
+ # LAST ```json fence wins, and multiple are NOT treated as ambiguous.
288
+ # Failing closed on two of them was tried and reverted on evidence: the
289
+ # anthropic adapter deliberately joins the text of EVERY result turn
290
+ # (claude emits spurious epilogue turns after delivering a verdict, and
291
+ # taking only the terminal turn lost real verdicts — see
292
+ # `adapters/anthropic.py`), so a normal multi-turn claude response
293
+ # legitimately contains two DIFFERING ```json fences whose last one is
294
+ # the real verdict. A recorded 2026-05-30 run has exactly that shape;
295
+ # refusing it would burn real rounds to close a residual the templates
296
+ # already forbid.
297
+ return labeled[-1][1].strip() or None, "the last ```json fence"
298
+
299
+ # Unlabeled fences beat bare objects. A bare object appearing AFTER the
300
+ # verdict fence must not override it — position alone cannot distinguish
301
+ # a verdict from an afterthought in closing prose. An unlabeled fence is
302
+ # a deliberate structural choice; bare objects in prose are not.
303
+ if unlabeled:
304
+ return unlabeled[-1][1].strip() or None, "the last unlabeled fence"
305
+
306
+ # Bare objects are scanned OUTSIDE every fence so a fence's contents compete
307
+ # once, as a fence, rather than twice.
308
+ bare = _find_last_json_object(_mask_fence_interiors(raw, labeled_only=False))
309
+ if bare is not None:
310
+ return bare[1].strip() or None, "the last bare JSON object"
311
+
312
+ return masked.strip() or None, "the whole response (no ```json fence or JSON object found)"
313
+
314
+
315
+ def _decode_verdict_object(raw: str) -> object:
316
+ """Select the verdict block and JSON-decode it.
317
+
318
+ Raises :class:`VerdictBlockError` with an operator-readable reason when
319
+ there is no block, the block is not valid JSON, or it decodes to something
320
+ other than a JSON object.
321
+ """
322
+ block, which = _select_verdict_block(raw)
323
+ if block is None:
324
+ raise VerdictBlockError(
325
+ f"{which} is empty (first {_SNIPPET_LEN} chars of the response: {raw[:_SNIPPET_LEN]!r})"
326
+ )
327
+ try:
328
+ parsed = json.loads(block, object_pairs_hook=_reject_duplicate_keys)
329
+ except RecursionError as exc:
330
+ # json's C/py decoders recurse per nesting level. Left uncaught this
331
+ # escapes as a bare RecursionError, which the dispatcher's broad handler
332
+ # turns into exit 40 ("subprocess failed") — the wrong story for output
333
+ # that arrived fine and simply cannot be parsed.
334
+ raise VerdictBlockError(
335
+ f"{which} nests too deeply to decode ({exc}); treated as unparseable output"
336
+ ) from exc
337
+ except json.JSONDecodeError as exc:
338
+ raise VerdictBlockError(
339
+ f"{which} is not valid JSON ({exc}); no earlier block is considered. "
340
+ f"Block: {block[:_SNIPPET_LEN]!r}"
341
+ ) from exc
342
+ if not isinstance(parsed, dict):
343
+ raise VerdictBlockError(
344
+ f"{which} decoded to {type(parsed).__name__}, not a JSON object. "
345
+ f"Block: {block[:_SNIPPET_LEN]!r}"
346
+ )
347
+ return parsed
348
+
349
+
350
+ _T = TypeVar("_T")
351
+
352
+
353
+ def decode_and_validate(
354
+ raw: str,
355
+ *,
356
+ validate: Callable[[object], _T],
357
+ error: type[Exception],
358
+ label: str,
359
+ model_name: str,
360
+ artifact: str,
361
+ ) -> _T:
362
+ """Select the verdict block, decode it, and validate it — for all four
363
+ cold-actor parsers.
364
+
365
+ Every parser needs the same two-step failure story, differing only in which
366
+ exception type names the phase and where the raw response was persisted.
367
+ Copying it produced four independently-editable copies of
368
+ ``"no earlier block is considered"``, a string five tests assert on, which
369
+ is exactly the drift the shared selector exists to prevent.
370
+
371
+ ``validate`` raises :class:`pydantic.ValidationError` on rejection; the
372
+ synthesizer passes a wrapper that tries its known-deviation repair first.
373
+
374
+ Raises ``error`` — the caller's phase-named type — so exit 70 still says
375
+ which actor to debug.
376
+ """
377
+ try:
378
+ parsed = _decode_verdict_object(raw)
379
+ except VerdictBlockError as exc:
380
+ raise error(
381
+ f"{label} output had no parseable {model_name} JSON: {exc}; "
382
+ f"the raw response is preserved at {artifact}."
383
+ ) from exc
384
+ try:
385
+ return validate(parsed)
386
+ except ValidationError as exc:
387
+ raise error(
388
+ f"the {label}'s verdict block is not a valid {model_name} "
389
+ f"({exc.error_count()} schema error(s)): {exc}; no earlier block is "
390
+ f"considered. The raw response is preserved at {artifact}."
391
+ ) from exc
392
+
393
+
394
+ def _drop_key_at(payload: object, loc: tuple[object, ...]) -> bool:
395
+ """Delete the key/index named by a pydantic error ``loc``. False if it is not there."""
396
+ for step in loc[:-1]:
397
+ if isinstance(payload, dict) and step in payload:
398
+ payload = payload[step]
399
+ elif isinstance(payload, list) and isinstance(step, int) and step < len(payload):
400
+ payload = payload[step]
401
+ else:
402
+ return False
403
+ last = loc[-1]
404
+ if isinstance(payload, dict) and last in payload:
405
+ del payload[last]
406
+ return True
407
+ return False
408
+
409
+
410
+ def validate_dropping_forbidden_extras(
411
+ payload: object, validate: Callable[[object], _T], *, label: str
412
+ ) -> _T:
413
+ """Validate strictly; if the ONLY defect is forbidden extra keys, drop them and retry.
414
+
415
+ A model that returns a complete, correct verdict and adds one advisory key should not cost
416
+ the run. Measured (PR-h-field-05): a single ``recommended_fix`` on one finding rejected a
417
+ review worth 1,325,087 tokens, and took the other reviewer's 936,113 with it because the
418
+ judge is skipped when any reviewer fails.
419
+
420
+ **Eligibility is the SHAPE OF THE FAILURE, never a list of key names** — the rule
421
+ :mod:`syncade.synthesis_repair` states for its own repairs, because a name list rots and an
422
+ enumeration of what a model might invent is unbounded. Every error must be
423
+ ``extra_forbidden``; one error of any other type and the whole payload still raises.
424
+
425
+ That single condition is what keeps this narrow, and it excludes the dangerous case BY
426
+ CONSTRUCTION rather than by remembering to: a model that RENAMES a required field produces
427
+ a ``missing`` error beside the extra one, so mixed types never repair. Wrong types,
428
+ out-of-range values and blank required strings are untouched.
429
+
430
+ Dropping is a real loss and is therefore WARNED, naming every key. Unlike the synthesizer's
431
+ repairs — a duplicated coordinate, a cluster that groups nothing — an extra key may carry
432
+ content. The verdict cannot depend on it (severity, evidence and description are all
433
+ required fields that survive), so the trade is right; it is still a trade, and a silent one
434
+ would be a schema quietly bent.
435
+ """
436
+ try:
437
+ return validate(payload)
438
+ except ValidationError as exc:
439
+ errors = exc.errors()
440
+ if not errors or any(e.get("type") != "extra_forbidden" for e in errors):
441
+ raise
442
+ repaired = copy.deepcopy(payload)
443
+ dropped = [
444
+ ".".join(str(p) for p in e["loc"])
445
+ for e in errors
446
+ if _drop_key_at(repaired, tuple(e["loc"]))
447
+ ]
448
+ if not dropped:
449
+ raise
450
+ _log.warning(
451
+ "%s: dropped %d key(s) the schema forbids, so the verdict could be used: %s",
452
+ label,
453
+ len(dropped),
454
+ ", ".join(dropped),
455
+ )
456
+ return validate(repaired)
syncade/gc.py ADDED
@@ -0,0 +1,211 @@
1
+ """Run-bloat GC planning plus the public ``syncade.gc`` API.
2
+
3
+ ``syncade --gc`` prunes bulk transcripts from ``.syncade/runs/<run-id>/`` and
4
+ removes identity-checked ``/tmp/syncade/<run-id>/`` worktree leftovers, and safely
5
+ reaps orphaned reviewer/producer subprocesses left behind by an abnormal parent exit.
6
+ Run history (structured artifacts) is never deleted.
7
+
8
+ Planning stays here because it is pure and auditable. Destructive execution and
9
+ process reaping live in :mod:`syncade.gc_execute`; shared protection checks live
10
+ in :mod:`syncade.gc_protection`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from syncade.gc_execute import (
20
+ _git_worktree_prune,
21
+ _lsof_pids_in_tree,
22
+ _parse_lsof_pids,
23
+ _pid_cwd_is_still_in_tree,
24
+ _reap_and_remove_tree,
25
+ _reap_processes_in_tree,
26
+ execute_gc,
27
+ )
28
+ from syncade.gc_protection import (
29
+ current_protected_run_ids as _current_protected_run_ids,
30
+ )
31
+ from syncade.gc_protection import (
32
+ gc_should_conservatively_protect as _gc_should_conservatively_protect,
33
+ )
34
+ from syncade.gc_protection import (
35
+ orphan_worktree_still_orphan_now as _orphan_worktree_still_orphan_now,
36
+ )
37
+ from syncade.gc_protection import (
38
+ protected_run_ids_for_gc as _protected_run_ids_for_gc,
39
+ )
40
+ from syncade.gc_protection import run_dir_protected_now as _run_dir_protected_now
41
+ from syncade.gc_protection import run_dir_slimmable_now as _run_dir_slimmable_now
42
+ from syncade.gc_protection import run_id_protected_now as _run_id_protected_now
43
+ from syncade.gc_protection import safe_iter_subdirs as _safe_iter_subdirs
44
+ from syncade.gc_types import GcPlan, GcReport
45
+ from syncade.gc_worktrees import (
46
+ existing_worktree_trees,
47
+ repo_owned_orphan_trees,
48
+ tree_contains_repo_root,
49
+ tree_identity,
50
+ )
51
+ from syncade.persistence import RUN_INIT_FILENAME
52
+ from syncade.worktree import DEFAULT_WORKTREE_BASE
53
+
54
+ __all__ = [
55
+ "DEFAULT_KEEP",
56
+ "DEFAULT_MAX_AGE_DAYS",
57
+ "GcPlan",
58
+ "GcReport",
59
+ "_current_protected_run_ids",
60
+ "_gc_should_conservatively_protect",
61
+ "_git_worktree_prune",
62
+ "_lsof_pids_in_tree",
63
+ "_orphan_worktree_still_orphan_now",
64
+ "_parse_lsof_pids",
65
+ "_pid_cwd_is_still_in_tree",
66
+ "_protected_run_ids_for_gc",
67
+ "_reap_and_remove_tree",
68
+ "_reap_processes_in_tree",
69
+ "_run_dir_slimmable_now",
70
+ "_run_dir_protected_now",
71
+ "_run_id_protected_now",
72
+ "_safe_iter_subdirs",
73
+ "autoprune_transcripts",
74
+ "execute_gc",
75
+ "plan_gc",
76
+ ]
77
+
78
+ DEFAULT_KEEP: int = 20
79
+ """Default number of most-recent non-protected runs to keep."""
80
+
81
+ DEFAULT_MAX_AGE_DAYS: int = 0
82
+ """Default age floor in days. ``0`` disables the age gate."""
83
+
84
+
85
+ def autoprune_transcripts(
86
+ repo_root: Path,
87
+ *,
88
+ keep: int = DEFAULT_KEEP,
89
+ max_age_days: int = DEFAULT_MAX_AGE_DAYS,
90
+ ) -> GcReport:
91
+ """Prune old runs' transcripts. Called at the start of every fresh loop so
92
+ ``.syncade/runs/`` stays bounded without anyone remembering ``syncade --gc``.
93
+
94
+ **Deliberately narrower than ``--gc``: transcripts only.** It does not remove
95
+ worktrees, shell out to ``lsof``/``git worktree prune``, or reap processes. Those
96
+ are the slow and destructive half of GC, and a loop's opening moments — with a
97
+ concurrent syncade possibly mid-flight — are the wrong place for them. Disk growth
98
+ is what auto-prune exists to bound, and disk growth is transcripts (90.9% of the
99
+ corpus). ``--gc`` remains the explicit, full-power maintenance mode.
100
+
101
+ Bounded in practice, not just in intent: measured at **165 ms cold / 69 ms warm**
102
+ over the real 261-run corpus with 224 already-slim candidates (the worst case,
103
+ where every candidate is re-walked and nothing is freed). That is noise against a
104
+ review loop measured in minutes, so there is no artificial per-run cap — a cap
105
+ would only leave a backlog that never drains.
106
+
107
+ Protection is inherited whole from :func:`plan_gc`: resume-eligible runs, runs
108
+ with a live status breadcrumb, and the newest ``keep`` runs are never touched.
109
+ """
110
+ plan = plan_gc(repo_root, keep=keep, max_age_days=max_age_days, skip_worktrees=True)
111
+ return execute_gc(plan, dry_run=False, repo_root=repo_root)
112
+
113
+
114
+ def plan_gc(
115
+ repo_root: Path,
116
+ *,
117
+ keep: int = DEFAULT_KEEP,
118
+ max_age_days: int = DEFAULT_MAX_AGE_DAYS,
119
+ worktree_base: Path = DEFAULT_WORKTREE_BASE,
120
+ skip_worktrees: bool = False,
121
+ ) -> GcPlan:
122
+ """Partition ``.syncade/runs/`` into protected vs slimmable.
123
+
124
+ ``skip_worktrees=True`` omits all worktree and orphan discovery, avoiding
125
+ the ``git worktree list`` subprocess and the ``/tmp/syncade/`` walk. Used by
126
+ :func:`autoprune_transcripts` so routine loop startup never touches worktree
127
+ planning — that is the slow and destructive half of GC, unsuitable for the
128
+ opening moments of a loop.
129
+ """
130
+ runs_root = repo_root / ".syncade" / "runs"
131
+
132
+ run_dirs = _safe_iter_subdirs(runs_root)
133
+ protected = _protected_run_ids_for_gc(runs_root, run_dirs)
134
+
135
+ candidates = [d for d in run_dirs if d.name not in protected]
136
+ candidates.sort(key=_run_sort_key, reverse=True)
137
+
138
+ to_slim = _select_for_slimming(candidates, keep=keep, max_age_days=max_age_days)
139
+ slim_names = [d.name for d in to_slim]
140
+
141
+ if skip_worktrees:
142
+ return GcPlan(
143
+ protected_run_ids=sorted(protected),
144
+ runs_to_slim=slim_names,
145
+ worktree_trees_to_remove=[],
146
+ orphan_worktree_trees=[],
147
+ worktree_tree_identities={},
148
+ )
149
+
150
+ worktree_trees = [
151
+ tree
152
+ for tree in existing_worktree_trees(worktree_base, slim_names)
153
+ if not tree_contains_repo_root(tree, repo_root)
154
+ ]
155
+ known_run_ids = {d.name for d in run_dirs} | protected
156
+ orphan_trees = repo_owned_orphan_trees(
157
+ repo_root, _safe_iter_subdirs(worktree_base), known_run_ids
158
+ )
159
+ tree_identities = {
160
+ tree: identity
161
+ for tree in [*worktree_trees, *orphan_trees]
162
+ if (identity := tree_identity(tree)) is not None
163
+ }
164
+
165
+ return GcPlan(
166
+ protected_run_ids=sorted(protected),
167
+ runs_to_slim=slim_names,
168
+ worktree_trees_to_remove=worktree_trees,
169
+ orphan_worktree_trees=orphan_trees,
170
+ worktree_tree_identities=tree_identities,
171
+ )
172
+
173
+
174
+ def _run_sort_key(run_dir: Path) -> float:
175
+ started = _started_at_timestamp(run_dir)
176
+ if started is not None:
177
+ return started
178
+ try:
179
+ return run_dir.stat().st_mtime
180
+ except OSError:
181
+ return 0.0
182
+
183
+
184
+ def _started_at_timestamp(run_dir: Path) -> float | None:
185
+ run_init = run_dir / RUN_INIT_FILENAME
186
+ try:
187
+ data = json.loads(run_init.read_text(encoding="utf-8"))
188
+ except (OSError, json.JSONDecodeError):
189
+ return None
190
+ raw = data.get("started_at_utc")
191
+ if not isinstance(raw, str):
192
+ return None
193
+ try:
194
+ dt = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
195
+ except ValueError:
196
+ return None
197
+ return dt.timestamp()
198
+
199
+
200
+ def _select_for_slimming(
201
+ candidates_newest_first: list[Path],
202
+ *,
203
+ keep: int,
204
+ max_age_days: int,
205
+ ) -> list[Path]:
206
+ beyond_keep = candidates_newest_first[max(keep, 0) :]
207
+ if max_age_days <= 0:
208
+ return list(beyond_keep)
209
+
210
+ cutoff = datetime.now(UTC).timestamp() - (max_age_days * 86400)
211
+ return [d for d in beyond_keep if _run_sort_key(d) < cutoff]