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
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)