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/modes.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""One-shot CLI mode handlers: ``--selfcheck`` / ``--auth-check`` /
|
|
2
|
+
``--spec-audit`` / ``--draft-spec`` / ``--resume`` / ``--gc``.
|
|
3
|
+
|
|
4
|
+
Each handler resolves the git repo root, loads config, and dispatches to the
|
|
5
|
+
relevant module, mapping expected exceptions to the shared CLI exit-code
|
|
6
|
+
convention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from syncade.cli.auth_gate import auth_gate
|
|
15
|
+
from syncade.config_auth import (
|
|
16
|
+
ALL_BLOCKS,
|
|
17
|
+
AUDIT_BLOCKS,
|
|
18
|
+
DRAFT_BLOCKS,
|
|
19
|
+
SELFCHECK_BLOCKS,
|
|
20
|
+
)
|
|
21
|
+
from syncade.config_loader import ConfigError, load_config
|
|
22
|
+
from syncade.exit_codes import (
|
|
23
|
+
CLARIFICATION_NEEDED,
|
|
24
|
+
CLI_USAGE_ERROR,
|
|
25
|
+
CONFIG_ERROR,
|
|
26
|
+
REVIEWER_FAILURE,
|
|
27
|
+
REVIEWER_OUTPUT_UNPARSEABLE,
|
|
28
|
+
SUCCESS,
|
|
29
|
+
WORKTREE_ERROR,
|
|
30
|
+
)
|
|
31
|
+
from syncade.logging import Logger
|
|
32
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
33
|
+
|
|
34
|
+
from .paths import resolve_repo_relative_input_path, unique_markdown_path
|
|
35
|
+
from .resolve import _resolve_scope_base
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reject_diff_base_flags(args, mode_flag: str) -> int | None:
|
|
39
|
+
"""Reject ``--base`` / ``--scope`` for one-shot modes that render no
|
|
40
|
+
reviewer diff (``--selfcheck`` / ``--auth-check`` / ``--spec-audit`` /
|
|
41
|
+
``--gc``).
|
|
42
|
+
|
|
43
|
+
Those flags only select the reviewer's diff base for the review loop; the
|
|
44
|
+
diagnostic/maintenance modes never consume one, so accepting them would
|
|
45
|
+
silently ignore an operator's explicit intent. Reject up front (exit 2),
|
|
46
|
+
matching the existing ``--gc-keep``-without-``--gc`` and
|
|
47
|
+
``--transcript``-without-``--draft-spec`` "meaningful only in context"
|
|
48
|
+
guards. Returns the exit code on a violation, or ``None`` when clean so the
|
|
49
|
+
caller proceeds. (``--base`` and ``--scope`` are already mutually exclusive
|
|
50
|
+
with each other in ``main()``, so at most one is set here.)
|
|
51
|
+
"""
|
|
52
|
+
offenders = [
|
|
53
|
+
flag
|
|
54
|
+
for flag, used in (
|
|
55
|
+
("--base", args.base is not None),
|
|
56
|
+
("--scope", args.scope is not None),
|
|
57
|
+
("--two-dot", getattr(args, "two_dot", False)),
|
|
58
|
+
)
|
|
59
|
+
if used
|
|
60
|
+
]
|
|
61
|
+
if offenders:
|
|
62
|
+
print(
|
|
63
|
+
f"[syncade] error: {' / '.join(offenders)} cannot be combined with "
|
|
64
|
+
f"{mode_flag}; that flag selects the reviewer diff base and "
|
|
65
|
+
f"{mode_flag} renders no reviewer diff",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
return CLI_USAGE_ERROR
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_config_operands(
|
|
73
|
+
argv: list[str],
|
|
74
|
+
) -> tuple[list[str] | None, list[str], bool]:
|
|
75
|
+
"""Extract ``--config`` operands from raw argv before argparse consumes them.
|
|
76
|
+
|
|
77
|
+
Returns ``(operands, cleaned_argv, has_prefix_repo)``:
|
|
78
|
+
|
|
79
|
+
- ``operands``: ``None`` when ``--config`` is absent; otherwise the verb + data
|
|
80
|
+
tokens taken directly from raw argv so dash-prefixed values like ``-custom``
|
|
81
|
+
pass through where ``nargs="*"`` would stop.
|
|
82
|
+
- ``cleaned_argv``: argv with those operands removed so argparse can parse the
|
|
83
|
+
structural flags without interference.
|
|
84
|
+
- ``has_prefix_repo``: ``True`` when ``--repo`` precedes ``--config`` — the
|
|
85
|
+
forbidden prefix form (D1: ``--repo`` must trail the key/value).
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
cfg_pos = argv.index("--config")
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None, argv, False
|
|
91
|
+
has_prefix_repo = "--repo" in argv[:cfg_pos]
|
|
92
|
+
_verbs_n: dict[str, int] = {"set": 2, "get": 1, "list": 0}
|
|
93
|
+
operands: list[str] = []
|
|
94
|
+
pos = cfg_pos + 1
|
|
95
|
+
if pos < len(argv):
|
|
96
|
+
verb = argv[pos]
|
|
97
|
+
operands = [verb]
|
|
98
|
+
for _ in range(_verbs_n.get(verb, 0)):
|
|
99
|
+
pos += 1
|
|
100
|
+
# Stop consuming when a double-dash token occupies a missing-value slot so
|
|
101
|
+
# "--config set producer.model --repo" (and any other --flag) is rejected
|
|
102
|
+
# rather than silently writing model="--flag" to the config file.
|
|
103
|
+
if pos < len(argv) and not argv[pos].startswith("--"):
|
|
104
|
+
operands.append(argv[pos])
|
|
105
|
+
new_argv = argv[: cfg_pos + 1] + argv[cfg_pos + 1 + len(operands) :]
|
|
106
|
+
return operands, new_argv, has_prefix_repo
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _reject_config_mode_conflicts(args) -> int | None:
|
|
110
|
+
"""Reject flags incompatible with ``--config`` mode.
|
|
111
|
+
|
|
112
|
+
``--repo`` is meaningful only with ``--config set``; ``--config`` itself is
|
|
113
|
+
mutually exclusive with PR_DOC, every other one-shot mode, and the
|
|
114
|
+
review-loop-only flags (``--base``/``--scope`` select the diff base and
|
|
115
|
+
``--config`` renders no diff; ``--max-rounds``/``--timeout``/``--preset`` are
|
|
116
|
+
loop-level overrides ignored by the config inspector, which would mislead
|
|
117
|
+
operators into thinking the effective config they see reflects those overrides).
|
|
118
|
+
|
|
119
|
+
Returns the exit code on a violation, ``None`` when clean.
|
|
120
|
+
"""
|
|
121
|
+
if getattr(args, "all", False) and not (
|
|
122
|
+
args.config is not None and args.config and args.config[0] == "list"
|
|
123
|
+
):
|
|
124
|
+
print("[syncade] error: --all is meaningful only with --config list", file=sys.stderr)
|
|
125
|
+
return CLI_USAGE_ERROR
|
|
126
|
+
if getattr(args, "repo", False) and (
|
|
127
|
+
args.config is None or not args.config or args.config[0] != "set"
|
|
128
|
+
):
|
|
129
|
+
print("[syncade] error: --repo is meaningful only with --config set", file=sys.stderr)
|
|
130
|
+
return CLI_USAGE_ERROR
|
|
131
|
+
if args.config is not None:
|
|
132
|
+
for _flag, _used in (
|
|
133
|
+
("a PR_DOC positional argument", bool(args.pr_doc)),
|
|
134
|
+
("--resume", bool(args.resume)),
|
|
135
|
+
("--selfcheck", args.selfcheck),
|
|
136
|
+
("--auth-check", args.auth_check),
|
|
137
|
+
("--spec-audit", bool(args.spec_audit)),
|
|
138
|
+
("--draft-spec", args.draft_spec),
|
|
139
|
+
("--openspec", args.openspec is not None),
|
|
140
|
+
("--gc", args.gc),
|
|
141
|
+
("--metrics", args.metrics),
|
|
142
|
+
("--base", args.base is not None),
|
|
143
|
+
("--scope", args.scope is not None),
|
|
144
|
+
("--max-rounds", args.max_rounds is not None),
|
|
145
|
+
("--timeout", args.timeout is not None),
|
|
146
|
+
("--preset", args.preset is not None),
|
|
147
|
+
("--worktree-base", args.worktree_base is not None),
|
|
148
|
+
("--two-dot", getattr(args, "two_dot", False)),
|
|
149
|
+
("--force-dirty", getattr(args, "force_dirty", False)),
|
|
150
|
+
("--allow-default-branch", getattr(args, "allow_default_branch", False)),
|
|
151
|
+
("--install-skill", args.install_skill is not None),
|
|
152
|
+
):
|
|
153
|
+
if _used:
|
|
154
|
+
print(
|
|
155
|
+
f"[syncade] error: --config cannot be combined with {_flag}",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return CLI_USAGE_ERROR
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_selfcheck(args) -> int:
|
|
163
|
+
"""Dispatch ``syncade --selfcheck``.
|
|
164
|
+
|
|
165
|
+
Loads the operator's ``.syncade/config.toml`` from the resolved
|
|
166
|
+
git repo root and hands the producer block to
|
|
167
|
+
:func:`syncade.selfcheck.run_selfcheck`. The selfcheck itself
|
|
168
|
+
runs in a throwaway tmp_path workspace; the operator's repo is
|
|
169
|
+
used only to resolve config (so the operator's ``[producer]``
|
|
170
|
+
block drives provider / model / thinking / permissions).
|
|
171
|
+
|
|
172
|
+
Error mapping mirrors :func:`_run` — config errors map to exit
|
|
173
|
+
50, repo-discovery failures to exit 60, and the selfcheck's own
|
|
174
|
+
exit codes (0 / 30 / 60) pass through verbatim.
|
|
175
|
+
"""
|
|
176
|
+
rejection = _reject_diff_base_flags(args, "--selfcheck")
|
|
177
|
+
if rejection is not None:
|
|
178
|
+
return rejection
|
|
179
|
+
|
|
180
|
+
# Local import to keep the selfcheck module out of the
|
|
181
|
+
# default-import cost — operators not running --selfcheck don't
|
|
182
|
+
# pay for it.
|
|
183
|
+
from syncade.selfcheck import run_selfcheck
|
|
184
|
+
|
|
185
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
186
|
+
try:
|
|
187
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
188
|
+
except SnapshotError as exc:
|
|
189
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
190
|
+
return WORKTREE_ERROR
|
|
191
|
+
|
|
192
|
+
def _emit_deprecation(message: str) -> None:
|
|
193
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
|
|
197
|
+
except ConfigError as exc:
|
|
198
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
199
|
+
return CONFIG_ERROR
|
|
200
|
+
|
|
201
|
+
gate = auth_gate(config, SELFCHECK_BLOCKS)
|
|
202
|
+
if gate is not None:
|
|
203
|
+
return gate
|
|
204
|
+
|
|
205
|
+
return run_selfcheck(
|
|
206
|
+
config,
|
|
207
|
+
timeout_seconds=args.timeout,
|
|
208
|
+
quiet=args.quiet,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _run_auth_check(args) -> int:
|
|
213
|
+
"""Dispatch ``syncade --auth-check``.
|
|
214
|
+
|
|
215
|
+
Loads the operator's ``.syncade/config.toml`` from the resolved
|
|
216
|
+
git repo root and hands the configured credentials to
|
|
217
|
+
:func:`syncade.auth_check.run_auth_check`. Each distinct credential
|
|
218
|
+
is probed once; the function returns SUCCESS (0) iff every probe
|
|
219
|
+
passed, WORKTREE_ERROR (60) otherwise — same exit-60 semantics
|
|
220
|
+
as ``--selfcheck`` for "your environment isn't ready".
|
|
221
|
+
|
|
222
|
+
Error mapping mirrors :func:`_run_selfcheck` — repo-discovery
|
|
223
|
+
failures map to exit 60, config errors to exit 50; auth-check's
|
|
224
|
+
own exit codes (0 / 60) pass through verbatim.
|
|
225
|
+
|
|
226
|
+
``--timeout`` propagates to ``run_auth_check`` as a per-probe
|
|
227
|
+
override; the auth-check module's
|
|
228
|
+
:data:`DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS` (30s) is the
|
|
229
|
+
fallback when ``--timeout`` is unset.
|
|
230
|
+
"""
|
|
231
|
+
rejection = _reject_diff_base_flags(args, "--auth-check")
|
|
232
|
+
if rejection is not None:
|
|
233
|
+
return rejection
|
|
234
|
+
|
|
235
|
+
# Local import keeps the auth-check module out of the
|
|
236
|
+
# default-import cost — operators not running --auth-check
|
|
237
|
+
# don't pay for it.
|
|
238
|
+
from syncade.auth_check import run_auth_check
|
|
239
|
+
|
|
240
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
241
|
+
try:
|
|
242
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
243
|
+
except SnapshotError as exc:
|
|
244
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
245
|
+
return WORKTREE_ERROR
|
|
246
|
+
|
|
247
|
+
def _emit_deprecation(message: str) -> None:
|
|
248
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
|
|
252
|
+
except ConfigError as exc:
|
|
253
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
254
|
+
return CONFIG_ERROR
|
|
255
|
+
|
|
256
|
+
gate = auth_gate(config, ALL_BLOCKS)
|
|
257
|
+
if gate is not None:
|
|
258
|
+
return gate
|
|
259
|
+
|
|
260
|
+
return run_auth_check(
|
|
261
|
+
config,
|
|
262
|
+
timeout_seconds=args.timeout,
|
|
263
|
+
quiet=args.quiet,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _run_spec_audit(args) -> int:
|
|
268
|
+
"""Dispatch ``syncade --spec-audit <pr-doc>``.
|
|
269
|
+
|
|
270
|
+
Validates the PR doc path, resolves the git repo root, loads config
|
|
271
|
+
(for per-repo template overrides), then calls
|
|
272
|
+
:func:`syncade.spec_audit.run_spec_audit`. Prints findings to stdout
|
|
273
|
+
in a human-readable format; errors go to stderr.
|
|
274
|
+
|
|
275
|
+
Exit codes:
|
|
276
|
+
- 0 (SUCCESS): ``outcome == "ready"``, no blocker findings
|
|
277
|
+
- 10 (CLARIFICATION_NEEDED): ``outcome == "needs_clarification"``
|
|
278
|
+
- 40 (REVIEWER_FAILURE): subprocess error (e.g. codex CLI failure)
|
|
279
|
+
- 50 (CONFIG_ERROR): config load failure (pydantic ValidationError,
|
|
280
|
+
malformed TOML)
|
|
281
|
+
- 60 (WORKTREE_ERROR): path validation failure
|
|
282
|
+
- 70 (REVIEWER_OUTPUT_UNPARSEABLE): auditor output didn't parse
|
|
283
|
+
"""
|
|
284
|
+
rejection = _reject_diff_base_flags(args, "--spec-audit")
|
|
285
|
+
if rejection is not None:
|
|
286
|
+
return rejection
|
|
287
|
+
|
|
288
|
+
from syncade.spec_audit import SpecAuditOutputError, run_spec_audit
|
|
289
|
+
|
|
290
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
291
|
+
try:
|
|
292
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
293
|
+
except SnapshotError as exc:
|
|
294
|
+
preflight_path = Path(args.spec_audit).expanduser()
|
|
295
|
+
hint_path = (
|
|
296
|
+
repo_root_hint / preflight_path if not preflight_path.is_absolute() else preflight_path
|
|
297
|
+
)
|
|
298
|
+
if not preflight_path.is_file() and not hint_path.is_file():
|
|
299
|
+
print(
|
|
300
|
+
f"[syncade] error: --spec-audit path {preflight_path} is not a "
|
|
301
|
+
"readable file. Pass an absolute or relative path to a PR brief "
|
|
302
|
+
"markdown file.",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
)
|
|
305
|
+
return WORKTREE_ERROR
|
|
306
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
307
|
+
return WORKTREE_ERROR
|
|
308
|
+
|
|
309
|
+
pr_doc_path = resolve_repo_relative_input_path(
|
|
310
|
+
args.spec_audit, repo_root=repo_root, label="--spec-audit"
|
|
311
|
+
)
|
|
312
|
+
if not pr_doc_path.is_file():
|
|
313
|
+
print(
|
|
314
|
+
f"[syncade] error: --spec-audit path {pr_doc_path} is not a "
|
|
315
|
+
"readable file. Pass an absolute or relative path to a PR brief "
|
|
316
|
+
"markdown file.",
|
|
317
|
+
file=sys.stderr,
|
|
318
|
+
)
|
|
319
|
+
return WORKTREE_ERROR
|
|
320
|
+
|
|
321
|
+
def _emit_deprecation(message: str) -> None:
|
|
322
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
|
|
326
|
+
except ConfigError as exc:
|
|
327
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
328
|
+
return CONFIG_ERROR
|
|
329
|
+
|
|
330
|
+
gate = auth_gate(config, AUDIT_BLOCKS)
|
|
331
|
+
if gate is not None:
|
|
332
|
+
return gate
|
|
333
|
+
|
|
334
|
+
timeout = args.timeout if args.timeout is not None else 300.0
|
|
335
|
+
|
|
336
|
+
if not args.quiet:
|
|
337
|
+
print(f"[syncade] spec-audit: auditing {pr_doc_path.name} ...")
|
|
338
|
+
|
|
339
|
+
result = run_spec_audit(
|
|
340
|
+
pr_doc_path=pr_doc_path,
|
|
341
|
+
repo_root=repo_root,
|
|
342
|
+
timeout_seconds=timeout,
|
|
343
|
+
config=config.auditor,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if result.outcome == "subprocess_error":
|
|
347
|
+
if isinstance(result.error, SpecAuditOutputError):
|
|
348
|
+
print(
|
|
349
|
+
f"[syncade] spec-audit FAILED (parse error): {result.error}",
|
|
350
|
+
file=sys.stderr,
|
|
351
|
+
)
|
|
352
|
+
return REVIEWER_OUTPUT_UNPARSEABLE
|
|
353
|
+
print(
|
|
354
|
+
f"[syncade] spec-audit FAILED (subprocess error): {result.error}",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
return REVIEWER_FAILURE
|
|
358
|
+
|
|
359
|
+
assert result.output is not None
|
|
360
|
+
output = result.output
|
|
361
|
+
|
|
362
|
+
# Human-readable findings report to stdout.
|
|
363
|
+
print(
|
|
364
|
+
f"[syncade] spec-audit {output.verdict} "
|
|
365
|
+
f"({len(output.findings)} finding(s), {result.duration_seconds:.1f}s)"
|
|
366
|
+
)
|
|
367
|
+
if output.findings:
|
|
368
|
+
print()
|
|
369
|
+
print("Findings (priority order):")
|
|
370
|
+
for rank, idx in enumerate(output.priority_order, start=1):
|
|
371
|
+
f = output.findings[idx]
|
|
372
|
+
loc = f" {f.section}" + (f":{f.line}" if f.line is not None else "")
|
|
373
|
+
print(f" [{rank}] {f.severity.upper()} — {f.issue_class}")
|
|
374
|
+
print(f" @ {loc.strip()}")
|
|
375
|
+
print(f" {f.finding}")
|
|
376
|
+
if f.suggested_remediation:
|
|
377
|
+
print(f" → {f.suggested_remediation}")
|
|
378
|
+
if output.summary:
|
|
379
|
+
print()
|
|
380
|
+
print(f"Summary: {output.summary}")
|
|
381
|
+
if output.coverage_gaps:
|
|
382
|
+
print()
|
|
383
|
+
print("Coverage gaps:")
|
|
384
|
+
for gap in output.coverage_gaps:
|
|
385
|
+
print(f" - {gap}")
|
|
386
|
+
|
|
387
|
+
if result.outcome == "needs_clarification":
|
|
388
|
+
if not args.quiet:
|
|
389
|
+
print(
|
|
390
|
+
"\n[syncade] spec-audit found blocker-severity issues. "
|
|
391
|
+
"Edit the brief to address them, then re-run "
|
|
392
|
+
"`syncade --spec-audit <pr-doc>`. Proceeding with "
|
|
393
|
+
"`syncade <pr-doc>` despite these findings is your "
|
|
394
|
+
"choice — the audit is advisory in v1."
|
|
395
|
+
)
|
|
396
|
+
return CLARIFICATION_NEEDED
|
|
397
|
+
|
|
398
|
+
if not args.quiet:
|
|
399
|
+
print("\n[syncade] spec-audit OK: brief is ready for review dispatch.")
|
|
400
|
+
return SUCCESS
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
_DRAFT_DIALOGUE_SOFT_CAP = 200_000
|
|
404
|
+
"""Char threshold above which --draft-spec warns the dialogue may exceed the cold
|
|
405
|
+
drafter's context. A WARNING only — the full text is always passed (never silently
|
|
406
|
+
truncated; truncation would drop intent)."""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _run_draft_spec(args) -> int:
|
|
410
|
+
"""Dispatch ``syncade --draft-spec --transcript <jsonl> [--base/--scope]``.
|
|
411
|
+
|
|
412
|
+
Parses the session transcript for intent, captures the diff (only if
|
|
413
|
+
--base/--scope is given — dialogue-only otherwise), runs the cold drafter, and
|
|
414
|
+
writes a ratifiable ``.syncade/draft-spec-<session>.md``. Advisory: the operator
|
|
415
|
+
reviews/edits it, then runs ``syncade <file>``. Exit codes follow the CLI
|
|
416
|
+
mode-handler convention (0 drafted / 40 subprocess / 50 config / 60
|
|
417
|
+
path-or-transcript / 70 parse)."""
|
|
418
|
+
from syncade.snapshot import take_snapshot
|
|
419
|
+
from syncade.spec_draft import (
|
|
420
|
+
DEFAULT_SPEC_DRAFT_TIMEOUT_SECONDS,
|
|
421
|
+
SpecDraftOutputError,
|
|
422
|
+
render_draft_spec_markdown,
|
|
423
|
+
run_spec_draft,
|
|
424
|
+
)
|
|
425
|
+
from syncade.transcript import TranscriptError, parse_transcript
|
|
426
|
+
|
|
427
|
+
if not args.transcript:
|
|
428
|
+
print(
|
|
429
|
+
"[syncade] error: --draft-spec requires --transcript <session.jsonl>",
|
|
430
|
+
file=sys.stderr,
|
|
431
|
+
)
|
|
432
|
+
return CLI_USAGE_ERROR
|
|
433
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
434
|
+
try:
|
|
435
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
436
|
+
except SnapshotError as exc:
|
|
437
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
438
|
+
return WORKTREE_ERROR
|
|
439
|
+
|
|
440
|
+
transcript_path = resolve_repo_relative_input_path(
|
|
441
|
+
args.transcript, repo_root=repo_root, label="--transcript"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
logger = Logger("quiet" if args.quiet else "normal")
|
|
445
|
+
|
|
446
|
+
def _emit_deprecation(message: str) -> None:
|
|
447
|
+
print(f"[syncade] {message}", file=sys.stderr)
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
|
|
451
|
+
except ConfigError as exc:
|
|
452
|
+
print(f"[syncade] config error: {exc}", file=sys.stderr)
|
|
453
|
+
return CONFIG_ERROR
|
|
454
|
+
|
|
455
|
+
gate = auth_gate(config, DRAFT_BLOCKS)
|
|
456
|
+
if gate is not None:
|
|
457
|
+
return gate
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
dialogue = parse_transcript(transcript_path)
|
|
461
|
+
except TranscriptError as exc:
|
|
462
|
+
print(f"[syncade] transcript error: {exc}", file=sys.stderr)
|
|
463
|
+
return WORKTREE_ERROR
|
|
464
|
+
|
|
465
|
+
# Diff base is optional — dialogue-only draft when neither --base nor --scope.
|
|
466
|
+
base_ref = args.base
|
|
467
|
+
if args.scope is not None:
|
|
468
|
+
base_ref = _resolve_scope_base(repo_root, args.scope, logger)
|
|
469
|
+
if base_ref is None:
|
|
470
|
+
return WORKTREE_ERROR
|
|
471
|
+
two_dot = getattr(args, "two_dot", False)
|
|
472
|
+
diff = ""
|
|
473
|
+
if base_ref is not None:
|
|
474
|
+
try:
|
|
475
|
+
diff = take_snapshot(repo_root, base_ref=base_ref, three_dot=not two_dot).diff_text
|
|
476
|
+
except SnapshotError as exc:
|
|
477
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
478
|
+
return WORKTREE_ERROR
|
|
479
|
+
|
|
480
|
+
if len(dialogue) > _DRAFT_DIALOGUE_SOFT_CAP:
|
|
481
|
+
logger.warning(
|
|
482
|
+
f"draft-spec: dialogue is large ({len(dialogue)} chars > "
|
|
483
|
+
f"{_DRAFT_DIALOGUE_SOFT_CAP} soft cap); passing it in full — the cold "
|
|
484
|
+
"drafter may exceed its context. If the draft looks truncated, scope the "
|
|
485
|
+
"work (--base/--scope) or draft from a shorter session."
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if not args.quiet:
|
|
489
|
+
print(f"[syncade] draft-spec: drafting from {transcript_path.name} ...")
|
|
490
|
+
|
|
491
|
+
timeout = args.timeout if args.timeout is not None else DEFAULT_SPEC_DRAFT_TIMEOUT_SECONDS
|
|
492
|
+
result = run_spec_draft(
|
|
493
|
+
dialogue=dialogue,
|
|
494
|
+
diff=diff,
|
|
495
|
+
repo_root=repo_root,
|
|
496
|
+
timeout_seconds=timeout,
|
|
497
|
+
config=config.drafter,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if result.outcome == "subprocess_error":
|
|
501
|
+
if isinstance(result.error, SpecDraftOutputError):
|
|
502
|
+
print(f"[syncade] draft-spec FAILED (parse error): {result.error}", file=sys.stderr)
|
|
503
|
+
return REVIEWER_OUTPUT_UNPARSEABLE
|
|
504
|
+
print(f"[syncade] draft-spec FAILED (subprocess error): {result.error}", file=sys.stderr)
|
|
505
|
+
return REVIEWER_FAILURE
|
|
506
|
+
|
|
507
|
+
assert result.output is not None
|
|
508
|
+
out_dir = repo_root / ".syncade"
|
|
509
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
out_path = unique_markdown_path(out_dir, f"draft-spec-{transcript_path.stem}")
|
|
511
|
+
out_path.write_text(
|
|
512
|
+
render_draft_spec_markdown(
|
|
513
|
+
result.output, source_label=transcript_path.stem, base_ref=base_ref
|
|
514
|
+
),
|
|
515
|
+
encoding="utf-8",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
n_inferred = sum(1 for c in result.output.acceptance_criteria if c.origin == "inferred")
|
|
519
|
+
print(
|
|
520
|
+
f"[syncade] draft-spec OK: wrote {out_path} "
|
|
521
|
+
f"({len(result.output.acceptance_criteria)} criteria, {n_inferred} inferred, "
|
|
522
|
+
f"{len(result.output.assumptions)} assumption(s); {result.duration_seconds:.1f}s)"
|
|
523
|
+
)
|
|
524
|
+
base_suffix = f" --base {base_ref}" if base_ref else ""
|
|
525
|
+
two_dot_suffix = " --two-dot" if two_dot else ""
|
|
526
|
+
print(
|
|
527
|
+
"[syncade] REVIEW + edit the draft (especially 'Assumptions to confirm'), "
|
|
528
|
+
f"then run: syncade {out_path}{base_suffix}{two_dot_suffix}"
|
|
529
|
+
)
|
|
530
|
+
return SUCCESS
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ``--resume`` lives in its own module (the largest one-shot handler) to keep this
|
|
534
|
+
# file under the blocking file-length cap. It renders a real reviewer diff, so it
|
|
535
|
+
# takes no ``--base``/``--scope`` guard wrapper. ``main()`` reads ``_run_resume``
|
|
536
|
+
# from this module, so the re-export is what dispatch invokes.
|
|
537
|
+
# ``--gc`` lives in its own module to keep this file under the blocking
|
|
538
|
+
# file-length cap. It is wrapped (not bare re-exported) here so the
|
|
539
|
+
# ``--base`` / ``--scope`` rejection (N13) applies uniformly across every
|
|
540
|
+
# diagnostic/maintenance mode from the one ``_reject_diff_base_flags`` helper,
|
|
541
|
+
# without reaching into gc_mode.py. ``main()`` reads ``_run_gc`` from this
|
|
542
|
+
# module, so the wrapper is what dispatch invokes.
|
|
543
|
+
from .doctor_mode import _run_doctor as _run_doctor # noqa: E402
|
|
544
|
+
from .gc_mode import _run_gc as _run_gc_impl # noqa: E402
|
|
545
|
+
from .resume_mode import _run_resume as _run_resume # noqa: E402
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _run_gc(args) -> int:
|
|
549
|
+
"""N13 guard wrapper around :func:`syncade.cli.gc_mode._run_gc`.
|
|
550
|
+
|
|
551
|
+
``--gc`` renders no reviewer diff, so ``--base`` / ``--scope`` are rejected
|
|
552
|
+
(exit 2) before the maintenance pass runs; otherwise the call delegates
|
|
553
|
+
verbatim.
|
|
554
|
+
"""
|
|
555
|
+
rejection = _reject_diff_base_flags(args, "--gc")
|
|
556
|
+
if rejection is not None:
|
|
557
|
+
return rejection
|
|
558
|
+
return _run_gc_impl(args)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
from .metrics_mode import _run_metrics as _run_metrics_impl # noqa: E402
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _run_metrics(args) -> int:
|
|
565
|
+
"""N13 guard wrapper around :func:`syncade.cli.metrics_mode._run_metrics`.
|
|
566
|
+
|
|
567
|
+
``--metrics`` renders no reviewer diff, so ``--base`` / ``--scope`` are
|
|
568
|
+
rejected (exit 2) before the read-only aggregation pass runs.
|
|
569
|
+
"""
|
|
570
|
+
rejection = _reject_diff_base_flags(args, "--metrics")
|
|
571
|
+
if rejection is not None:
|
|
572
|
+
return rejection
|
|
573
|
+
return _run_metrics_impl(args)
|