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
syncade/cli/__init__.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Argparse-based CLI entry point for the syncade orchestrator.
|
|
2
|
+
|
|
3
|
+
The parser, one-shot mode handlers, and diff-base/spec-source resolvers live in
|
|
4
|
+
the package siblings. The main review path (``_run``) and ``main`` stay here so
|
|
5
|
+
the ``run_review`` import lives in the same namespace ``_run`` reads it from —
|
|
6
|
+
that keeps the ``syncade.cli.run_review`` monkeypatch working without any
|
|
7
|
+
rebind. Public CLI helpers are re-exported here so supported ``syncade.cli.X``
|
|
8
|
+
import paths remain stable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from syncade import run_status
|
|
17
|
+
from syncade.cli.auth_gate import auth_gate
|
|
18
|
+
from syncade.config_auth import REVIEW_BLOCKS
|
|
19
|
+
from syncade.config_loader import ConfigError, load_config
|
|
20
|
+
from syncade.exit_codes import (
|
|
21
|
+
CLI_USAGE_ERROR,
|
|
22
|
+
CONFIG_ERROR,
|
|
23
|
+
WORKTREE_ERROR,
|
|
24
|
+
)
|
|
25
|
+
from syncade.git_preconditions import (
|
|
26
|
+
AutoInitRefusedError,
|
|
27
|
+
GitUnavailableError,
|
|
28
|
+
ensure_repo_initialized,
|
|
29
|
+
undo_auto_init,
|
|
30
|
+
)
|
|
31
|
+
from syncade.logging import Logger
|
|
32
|
+
from syncade.orchestrator import run_review
|
|
33
|
+
from syncade.orchestrator.branch_guard import current_branch_name, guard_default_branch
|
|
34
|
+
from syncade.process import SubprocessError
|
|
35
|
+
from syncade.run_inputs import validate_run_inputs
|
|
36
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
37
|
+
from syncade.worktree import WorktreeError
|
|
38
|
+
|
|
39
|
+
from .config_overrides import OverrideError, apply_cli_overrides
|
|
40
|
+
from .modes import (
|
|
41
|
+
_DRAFT_DIALOGUE_SOFT_CAP,
|
|
42
|
+
_extract_config_operands,
|
|
43
|
+
_run_auth_check,
|
|
44
|
+
_run_doctor,
|
|
45
|
+
_run_draft_spec,
|
|
46
|
+
_run_gc,
|
|
47
|
+
_run_metrics,
|
|
48
|
+
_run_resume,
|
|
49
|
+
_run_selfcheck,
|
|
50
|
+
_run_spec_audit,
|
|
51
|
+
)
|
|
52
|
+
from .parser import _max_rounds, _positive_float, build_parser
|
|
53
|
+
from .paths import resolve_repo_relative_input_path
|
|
54
|
+
from .preflight_paths import check_write_targets
|
|
55
|
+
from .resolve import (
|
|
56
|
+
_cli_proves_commit,
|
|
57
|
+
_current_branch,
|
|
58
|
+
_resolve_openspec_pr_doc,
|
|
59
|
+
_resolve_scope_base,
|
|
60
|
+
)
|
|
61
|
+
from .validate import validate_command_shape
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"main",
|
|
65
|
+
"build_parser",
|
|
66
|
+
"run_review",
|
|
67
|
+
"_run",
|
|
68
|
+
"_positive_float",
|
|
69
|
+
"_max_rounds",
|
|
70
|
+
"_current_branch",
|
|
71
|
+
"_resolve_scope_base",
|
|
72
|
+
"_resolve_openspec_pr_doc",
|
|
73
|
+
"_run_selfcheck",
|
|
74
|
+
"_run_auth_check",
|
|
75
|
+
"_run_doctor",
|
|
76
|
+
"_run_spec_audit",
|
|
77
|
+
"_run_draft_spec",
|
|
78
|
+
"_run_resume",
|
|
79
|
+
"_run_gc",
|
|
80
|
+
"_run_metrics",
|
|
81
|
+
"_DRAFT_DIALOGUE_SOFT_CAP",
|
|
82
|
+
"resolve_repo_relative_input_path",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run(args, repo_root: Path) -> int:
|
|
87
|
+
"""Execute a ``syncade <PR_DOC>`` invocation.
|
|
88
|
+
|
|
89
|
+
Wraps :func:`syncade.orchestrator.run_review` with CLI-friendly error
|
|
90
|
+
handling; expected exceptions map to exit codes from
|
|
91
|
+
:mod:`syncade.exit_codes` with a single-line stderr message.
|
|
92
|
+
|
|
93
|
+
``repo_root`` arrives as the user-supplied *hint* (cwd or
|
|
94
|
+
``--repo-root``). ``_run`` resolves it to the actual git repo root
|
|
95
|
+
via :func:`~syncade.snapshot.discover_repo_root`. Config is loaded
|
|
96
|
+
from the discovered root before any mutation so that a bad config
|
|
97
|
+
or bad CLI override is caught before ``ensure_repo_initialized``
|
|
98
|
+
creates ``.git`` and a baseline commit. A hint that isn't inside a
|
|
99
|
+
git repo is initialized with a baseline commit and the run proceeds.
|
|
100
|
+
Only a missing ``git`` *binary* fails here with exit 60.
|
|
101
|
+
|
|
102
|
+
The end-of-run summary is printed by ``run_review`` itself via the
|
|
103
|
+
:class:`~syncade.logging.Logger` constructed here from ``--quiet``; the CLI
|
|
104
|
+
no longer formats its own summary line.
|
|
105
|
+
"""
|
|
106
|
+
# Reset the process-global run-state before anything reads it. One review per process is
|
|
107
|
+
# the normal case, but tests (and any library caller) invoke main() repeatedly, and
|
|
108
|
+
# `began()` below is deliberately sticky — without this, a previous invocation's run makes
|
|
109
|
+
# this one look like it took ownership.
|
|
110
|
+
run_status.clear_active()
|
|
111
|
+
|
|
112
|
+
# A repo syncade itself auto-initializes (a fresh non-git dir) is EXEMPT from the
|
|
113
|
+
# default-branch guard below: there is no pre-existing integration branch to protect and
|
|
114
|
+
# the operator explicitly ran syncade here. Detect it BEFORE ensure_repo_initialized
|
|
115
|
+
# creates the repo.
|
|
116
|
+
#
|
|
117
|
+
# Capture the discovered root here: the pre-flight below needs it for correct path
|
|
118
|
+
# resolution when --repo-root is a subdirectory (PR-h-04 finding).
|
|
119
|
+
try:
|
|
120
|
+
_discovered_root = discover_repo_root(repo_root)
|
|
121
|
+
repo_preexisted = True
|
|
122
|
+
except SnapshotError:
|
|
123
|
+
_discovered_root = None
|
|
124
|
+
repo_preexisted = False
|
|
125
|
+
|
|
126
|
+
# VALIDATE BEFORE MUTATE (PR-h-04 item A). Everything below this point can change the
|
|
127
|
+
# operator's directory — `ensure_repo_initialized` creates a repo and a baseline commit,
|
|
128
|
+
# and `guard_default_branch` further down refuses with a message about BRANCHES. A
|
|
129
|
+
# mistyped brief path therefore either left a repo behind (fresh dir) or was reported as
|
|
130
|
+
# a default-branch problem (existing repo), when the real mistake was a filename.
|
|
131
|
+
#
|
|
132
|
+
# `validate_run_inputs` is the SAME function `run_review` calls, so this pre-flight
|
|
133
|
+
# cannot refuse something the library would accept. `--openspec` is validated HERE too:
|
|
134
|
+
# resolving the proposal folder before mutation ensures a bad change-id is caught before
|
|
135
|
+
# auto-init creates .git and a baseline commit.
|
|
136
|
+
#
|
|
137
|
+
# Use the discovered root for path resolution when the repo already exists: a
|
|
138
|
+
# --repo-root hint that is a subdirectory of the real root must not reject PR docs
|
|
139
|
+
# that exist at the repo root. Fall back to the hint for the auto-init case (no root yet).
|
|
140
|
+
|
|
141
|
+
# NOTE: an unresolvable `--base` needs no pre-flight here. It used to: a ~45-line
|
|
142
|
+
# allowlist of the refs auto-init creates, plus a loop normalizing `@{0}`, `^{commit}`,
|
|
143
|
+
# `^{}`, `^0` and `~0` suffixes. That allowlist was revised FIVE times across two dogfoods
|
|
144
|
+
# and was still rejecting spellings git would resolve, because enumerating valid revision
|
|
145
|
+
# syntax does not terminate. `take_snapshot` already resolves the ref authoritatively, and
|
|
146
|
+
# undo (see the finally below) makes reaching it harmless — so the enumeration is deleted
|
|
147
|
+
# rather than extended, and git remains the only authority on what a ref means.
|
|
148
|
+
|
|
149
|
+
# Logger only depends on --quiet; create it here so the OpenSpec preflight can use it,
|
|
150
|
+
# and so it is available before config is loaded.
|
|
151
|
+
logger = Logger("quiet" if args.quiet else "normal")
|
|
152
|
+
|
|
153
|
+
# Initialized before mutation so the outer try-finally can clean up on every exit path.
|
|
154
|
+
openspec_tmp_path: Path | None = None
|
|
155
|
+
pr_doc_artifact_name: str | None = None
|
|
156
|
+
# PR-h-04.6: was this directory EMPTY before syncade touched it? Captured before the
|
|
157
|
+
# first write, so a partial init cannot lose it and no filesystem race can invalidate it.
|
|
158
|
+
# If it was empty, everything present afterwards is syncade's and undo needs no ownership
|
|
159
|
+
# proof; if it was not, syncade does not undo (see --allow-auto-init's help).
|
|
160
|
+
_started_empty = False
|
|
161
|
+
# Undo is decided in ONE place — the `finally` at the bottom — from these two positional
|
|
162
|
+
# facts. Neither classifies anything, which is the point: naming which exceptions or exit
|
|
163
|
+
# codes mean "the run began" is the enumeration this PR exists to delete, and three
|
|
164
|
+
# successive guards each looked correct and each was wrong. `sys.exc_info()` read empty
|
|
165
|
+
# after a caught signal; `run_status.active()` reads None once `run_review` finalizes, so
|
|
166
|
+
# a real mid-run failure looked like a clean refusal; `exit_code != CONFIG_ERROR` called
|
|
167
|
+
# every refusal exit code review work.
|
|
168
|
+
_result = None # set IFF run_review returned — it is then the authority on what it did
|
|
169
|
+
_abnormal = False # set IFF a signal or an unexplained exception ended the run (L4, L6)
|
|
170
|
+
|
|
171
|
+
_preflight_root = _discovered_root if _discovered_root is not None else repo_root
|
|
172
|
+
|
|
173
|
+
# VALIDATE BEFORE MUTATE — config and CLI overrides (PR-h-04 item A, extended).
|
|
174
|
+
# load_config and apply_cli_overrides are PURE (no filesystem mutations); running them
|
|
175
|
+
# BEFORE the OpenSpec tempfile is created ensures a bad .syncade/config.toml or an
|
|
176
|
+
# unknown --reviewer-* name is caught before any tempfile or .git exists. An early
|
|
177
|
+
# config failure would otherwise return CONFIG_ERROR after the OpenSpec tempfile was
|
|
178
|
+
# written but before the outer try-finally's cleanup started, leaking private content
|
|
179
|
+
# in the system temp directory. Uses _preflight_root (discovered root when the repo
|
|
180
|
+
# already exists, raw hint otherwise) — the same anchor used for PR_DOC validation.
|
|
181
|
+
#
|
|
182
|
+
# deprecation warnings are emitted directly to stderr (not through `logger.warning`
|
|
183
|
+
# which is suppressed in quiet mode). Operators using --quiet still see the warning
|
|
184
|
+
# because deprecated config is actionable regardless of verbosity preference.
|
|
185
|
+
def _emit_deprecation(message: str) -> None:
|
|
186
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
config = load_config(
|
|
190
|
+
_preflight_root, preset=args.preset, deprecation_callback=_emit_deprecation
|
|
191
|
+
)
|
|
192
|
+
except ConfigError as exc:
|
|
193
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
194
|
+
return CONFIG_ERROR
|
|
195
|
+
|
|
196
|
+
# Apply CLI overrides before mutation — same reasoning: a bad --reviewer-model name
|
|
197
|
+
# or an invalid timeout must refuse before .git is created. apply_cli_overrides is
|
|
198
|
+
# pure (no I/O); CLI beats config.
|
|
199
|
+
try:
|
|
200
|
+
config = apply_cli_overrides(config, args)
|
|
201
|
+
except OverrideError as exc:
|
|
202
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
203
|
+
return CONFIG_ERROR
|
|
204
|
+
|
|
205
|
+
if args.openspec is not None:
|
|
206
|
+
# Resolve/validate the OpenSpec proposal BEFORE any mutation so that a bad
|
|
207
|
+
# change-id is caught here, not after ensure_repo_initialized runs.
|
|
208
|
+
# Config is loaded first so a bad config fails before the tempfile is created.
|
|
209
|
+
_early_path = _resolve_openspec_pr_doc(_preflight_root, args.openspec or None, logger)
|
|
210
|
+
if _early_path is None:
|
|
211
|
+
return WORKTREE_ERROR
|
|
212
|
+
openspec_tmp_path = _early_path
|
|
213
|
+
pr_doc_artifact_name = _early_path.name
|
|
214
|
+
else:
|
|
215
|
+
try:
|
|
216
|
+
_pr_doc_path = resolve_repo_relative_input_path(
|
|
217
|
+
args.pr_doc, repo_root=_preflight_root, label="PR_DOC"
|
|
218
|
+
)
|
|
219
|
+
validate_run_inputs(_preflight_root, _pr_doc_path)
|
|
220
|
+
except (FileNotFoundError, NotADirectoryError) as exc:
|
|
221
|
+
print(f"[syncade] error: {exc}", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
|
|
224
|
+
# Outer try-finally ensures openspec_tmp_path is cleaned up on every exit path —
|
|
225
|
+
# including error returns from ensure_repo_initialized and post-mutation setup that
|
|
226
|
+
# would otherwise leak the staged tempfile.
|
|
227
|
+
try:
|
|
228
|
+
# Auth reality check (PR-v2-24). Moved BEFORE ensure_repo_initialized so that an
|
|
229
|
+
# auth mismatch refuses without creating .git in a fresh directory. `codex` IGNORES
|
|
230
|
+
# OPENAI_API_KEY entirely — auth comes only from its stored login — so an
|
|
231
|
+
# `auth = "api"` declaration on a ChatGPT login cannot be enforced by anything
|
|
232
|
+
# syncade controls. Refusing here, before any mutation, keeps the fresh directory
|
|
233
|
+
# byte-inert on auth failure.
|
|
234
|
+
# The SAME gate every other entry point uses -- one function, not a policy each mode
|
|
235
|
+
# is trusted to remember. See cli/auth_gate.py for why that distinction matters.
|
|
236
|
+
gate = auth_gate(config, REVIEW_BLOCKS)
|
|
237
|
+
if gate is not None:
|
|
238
|
+
return gate
|
|
239
|
+
|
|
240
|
+
# Refuse unusable write targets before auto-init mutates anything. Both dirs are
|
|
241
|
+
# created later (worktree_base by provisioning, .syncade/runs by the run-dir mkdir),
|
|
242
|
+
# so without this a refused run would create a repository on its way to failing.
|
|
243
|
+
_bad_target = check_write_targets(config.worktree_base, _preflight_root)
|
|
244
|
+
if _bad_target is not None:
|
|
245
|
+
return _bad_target
|
|
246
|
+
|
|
247
|
+
# In a non-repo directory, initialize a conservative baseline repo so the
|
|
248
|
+
# snapshot below has a tree to work with. Diagnostic modes still use hard
|
|
249
|
+
# repo discovery and never mutate the caller's directory.
|
|
250
|
+
# Capture emptiness BEFORE the call: if `git init` fails halfway, the bit is already
|
|
251
|
+
# recorded and undo still removes the repository. A receipt written by the callee
|
|
252
|
+
# cannot do that — round 0 of the dogfood proved exactly this.
|
|
253
|
+
if not repo_preexisted:
|
|
254
|
+
try:
|
|
255
|
+
_started_empty = not any(repo_root.iterdir())
|
|
256
|
+
except OSError:
|
|
257
|
+
_started_empty = False
|
|
258
|
+
try:
|
|
259
|
+
ensure_repo_initialized(repo_root, allow_populated=args.allow_auto_init)
|
|
260
|
+
except AutoInitRefusedError as exc:
|
|
261
|
+
print(f"[syncade] {exc}", file=sys.stderr)
|
|
262
|
+
return WORKTREE_ERROR
|
|
263
|
+
except GitUnavailableError as exc:
|
|
264
|
+
print(f"[syncade] {exc}", file=sys.stderr)
|
|
265
|
+
return WORKTREE_ERROR
|
|
266
|
+
except SubprocessError as exc:
|
|
267
|
+
# A malformed repo_root hint (a path that does not exist or is a
|
|
268
|
+
# file — run_subprocess pre-validates cwd) or a genuine init /
|
|
269
|
+
# baseline-commit failure (read-only target dir, unresolvable git
|
|
270
|
+
# identity, ...) surfaces out of the precondition as a
|
|
271
|
+
# SubprocessError. These are environment/precondition failures →
|
|
272
|
+
# exit 60, mirroring the discover_repo_root/SnapshotError mapping
|
|
273
|
+
# just below.
|
|
274
|
+
print(f"[syncade] git precondition error: {exc}", file=sys.stderr)
|
|
275
|
+
return WORKTREE_ERROR
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
# Filesystem-level precondition failures that are neither a missing
|
|
278
|
+
# git binary nor a git subprocess error: an over-long --repo-root
|
|
279
|
+
# component (OSError(ENAMETOOLONG) from the path stat inside
|
|
280
|
+
# discover_repo_root), or a write that cannot complete (e.g. a
|
|
281
|
+
# read-only target dir when writing .git/info/exclude or the starter
|
|
282
|
+
# The .gitignore write can escape the precondition as a bare OSError; map
|
|
283
|
+
# them to the same environment/precondition exit 60 as above rather
|
|
284
|
+
# than letting them surface as an uncaught traceback (exit 1).
|
|
285
|
+
print(f"[syncade] git precondition error: {exc}", file=sys.stderr)
|
|
286
|
+
return WORKTREE_ERROR
|
|
287
|
+
|
|
288
|
+
# Resolve the hint to the real git repo root — the whole run must be
|
|
289
|
+
# anchored there, not under whatever subdirectory the user invoked from.
|
|
290
|
+
try:
|
|
291
|
+
repo_root = discover_repo_root(repo_root)
|
|
292
|
+
except SnapshotError as exc:
|
|
293
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
294
|
+
return WORKTREE_ERROR
|
|
295
|
+
|
|
296
|
+
# Default-branch guard (PR-v2-26). auth_gate already ran above; this guard runs after
|
|
297
|
+
# repo discovery so it can read the current branch name. Based/scoped runs defer
|
|
298
|
+
# (D1(c), PR-h-02d.5): run_review enforces the guard, so both paths refuse at exit 60.
|
|
299
|
+
# --max-rounds is already folded into config.loop.max_rounds by apply_cli_overrides above.
|
|
300
|
+
effective_rounds = config.loop.max_rounds
|
|
301
|
+
# A syncade-auto-created repo (fresh dir) is exempt — see repo_preexisted above.
|
|
302
|
+
allow_default = args.allow_default_branch or not repo_preexisted
|
|
303
|
+
|
|
304
|
+
# Resolve --scope BEFORE the guard: a scope that resolves to HEAD is a known no-change run
|
|
305
|
+
# (no producer fires), so the guard must not refuse it. --base and --scope are mutually
|
|
306
|
+
# exclusive; only one path runs.
|
|
307
|
+
base_ref = args.base
|
|
308
|
+
if args.scope is not None:
|
|
309
|
+
base_ref = _resolve_scope_base(repo_root, args.scope, logger)
|
|
310
|
+
if base_ref is None:
|
|
311
|
+
return WORKTREE_ERROR
|
|
312
|
+
|
|
313
|
+
# Refuse ONLY when the CLI can PROVE the run commits (D1(c), PR-h-02d.5).
|
|
314
|
+
#
|
|
315
|
+
# Whether a run commits depends on the FILTERED diff, which is not knowable here:
|
|
316
|
+
# the CLI has not snapshotted or resolved scope. (Config IS loaded, so
|
|
317
|
+
# `strip_repo_context_files` is available — but the filtered diff isn't.) Four
|
|
318
|
+
# earlier attempts substituted a cheaper predicate — base == HEAD, then merge-base,
|
|
319
|
+
# then merge-base plus `--two-dot` — and each was wrong for a different input, because
|
|
320
|
+
# the question simply is not expressible from what the CLI has.
|
|
321
|
+
#
|
|
322
|
+
# So the direction is inverted. With NO diff-shaping flag the reviewer diff is full
|
|
323
|
+
# HEAD, which is non-empty in any repo that has a commit, so a multi-round run will
|
|
324
|
+
# produce a producer commit — provable, and the common case this pre-auth guard exists
|
|
325
|
+
# for. With a base or a scope, defer: `run_review` classifies authoritatively at the
|
|
326
|
+
# run-entry choke, still BEFORE any reviewer/producer subprocess. The cost of deferring
|
|
327
|
+
# is one auth probe; the cost of guessing wrong was refusing valid no-change runs.
|
|
328
|
+
#
|
|
329
|
+
# `--two-dot` needs `--base`/`--scope` (enforced in validate), so it is covered.
|
|
330
|
+
_cli_will_commit = effective_rounds > 1 and _cli_proves_commit(
|
|
331
|
+
repo_root, base_ref, config.review.strip_repo_context_files, two_dot=args.two_dot
|
|
332
|
+
)
|
|
333
|
+
try:
|
|
334
|
+
guard_default_branch(
|
|
335
|
+
repo_root,
|
|
336
|
+
current_branch_name(repo_root),
|
|
337
|
+
allow=allow_default,
|
|
338
|
+
will_commit=_cli_will_commit,
|
|
339
|
+
)
|
|
340
|
+
except WorktreeError as exc:
|
|
341
|
+
print(f"[syncade] worktree error: {exc}", file=sys.stderr)
|
|
342
|
+
return WORKTREE_ERROR
|
|
343
|
+
|
|
344
|
+
# ``--openspec`` spec was already resolved in the preflight above; use the staged
|
|
345
|
+
# tempfile directly. For the normal path, resolve the PR_DOC relative to the
|
|
346
|
+
# (now-confirmed) repo root.
|
|
347
|
+
if args.openspec is not None:
|
|
348
|
+
pr_doc_path = openspec_tmp_path # type: ignore[assignment]
|
|
349
|
+
else:
|
|
350
|
+
pr_doc_path = resolve_repo_relative_input_path(
|
|
351
|
+
args.pr_doc, repo_root=repo_root, label="PR_DOC"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
with run_status.install_signal_handlers():
|
|
355
|
+
try:
|
|
356
|
+
result = run_review(
|
|
357
|
+
repo_root=repo_root,
|
|
358
|
+
pr_doc_path=pr_doc_path,
|
|
359
|
+
config=config,
|
|
360
|
+
base_ref=base_ref,
|
|
361
|
+
timeout_seconds=args.timeout,
|
|
362
|
+
logger=logger,
|
|
363
|
+
force_dirty=args.force_dirty,
|
|
364
|
+
two_dot=args.two_dot,
|
|
365
|
+
allow_default_branch=allow_default,
|
|
366
|
+
pr_doc_artifact_name=pr_doc_artifact_name,
|
|
367
|
+
worktree_base=config.worktree_base,
|
|
368
|
+
)
|
|
369
|
+
except FileNotFoundError as exc:
|
|
370
|
+
print(f"[syncade] error: {exc}", file=sys.stderr)
|
|
371
|
+
# PR_DOC is a CLI-input issue; exit 2 matches argparse's
|
|
372
|
+
# convention for "user-supplied argument problem".
|
|
373
|
+
return 2
|
|
374
|
+
except NotADirectoryError as exc:
|
|
375
|
+
print(f"[syncade] error: {exc}", file=sys.stderr)
|
|
376
|
+
return 2
|
|
377
|
+
except SnapshotError as exc:
|
|
378
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
379
|
+
# A mid-loop SnapshotError is caught HERE, not by the catch-all below,
|
|
380
|
+
# so it must finalize the breadcrumb itself — else status.json stays
|
|
381
|
+
# `running` and a clean exit-60 falsely reads as a hard kill.
|
|
382
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
|
|
383
|
+
return WORKTREE_ERROR
|
|
384
|
+
except WorktreeError as exc:
|
|
385
|
+
print(f"[syncade] worktree error: {exc}", file=sys.stderr)
|
|
386
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
|
|
387
|
+
return WORKTREE_ERROR
|
|
388
|
+
except KeyboardInterrupt:
|
|
389
|
+
# L4: an interrupted run is never a clean refusal. This handler RETURNS, so
|
|
390
|
+
# the outer catch-all below never sees it and it must record that itself.
|
|
391
|
+
_abnormal = True
|
|
392
|
+
if run_status.received_signal():
|
|
393
|
+
# Signal-induced KI: finalize with signal:<NAME> + 128+signum.
|
|
394
|
+
return run_status.finalize_signal()
|
|
395
|
+
# Non-signal KI: the orchestrator guard already finalized status.json
|
|
396
|
+
# as exception:KeyboardInterrupt. Finalize here only if still active
|
|
397
|
+
# (e.g. raised before begin()), then return the conventional 130.
|
|
398
|
+
run_status.finalize_active("exception:KeyboardInterrupt", None)
|
|
399
|
+
return 130
|
|
400
|
+
except BaseException as exc:
|
|
401
|
+
# Unexpected mid-run failure: record it before it propagates so the
|
|
402
|
+
# breadcrumb never lies about why the run ended. It re-raises, so the outer
|
|
403
|
+
# catch-all marks it abnormal — an unexplained failure is not a refusal (L6).
|
|
404
|
+
if isinstance(exc, Exception):
|
|
405
|
+
run_status.finalize_active(f"exception:{type(exc).__name__}", None)
|
|
406
|
+
raise
|
|
407
|
+
|
|
408
|
+
# run_review already printed the summary via Logger.summary.
|
|
409
|
+
_result = result
|
|
410
|
+
return result.exit_code
|
|
411
|
+
except KeyboardInterrupt:
|
|
412
|
+
# Signal arriving in the narrow window BEFORE run_status.install_signal_handlers()
|
|
413
|
+
# is entered (between ensure_repo_initialized success and the with-block). The
|
|
414
|
+
# inner handler covers signals during run_review; this catches the gap. L4: not a
|
|
415
|
+
# clean refusal — signals must not trigger undo.
|
|
416
|
+
_abnormal = True
|
|
417
|
+
if run_status.received_signal():
|
|
418
|
+
return run_status.finalize_signal()
|
|
419
|
+
run_status.finalize_active("exception:KeyboardInterrupt", None)
|
|
420
|
+
return 130
|
|
421
|
+
except BaseException:
|
|
422
|
+
# Unexpected exception, here or re-raised from the run itself. Not a refusal (L6).
|
|
423
|
+
_abnormal = True
|
|
424
|
+
raise
|
|
425
|
+
finally:
|
|
426
|
+
# UNDO (PR-h-04.6), decided HERE and nowhere else. Any return between auto-init and
|
|
427
|
+
# the review — present or added later — lands in this `finally`, so no list of refusal
|
|
428
|
+
# sites exists to fall out of date. That list is what two dogfoods could not complete.
|
|
429
|
+
#
|
|
430
|
+
# `_started_empty` is what makes deletion safe at all: the directory was empty before
|
|
431
|
+
# the first write, so everything in it now is syncade's (item 1). The rest decides
|
|
432
|
+
# whether this invocation is a refusal, from facts rather than judgements:
|
|
433
|
+
#
|
|
434
|
+
# run_review RETURNED → it is the authority on what it did. A run is a refusal
|
|
435
|
+
# when no reviewer subprocess was started across any round. That covers
|
|
436
|
+
# `no_changes_to_review` (exit 0), `diff_malformed` (exit 60), and adapter-
|
|
437
|
+
# lookup / auth-preflight failures — all of which have no dispatcher Phase 3.
|
|
438
|
+
# `DispatchResult.reviewer_subprocess_started` is the explicit fact;
|
|
439
|
+
# `dispatch_result.results` is NOT (adapter lookup failures return non-empty
|
|
440
|
+
# results before any subprocess runs, and `producer_emptied_diff` has an empty
|
|
441
|
+
# final result after real reviewers ran in prior rounds).
|
|
442
|
+
# run_review RAISED → `began()` says whether it had taken ownership. Unlike
|
|
443
|
+
# `active()` it survives the finalization `run_review` performs before
|
|
444
|
+
# re-raising, which is exactly what the previous guard got wrong.
|
|
445
|
+
# ABNORMAL → a signal or an unexplained exception is never a refusal.
|
|
446
|
+
_refused = not _abnormal and (
|
|
447
|
+
not any(r.dispatch_result.reviewer_subprocess_started for r in _result.rounds)
|
|
448
|
+
if _result is not None
|
|
449
|
+
else not run_status.began()
|
|
450
|
+
)
|
|
451
|
+
if _started_empty and _refused:
|
|
452
|
+
_failed = undo_auto_init(repo_root)
|
|
453
|
+
if _failed:
|
|
454
|
+
# Never abort on a failed cleanup: the run is already refusing, and a leftover
|
|
455
|
+
# repo is the OLD behaviour rather than a new hazard. Printed straight to
|
|
456
|
+
# stderr so it survives --quiet, like the auth block.
|
|
457
|
+
print(
|
|
458
|
+
"[syncade] warning: could not remove what auto-init created: "
|
|
459
|
+
+ ", ".join(_failed),
|
|
460
|
+
file=sys.stderr,
|
|
461
|
+
)
|
|
462
|
+
if openspec_tmp_path is not None:
|
|
463
|
+
try:
|
|
464
|
+
openspec_tmp_path.unlink(missing_ok=True)
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
print(
|
|
467
|
+
f"[syncade] warning: could not remove OpenSpec tempfile: {exc}", file=sys.stderr
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main(argv: list[str] | None = None) -> int:
|
|
472
|
+
"""Entry point used by both ``python -m syncade`` and the installed
|
|
473
|
+
``syncade`` console script.
|
|
474
|
+
|
|
475
|
+
Returns the process exit code; callers (e.g. ``__main__.py``) are
|
|
476
|
+
responsible for passing the return value to :func:`sys.exit`.
|
|
477
|
+
|
|
478
|
+
Validation order:
|
|
479
|
+
|
|
480
|
+
1. Command-shape checks (mutex, required pairs, no-command) — these
|
|
481
|
+
happen *before* any filesystem work, so a user running
|
|
482
|
+
``syncade`` with no command sees help, never a config or
|
|
483
|
+
snapshot error from a stale ``.syncade/`` or a non-git cwd.
|
|
484
|
+
2. Dispatch to the right command handler. For a real review, :func:`_run`
|
|
485
|
+
resolves the git repo root and loads ``.syncade/config.toml`` *from that root* — not from the
|
|
486
|
+
user-supplied ``--repo-root``/cwd hint.
|
|
487
|
+
"""
|
|
488
|
+
_argv = list(sys.argv[1:]) if argv is None else list(argv)
|
|
489
|
+
# Extract --config operands from raw argv before argparse sees them: argparse's nargs="*"
|
|
490
|
+
# stops at dash-prefixed tokens (treating them as unknown options), so a model string like
|
|
491
|
+
# "-custom-model" would be rejected. We pull the operands manually, strip them from argv, and
|
|
492
|
+
# re-inject after parsing. Also detect the forbidden prefix form (--repo before --config).
|
|
493
|
+
_config_operands, _argv_parsed, _repo_prefix = _extract_config_operands(_argv)
|
|
494
|
+
parser = build_parser()
|
|
495
|
+
args = parser.parse_args(_argv_parsed)
|
|
496
|
+
if _config_operands is not None:
|
|
497
|
+
# Merge any trailing non-flag operands that argparse still captured (e.g. `--config list
|
|
498
|
+
# extra`) with the dash-prefixed ones we extracted before parsing.
|
|
499
|
+
args.config = _config_operands + (args.config or [])
|
|
500
|
+
# Reject the prefix form: --repo before --config set violates the suffix-only contract (D1).
|
|
501
|
+
# Only applies to "set" — for other verbs (list/get) or no verb, _reject_config_mode_conflicts
|
|
502
|
+
# will catch the --repo misuse with the appropriate "meaningful only with --config set" message.
|
|
503
|
+
if _repo_prefix and args.config and args.config[0] == "set":
|
|
504
|
+
print(
|
|
505
|
+
"[syncade] error: --repo must trail the key/value: `--config set <key> <value> --repo`",
|
|
506
|
+
file=sys.stderr,
|
|
507
|
+
)
|
|
508
|
+
return CLI_USAGE_ERROR
|
|
509
|
+
|
|
510
|
+
rc = validate_command_shape(args, parser)
|
|
511
|
+
if rc is not None:
|
|
512
|
+
return rc
|
|
513
|
+
|
|
514
|
+
# --- dispatch -------------------------------------------------------
|
|
515
|
+
# --install-skill is a standalone local operation — copies bundled skill files into the
|
|
516
|
+
# harness dirs. Dispatched after command-shape validation so --doctor/--quick combinations
|
|
517
|
+
# are rejected before the filesystem mutation.
|
|
518
|
+
if args.install_skill is not None:
|
|
519
|
+
from syncade.cli.install_skill import install_skill
|
|
520
|
+
|
|
521
|
+
return install_skill(args.install_skill, force=args.force_install)
|
|
522
|
+
if args.config is not None:
|
|
523
|
+
from syncade.cli.config_mode import run_config
|
|
524
|
+
|
|
525
|
+
return run_config(args.config, args=args)
|
|
526
|
+
if args.gc:
|
|
527
|
+
return _run_gc(args)
|
|
528
|
+
if args.metrics:
|
|
529
|
+
return _run_metrics(args)
|
|
530
|
+
if args.resume is not None:
|
|
531
|
+
return _run_resume(args)
|
|
532
|
+
if args.selfcheck:
|
|
533
|
+
return _run_selfcheck(args)
|
|
534
|
+
if args.auth_check:
|
|
535
|
+
return _run_auth_check(args)
|
|
536
|
+
if args.doctor:
|
|
537
|
+
return _run_doctor(args)
|
|
538
|
+
if args.spec_audit is not None:
|
|
539
|
+
return _run_spec_audit(args)
|
|
540
|
+
if args.draft_spec:
|
|
541
|
+
return _run_draft_spec(args)
|
|
542
|
+
|
|
543
|
+
# repo_root here is a starting *hint* (cwd or --repo-root); _run
|
|
544
|
+
# resolves it to the actual git repo root before doing anything else.
|
|
545
|
+
repo_root = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
546
|
+
return _run(args, repo_root)
|
syncade/cli/auth_gate.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The one auth gate every entry point passes through.
|
|
2
|
+
|
|
3
|
+
Wiring the preflight into the review path only was not enough, and syncade's own panel
|
|
4
|
+
caught it unanimously: FIVE other modes load config and spawn provider subprocesses, and
|
|
5
|
+
none of them checked anything.
|
|
6
|
+
|
|
7
|
+
--resume resumes a FULL review loop -- reviewers, judge, producer
|
|
8
|
+
--selfcheck spawns real provider subprocesses
|
|
9
|
+
--spec-audit spawns the cold auditor
|
|
10
|
+
--draft-spec spawns the cold drafter
|
|
11
|
+
--auth-check probes each configured provider
|
|
12
|
+
|
|
13
|
+
``--resume`` is the one that stings: a user could be refused on a fresh run and then
|
|
14
|
+
simply resume past the refusal, billing the account they were protected from thirty
|
|
15
|
+
seconds earlier.
|
|
16
|
+
|
|
17
|
+
The mistake is worth naming, because it is the same one twice. In issue 2 I checked that
|
|
18
|
+
enforcement covered all five ACTOR TYPES and was pleased with myself. It never occurred to
|
|
19
|
+
me to check that it covered all six ENTRY POINTS. Right instinct, wrong axis. So the gate
|
|
20
|
+
now lives in ONE function that every mode calls, rather than in a policy each mode is
|
|
21
|
+
trusted to remember.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from syncade import auth_preflight
|
|
30
|
+
from syncade.config import SyncadeConfig
|
|
31
|
+
from syncade.exit_codes import CONFIG_ERROR
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def auth_gate(config: SyncadeConfig, blocks: frozenset[str] | None = None) -> int | None:
|
|
35
|
+
"""Refuse impossible declarations; announce who is about to be billed.
|
|
36
|
+
|
|
37
|
+
Returns ``CONFIG_ERROR`` when the run must not start, else ``None``.
|
|
38
|
+
|
|
39
|
+
There is no ``announce=False``. It existed for ``--auth-check`` on the theory that a
|
|
40
|
+
second auth block would be "noise" — and that was exactly the mutation I had warned
|
|
41
|
+
about in issue 5: a quiet ``--auth-check`` could then spawn a probe under ``auto``,
|
|
42
|
+
hit the API because ANTHROPIC_API_KEY was set, and never say so. The command whose
|
|
43
|
+
entire job is auth is the LAST place to suppress the auth line.
|
|
44
|
+
"""
|
|
45
|
+
env = dict(os.environ)
|
|
46
|
+
# Scoped to the actors THIS command can spawn. `--spec-audit` runs only the auditor, so
|
|
47
|
+
# refusing it over a REVIEWER's declaration blocks a command that would never have run
|
|
48
|
+
# that reviewer -- and the report would announce billing for actors that will not bill.
|
|
49
|
+
problems = auth_preflight.preflight(config, env, blocks)
|
|
50
|
+
if problems:
|
|
51
|
+
print("[syncade] auth error: declared mode contradicts this machine", file=sys.stderr)
|
|
52
|
+
for problem in problems:
|
|
53
|
+
print(f" - {problem}", file=sys.stderr)
|
|
54
|
+
return CONFIG_ERROR
|
|
55
|
+
|
|
56
|
+
print("[syncade] auth:", file=sys.stderr)
|
|
57
|
+
for line in auth_preflight.report_lines(config, env, blocks):
|
|
58
|
+
print(line, file=sys.stderr)
|
|
59
|
+
return None
|