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/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"
|