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,249 @@
|
|
|
1
|
+
"""Mechanical-check leg persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/<check_name>.check.{stdout,stderr,exit-code.txt}`` for each
|
|
4
|
+
configured check that ran, mirroring :func:`persist_test_run_result`'s
|
|
5
|
+
file-layout convention. The orchestrator calls this once per check on rounds
|
|
6
|
+
where checks ran (reviewers succeeded AND the synth produced output). The
|
|
7
|
+
manifest / findings surfacing of these results lives in the round-manifest +
|
|
8
|
+
findings_md + run_summary writers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from syncade.synthesis import has_active_blocker
|
|
18
|
+
from syncade.test_runner import TestRunResult
|
|
19
|
+
|
|
20
|
+
from ._atomic import atomic_write_text
|
|
21
|
+
from ._validation import _validate_reviewer_filename_basename
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from syncade.synthesizer import SynthesizerResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CheckArtifactPaths:
|
|
29
|
+
"""Where one mechanical check's artifacts land on disk.
|
|
30
|
+
|
|
31
|
+
All paths are absolute and rooted at ``<round_dir>``. ``name`` is the
|
|
32
|
+
check's name (a validated plain basename) so a consumer can map the files
|
|
33
|
+
back to the configured check without re-deriving the layout convention.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
stdout: Path
|
|
38
|
+
stderr: Path
|
|
39
|
+
exit_code: Path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def persist_check_result(
|
|
43
|
+
round_dir: Path,
|
|
44
|
+
check_result: TestRunResult,
|
|
45
|
+
) -> CheckArtifactPaths:
|
|
46
|
+
"""Write one check's outputs to
|
|
47
|
+
``<round_dir>/<name>.check.{stdout,stderr,exit-code.txt}``.
|
|
48
|
+
|
|
49
|
+
Mirrors :func:`persist_test_run_result`; the ``.check.`` infix groups the
|
|
50
|
+
files and keeps them distinct from the per-reviewer and test-run artifacts.
|
|
51
|
+
The check name is a validated plain basename (rejected at config-load
|
|
52
|
+
otherwise); this re-validates defensively before using it as a filename so
|
|
53
|
+
a check name can never escape ``round_dir``.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
round_dir: The round directory to write into (must already exist).
|
|
57
|
+
check_result: One :class:`~syncade.test_runner.TestRunResult`
|
|
58
|
+
populated with mechanical-check ``name`` and ``severity``.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
:class:`CheckArtifactPaths` naming all three written files.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
FileNotFoundError: If ``round_dir`` does not exist.
|
|
65
|
+
"""
|
|
66
|
+
_require_check_metadata(check_result)
|
|
67
|
+
_validate_reviewer_filename_basename(check_result.name)
|
|
68
|
+
if not round_dir.is_dir():
|
|
69
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
70
|
+
|
|
71
|
+
base = check_result.name
|
|
72
|
+
stdout_path = round_dir / f"{base}.check.stdout"
|
|
73
|
+
stderr_path = round_dir / f"{base}.check.stderr"
|
|
74
|
+
exit_code_path = round_dir / f"{base}.check.exit-code.txt"
|
|
75
|
+
|
|
76
|
+
atomic_write_text(stdout_path, check_result.stdout)
|
|
77
|
+
atomic_write_text(stderr_path, check_result.stderr)
|
|
78
|
+
atomic_write_text(exit_code_path, f"{check_result.exit_code}\n")
|
|
79
|
+
|
|
80
|
+
return CheckArtifactPaths(
|
|
81
|
+
name=base,
|
|
82
|
+
stdout=stdout_path,
|
|
83
|
+
stderr=stderr_path,
|
|
84
|
+
exit_code=exit_code_path,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_STATUS_LABEL = {"passed": "PASS", "failed": "FAIL", "subprocess_error": "ERROR"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_checks_section(check_results: list[TestRunResult]) -> list[str]:
|
|
92
|
+
"""Render the ``## Mechanical checks`` section (findings.md / summary.md) as
|
|
93
|
+
a list of lines, or ``[]`` when there are no checks.
|
|
94
|
+
|
|
95
|
+
Advisory failures are tagged ``(advisory — non-blocking)`` and counted in a
|
|
96
|
+
trailing note; blocking failures read plainly (they drove the verdict). A
|
|
97
|
+
failing/erroring check links its captured output files.
|
|
98
|
+
"""
|
|
99
|
+
if not check_results:
|
|
100
|
+
return []
|
|
101
|
+
lines = ["## Mechanical checks", ""]
|
|
102
|
+
advisory_failures = 0
|
|
103
|
+
for c in check_results:
|
|
104
|
+
status = _STATUS_LABEL.get(c.outcome, c.outcome.upper())
|
|
105
|
+
failed = c.outcome != "passed"
|
|
106
|
+
if failed and c.severity == "advisory":
|
|
107
|
+
tag = " (advisory — non-blocking)"
|
|
108
|
+
advisory_failures += 1
|
|
109
|
+
elif failed:
|
|
110
|
+
tag = " (blocking)"
|
|
111
|
+
else:
|
|
112
|
+
tag = ""
|
|
113
|
+
lines.append(f"- **{c.name}** ({c.severity}): {status} — exit {c.exit_code}{tag} ")
|
|
114
|
+
if failed:
|
|
115
|
+
lines.append(
|
|
116
|
+
f" Output: [{c.name}.check.stdout]({c.name}.check.stdout) | "
|
|
117
|
+
f"[{c.name}.check.stderr]({c.name}.check.stderr)"
|
|
118
|
+
)
|
|
119
|
+
if advisory_failures:
|
|
120
|
+
noun = "check" if advisory_failures == 1 else "checks"
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append(f"Advisory: {advisory_failures} mechanical {noun} failed (non-blocking).")
|
|
123
|
+
lines.append("")
|
|
124
|
+
return lines
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _require_check_metadata(check_result: TestRunResult) -> None:
|
|
128
|
+
if check_result.name is None or check_result.severity is None:
|
|
129
|
+
raise ValueError("check result requires name and severity")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_manifest_entry(check_result: TestRunResult) -> dict[str, object]:
|
|
133
|
+
"""Build one ``checks[]`` entry for the round manifest. ``blocking`` is
|
|
134
|
+
surfaced as an explicit boolean so a consumer can filter gating vs advisory
|
|
135
|
+
checks without re-deriving it from ``severity``."""
|
|
136
|
+
_require_check_metadata(check_result)
|
|
137
|
+
return {
|
|
138
|
+
"name": check_result.name,
|
|
139
|
+
"severity": check_result.severity,
|
|
140
|
+
"blocking": check_result.severity == "blocking",
|
|
141
|
+
"outcome": check_result.outcome,
|
|
142
|
+
"exit_code": check_result.exit_code,
|
|
143
|
+
"command": check_result.command,
|
|
144
|
+
"duration_seconds": check_result.duration_seconds,
|
|
145
|
+
"stdout_path": f"{check_result.name}.check.stdout",
|
|
146
|
+
"stderr_path": f"{check_result.name}.check.stderr",
|
|
147
|
+
"exit_code_path": f"{check_result.name}.check.exit-code.txt",
|
|
148
|
+
"error_type": (
|
|
149
|
+
type(check_result.error).__name__ if check_result.error is not None else None
|
|
150
|
+
),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# check-aware Next-steps for the per-round summary.md.
|
|
155
|
+
# When a BLOCKING mechanical check (not a reviewer / synth / test phase)
|
|
156
|
+
# drove the round's exit code, the generic per-exit-code guidance points
|
|
157
|
+
# the operator at the wrong artifact ("read the active synth blockers" /
|
|
158
|
+
# "a reviewer subprocess failed"). These variants point at the check.
|
|
159
|
+
_NEXT_STEPS_30_CHECK_FAILED = (
|
|
160
|
+
"- A blocking mechanical check FAILED (the cold synthesizer was\n"
|
|
161
|
+
" clean — no consolidated blockers). The `## Mechanical checks`\n"
|
|
162
|
+
" section of `findings.md` names which check failed; open that\n"
|
|
163
|
+
" check's `<name>.check.stdout` / `<name>.check.stderr` for its\n"
|
|
164
|
+
" output. This is a NO-SHIP from the mechanical lane, not a\n"
|
|
165
|
+
" reviewer / synth finding — `findings.md`'s consolidated review\n"
|
|
166
|
+
" has zero active blockers."
|
|
167
|
+
)
|
|
168
|
+
_NEXT_STEPS_40_CHECK_SUBPROCESS = (
|
|
169
|
+
"- A blocking mechanical check's subprocess could not run to\n"
|
|
170
|
+
" completion (every reviewer succeeded; the synthesizer\n"
|
|
171
|
+
" succeeded). `findings.md` renders Verdict: ABORT — the check\n"
|
|
172
|
+
" signal is indeterminate until the environment problem is\n"
|
|
173
|
+
" fixed. Read the failing check's `<name>.check.stderr` and the\n"
|
|
174
|
+
" manifest's `checks[].error_type`; common shapes are a missing\n"
|
|
175
|
+
" binary (the check `command` references a tool not on PATH in\n"
|
|
176
|
+
" the check worktree) or a timeout."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_aware_next_steps(
|
|
181
|
+
exit_code: int,
|
|
182
|
+
synth_result: SynthesizerResult | None,
|
|
183
|
+
test_result: TestRunResult | None,
|
|
184
|
+
check_results: list[TestRunResult] | None,
|
|
185
|
+
) -> str | None:
|
|
186
|
+
"""Return a check-driven Next-steps override for the per-round
|
|
187
|
+
summary.md, or ``None`` when the round's failure was NOT driven by a
|
|
188
|
+
blocking mechanical check (the caller keeps its normal routing).
|
|
189
|
+
|
|
190
|
+
Fires only for the two check-driven exits — a failing blocking check
|
|
191
|
+
on a synth-clean round (exit 30) and a blocking-check subprocess
|
|
192
|
+
error (exit 40) — and only when no reviewer / synth / test phase is
|
|
193
|
+
the real cause. Every zero-check round passes ``check_results``
|
|
194
|
+
empty and gets ``None`` here, so the generic guidance stays
|
|
195
|
+
byte-identical everywhere else.
|
|
196
|
+
"""
|
|
197
|
+
checks = check_results or []
|
|
198
|
+
blocking_failed = any(c.severity == "blocking" and c.outcome == "failed" for c in checks)
|
|
199
|
+
blocking_errored = any(
|
|
200
|
+
c.severity == "blocking" and c.outcome == "subprocess_error" for c in checks
|
|
201
|
+
)
|
|
202
|
+
if exit_code == 40 and blocking_errored:
|
|
203
|
+
synth_failed = synth_result is not None and synth_result.error is not None
|
|
204
|
+
test_errored = test_result is not None and test_result.outcome == "subprocess_error"
|
|
205
|
+
if not synth_failed and not test_errored:
|
|
206
|
+
return _NEXT_STEPS_40_CHECK_SUBPROCESS
|
|
207
|
+
if exit_code == 30 and blocking_failed:
|
|
208
|
+
test_failed = test_result is not None and test_result.outcome == "failed"
|
|
209
|
+
synth_clean = (
|
|
210
|
+
synth_result is not None
|
|
211
|
+
and synth_result.output is not None
|
|
212
|
+
and not has_active_blocker(synth_result.output)
|
|
213
|
+
)
|
|
214
|
+
if synth_clean and not test_failed:
|
|
215
|
+
return _NEXT_STEPS_30_CHECK_FAILED
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# validation fix (codex blocker): the LOOP-summary equivalent of the
|
|
220
|
+
# per-round check-aware next-steps. When the FINAL round's NO-SHIP was a
|
|
221
|
+
# synth-clean blocking-check failure, loop-summary.md points at the check
|
|
222
|
+
# instead of the termination_reason-keyed text (e.g. max_rounds_reached's
|
|
223
|
+
# "read the remaining active blockers"), which misleads — there are none.
|
|
224
|
+
_LOOP_NEXT_STEPS_CHECK_FAILURE = (
|
|
225
|
+
"- The final round was a NO-SHIP driven by a failing BLOCKING mechanical "
|
|
226
|
+
"check, not by the synthesizer's consolidated findings (which were clean). "
|
|
227
|
+
"Read the `## Mechanical checks` section of that round's `findings.md` to "
|
|
228
|
+
"see which check failed, then open its `<name>.check.stdout` / "
|
|
229
|
+
"`<name>.check.stderr` in the round directory. There are NO active synth "
|
|
230
|
+
"blockers here — the mechanical lane is the NO-SHIP signal."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _final_round_blocking_check_failure(rounds: list) -> bool:
|
|
235
|
+
"""True when the LAST round's NO-SHIP was a synth-clean BLOCKING
|
|
236
|
+
mechanical-check failure (duck-typed over ``RoundResult``). Drives the
|
|
237
|
+
loop-summary next-steps override above."""
|
|
238
|
+
if not rounds:
|
|
239
|
+
return False
|
|
240
|
+
r = rounds[-1]
|
|
241
|
+
synth = r.synth_result
|
|
242
|
+
synth_clean = (
|
|
243
|
+
synth is not None and synth.output is not None and not has_active_blocker(synth.output)
|
|
244
|
+
)
|
|
245
|
+
if not synth_clean:
|
|
246
|
+
return False
|
|
247
|
+
if r.test_result is not None and r.test_result.outcome == "failed":
|
|
248
|
+
return False
|
|
249
|
+
return any(c.severity == "blocking" and c.outcome == "failed" for c in r.check_results)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Decision-needed checkpoint persistence.
|
|
2
|
+
|
|
3
|
+
When a NO-SHIP round's producer escalates a finding as an operator
|
|
4
|
+
decision (see :mod:`syncade.producer_escalation`) AND the loop honors
|
|
5
|
+
that escalation, the loop terminator writes
|
|
6
|
+
``<run_dir>/decision-needed.md`` — the operator-facing checkpoint
|
|
7
|
+
carrying the producer's case (the finding, the decision to make, the
|
|
8
|
+
concrete options, the reproduction-backed rationale) and the
|
|
9
|
+
record-a-decision-then-resume instructions. Since the escalation
|
|
10
|
+
is honored (and this file written) ONLY when its ``finding_indices``
|
|
11
|
+
cover every active blocker in the round; an escalation that leaves a
|
|
12
|
+
blocker uncovered is treated as a producer stall (exit 30) and this
|
|
13
|
+
file is not written.
|
|
14
|
+
|
|
15
|
+
The companion is ``decision.txt``: the operator writes their decision
|
|
16
|
+
into ``<run_dir>/decision.txt`` and runs ``syncade --resume <run-id>``;
|
|
17
|
+
the resumed round's producer receives that text. The filename constant
|
|
18
|
+
and the reader live here so the writer (which tells the operator where to
|
|
19
|
+
write) and the reader (resume) share one source of truth.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from syncade.producer_escalation import ProducerEscalation
|
|
28
|
+
|
|
29
|
+
from ._atomic import atomic_write_text
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from syncade.test_runner import TestRunResult
|
|
33
|
+
|
|
34
|
+
DECISION_NEEDED_FILENAME = "decision-needed.md"
|
|
35
|
+
"""The operator-facing escalation checkpoint, at the run root."""
|
|
36
|
+
|
|
37
|
+
OPERATOR_DECISION_FILENAME = "decision.txt"
|
|
38
|
+
"""Where the operator records the decision the resume reads back. Plain
|
|
39
|
+
text (the producer receives it verbatim). Single source of truth shared
|
|
40
|
+
by :func:`persist_decision_needed` (which instructs the operator) and
|
|
41
|
+
:func:`read_operator_decision` (which the resume path calls)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def persist_decision_needed(
|
|
45
|
+
run_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
round_idx: int,
|
|
48
|
+
escalation: ProducerEscalation,
|
|
49
|
+
run_id: str,
|
|
50
|
+
branch_advanced: bool = False,
|
|
51
|
+
check_results: list[TestRunResult] | None = None,
|
|
52
|
+
) -> Path:
|
|
53
|
+
"""Write ``<run_dir>/decision-needed.md`` for an honored escalation.
|
|
54
|
+
|
|
55
|
+
The loop terminator calls this only when the round's producer
|
|
56
|
+
escalated AND the escalation's ``finding_indices`` covered every
|
|
57
|
+
active blocker; a non-honored escalation maps to a producer
|
|
58
|
+
stall (exit 30) and never reaches this writer.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
|
|
62
|
+
round_idx: The 0-indexed round whose producer escalated.
|
|
63
|
+
escalation: The structured :class:`ProducerEscalation` the
|
|
64
|
+
producer emitted.
|
|
65
|
+
run_id: The run-id, for the heading + the resume command.
|
|
66
|
+
branch_advanced: Whether an EARLIER round in this run already
|
|
67
|
+
fast-forwarded the branch. Passed in from the loop's cumulative
|
|
68
|
+
state rather than assumed: a multi-round run can reach this
|
|
69
|
+
state after a prior producer round landed commits.
|
|
70
|
+
check_results: The mechanical-check results from this round, if any.
|
|
71
|
+
Failing blocking checks are listed in the document so the operator
|
|
72
|
+
knows that resuming addresses the producer question but the checks
|
|
73
|
+
will still rerun and must also pass.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Path of the written ``decision-needed.md``.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
FileNotFoundError: If ``run_dir`` does not exist.
|
|
80
|
+
"""
|
|
81
|
+
if not run_dir.is_dir():
|
|
82
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
83
|
+
|
|
84
|
+
_branch_note = (
|
|
85
|
+
"**Your branch was already advanced** by an earlier round in this "
|
|
86
|
+
"run — producer commits from that round are on it. Nothing was "
|
|
87
|
+
"advanced for THIS round."
|
|
88
|
+
if branch_advanced
|
|
89
|
+
else "No branch was advanced."
|
|
90
|
+
)
|
|
91
|
+
lines: list[str] = [
|
|
92
|
+
f"# Decision needed — Syncade run {run_id}",
|
|
93
|
+
"",
|
|
94
|
+
f"The producer escalated a finding in round {round_idx} that it "
|
|
95
|
+
"determined is an **operator decision** — a spec/design conflict it "
|
|
96
|
+
"cannot resolve in code without a human ruling, not a defect it can "
|
|
97
|
+
f"fix. The loop checkpointed and terminated (exit 10); {_branch_note} "
|
|
98
|
+
"The mechanical verdict is unchanged (still NO-SHIP).",
|
|
99
|
+
"",
|
|
100
|
+
"## The finding",
|
|
101
|
+
"",
|
|
102
|
+
*_md_text_block_lines(escalation.finding),
|
|
103
|
+
"",
|
|
104
|
+
"## The decision you must make",
|
|
105
|
+
"",
|
|
106
|
+
*_md_text_block_lines(escalation.decision),
|
|
107
|
+
"",
|
|
108
|
+
"## Options",
|
|
109
|
+
"",
|
|
110
|
+
]
|
|
111
|
+
for idx, option in enumerate(escalation.options, start=1):
|
|
112
|
+
lines.extend([f"### Option {idx}", "", *_md_text_block_lines(option), ""])
|
|
113
|
+
lines += [
|
|
114
|
+
"## The producer's rationale (reproduction-backed)",
|
|
115
|
+
"",
|
|
116
|
+
*_md_text_block_lines(escalation.rationale),
|
|
117
|
+
"",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
failing_blocking_checks = [
|
|
121
|
+
c for c in (check_results or []) if c.severity == "blocking" and c.outcome != "passed"
|
|
122
|
+
]
|
|
123
|
+
if failing_blocking_checks:
|
|
124
|
+
lines += [
|
|
125
|
+
"## Co-failing blocking checks",
|
|
126
|
+
"",
|
|
127
|
+
"These blocking mechanical checks also failed this round. Resuming "
|
|
128
|
+
"addresses the producer decision above, but the checks rerun on "
|
|
129
|
+
"resume and must also pass before the round can SHIP:",
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
for c in failing_blocking_checks:
|
|
133
|
+
status = "FAIL" if c.outcome == "failed" else "ERROR"
|
|
134
|
+
lines.append(f"- **{c.name}**: {status} (exit {c.exit_code})")
|
|
135
|
+
if c.name:
|
|
136
|
+
lines.append(
|
|
137
|
+
f" Output: `{c.name}.check.stdout` / `{c.name}.check.stderr` "
|
|
138
|
+
f"in `round-{round_idx}/`"
|
|
139
|
+
)
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
lines += [
|
|
143
|
+
"## How to continue",
|
|
144
|
+
"",
|
|
145
|
+
f"1. Decide, then write your decision (the option you chose plus any "
|
|
146
|
+
f"specifics the producer needs) into `{OPERATOR_DECISION_FILENAME}` in "
|
|
147
|
+
"this run directory.",
|
|
148
|
+
f"2. Resume: `syncade --resume {run_id}`. The escalated round re-runs "
|
|
149
|
+
"with your decision fed to the producer. (Resume refuses if "
|
|
150
|
+
f"`{OPERATOR_DECISION_FILENAME}` is missing or empty.)",
|
|
151
|
+
"",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
path = run_dir / DECISION_NEEDED_FILENAME
|
|
155
|
+
atomic_write_text(path, "\n".join(lines))
|
|
156
|
+
return path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _md_text_block_lines(text: str) -> list[str]:
|
|
160
|
+
max_run = 0
|
|
161
|
+
current = 0
|
|
162
|
+
for ch in text:
|
|
163
|
+
if ch == "`":
|
|
164
|
+
current += 1
|
|
165
|
+
if current > max_run:
|
|
166
|
+
max_run = current
|
|
167
|
+
else:
|
|
168
|
+
current = 0
|
|
169
|
+
fence = "`" * max(4, max_run + 1)
|
|
170
|
+
return [f"{fence}text", text.rstrip("\n"), fence]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read_operator_decision(run_dir: Path) -> str | None:
|
|
174
|
+
"""Return the operator's recorded decision from
|
|
175
|
+
``<run_dir>/decision.txt``, or ``None`` when the file is absent or
|
|
176
|
+
blank (whitespace-only counts as absent — an empty file is not a
|
|
177
|
+
decision). Stripped of surrounding whitespace.
|
|
178
|
+
"""
|
|
179
|
+
decision_path = run_dir / OPERATOR_DECISION_FILENAME
|
|
180
|
+
if not decision_path.is_file():
|
|
181
|
+
return None
|
|
182
|
+
text = decision_path.read_text(encoding="utf-8").strip()
|
|
183
|
+
return text or None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def persist_deactivated_blockers_decision_needed(
|
|
187
|
+
run_dir: Path,
|
|
188
|
+
*,
|
|
189
|
+
round_idx: int,
|
|
190
|
+
run_id: str,
|
|
191
|
+
deactivated: list[tuple[str, str, str]],
|
|
192
|
+
branch_advanced: bool = False,
|
|
193
|
+
) -> Path:
|
|
194
|
+
"""Write ``decision-needed.md`` for a PR-h-01 increment-D escalation.
|
|
195
|
+
|
|
196
|
+
The other escalation in this module is the producer's: it argues a case and
|
|
197
|
+
asks the operator to choose. This one has no advocate. Two or more distinct
|
|
198
|
+
reviewers independently raised blockers, the synthesizer ruled every one of
|
|
199
|
+
them out, and nothing checked whether those rulings were right — so the
|
|
200
|
+
round is neither a defensible SHIP nor a mechanical NO-SHIP.
|
|
201
|
+
|
|
202
|
+
The document's whole job is to put the reviewers' own words in front of the
|
|
203
|
+
operator next to what the synthesizer did with them, because that
|
|
204
|
+
comparison is the decision. Quotes are verbatim: provenance text is
|
|
205
|
+
cross-checked against the source reviewer's finding
|
|
206
|
+
(``syncade.synthesizer.validation``), so what is printed here is what the
|
|
207
|
+
reviewer actually wrote.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
|
|
211
|
+
round_idx: The 0-indexed round that escalated.
|
|
212
|
+
run_id: The run-id, for the heading.
|
|
213
|
+
deactivated: ``(reviewer_name, verbatim_finding_text, disposition)``
|
|
214
|
+
per deactivated source blocker, where ``disposition`` describes how
|
|
215
|
+
the synthesizer removed it (e.g. ``dismissed: <rationale>``).
|
|
216
|
+
branch_advanced: Whether an EARLIER round in this run already
|
|
217
|
+
fast-forwarded the branch. Passed in rather than assumed: a
|
|
218
|
+
multi-round run can reach this state after a producer round
|
|
219
|
+
landed commits, and telling an operator their branch is untouched
|
|
220
|
+
when it is not is exactly the kind of wrong that gets acted on.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Path of the written ``decision-needed.md``.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
FileNotFoundError: If ``run_dir`` does not exist.
|
|
227
|
+
"""
|
|
228
|
+
if not run_dir.is_dir():
|
|
229
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
230
|
+
|
|
231
|
+
reviewers = sorted({name for name, _, _ in deactivated})
|
|
232
|
+
lines: list[str] = [
|
|
233
|
+
f"# Decision needed — Syncade run {run_id}",
|
|
234
|
+
"",
|
|
235
|
+
f"In round {round_idx}, **{len(reviewers)} independent reviewers "
|
|
236
|
+
f"({', '.join(reviewers)}) each raised at least one blocker, and the "
|
|
237
|
+
"synthesizer deactivated every one of them** — by dismissal, by "
|
|
238
|
+
"downgrade, or by splitting one concern into separate single-reviewer "
|
|
239
|
+
"findings it could then rule out individually.",
|
|
240
|
+
"",
|
|
241
|
+
(
|
|
242
|
+
"That may be entirely correct. But independent corroboration is the "
|
|
243
|
+
"strongest signal this tool produces, and a machine should not "
|
|
244
|
+
"discard all of it silently. The loop terminated at exit 10 rather "
|
|
245
|
+
"than reporting a SHIP it cannot justify."
|
|
246
|
+
),
|
|
247
|
+
"",
|
|
248
|
+
(
|
|
249
|
+
"**Your branch was already advanced** by an earlier round in this "
|
|
250
|
+
"run — producer commits from that round are on it. Nothing was "
|
|
251
|
+
"advanced for THIS round."
|
|
252
|
+
if branch_advanced
|
|
253
|
+
else "No branch was advanced."
|
|
254
|
+
),
|
|
255
|
+
"",
|
|
256
|
+
"## What each reviewer actually said",
|
|
257
|
+
"",
|
|
258
|
+
"Quoted verbatim from the reviewers' own output — not the synthesizer's restatement of it.",
|
|
259
|
+
"",
|
|
260
|
+
]
|
|
261
|
+
for idx, (reviewer, text, disposition) in enumerate(deactivated, start=1):
|
|
262
|
+
lines.extend(
|
|
263
|
+
[
|
|
264
|
+
f"### {idx}. {reviewer} — blocker",
|
|
265
|
+
"",
|
|
266
|
+
*_md_text_block_lines(text),
|
|
267
|
+
"",
|
|
268
|
+
f"**Synthesizer's disposition:** {disposition}",
|
|
269
|
+
"",
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
lines += [
|
|
273
|
+
"## How to continue",
|
|
274
|
+
"",
|
|
275
|
+
"Read the quotes above and decide whether the synthesizer was right.",
|
|
276
|
+
"",
|
|
277
|
+
"- **It was right** — the concerns really are false positives. This "
|
|
278
|
+
"round is a SHIP; nothing is blocking you.",
|
|
279
|
+
"- **It was wrong about any of them** — that concern is real and "
|
|
280
|
+
"unfixed. Address it and re-run.",
|
|
281
|
+
"",
|
|
282
|
+
"The full consolidated view, including each dismissal rationale in "
|
|
283
|
+
f"context, is in `round-{round_idx}/findings.md`.",
|
|
284
|
+
"",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
path = run_dir / DECISION_NEEDED_FILENAME
|
|
288
|
+
atomic_write_text(path, "\n".join(lines))
|
|
289
|
+
return path
|