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,389 @@
1
+ """Loop-level handoff.md persistence.
2
+
3
+ Writes ``<run_dir>/handoff.md`` — the structured operator handoff
4
+ artifact when the loop terminates with work remaining. Auto-
5
+ classifies remaining active blockers into a small set of disposition
6
+ categories so the operator reading one file sees what's left, what
7
+ the producer tried, and how to disposition each item.
8
+
9
+ The classification is HEURISTIC. The rendered handoff says so
10
+ explicitly; the operator's judgment owns the final call.
11
+
12
+ the heuristic classifier + its phrase tables + category
13
+ labels/descriptions live in :mod:`.handoff_classify`; this module renders
14
+ handoff.md.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from syncade.synthesis import ConsolidatedFinding
22
+
23
+ from ._atomic import atomic_write_text
24
+ from ._markdown import _file_or_repo_wide
25
+ from .handoff_classify import (
26
+ _HANDOFF_CATEGORY_DESCRIPTIONS,
27
+ _HANDOFF_CATEGORY_LABELS,
28
+ _classify_handoff_finding,
29
+ )
30
+
31
+ _HANDOFF_TERMINATION_REASON_LABELS: dict[str, str] = {
32
+ "ship": "SHIP",
33
+ "findings_present": "findings present",
34
+ "max_rounds_reached": "max rounds reached",
35
+ "producer_stalled": "producer stalled",
36
+ "producer_subprocess_error": "producer subprocess error",
37
+ "reviewer_failure": "reviewer failure",
38
+ "synth_failure": "synthesizer failure",
39
+ "test_subprocess_error": "test subprocess error",
40
+ "worktree_error": "worktree provisioning error",
41
+ "diff_malformed": "diff filter refusal (unidentifiable headers)",
42
+ "diff_too_large": "diff exceeds [loop] max_diff_bytes",
43
+ "prompt_too_large": "assembled reviewer prompt exceeds provider ceiling",
44
+ "parse_failure": "output parse failure",
45
+ "config_error": "config error",
46
+ }
47
+
48
+
49
+ def _handoff_producer_commit_subject(repo_root: Path | None, ending_sha: str) -> str:
50
+ """Look up the producer commit subject without importing loop_summary.
51
+
52
+ Handoff and loop_summary are peers in the top persistence layer, so
53
+ handoff keeps this small best-effort helper local to preserve the
54
+ documented acyclic layer direction.
55
+ """
56
+ if repo_root is None or not ending_sha:
57
+ return ""
58
+ try:
59
+ from syncade.process import run_subprocess
60
+
61
+ result = run_subprocess(
62
+ ["git", "log", "-1", "--pretty=format:%s", ending_sha],
63
+ cwd=repo_root,
64
+ timeout=5.0,
65
+ )
66
+ except Exception:
67
+ return ""
68
+ if result.returncode != 0:
69
+ return ""
70
+ return result.stdout.strip()
71
+
72
+
73
+ def persist_handoff(
74
+ run_dir: Path,
75
+ *,
76
+ final_exit_code: int,
77
+ final_round: int, # noqa: ARG001 — accepted for API symmetry with persist_loop_summary/manifest
78
+ termination_reason: str,
79
+ rounds: list, # list[RoundResult]; typed as list to avoid circular import
80
+ max_rounds: int,
81
+ pr_doc_path: Path | None = None,
82
+ repo_root: Path | None = None,
83
+ ) -> Path | None:
84
+ """Write ``<run_dir>/handoff.md`` — structured operator handoff
85
+ when the loop terminates with work remaining.
86
+
87
+ Generated only when ``final_exit_code in (20, 30)`` AND the final
88
+ round's synthesizer surfaced at least one active (non-dismissed)
89
+ blocker. Returns ``None`` on any other path so the caller (the
90
+ orchestrator) can call this unconditionally and rely on the
91
+ function's own gate.
92
+
93
+ The handoff is APPEND, not REPLACE: ``loop-summary.md`` is still
94
+ written by :func:`persist_loop_summary` with its current shape
95
+ and role (high-level rollup). The handoff focuses narrowly on
96
+ "what's still blocking, here's what the producer tried, here's
97
+ how to disposition the remaining work" so the operator reading
98
+ one file gets the whole picture without grepping multiple
99
+ artifacts.
100
+
101
+ Auto-classification is HEURISTIC. The rendered handoff explicitly
102
+ documents this so the operator doesn't over-trust the
103
+ categorization. See :func:`_classify_handoff_finding` for the
104
+ classification rules and priority order.
105
+
106
+ Args:
107
+ run_dir: Top-level run directory
108
+ (``<repo>/.syncade/runs/<id>/``).
109
+ final_exit_code: The loop's final exit code. The handoff
110
+ fires only for ``20`` (max_rounds_reached) and ``30``
111
+ (findings present / producer stalled).
112
+ final_round: 0-indexed round that terminated the loop.
113
+ termination_reason: Categorical termination label (see
114
+ :data:`syncade.orchestrator.TerminationReason`).
115
+ rounds: List of :class:`syncade.orchestrator.RoundResult`,
116
+ one per round executed.
117
+ max_rounds: Configured ``[loop] max_rounds`` ceiling.
118
+ pr_doc_path: Path to the PR brief that drove the run. Used
119
+ by the auto-classifier to recognize "this finding's file
120
+ IS the PR brief" → category ``"P"``. ``None`` skips the
121
+ path-based check (phrase-based classification still
122
+ runs).
123
+ repo_root: Repo root, used for ``git log`` lookups when
124
+ rendering producer commit subjects. Optional — falls
125
+ back to the SHA-only form when missing.
126
+
127
+ Returns:
128
+ Path of the written ``handoff.md`` on the write path;
129
+ ``None`` when the exit code or active-blocker conditions
130
+ aren't met (no handoff to write).
131
+
132
+ Raises:
133
+ FileNotFoundError: If ``run_dir`` does not exist (caller
134
+ bug — the orchestrator creates it during run setup).
135
+ """
136
+ if not run_dir.is_dir():
137
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
138
+
139
+ # Gate 1: only fire on exit 20 / 30.
140
+ if final_exit_code not in (20, 30):
141
+ return None
142
+
143
+ # Gate 2: must have active blockers in the FINAL round's synth.
144
+ # The spec contract is: final_exit_code in (20, 30) AND
145
+ # active_blocker_count > 0. Zero-blocker exit-30 paths (e.g.
146
+ # producer_stalled after a clean synth) do not produce a handoff
147
+ # — there are no remaining blockers for the operator to action.
148
+ if not rounds:
149
+ return None
150
+ last_round = rounds[-1]
151
+ active_blockers: list[ConsolidatedFinding] = []
152
+ if last_round.synth_result is not None and last_round.synth_result.output is not None:
153
+ for f in last_round.synth_result.output.consolidated_findings:
154
+ if not f.dismissed and f.severity == "blocker":
155
+ active_blockers.append(f)
156
+
157
+ if not active_blockers:
158
+ return None
159
+
160
+ run_id = run_dir.name
161
+
162
+ # ---- Header ------------------------------------------------------
163
+ verdict_label = "NO-SHIP"
164
+ reason_label = _HANDOFF_TERMINATION_REASON_LABELS.get(termination_reason, termination_reason)
165
+
166
+ # SHA annotation. The handoff describes the FINAL round's
167
+ # outstanding blockers, so the SHA the operator (or future agent)
168
+ # needs is the FINAL round's snapshot SHA — what reviewers had as
169
+ # HEAD when they produced the findings under inspection. The
170
+ # empty-``rounds`` branch is defensive; the gate above returns
171
+ # ``None`` before we get here when no rounds ran.
172
+ last_round_sha = rounds[-1].snapshot.commit_sha if rounds else ""
173
+ if last_round_sha:
174
+ sha_line = (
175
+ f"**Generated against SHA:** `{last_round_sha[:12]}` (full: `{last_round_sha}`) "
176
+ )
177
+ else:
178
+ sha_line = "**Generated against SHA:** (unknown — no rounds executed) "
179
+
180
+ lines: list[str] = [
181
+ f"# Syncade run {run_id} — handoff",
182
+ "",
183
+ f"**Final verdict:** {verdict_label} ",
184
+ f"**Final exit code:** {final_exit_code} ",
185
+ f"**Termination reason:** {reason_label} ",
186
+ sha_line,
187
+ f"**Rounds executed:** {len(rounds)} of {max_rounds} ",
188
+ f"**Active blockers remaining:** {len(active_blockers)}",
189
+ "",
190
+ "> This handoff is generated automatically when the loop "
191
+ "terminates with work remaining. The disposition categories "
192
+ "below are HEURISTIC — the operator's judgment owns the "
193
+ "final call. See the per-blocker provenance for the raw "
194
+ "reviewer outputs.",
195
+ "",
196
+ ]
197
+
198
+ # ---- What's left -------------------------------------------------
199
+ # The zero-blockers path returns None at the gate above, so by here ``active_blockers`` is
200
+ # always non-empty. Classify and render directly.
201
+ lines.append("## What's left")
202
+ lines.append("")
203
+ classifications: list[tuple[ConsolidatedFinding, str]] = [
204
+ (f, _classify_handoff_finding(f, pr_doc_path=pr_doc_path)) for f in active_blockers
205
+ ]
206
+ for i, (finding, category) in enumerate(classifications, start=1):
207
+ first_line = finding.description.strip().splitlines()[0]
208
+ lines.append(f"### Blocker {i} — {first_line}")
209
+ lines.append("")
210
+ # File + provenance
211
+ file_md = _file_or_repo_wide(finding.file)
212
+ lines.append(f"- **File:** {file_md}")
213
+ provenance_md = ", ".join(
214
+ f"{p.reviewer_name} ({p.original_severity})" for p in finding.provenance
215
+ )
216
+ lines.append(f"- **Provenance:** {provenance_md}")
217
+ # Description body. Flatten newlines to spaces so a multi-line
218
+ # synthesizer description doesn't break the bullet list — mirrors
219
+ # findings_md.py's per-reviewer-description handling.
220
+ description = finding.description.strip().replace("\n", " ")
221
+ lines.append(f"- **Description:** {description}")
222
+ # Classification
223
+ cat_label = _HANDOFF_CATEGORY_LABELS.get(category, category)
224
+ lines.append(f"- **Suggested disposition category:** {category} — {cat_label}")
225
+ lines.append(f"- **Operator action:** {_HANDOFF_CATEGORY_DESCRIPTIONS.get(category, '')}")
226
+ lines.append("")
227
+
228
+ # ---- What the producer attempted --------------------------------
229
+ lines.append("## What the producer attempted")
230
+ lines.append("")
231
+ producer_rounds = [r for r in rounds if r.producer_result is not None]
232
+ if not producer_rounds:
233
+ lines.append("_(no producer rounds ran)_")
234
+ lines.append("")
235
+ else:
236
+ # Build round_idx → active blocker count for "Findings
237
+ # addressed" / "Remaining forwarded" heuristic rollup.
238
+ round_blocker_count: dict[int, int] = {}
239
+ for r in rounds:
240
+ count = 0
241
+ if r.synth_result is not None and r.synth_result.output is not None:
242
+ for f in r.synth_result.output.consolidated_findings:
243
+ if not f.dismissed and f.severity == "blocker":
244
+ count += 1
245
+ round_blocker_count[r.round_idx] = count
246
+
247
+ for r in producer_rounds:
248
+ pr = r.producer_result
249
+ if pr.outcome == "committed":
250
+ short_sha = pr.ending_sha[:12]
251
+ subject = _handoff_producer_commit_subject(repo_root, pr.ending_sha)
252
+ if subject:
253
+ lines.append(
254
+ f'- **Round {r.round_idx} producer commit:** `{short_sha}` ("{subject}")'
255
+ )
256
+ else:
257
+ lines.append(f"- **Round {r.round_idx} producer commit:** `{short_sha}`")
258
+ # Heuristic rollup: compare this round's synth blocker
259
+ # count to the next round's to approximate how many the
260
+ # producer addressed. "Heuristic" is surfaced explicitly
261
+ # so the operator doesn't over-trust the count.
262
+ k_before = round_blocker_count.get(r.round_idx, 0)
263
+ next_idx = r.round_idx + 1
264
+ if next_idx in round_blocker_count:
265
+ k_after = round_blocker_count[next_idx]
266
+ addressed = max(0, k_before - k_after)
267
+ lines.append(
268
+ f"- **Findings addressed:** (heuristic) ~{addressed} of {k_before}"
269
+ )
270
+ lines.append(
271
+ f"- **Remaining findings forwarded to round {next_idx}:** {k_after}"
272
+ )
273
+ else:
274
+ lines.append(
275
+ "- **Findings addressed:** (heuristic) unknown — no subsequent round synth"
276
+ )
277
+ lines.append(
278
+ f"- **Remaining findings forwarded to round {next_idx}:** N/A (final round)"
279
+ )
280
+ elif pr.outcome == "stalled":
281
+ lines.append(
282
+ f"- **Round {r.round_idx} producer:** stalled "
283
+ f"(no commit; HEAD stayed at `{pr.starting_sha[:12]}`)"
284
+ )
285
+ elif pr.outcome == "escalated":
286
+ # the handoff fires only on exit 20 / 30; an HONORED
287
+ # escalation exits 10 and never reaches here. So an escalated
288
+ # producer in the handoff was NOT honored — its escalation left
289
+ # active blocker(s) uncovered and the loop treated the round as
290
+ # a stall. Classify it as such.
291
+ lines.append(
292
+ f"- **Round {r.round_idx} producer:** escalated but not honored "
293
+ f"(left active blocker(s) uncovered → treated as stall; "
294
+ f"HEAD stayed at `{pr.starting_sha[:12]}`)"
295
+ )
296
+ else:
297
+ err = type(pr.error).__name__ if pr.error else "Unknown"
298
+ lines.append(
299
+ f"- **Round {r.round_idx} producer:** subprocess_error "
300
+ f"({err}; HEAD stayed at `{pr.starting_sha[:12]}`)"
301
+ )
302
+ lines.append("")
303
+
304
+ # ---- Suggested next-step categories -----------------------------
305
+ lines.append("## Suggested next-step categories")
306
+ lines.append("")
307
+ lines.append(
308
+ "The operator-procedural pattern says some findings "
309
+ "self-resolve in the next commit, some are environment-bound, "
310
+ "some are real-code-defects. Below is the auto-classification "
311
+ "for THIS run's remaining blockers. The classification is "
312
+ "HEURISTIC — phrase-matching against synthesizer + reviewer "
313
+ "descriptions plus a file-path check for modified-file findings. "
314
+ "Treat it as a starting point, not a decision."
315
+ )
316
+ lines.append("")
317
+ # ``active_blockers`` is non-empty by the gate (see above), so
318
+ # ``classifications`` is always populated here. Group by category.
319
+ category_buckets: dict[str, list[tuple[int, ConsolidatedFinding]]] = {
320
+ c: [] for c in ("M", "F", "P", "A", "D")
321
+ }
322
+ for i, (finding, category) in enumerate(classifications, start=1):
323
+ category_buckets.setdefault(category, []).append((i, finding))
324
+ for cat in ("M", "F", "P", "A", "D"):
325
+ bucket = category_buckets.get(cat, [])
326
+ label = _HANDOFF_CATEGORY_LABELS[cat]
327
+ description = _HANDOFF_CATEGORY_DESCRIPTIONS[cat]
328
+ lines.append(f"### {cat} — {label}")
329
+ lines.append("")
330
+ lines.append(description)
331
+ lines.append("")
332
+ if not bucket:
333
+ lines.append("- _(none)_")
334
+ else:
335
+ for idx, finding in bucket:
336
+ first_line = finding.description.strip().splitlines()[0]
337
+ lines.append(f"- Blocker {idx}: {first_line}")
338
+ lines.append("")
339
+
340
+ # ---- Next steps -------------------------------------------------
341
+ lines.append("## Next steps")
342
+ lines.append("")
343
+ if termination_reason == "max_rounds_reached":
344
+ lines.append(
345
+ "- The loop ran the configured ``max_rounds`` without "
346
+ "converging. Address the M-category findings in a new "
347
+ "commit, then re-run with ``--max-rounds 1`` to verify "
348
+ "just the fixes. P-category findings self-resolve when "
349
+ "the operator commits the completion record. F-category "
350
+ "findings reflect brief/implementation drift — amend the "
351
+ "brief or add the convention to CLAUDE.md. A-category "
352
+ "findings require the operator to run the gate locally "
353
+ "and attest in the completion record."
354
+ )
355
+ elif termination_reason == "findings_present":
356
+ lines.append(
357
+ "- The single-pass run found remaining active blockers. "
358
+ "Address the findings manually, then re-run syncade to "
359
+ "verify the updated tree."
360
+ )
361
+ elif termination_reason == "producer_stalled":
362
+ lines.append(
363
+ "- The producer ran but didn't commit. Inspect the "
364
+ "final round's ``producer.stdout`` to see what it "
365
+ "attempted; common causes are under-specified findings "
366
+ "or the producer concluding the existing code is "
367
+ "already correct. Address the finding manually or "
368
+ "refine the producer prompt, then re-run."
369
+ )
370
+ elif termination_reason == "producer_subprocess_error":
371
+ lines.append(
372
+ "- The producer subprocess failed before it could "
373
+ "attempt a fix. Read the final round's "
374
+ "``producer.stderr`` and ``producer.error.txt`` for the "
375
+ "exception trace. Common causes: auth (run ``claude "
376
+ "login`` / ``codex login``), network errors, missing "
377
+ "CLI binary."
378
+ )
379
+ else:
380
+ lines.append(
381
+ f"- Loop terminated with reason ``{termination_reason}``. "
382
+ "Read the per-round artifacts under this run directory "
383
+ "for the failure shape."
384
+ )
385
+ lines.append("")
386
+
387
+ handoff_path = run_dir / "handoff.md"
388
+ atomic_write_text(handoff_path, "\n".join(lines))
389
+ return handoff_path
@@ -0,0 +1,196 @@
1
+ """Heuristic blocker classification for handoff.md.
2
+
3
+ Holds the disposition-category labels/descriptions, the phrase tables, and
4
+ ``_classify_handoff_finding`` — the heuristic that buckets a remaining active
5
+ blocker into one of ``M | F | P | A | D``. ``handoff.py`` imports the classifier
6
+ and the two category dicts it renders.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from syncade.synthesis import ConsolidatedFinding
14
+
15
+ # Heuristic categories for auto-classifying remaining active blockers
16
+ # when the loop terminates with work left. The labels are surfaced in
17
+ # the rendered handoff.md so the operator knows what each category
18
+ # means without consulting external docs. The handoff also states
19
+ # explicitly that this is HEURISTIC — the operator's judgment owns
20
+ # the final disposition.
21
+ _HANDOFF_CATEGORY_LABELS: dict[str, str] = {
22
+ "M": "Manual fix needed",
23
+ "F": "False positive / convention mismatch",
24
+ "P": "Operator-procedural / self-resolving",
25
+ "A": "Operator-attested",
26
+ "D": "Dismiss with rationale",
27
+ }
28
+
29
+ _HANDOFF_CATEGORY_DESCRIPTIONS: dict[str, str] = {
30
+ "M": ("Real code defect. Operator addresses in a new commit."),
31
+ "F": (
32
+ "Implementation correctly follows established convention; brief "
33
+ "was imprecise. Operator amends the brief or adds the "
34
+ "convention to CLAUDE.md."
35
+ ),
36
+ "P": (
37
+ "Workflow-state finding (completion record absent, brief still "
38
+ "DRAFT, status header not yet updated, commit hashes still "
39
+ "`(to fill)`). Resolves when the operator commits the "
40
+ "completion record."
41
+ ),
42
+ "A": (
43
+ "Reviewer couldn't verify due to sandbox limitation (real-CLI "
44
+ "smoke, network, etc.). Operator runs the gate locally and "
45
+ "attests in the completion record with an "
46
+ "``Operator-attested: <run-id>`` rationale line."
47
+ ),
48
+ "D": ("Finding is structurally noise — empty repo state, harness-induced artifact, etc."),
49
+ }
50
+
51
+ # Phrase substrings (lowercase match). Order in the classification
52
+ # helper matters because the first match wins; the rule set below
53
+ # documents that order. See ``_classify_handoff_finding`` for the priority
54
+ # order.
55
+ _HANDOFF_OPERATOR_PROCEDURAL_PHRASES = (
56
+ "completion record",
57
+ "pr brief",
58
+ "status header",
59
+ "status line",
60
+ "commit hashes",
61
+ "to fill",
62
+ )
63
+ _HANDOFF_OPERATOR_ATTESTED_PHRASES = (
64
+ "sandbox",
65
+ "couldn't run",
66
+ "could not run",
67
+ "sandboxed environment",
68
+ "smoke not affirmatively verified",
69
+ "recursive `claude -p`",
70
+ "recursive `codex exec`",
71
+ )
72
+ _HANDOFF_CONVENTION_PHRASES = (
73
+ "convention mismatch",
74
+ "brief was imprecise",
75
+ "implementation correctly follows",
76
+ "implementation is more correct",
77
+ "intentional convention",
78
+ )
79
+ # Worktree-strip artifact phrases. Reviewer worktrees deliberately
80
+ # strip CLAUDE.md (architectural invariant — reviewers must not see
81
+ # project memory). Any PR that legitimately edits CLAUDE.md produces
82
+ # a reviewer-side phantom "tracked deletion" finding that the cold
83
+ # synth cannot dismiss (cannot-invent invariant blocks the dismissal
84
+ # rationale). Routing these phrases to category F lets the operator see the
85
+ # structural false positive at the top of the handoff rather than buried under
86
+ # M-category defects.
87
+ _HANDOFF_WORKTREE_STRIP_PHRASES = (
88
+ "deleted in the reviewed worktree",
89
+ "deleted from the reviewed worktree",
90
+ "tracked deletion",
91
+ "tracked file deletion",
92
+ "claude.md is deleted",
93
+ "claude.md is still deleted",
94
+ )
95
+ _HANDOFF_TEST_REGRESSION_PHRASES = (
96
+ "test caller broke",
97
+ "kwarg mismatch",
98
+ )
99
+
100
+
101
+ def _classify_handoff_finding(
102
+ finding: ConsolidatedFinding,
103
+ pr_doc_path: Path | None = None,
104
+ ) -> str:
105
+ """Heuristically classify one ``ConsolidatedFinding`` into a
106
+ disposition category for the handoff artifact.
107
+
108
+ Returns one of ``"M" | "F" | "P" | "A" | "D"`` (see
109
+ :data:`_HANDOFF_CATEGORY_LABELS` for the human-readable
110
+ labels). This is HEURISTIC — the operator's judgment owns the
111
+ final disposition. The handoff itself states this explicitly so
112
+ the categorization is treated as a hint, not a decision.
113
+
114
+ Priority order (first match wins):
115
+
116
+ 1. ``file`` equals the PR brief itself (matched on basename to
117
+ tolerate relative-vs-absolute path mismatches) → ``"P"``.
118
+ 2. Description (synth-consolidated + every provenance entry's
119
+ original_description) contains a workflow-state phrase
120
+ (``"completion record"``, ``"pr brief"``, ``"status
121
+ header"``, etc.) → ``"P"``.
122
+ 3. Description contains an operator-attested phrase
123
+ (``"sandbox"``, ``"couldn't run"``, etc.) → ``"A"``.
124
+ 4. Description contains a convention-mismatch phrase
125
+ (``"convention mismatch"``, ``"implementation correctly
126
+ follows"``, etc.) → ``"F"``.
127
+ 5. Description contains a worktree-strip artifact phrase
128
+ (``"deleted in the reviewed worktree"``, ``"tracked
129
+ deletion"``, etc.) → ``"F"``. Reviewer worktrees strip
130
+ CLAUDE.md per the architectural invariant; any PR that
131
+ legitimately edits CLAUDE.md surfaces a phantom "tracked
132
+ deletion" finding the cold synth cannot dismiss.
133
+ 6. ``file`` is under ``tests/`` AND description references a
134
+ producer-regression phrase (``"test caller broke"``, ``"kwarg
135
+ mismatch"``) → ``"M"``.
136
+ 7. Default → ``"M"``.
137
+
138
+ The phrase lists cover observed reviewer-output shapes for each handoff
139
+ category and the worktree-strip pattern (rule 5). Future updates may extend the
140
+ phrase lists as new patterns emerge; the priority order above
141
+ is part of the contract and shouldn't be reordered without
142
+ re-checking the test fixtures.
143
+
144
+ The ``"D"`` (dismiss) category exists for completeness but is
145
+ never assigned by the heuristic — the cold synthesizer can't
146
+ dismiss findings (cannot-invent invariant), so a category-D
147
+ blocker would only ever land in the handoff if a future PR adds
148
+ a "dismiss this in the completion record" workflow.
149
+ """
150
+ file = finding.file or ""
151
+
152
+ # Combine synth description with every provenance's
153
+ # original_description so phrase matches catch the original
154
+ # reviewer's wording (which the synth may paraphrase). Lower-
155
+ # cased once for the substring scan.
156
+ desc_parts = [finding.description or ""]
157
+ for p in finding.provenance:
158
+ desc_parts.append(p.original_description or "")
159
+ combined_desc = " ".join(desc_parts).lower()
160
+
161
+ # Rule 1: modified path (basename match) → P.
162
+ if pr_doc_path is not None and file:
163
+ if Path(file).name == pr_doc_path.name:
164
+ return "P"
165
+
166
+ # Rule 2: workflow-state phrase → P.
167
+ if any(phrase in combined_desc for phrase in _HANDOFF_OPERATOR_PROCEDURAL_PHRASES):
168
+ return "P"
169
+
170
+ # Rule 3: operator-attested phrase → A.
171
+ if any(phrase in combined_desc for phrase in _HANDOFF_OPERATOR_ATTESTED_PHRASES):
172
+ return "A"
173
+
174
+ # Rule 4: convention-mismatch phrase → F.
175
+ if any(phrase in combined_desc for phrase in _HANDOFF_CONVENTION_PHRASES):
176
+ return "F"
177
+
178
+ # Rule 5: worktree-strip artifact phrase → F. Reviewer worktrees
179
+ # strip CLAUDE.md per the architectural invariant; any PR that
180
+ # legitimately edits CLAUDE.md surfaces a phantom "tracked
181
+ # deletion" finding the cold synth cannot dismiss. Route to F so
182
+ # the operator's disposition path is "annotate-as-FP in the
183
+ # completion record" rather than "fix CLAUDE.md" (which would
184
+ # double-write or no-op against the already-present file).
185
+ if any(phrase in combined_desc for phrase in _HANDOFF_WORKTREE_STRIP_PHRASES):
186
+ return "F"
187
+
188
+ # Rule 6: tests/ + producer-regression phrase → M (explicit
189
+ # path; same as the default but documented separately).
190
+ if file.startswith("tests/") and any(
191
+ phrase in combined_desc for phrase in _HANDOFF_TEST_REGRESSION_PHRASES
192
+ ):
193
+ return "M"
194
+
195
+ # Rule 7: default → M.
196
+ return "M"
@@ -0,0 +1,67 @@
1
+ """Per-branch last-reviewed SHA persistence.
2
+
3
+ ``<repo>/.syncade/last-reviewed.json`` is **cross-run** state (a sibling of
4
+ ``runs/``, NOT under any single run dir) that records, per branch, the HEAD a
5
+ completed review last covered. A subsequent ``--scope since-last-review`` bounds
6
+ its diff to commits after that SHA, so each review covers only new work instead
7
+ of re-reviewing the whole branch every run.
8
+
9
+ It is per-machine run state — gitignored, never committed, never cross-branch.
10
+ Shape::
11
+
12
+ { "<branch>": { "sha": "<reviewed-HEAD>", "run_id": "...", "recorded_at_utc": "..." } }
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ from ._atomic import atomic_write_json
21
+
22
+ LAST_REVIEWED_FILENAME = "last-reviewed.json"
23
+ """Basename under ``<repo>/.syncade/`` of the per-branch last-reviewed record."""
24
+
25
+
26
+ def _path(repo_root: Path) -> Path:
27
+ return repo_root / ".syncade" / LAST_REVIEWED_FILENAME
28
+
29
+
30
+ def _load(repo_root: Path) -> dict:
31
+ path = _path(repo_root)
32
+ if not path.is_file():
33
+ return {}
34
+ try:
35
+ data = json.loads(path.read_text(encoding="utf-8"))
36
+ except (json.JSONDecodeError, OSError):
37
+ return {}
38
+ return data if isinstance(data, dict) else {}
39
+
40
+
41
+ def persist_last_reviewed(
42
+ repo_root: Path,
43
+ *,
44
+ branch: str,
45
+ sha: str,
46
+ run_id: str,
47
+ recorded_at_utc: str,
48
+ ) -> Path:
49
+ """Record ``sha`` as ``branch``'s last-reviewed HEAD, merging into the
50
+ existing file so other branches' records are preserved. Creates
51
+ ``<repo>/.syncade/`` if needed. Returns the written path."""
52
+ data = _load(repo_root)
53
+ data[branch] = {"sha": sha, "run_id": run_id, "recorded_at_utc": recorded_at_utc}
54
+ path = _path(repo_root)
55
+ atomic_write_json(path, data, sort_keys=True)
56
+ return path
57
+
58
+
59
+ def read_last_reviewed(repo_root: Path, branch: str) -> str | None:
60
+ """Return the recorded last-reviewed SHA for ``branch``, or ``None`` when the
61
+ file is absent/unparseable or has no entry for that branch."""
62
+ entry = _load(repo_root).get(branch)
63
+ if isinstance(entry, dict):
64
+ sha = entry.get("sha")
65
+ if isinstance(sha, str) and sha:
66
+ return sha
67
+ return None