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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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
|