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/spec_draft.py ADDED
@@ -0,0 +1,423 @@
1
+ # SIZE_OK: 334 pure LOC; cold drafter schema, parser, and renderer move together.
2
+ # Retained to avoid separating the ratified draft contract from its parser.
3
+ # Future split: move output models/rendering to spec_draft_models.py.
4
+ """Cold-drafter engine.
5
+
6
+ Given a session *dialogue* (from :mod:`syncade.transcript`) + a *diff*, a cold
7
+ subprocess manufactures an OpenSpec-shaped :class:`SpecDraftOutput` with
8
+ **self-flagged assumptions**, so an independent reviewer can later measure the
9
+ work against a checkable intent. Structurally mirrors :mod:`syncade.spec_audit`
10
+ (registry-resolved cold adapter, isolated git-init'd tempdir, env scrub, never-raises,
11
+ mechanical ``_classify_outcome``).
12
+
13
+ The honesty property: the drafter is *disciplined*-cold, not hard-isolated — it
14
+ reads the dialogue (to find affirmed proposals) but the prompt
15
+ (``templates/spec_draft.md``) enforces the direction-based firewall (admit
16
+ forward-looking intent; exclude backward-looking self-justification). This is
17
+ coherent only because the output is **ratified by a human before use**.
18
+
19
+ Exit-code mapping (CLI mode handler, ``--draft-spec``): a well-formed draft → 0;
20
+ subprocess failure → 40; parse failure → 70 (``SpecDraftOutputError``). The
21
+ drafter has no "blocker" concept — a draft is advisory.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import tempfile
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
33
+
34
+ from syncade.adapters.base import ReviewerAdapter, ReviewerInvocationError
35
+ from syncade.adapters.registry import get_adapter
36
+ from syncade.config import ReviewerConfig
37
+ from syncade.config_cold import (
38
+ DRAFTER_MODEL as DRAFTER_MODEL,
39
+ )
40
+ from syncade.config_cold import (
41
+ DRAFTER_PERMISSIONS as DRAFTER_PERMISSIONS,
42
+ )
43
+ from syncade.config_cold import (
44
+ DRAFTER_PROVIDER as DRAFTER_PROVIDER,
45
+ )
46
+ from syncade.config_cold import (
47
+ DRAFTER_THINKING as DRAFTER_THINKING,
48
+ )
49
+ from syncade.config_cold import (
50
+ DrafterConfig,
51
+ )
52
+ from syncade.findings_json import decode_and_validate, validate_dropping_forbidden_extras
53
+ from syncade.process import (
54
+ SubprocessError,
55
+ SubprocessNotFoundError,
56
+ SubprocessResult,
57
+ SubprocessTimeoutError,
58
+ run_subprocess,
59
+ )
60
+ from syncade.prompts import load_spec_draft_template, render_spec_draft_prompt
61
+
62
+ # The drafter provisions an isolated tempdir, git-inits it, and scrubs env —
63
+ # identical cold-isolation needs as the synthesizer + auditor. Reuse their helpers.
64
+ from syncade.synthesizer import _init_workspace_git, _scrub_env_for_cold_synth
65
+
66
+ DEFAULT_SPEC_DRAFT_TIMEOUT_SECONDS = 600.0
67
+
68
+
69
+ class SpecDraftOutputError(Exception):
70
+ """Raised when the drafter's stdout can't be parsed as a
71
+ :class:`SpecDraftOutput`. The CLI routes this to exit 70."""
72
+
73
+
74
+ def _reject_whitespace(value: str) -> str:
75
+ if not value.strip():
76
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
77
+ "must contain non-whitespace content; got an all-whitespace value"
78
+ )
79
+ return value
80
+
81
+
82
+ class Criterion(BaseModel):
83
+ """One acceptance criterion + its honesty tag. ``origin="inferred"`` marks a
84
+ criterion the drafter inferred rather than transcribed from the user's explicit
85
+ words — a ratification confirm-point."""
86
+
87
+ model_config = ConfigDict(extra="forbid")
88
+
89
+ text: str = Field(description="A discrete, independently checkable, scenario-style criterion.")
90
+ origin: Literal["transcribed", "inferred"] = Field(
91
+ description="`transcribed` = from the user's explicit words; `inferred` = drafter-inferred."
92
+ )
93
+
94
+ _no_ws_text = field_validator("text")(_reject_whitespace)
95
+
96
+
97
+ class Delta(BaseModel):
98
+ """An OpenSpec-style requirement delta inferred from the diff."""
99
+
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ kind: Literal["ADDED", "MODIFIED", "REMOVED"]
103
+ capability: str = Field(description="The capability/area this requirement touches.")
104
+ requirement: str = Field(description="What the requirement is.")
105
+
106
+ _no_ws_cap = field_validator("capability")(_reject_whitespace)
107
+ _no_ws_req = field_validator("requirement")(_reject_whitespace)
108
+
109
+
110
+ class SpecDraftOutput(BaseModel):
111
+ """The cold drafter's manufactured spec (OpenSpec-shaped, self-flagged)."""
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ proposal: str = Field(description="Why this work exists and what changes, in the user's terms.")
116
+ acceptance_criteria: list[Criterion] = Field(
117
+ min_length=1, description="The yardstick units; at least one."
118
+ )
119
+ deltas: list[Delta] = Field(default_factory=list)
120
+ assumptions: list[str] = Field(
121
+ default_factory=list,
122
+ description="Cross-cutting inferences the drafter made; ratification confirm-points.",
123
+ )
124
+
125
+ _no_ws_proposal = field_validator("proposal")(_reject_whitespace)
126
+
127
+ @field_validator("assumptions")
128
+ @classmethod
129
+ def _assumptions_non_whitespace(cls, v: list[str]) -> list[str]:
130
+ for item in v:
131
+ if not item.strip():
132
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
133
+ "assumptions entries must be non-whitespace"
134
+ )
135
+ return v
136
+
137
+
138
+ def get_spec_draft_schema_string() -> str:
139
+ """The schema description embedded in the drafter prompt's ``{json_schema}``
140
+ placeholder (a value, not a format string — single braces)."""
141
+ return (
142
+ "{\n"
143
+ ' "proposal": "<why this work exists + what changes, in the user\'s terms>",\n'
144
+ ' "acceptance_criteria": [\n'
145
+ ' {"text": "<discrete, checkable, scenario-style criterion>",\n'
146
+ ' "origin": "transcribed" | "inferred"}\n'
147
+ " ],\n"
148
+ ' "deltas": [\n'
149
+ ' {"kind": "ADDED" | "MODIFIED" | "REMOVED",\n'
150
+ ' "capability": "<area>", "requirement": "<what changes>"}\n'
151
+ " ],\n"
152
+ ' "assumptions": ["<a cross-cutting inference the user should confirm>"]\n'
153
+ "}\n\n"
154
+ "Rules: acceptance_criteria must have at least one entry; every criterion\n"
155
+ "needs an `origin`; `deltas` and `assumptions` may be empty lists; no extra\n"
156
+ "fields (the parser uses extra=forbid and will reject unknown keys)."
157
+ )
158
+
159
+
160
+ def render_draft_spec_markdown(
161
+ output: SpecDraftOutput, *, source_label: str, base_ref: str | None = None
162
+ ) -> str:
163
+ """Render a :class:`SpecDraftOutput` into a ratifiable OpenSpec-shaped markdown
164
+ spec. The **Assumptions to confirm** section surfaces every inferred criterion
165
+ + cross-cutting assumption as the human's ratification confirm-points (the
166
+ honesty backstop — review/edit before running ``syncade <this-file>``).
167
+
168
+ ``base_ref`` is the ``--base`` ref used when drafting, if any. When supplied it
169
+ is embedded in the ratification command so the later review runs against the
170
+ same diff the drafter saw."""
171
+ syncade_cmd = (
172
+ f"`syncade <this-file> --base {base_ref}`" if base_ref else "`syncade <this-file>`"
173
+ )
174
+ lines: list[str] = [
175
+ f"# Draft spec: {source_label}",
176
+ "",
177
+ (
178
+ "> Manufactured by `syncade --draft-spec` from a session transcript. This is a "
179
+ "DRAFT — review and edit it before use. The **Assumptions to confirm** section "
180
+ "lists everything the drafter inferred rather than transcribed; confirm or correct "
181
+ f"each. Once ratified, run {syncade_cmd} to review your work against it."
182
+ ),
183
+ "",
184
+ "## Why / What",
185
+ "",
186
+ output.proposal.strip(),
187
+ "",
188
+ "## Acceptance criteria",
189
+ "",
190
+ ]
191
+ for i, criterion in enumerate(output.acceptance_criteria, start=1):
192
+ tag = "" if criterion.origin == "transcribed" else " _(inferred — confirm)_"
193
+ lines.append(f"{i}. {criterion.text.strip()}{tag}")
194
+ if output.deltas:
195
+ lines += ["", "## Deltas", ""]
196
+ for delta in output.deltas:
197
+ lines.append(f"- **{delta.kind}** ({delta.capability}): {delta.requirement}")
198
+ lines += ["", "## Assumptions to confirm", ""]
199
+ inferred = [c for c in output.acceptance_criteria if c.origin == "inferred"]
200
+ if inferred or output.assumptions:
201
+ lines.append(
202
+ "These were INFERRED, not stated outright — confirm or correct each before "
203
+ "relying on this spec:"
204
+ )
205
+ lines.append("")
206
+ for criterion in inferred:
207
+ lines.append(f"- (criterion) {criterion.text.strip()}")
208
+ for assumption in output.assumptions:
209
+ lines.append(f"- {assumption.strip()}")
210
+ else:
211
+ lines.append(
212
+ "(none — every acceptance criterion was transcribed from your explicit words; "
213
+ "no cross-cutting assumptions were flagged.)"
214
+ )
215
+ return "\n".join(lines) + "\n"
216
+
217
+
218
+ def parse_spec_draft_output(raw: str) -> SpecDraftOutput:
219
+ """Parse the drafter's raw stdout into a :class:`SpecDraftOutput`.
220
+
221
+ Selects exactly ONE verdict block via
222
+ :func:`syncade.findings_json._decode_verdict_object` (last
223
+ ``json``/unlabeled fence, else the whole response) and validates it. No
224
+ fallback to an earlier block — see :mod:`syncade.findings_json`. Raises
225
+ :class:`SpecDraftOutputError` on failure (→ exit 70)."""
226
+ return decode_and_validate(
227
+ raw,
228
+ # Same repair the reviewer gets (PR-h-field-05 item 2): a verdict whose ONLY
229
+ # defect is a forbidden extra key is repaired, not discarded. Cheaper leg than
230
+ # a reviewer, but the failure mode and the eligibility rule are identical.
231
+ validate=lambda payload: validate_dropping_forbidden_extras(
232
+ payload, SpecDraftOutput.model_validate, label="spec draft"
233
+ ),
234
+ error=SpecDraftOutputError,
235
+ label="spec draft",
236
+ model_name="SpecDraftOutput",
237
+ artifact="spec-drafter.stdout in the run directory",
238
+ )
239
+
240
+
241
+ # --- Cold drafter knobs -------------------------------------------------------
242
+ # The four model knobs are now the DEFAULTS of the [drafter] config block and live
243
+ # in `syncade.config_cold` (PR-v2-23); re-exported here so existing importers keep
244
+ # working. DRAFTER_NAME stays a real constant — it is an artifact basename, not a
245
+ # knob. Anything wanting the values in effect for THIS run reads
246
+ # `SyncadeConfig.drafter`, not these.
247
+ DRAFTER_NAME = "spec-drafter"
248
+
249
+
250
+ @dataclass # MUTABLE_OK, SLOTS_OK: result shape is persisted and kept stable.
251
+ class SpecDraftResult:
252
+ """Outcome of a drafter run. ``drafted`` ⟺ output set + error None;
253
+ ``subprocess_error`` ⟺ output None + error set (parse failures carry a
254
+ :class:`SpecDraftOutputError`, which the CLI maps to exit 70)."""
255
+
256
+ outcome: Literal["drafted", "subprocess_error"]
257
+ output: SpecDraftOutput | None
258
+ error: Exception | None
259
+ duration_seconds: float
260
+ raw_subprocess_result: SubprocessResult | None = field(default=None)
261
+
262
+ def __post_init__(self) -> None:
263
+ if self.outcome == "subprocess_error": # IF_VARIANT_OK: existing invariant guard.
264
+ if self.output is not None:
265
+ raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
266
+ "subprocess_error requires output=None"
267
+ )
268
+ if self.error is None:
269
+ raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
270
+ "subprocess_error requires a non-None error"
271
+ )
272
+ elif self.outcome == "drafted":
273
+ if self.output is None:
274
+ raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
275
+ "drafted requires a non-None output"
276
+ )
277
+ if self.error is not None:
278
+ raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
279
+ "drafted requires error=None"
280
+ )
281
+ else:
282
+ raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
283
+ f"unknown outcome {self.outcome!r}"
284
+ )
285
+
286
+
287
+ def _classify_outcome(output: SpecDraftOutput) -> Literal["drafted"]:
288
+ """A well-formed draft is always ``drafted`` — the drafter is advisory and has
289
+ no blocker concept (the human ratifies; the later review judges)."""
290
+ del output
291
+ return "drafted"
292
+
293
+
294
+ def run_spec_draft(
295
+ *,
296
+ dialogue: str,
297
+ diff: str,
298
+ repo_root: Path,
299
+ timeout_seconds: float = DEFAULT_SPEC_DRAFT_TIMEOUT_SECONDS,
300
+ config: DrafterConfig | None = None,
301
+ adapter: ReviewerAdapter | None = None,
302
+ ) -> SpecDraftResult:
303
+ """Manufacture a spec from ``dialogue`` + ``diff`` via a cold subprocess.
304
+
305
+ Writes the two inputs into an isolated git-init'd tempdir, renders the
306
+ firewall prompt, runs the drafter, parses the output. Never raises — every
307
+ failure maps to ``outcome="subprocess_error"`` (parse failures carry a
308
+ :class:`SpecDraftOutputError`). ``repo_root`` is used only for per-repo
309
+ template override + the env-scrub substring check, never passed to the
310
+ subprocess as cwd.
311
+
312
+ ``config`` is the ``[drafter]`` block; ``adapter`` defaults to
313
+ ``get_adapter(config.provider)`` — the same registry the dispatcher uses, so a
314
+ box with no ``codex`` on PATH can still draft a spec (PR-v2-23)."""
315
+ run_start = time.monotonic()
316
+ draft_cfg = config if config is not None else DrafterConfig()
317
+ adapter = adapter if adapter is not None else get_adapter(draft_cfg.provider)
318
+
319
+ def _err(exc: Exception, raw: SubprocessResult | None = None) -> SpecDraftResult:
320
+ return SpecDraftResult(
321
+ outcome="subprocess_error",
322
+ output=None,
323
+ error=exc,
324
+ duration_seconds=time.monotonic() - run_start,
325
+ raw_subprocess_result=raw,
326
+ )
327
+
328
+ with tempfile.TemporaryDirectory(prefix="syncade-draft-") as workspace_str:
329
+ workspace = Path(workspace_str)
330
+ try:
331
+ _init_workspace_git(workspace)
332
+ except SubprocessError as exc:
333
+ return _err(exc)
334
+
335
+ try:
336
+ dialogue_file = workspace / "dialogue.md"
337
+ dialogue_file.write_text(dialogue, encoding="utf-8")
338
+ diff_file = workspace / "diff.txt"
339
+ diff_file.write_text(
340
+ diff if diff.strip() else "(no diff provided — draft from the dialogue alone)\n",
341
+ encoding="utf-8",
342
+ )
343
+ except OSError as exc:
344
+ return _err(
345
+ SubprocessError(f"spec draft: failed to write inputs to {workspace}: {exc}")
346
+ )
347
+
348
+ template = load_spec_draft_template(repo_root)
349
+ try:
350
+ prompt = render_spec_draft_prompt(
351
+ template,
352
+ dialogue_path=str(dialogue_file),
353
+ diff_path=str(diff_file),
354
+ json_schema=get_spec_draft_schema_string(),
355
+ )
356
+ except KeyError as exc:
357
+ return _err(
358
+ SubprocessError(
359
+ f"spec draft: template has unknown placeholder {exc} — "
360
+ "check .syncade/templates/spec_draft.md"
361
+ )
362
+ )
363
+
364
+ draft_config = ReviewerConfig(
365
+ name=DRAFTER_NAME,
366
+ provider=draft_cfg.provider,
367
+ model=draft_cfg.model,
368
+ thinking=draft_cfg.thinking,
369
+ permissions=draft_cfg.permissions,
370
+ # PR-v2-24: carry the auth declaration onto the synthetic config the
371
+ # adapter sees. Without this the cold actors would silently run
372
+ # unenforced -- the classic 'guarded four of five actors' leak.
373
+ auth=draft_cfg.auth,
374
+ api_key_env=draft_cfg.api_key_env,
375
+ )
376
+ try:
377
+ invocation = adapter.build_invocation(draft_config, workspace, prompt)
378
+ except ValueError as exc:
379
+ return _err(exc)
380
+
381
+ try:
382
+ subprocess_result = run_subprocess(
383
+ invocation.argv,
384
+ cwd=workspace,
385
+ env=_scrub_env_for_cold_synth(invocation.env, repo_root),
386
+ timeout=timeout_seconds,
387
+ input_text=invocation.stdin_text,
388
+ )
389
+ except SubprocessTimeoutError as exc:
390
+ elapsed = time.monotonic() - run_start
391
+ return SpecDraftResult(
392
+ outcome="subprocess_error",
393
+ output=None,
394
+ error=exc,
395
+ duration_seconds=elapsed,
396
+ raw_subprocess_result=SubprocessResult(
397
+ returncode=-1, stdout=exc.stdout, stderr=exc.stderr, duration_seconds=elapsed
398
+ ),
399
+ )
400
+ except (SubprocessNotFoundError, SubprocessError) as exc:
401
+ return _err(exc)
402
+
403
+ try:
404
+ final_text = adapter.extract_final_text(
405
+ subprocess_result, empty_output_exception_class=SpecDraftOutputError
406
+ )
407
+ except (ReviewerInvocationError, SpecDraftOutputError) as exc:
408
+ return _err(exc, subprocess_result)
409
+ except Exception as exc: # noqa: BLE001 # BROAD_EXCEPT_OK: boundary converts parser surprises.
410
+ return _err(exc, subprocess_result)
411
+
412
+ try:
413
+ output = parse_spec_draft_output(final_text)
414
+ except SpecDraftOutputError as exc:
415
+ return _err(exc, subprocess_result)
416
+
417
+ return SpecDraftResult(
418
+ outcome=_classify_outcome(output),
419
+ output=output,
420
+ error=None,
421
+ duration_seconds=time.monotonic() - run_start,
422
+ raw_subprocess_result=subprocess_result,
423
+ )
syncade/spec_source.py ADDED
@@ -0,0 +1,135 @@
1
+ """OpenSpec tier-B consumption.
2
+
3
+ Turns an existing **OpenSpec change folder** into the one artifact the syncade
4
+ core already consumes — a markdown spec at ``pr_doc_path`` — so a team that
5
+ already lives in OpenSpec can run syncade without re-writing a brief
6
+ ("if you have openspec, we work with openspec").
7
+
8
+ **Consume, don't depend** (the load-bearing front-door constraint): this module
9
+ reads the OpenSpec markdown files *directly* with :mod:`pathlib`. It never invokes
10
+ the ``openspec`` binary, imports its tooling, adds a dependency, or mutates the
11
+ ``openspec/`` folder. syncade stays Python / minimal-deps / format-agnostic — the
12
+ core cannot tell an OpenSpec-derived spec from a hand-written brief.
13
+
14
+ Folder shape consumed (verified against OpenSpec CLI v0.13.0)::
15
+
16
+ openspec/changes/<change-id>/
17
+ proposal.md # ## Why / ## What Changes / ## Impact
18
+ specs/<capability>/spec.md # ## ADDED|MODIFIED|REMOVED Requirements
19
+
20
+ The assembled spec concatenates ``proposal.md`` + every capability's spec delta
21
+ **verbatim** under generated headers (zero interpretation — the reviewers read
22
+ the OpenSpec markdown as-is; nothing is manufactured). Capabilities are ordered
23
+ deterministically (sorted) so the assembled spec is stable across runs.
24
+
25
+ The diff base is unchanged — it still comes from ``--base`` / ``--scope``.
26
+ OpenSpec deltas are relative to a living-spec base; syncade's diff is relative to
27
+ a git SHA. change does NOT reconcile those anchors: it consumes the change folder as
28
+ *intent* (the spec) only.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+
35
+ _ARCHIVE_DIRNAME = "archive"
36
+ """OpenSpec archives completed changes under ``changes/archive/``; it is never an
37
+ active change to review."""
38
+
39
+
40
+ class SpecSourceError(Exception):
41
+ """A spec source could not be resolved or assembled (no ``openspec/`` folder,
42
+ unknown / ambiguous change-id, or a change folder missing ``proposal.md``).
43
+ The CLI surfaces it as a stop-before-the-loop per CLAUDE.md's "Exit-code
44
+ convention for CLI mode handlers"."""
45
+
46
+
47
+ def _changes_dir(repo_root: Path) -> Path:
48
+ return repo_root / "openspec" / "changes"
49
+
50
+
51
+ def find_openspec_changes(repo_root: Path) -> list[str]:
52
+ """Return the active OpenSpec change-ids under ``<repo>/openspec/changes/``,
53
+ sorted. An active change is a subdirectory containing a ``proposal.md`` (the
54
+ ``archive/`` subdir and any dir without a proposal are excluded). Returns
55
+ ``[]`` when there is no ``openspec/changes/`` folder at all."""
56
+ changes_dir = _changes_dir(repo_root)
57
+ if not changes_dir.is_dir():
58
+ return []
59
+ ids: list[str] = []
60
+ for child in sorted(changes_dir.iterdir()):
61
+ if child.name == _ARCHIVE_DIRNAME:
62
+ continue
63
+ if child.is_dir() and (child / "proposal.md").is_file():
64
+ ids.append(child.name)
65
+ return ids
66
+
67
+
68
+ def resolve_openspec_change(repo_root: Path, change_id: str | None) -> str:
69
+ """Pick the change-id to consume.
70
+
71
+ - ``change_id`` given → it must be an active change, else
72
+ :class:`SpecSourceError` (names the unknown id + lists the active ones).
73
+ - ``change_id is None`` → auto-resolve IFF exactly one active change exists;
74
+ zero or several → :class:`SpecSourceError` (ask, never guess — names the
75
+ candidates so the operator can pass ``--openspec <id>``).
76
+ """
77
+ changes = find_openspec_changes(repo_root)
78
+ if change_id is not None:
79
+ if change_id not in changes:
80
+ active = ", ".join(changes) if changes else "none"
81
+ raise SpecSourceError(
82
+ f"openspec change {change_id!r} not found under openspec/changes/ "
83
+ f"(active changes: {active})"
84
+ )
85
+ return change_id
86
+ if len(changes) == 1:
87
+ return changes[0]
88
+ if not changes:
89
+ raise SpecSourceError(
90
+ "no active openspec changes found under openspec/changes/ — pass a PR "
91
+ "brief path, or create an openspec change proposal first"
92
+ )
93
+ raise SpecSourceError(
94
+ "multiple active openspec changes ("
95
+ + ", ".join(changes)
96
+ + "); pass one explicitly: --openspec <change-id>"
97
+ )
98
+
99
+
100
+ def assemble_openspec_spec(repo_root: Path, change_id: str) -> str:
101
+ """Assemble ``openspec/changes/<change_id>/`` into a single self-contained
102
+ markdown spec string: the proposal + every capability's spec delta, verbatim,
103
+ under generated headers. Capabilities are sorted for determinism.
104
+
105
+ Raises :class:`SpecSourceError` if the change folder has no ``proposal.md``.
106
+ """
107
+ change_dir = _changes_dir(repo_root) / change_id
108
+ proposal = change_dir / "proposal.md"
109
+ if not proposal.is_file():
110
+ raise SpecSourceError(
111
+ f"openspec change {change_id!r} has no proposal.md at {proposal} — "
112
+ "cannot assemble a spec from it"
113
+ )
114
+
115
+ parts: list[str] = [
116
+ f"# OpenSpec change: {change_id}",
117
+ (
118
+ "> Assembled by syncade from the OpenSpec change folder "
119
+ f"`openspec/changes/{change_id}/` (read-only). The sections below are "
120
+ "the change's proposal and spec deltas verbatim; review the diff "
121
+ "against this intent."
122
+ ),
123
+ "## Proposal",
124
+ proposal.read_text(encoding="utf-8").strip(),
125
+ ]
126
+
127
+ specs_dir = change_dir / "specs"
128
+ if specs_dir.is_dir():
129
+ delta_files = sorted(specs_dir.rglob("spec.md"), key=lambda p: p.as_posix())
130
+ for delta in delta_files:
131
+ capability = delta.parent.relative_to(specs_dir).as_posix()
132
+ parts.append(f"## Spec delta: {capability}")
133
+ parts.append(delta.read_text(encoding="utf-8").strip())
134
+
135
+ return "\n\n".join(parts) + "\n"