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,90 @@
1
+ """Refuse unusable write targets BEFORE auto-init mutates the operator's directory.
2
+
3
+ Both directories checked here are created later — ``worktree_base`` by the loop's worktree
4
+ provisioning, ``.syncade/runs/`` by the run-dir mkdir. Left alone, an unusable one surfaces
5
+ as a ``WorktreeError``/exit-60 or an uncaught ``PermissionError`` *after* the baseline commit,
6
+ so a refused run would have created a repository on its way to failing. Checking here keeps
7
+ that refusal free.
8
+
9
+ Split out of ``cli/__init__.py``: it is one self-contained concern (*can syncade write where
10
+ it is about to write?*) with one output, and the dogfood panel called out `_run` for holding
11
+ validation, mutation, dispatch and cleanup at once.
12
+
13
+ ``exists()`` follows symlinks and reports a DANGLING link as absent, which would read as
14
+ "absent but creatable" and let a broken link silently become a creation target. Every check
15
+ below tests ``is_symlink()`` first for that reason.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from syncade.exit_codes import CONFIG_ERROR, WORKTREE_ERROR
25
+
26
+
27
+ def _unusable(path: Path, *, need_read: bool = False) -> str | None:
28
+ """Why ``path`` cannot be written to, as a message SUFFIX, or ``None`` if it can.
29
+
30
+ The suffix carries its own separator so callers concatenate rather than choosing one.
31
+ """
32
+ if path.is_symlink() and not path.exists():
33
+ return " is a dangling symlink"
34
+ if path.exists() and not path.is_dir():
35
+ return " exists but is not a directory"
36
+ if path.is_dir():
37
+ if need_read and not os.access(path, os.R_OK | os.W_OK | os.X_OK):
38
+ return ": directory is not readable and writable"
39
+ if not need_read and not os.access(path, os.W_OK | os.X_OK):
40
+ return ": directory is not writable"
41
+ return None
42
+
43
+
44
+ def check_write_targets(worktree_base: Path, repo_root: Path) -> int | None:
45
+ """Return an exit code if a write target is unusable, else ``None``.
46
+
47
+ ``worktree_base`` problems are CONFIG errors (the operator configured the path);
48
+ ``.syncade`` problems are environment errors, matching where each value comes from.
49
+ """
50
+ reason = _unusable(worktree_base)
51
+ if reason is not None:
52
+ print(
53
+ f"[syncade] config error: worktree-base {str(worktree_base)!r}{reason}",
54
+ file=sys.stderr,
55
+ )
56
+ return CONFIG_ERROR
57
+
58
+ # worktree_base is created on demand, so an absent one shifts the question to its parent.
59
+ if not worktree_base.exists():
60
+ parent = worktree_base.parent
61
+ if not parent.exists():
62
+ print(
63
+ f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
64
+ f"parent directory {str(parent)!r} does not exist",
65
+ file=sys.stderr,
66
+ )
67
+ return CONFIG_ERROR
68
+ if not parent.is_dir():
69
+ print(
70
+ f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
71
+ f"parent {str(parent)!r} exists but is not a directory",
72
+ file=sys.stderr,
73
+ )
74
+ return CONFIG_ERROR
75
+ if not os.access(parent, os.W_OK | os.X_OK):
76
+ print(
77
+ f"[syncade] config error: worktree-base {str(worktree_base)!r}: "
78
+ f"parent directory {str(parent)!r} is not writable",
79
+ file=sys.stderr,
80
+ )
81
+ return CONFIG_ERROR
82
+
83
+ syncade_dir = repo_root / ".syncade"
84
+ # runs/ needs READ too: resume and the run-id collision loop enumerate it.
85
+ for path, need_read in ((syncade_dir, False), (syncade_dir / "runs", True)):
86
+ reason = _unusable(path, need_read=need_read)
87
+ if reason is not None:
88
+ print(f"[syncade] error: {str(path)!r}{reason}", file=sys.stderr)
89
+ return WORKTREE_ERROR
90
+ return None
syncade/cli/resolve.py ADDED
@@ -0,0 +1,116 @@
1
+ """Diff-base / spec-source resolution helpers for the CLI.
2
+
3
+ These turn a ``--scope`` token or an ``--openspec`` request into the concrete
4
+ inputs the loop consumes, printing an operator-facing message and returning
5
+ ``None`` when the request can't be resolved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+
14
+ from syncade.logging import Logger
15
+
16
+
17
+ def _current_branch(repo_root: Path) -> str | None:
18
+ """The current branch name via ``git rev-parse --abbrev-ref HEAD``, or
19
+ ``None`` for detached HEAD / failure (used to key the last-reviewed lookup)."""
20
+ from syncade.process import run_subprocess
21
+
22
+ try:
23
+ result = run_subprocess(
24
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root, timeout=10.0
25
+ )
26
+ except Exception:
27
+ return None
28
+ name = result.stdout.strip()
29
+ return name if result.returncode == 0 and name and name != "HEAD" else None
30
+
31
+
32
+ def _resolve_scope_base(repo_root: Path, scope: str, logger: Logger) -> str | None:
33
+ """Resolve a ``--scope`` token to a concrete base SHA, or ``None`` on
34
+ an unresolvable scope (a message is printed; the caller stops before the
35
+ loop). ``since-last-review`` reads the per-branch recorded SHA."""
36
+ from syncade.base_resolution import BaseResolutionError, resolve_scope
37
+ from syncade.persistence import read_last_reviewed
38
+
39
+ branch = _current_branch(repo_root)
40
+ last = read_last_reviewed(repo_root, branch) if branch else None
41
+ try:
42
+ resolved = resolve_scope(repo_root, scope, last_reviewed_sha=last)
43
+ except BaseResolutionError as exc:
44
+ print(f"[syncade] scope error: {exc}", file=sys.stderr)
45
+ return None
46
+ if resolved.note:
47
+ # Scope fallback notes are operator-facing invariants that must surface
48
+ # even in --quiet mode; bypass logger.warning (suppressed when quiet).
49
+ print(f"[syncade] scope: {resolved.note}", file=sys.stderr)
50
+ return resolved.base_sha
51
+
52
+
53
+ def _cli_proves_commit(
54
+ repo_root: Path,
55
+ base_ref: str | None,
56
+ strip_files: Iterable[str],
57
+ *,
58
+ two_dot: bool = False,
59
+ ) -> bool:
60
+ """True ONLY when the CLI can prove this run will produce reviewable changes.
61
+
62
+ The pre-auth commit guard exists so a doomed committing run does not first pay for a
63
+ provider auth probe. Whether a run commits depends on the FILTERED diff, which the CLI
64
+ cannot compute — it has not snapshotted. Four earlier attempts asked the opposite
65
+ question ("can I prove there is NO change?") and each was wrong for a different input,
66
+ because being wrong there REFUSES A VALID RUN (PR-h-02d.5).
67
+
68
+ So the question is inverted and the answer is conservative: return True only when
69
+ certain, and let ``run_review`` decide otherwise. A wrong answer here costs one auth
70
+ probe; it can never refuse a run the library would accept.
71
+
72
+ Certain case: no base — reviewers see full HEAD, non-empty in any repo with a commit.
73
+
74
+ Everything else (any diff-shaping base) defers. ``git diff --name-only`` cannot
75
+ replicate the authoritative classifier: it C-quotes non-ASCII paths (so strip-list
76
+ basenames do not match the quoted form), and paths containing `` b/`` produce ambiguous
77
+ ``diff --git`` headers that ``run_review`` classifies as ``diff_malformed`` with zero
78
+ dispatches. Both shapes produce false proofs of commit under the basename approach,
79
+ causing the CLI to refuse runs the library accepts.
80
+ """
81
+ return base_ref is None
82
+
83
+
84
+ def _resolve_openspec_pr_doc(repo_root: Path, change_id: str | None, logger: Logger) -> Path | None:
85
+ """Resolve a ``--openspec`` request to a concrete ``pr_doc_path``, or
86
+ ``None`` on an unresolvable proposal (a message is printed; the caller stops
87
+ before the loop). Reads the OpenSpec proposal folder's markdown directly
88
+ (consume-don't-depend — no ``openspec`` binary), assembles it into a spec, and
89
+ writes it to a staging ``.md`` tempfile. The CLI asks ``run_review`` to copy
90
+ that staged file into the run directory before ``run-init.json`` is written,
91
+ then removes the staging tempfile after the run."""
92
+ import tempfile
93
+
94
+ from syncade.spec_source import (
95
+ SpecSourceError,
96
+ assemble_openspec_spec,
97
+ resolve_openspec_change,
98
+ )
99
+
100
+ try:
101
+ resolved_id = resolve_openspec_change(repo_root, change_id)
102
+ spec = assemble_openspec_spec(repo_root, resolved_id)
103
+ except SpecSourceError as exc:
104
+ print(f"[syncade] openspec error: {exc}", file=sys.stderr)
105
+ return None
106
+ tmp = tempfile.NamedTemporaryFile(
107
+ mode="w",
108
+ suffix=".md",
109
+ prefix=f"syncade-openspec-{resolved_id}-",
110
+ delete=False,
111
+ encoding="utf-8",
112
+ )
113
+ tmp.write(spec)
114
+ tmp.close()
115
+ logger.warning(f"openspec: reviewing proposal {resolved_id!r} (assembled spec)")
116
+ return Path(tmp.name)
@@ -0,0 +1,324 @@
1
+ """The ``syncade --resume <run-id>`` handler.
2
+
3
+ Lives in its own module to keep :mod:`syncade.cli.modes` under the file-length
4
+ cap: ``--resume`` is the largest one-shot handler (it rebuilds a ResumePlan from
5
+ on-disk artifacts and re-enters the full review loop), and piling it alongside
6
+ the other mode dispatchers is exactly what makes an entry-point policy easy to
7
+ miss. ``modes`` re-exports :func:`_run_resume` so ``main()``'s dispatch is
8
+ unchanged.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from syncade.cli.auth_gate import auth_gate
19
+ from syncade.cli.config_overrides import apply_worktree_base_override
20
+ from syncade.config_auth import REVIEW_BLOCKS
21
+ from syncade.config_loader import ConfigError, load_config
22
+ from syncade.exit_codes import CONFIG_ERROR, WORKTREE_ERROR
23
+ from syncade.logging import Logger
24
+ from syncade.snapshot import SnapshotError
25
+ from syncade.worktree import WorktreeError
26
+
27
+
28
+ def _original_budget(config_snapshot_path) -> dict:
29
+ """The ORIGINAL run's configured budget, read from its run-init.json config snapshot.
30
+
31
+ Returns ``{'budget_tokens': ..., 'budget_usd': ...}`` with ONLY the dimensions the original
32
+ run actually set (a CLI ``--budget-*`` was folded into that snapshot at launch). ``{}`` on a
33
+ missing path, any read/parse failure, or a malformed value — budget inheritance is best-effort
34
+ and must never block a resume or bypass LoopConfig validators."""
35
+ if config_snapshot_path is None:
36
+ return {}
37
+ try:
38
+ data = json.loads(Path(config_snapshot_path).read_text(encoding="utf-8"))
39
+ loop = data.get("config_snapshot", {}).get("loop", {}) or {}
40
+ result = {}
41
+ for k in ("budget_tokens", "budget_usd"):
42
+ v = loop.get(k)
43
+ if v is None:
44
+ continue
45
+ if isinstance(v, bool):
46
+ continue
47
+ # budget_tokens requires a plain int (LoopConfig._strict_budget_tokens rejects
48
+ # floats — including 0.0, NaN, and Infinity — so inheriting them via model_copy
49
+ # would bypass the schema validator). budget_usd allows int/float but must be
50
+ # finite and non-negative (LoopConfig._budget_usd_isfinite rejects NaN/Inf).
51
+ if k == "budget_tokens":
52
+ if not isinstance(v, int) or v < 0:
53
+ continue
54
+ else:
55
+ if not isinstance(v, (int, float)) or not math.isfinite(v) or v < 0:
56
+ continue
57
+ result[k] = v
58
+ return result
59
+ except (OSError, ValueError, TypeError, AttributeError):
60
+ # AttributeError covers a malformed config_snapshot/loop that isn't a dict.
61
+ return {}
62
+
63
+
64
+ def _resolve_resume_budget(config, args, plan):
65
+ """Resolve the resumed run's budget and announce it (PR-v2-11).
66
+
67
+ Precedence PER DIMENSION: CLI ``--budget-*`` > current ``config.toml`` > the ORIGINAL run's
68
+ ceiling > none. Inheriting the original ceiling is Finding 2's fix: a run launched with a
69
+ CLI-only ``--budget-tokens`` must NOT silently resume with no guardrail just because resume
70
+ reloads config from disk. Only the CEILING is inherited — the TALLY is still fresh (bounds
71
+ only the resumed spend). The notice fires even under ``--quiet`` (a spend-authorization fact,
72
+ like the resolved auth mode) so the operator is never surprised by the resumed budget."""
73
+ cli = {
74
+ field: value
75
+ for field, value in (
76
+ ("budget_tokens", args.budget_tokens),
77
+ ("budget_usd", args.budget_usd),
78
+ )
79
+ if value is not None
80
+ }
81
+ if cli:
82
+ config = config.model_copy(update={"loop": config.loop.model_copy(update=cli)})
83
+
84
+ # Inherit when the reloaded config did not EXPLICITLY set this dimension. Testing for
85
+ # None was equivalent until budget_tokens gained a default (PR-h-field-06) — after which
86
+ # it is never None, so inheritance silently stopped firing and a run launched at 3,500
87
+ # tokens resumed at the 50,000,000 default. Not unguarded, but guarded 14,000x looser,
88
+ # which is the same surprise Finding 2 exists to prevent. `model_fields_set` is the
89
+ # distinction the layered loader preserves: an omitted key is absent from it, an
90
+ # explicitly-configured one is present, whatever its value.
91
+ original = _original_budget(plan.config_snapshot_path)
92
+ inherited = {
93
+ dim: original[dim]
94
+ for dim in ("budget_tokens", "budget_usd")
95
+ if dim not in config.loop.model_fields_set and original.get(dim) is not None
96
+ }
97
+ if inherited:
98
+ config = config.model_copy(update={"loop": config.loop.model_copy(update=inherited)})
99
+
100
+ if config.loop.budget_tokens or config.loop.budget_usd: # 0 sentinels = no active ceiling
101
+ msg = (
102
+ "[syncade] note: --resume applies a FRESH budget to this run's spend; the original "
103
+ "run's tokens/cost are NOT carried over, so original + resume can exceed a single "
104
+ "budget ceiling (resuming re-authorizes the spend)."
105
+ )
106
+ if inherited:
107
+ names = ", ".join(f"{k}={v}" for k, v in inherited.items())
108
+ msg += (
109
+ f" No budget was set for this resume, so the original run's ceiling was "
110
+ f"inherited ({names}); pass --budget-tokens/--budget-usd to override."
111
+ )
112
+ print(msg, file=sys.stderr)
113
+ return config
114
+
115
+
116
+ def _report_hard_kill(run_dir: Path, run_status) -> None:
117
+ """Say so when the run being resumed was HARD-KILLED (PR-h-field-02).
118
+
119
+ ``run_status.is_stale_running`` has existed since the breadcrumb landed and, until now,
120
+ nothing in the product called it — a ``running`` state against a dead pid was detectable and
121
+ never reported. Three field runs died exactly that way, and the operator's only clue was a
122
+ status file still claiming the run was in progress.
123
+
124
+ Nothing in-process can finalize that file after SIGKILL, by definition. What CAN be fixed is
125
+ the silence: resume is where someone goes after a run vanishes, so resume is where it should
126
+ be told what it is looking at.
127
+ """
128
+ try:
129
+ status = json.loads((run_dir / "status.json").read_text(encoding="utf-8"))
130
+ except (OSError, ValueError):
131
+ return
132
+ if not run_status.is_stale_running(status):
133
+ return
134
+ phase = status.get("phase") or "an unrecorded phase"
135
+ print(
136
+ f"[syncade] the run you are resuming was HARD-KILLED during '{phase}' — its status file "
137
+ f"still reads 'running' because nothing in the process survived to finalize it "
138
+ f"(SIGKILL, OOM, or the machine going down). Completed rounds are intact and will be "
139
+ f"reused; the interrupted round is dropped and retried.",
140
+ file=sys.stderr,
141
+ )
142
+
143
+
144
+ def _run_resume(args) -> int:
145
+ """Dispatch ``syncade --resume <run-id>``.
146
+
147
+ Resolves the target run-id (specific or 'latest'), builds a
148
+ :class:`ResumePlan` from on-disk artifacts, and invokes
149
+ :func:`run_review` with the resume_plan threaded in. Maps the
150
+ resume-specific exceptions to exit codes:
151
+
152
+ - Config load failure → 50 (CONFIG_ERROR).
153
+ - repo_root discovery failure → 60 (WORKTREE_ERROR).
154
+ - :class:`ResumeError` (run-id not found / not eligible /
155
+ malformed) → 60.
156
+ - :class:`TreeDriftError` (drift without --force-drift) → 60.
157
+ - Any other orchestrator exception bubbles up to ``main()``
158
+ where it gets the same mapping as the fresh-run path.
159
+
160
+ The actual loop runs through the existing
161
+ :func:`syncade.orchestrator.run_review` with
162
+ ``resume_plan=plan, force_drift=args.force_drift``; its
163
+ exit code is the resumed run's verdict.
164
+ """
165
+ # Import the resume helpers lazily — operators not running
166
+ # --resume don't need to pay the import cost.
167
+ from syncade import run_status
168
+ from syncade.orchestrator import run_review
169
+ from syncade.orchestrator.resume import (
170
+ ResumeError,
171
+ plan_resume,
172
+ read_resume_decision,
173
+ resolve_resume_target,
174
+ )
175
+ from syncade.snapshot import discover_repo_root
176
+
177
+ # Discover the repo root for runs_root resolution and for any
178
+ # downstream code that wants to know "what tree are we in".
179
+ repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
180
+ try:
181
+ repo_root = discover_repo_root(repo_root_hint)
182
+ except SnapshotError as exc:
183
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
184
+ return WORKTREE_ERROR
185
+
186
+ def _emit_deprecation(message: str) -> None:
187
+ print(f"[syncade] {message}", file=sys.stderr)
188
+
189
+ try:
190
+ config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
191
+ except ConfigError as exc:
192
+ print(f"[syncade] config error: {exc}", file=sys.stderr)
193
+ return CONFIG_ERROR
194
+
195
+ # --max-rounds overrides the original run's cap (same model_copy as the fresh handler).
196
+ if args.max_rounds is not None:
197
+ new_loop = config.loop.model_copy(update={"max_rounds": args.max_rounds})
198
+ config = config.model_copy(update={"loop": new_loop})
199
+
200
+ runs_root = repo_root / ".syncade" / "runs"
201
+
202
+ # Read the current branch to filter "latest" by branch (detached HEAD → None).
203
+ current_branch: str | None = None
204
+ try:
205
+ from syncade.orchestrator.resume import _current_head_branch
206
+
207
+ current_branch = _current_head_branch(repo_root)
208
+ except Exception:
209
+ # Best-effort; a git failure falls back to no branch filter (drift check catches it).
210
+ current_branch = None
211
+
212
+ # Resolve the resume target + plan BEFORE the guard and auth_gate: the guard needs the
213
+ # EFFECTIVE cap the resumed run will use (max(config, plan.max_rounds)), and a resume
214
+ # error should surface without spawning a provider auth probe.
215
+ try:
216
+ run_id = resolve_resume_target(runs_root, args.resume, current_branch)
217
+ plan = plan_resume(
218
+ repo_root,
219
+ runs_root / run_id,
220
+ max_rounds_override=config.loop.max_rounds if args.max_rounds is not None else None,
221
+ )
222
+ # If the resumed round escalated, read the operator's decision (refuses with
223
+ # ResumeError when none was recorded). Read BEFORE run_review drops the round dir.
224
+ operator_decision = read_resume_decision(runs_root / run_id, plan.resumed_round)
225
+ _report_hard_kill(runs_root / run_id, run_status)
226
+ except ResumeError as exc:
227
+ print(f"[syncade] resume error: {exc}", file=sys.stderr)
228
+ return WORKTREE_ERROR
229
+
230
+ config = _resolve_resume_budget(config, args, plan)
231
+ # Honor the configured/overridden worktree base on resume too (dogfood B2): without this a
232
+ # resumed run silently falls back to /tmp/syncade even when the run relocated its worktrees.
233
+ config = apply_worktree_base_override(config, args)
234
+
235
+ # Default-branch refusal (PR-v2-26, D1(c)). will_commit uses the EFFECTIVE cap
236
+ # run_review will use — a single-pass resume (effective == 1) commits nothing and is
237
+ # exempt; a multi-round resume with a base defers to run_review (auth probe first);
238
+ # only a baseless multi-round resume is refused here before auth. Deriving it from
239
+ # the current config/CLI max_rounds alone would be wrong: a run launched at max_rounds=3
240
+ # but resumed with the config drifted to 1 still commits (effective == 3).
241
+ from syncade.orchestrator.branch_guard import current_branch_name, guard_default_branch
242
+
243
+ effective_max_rounds = max(config.loop.max_rounds, plan.max_rounds)
244
+ # Refuse ONLY when we can PROVE the resumed run commits (D1(c), PR-h-02d.5) — the same
245
+ # inversion as the fresh path. Literal `base_oid == HEAD` equality was too narrow: a
246
+ # resumed run whose pinned base is NOT HEAD but whose diff filters empty takes the
247
+ # no_changes_to_review path, and refusing it before auth diverged from run_review.
248
+ #
249
+ # `two_dot=True` is correct and NOT the merge-base hazard the previous comment warned
250
+ # about: plan.base_oid is the ALREADY-RESOLVED effective diff base, and a resumed run
251
+ # re-snapshots with three_dot=False against it (loop.py). So the literal `base..HEAD`
252
+ # range is exactly what run_review will diff — re-deriving a merge base here is what
253
+ # would be wrong.
254
+ from .resolve import _cli_proves_commit
255
+
256
+ _resume_will_commit = effective_max_rounds > 1 and _cli_proves_commit(
257
+ repo_root, plan.base_oid, config.review.strip_repo_context_files, two_dot=True
258
+ )
259
+ try:
260
+ guard_default_branch(
261
+ repo_root,
262
+ current_branch_name(repo_root),
263
+ allow=args.allow_default_branch,
264
+ will_commit=_resume_will_commit,
265
+ )
266
+ except WorktreeError as exc:
267
+ print(f"[syncade] worktree error: {exc}", file=sys.stderr)
268
+ return WORKTREE_ERROR
269
+
270
+ gate = auth_gate(config, REVIEW_BLOCKS)
271
+ if gate is not None:
272
+ return gate
273
+
274
+ logger = Logger("quiet" if args.quiet else "normal")
275
+ if not args.quiet:
276
+ print(
277
+ f"[syncade] resuming run {plan.run_id} at round "
278
+ f"{plan.resumed_round} (completed: {plan.completed_rounds}; "
279
+ f"expected snapshot SHA: {plan.expected_sha[:12]})"
280
+ )
281
+
282
+ with run_status.install_signal_handlers():
283
+ try:
284
+ result = run_review(
285
+ repo_root=repo_root,
286
+ pr_doc_path=plan.pr_doc_path,
287
+ config=config,
288
+ base_ref=plan.base_oid if plan.base_oid is not None else plan.base_ref,
289
+ timeout_seconds=args.timeout,
290
+ logger=logger,
291
+ force_dirty=args.force_dirty,
292
+ allow_default_branch=args.allow_default_branch,
293
+ resume_plan=plan,
294
+ force_drift=args.force_drift,
295
+ operator_decision=operator_decision,
296
+ worktree_base=config.worktree_base,
297
+ )
298
+ except FileNotFoundError as exc:
299
+ print(f"[syncade] error: {exc}", file=sys.stderr)
300
+ return 2
301
+ except NotADirectoryError as exc:
302
+ print(f"[syncade] error: {exc}", file=sys.stderr)
303
+ return 2
304
+ except SnapshotError as exc:
305
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
306
+ # These typed handlers intercept before the catch-all, so each finalizes
307
+ # the breadcrumb itself — else a clean exit-60 leaves status.json `running`
308
+ # and reads as a hard kill.
309
+ run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
310
+ return WORKTREE_ERROR
311
+ except ResumeError as exc:
312
+ print(f"[syncade] resume error: {exc}", file=sys.stderr)
313
+ run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
314
+ return WORKTREE_ERROR
315
+ except WorktreeError as exc:
316
+ print(f"[syncade] worktree error: {exc}", file=sys.stderr)
317
+ run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
318
+ return WORKTREE_ERROR
319
+ except KeyboardInterrupt:
320
+ return run_status.finalize_signal()
321
+ except Exception as exc:
322
+ run_status.finalize_active(f"exception:{type(exc).__name__}", None)
323
+ raise
324
+ return result.exit_code