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_audit.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# SIZE_OK: 357 pure LOC; spec-audit keeps one cold diagnostic contract.
|
|
2
|
+
# Retained to avoid splitting tightly coupled prompt/workspace/result handling.
|
|
3
|
+
# Future split: extract prompt/workspace orchestration when that behavior changes.
|
|
4
|
+
"""Spec audit subprocess phase.
|
|
5
|
+
|
|
6
|
+
Pre-flight diagnostic that audits the PR brief itself BEFORE the review
|
|
7
|
+
loop dispatches reviewers. Surfaces: unverified claims about external
|
|
8
|
+
behavior, internal contradictions, ambiguous acceptance criteria, missing
|
|
9
|
+
references, scope drift, and missing structural sections. The auditor is
|
|
10
|
+
a single cold subprocess, provider resolved from the ``[auditor]`` config
|
|
11
|
+
block (PR-v2-23). Inputs: just the PR brief. No diff, no reviewer outputs,
|
|
12
|
+
no worktree context.
|
|
13
|
+
|
|
14
|
+
Advisory for v1 — the loop does not refuse to run when the spec audit
|
|
15
|
+
finds blockers; the operator decides whether to edit the brief or proceed.
|
|
16
|
+
|
|
17
|
+
Exit codes via ``syncade --spec-audit`` are split between the auditor's
|
|
18
|
+
own outcomes (this module) and the CLI mode handler's pre-flight
|
|
19
|
+
(``syncade.cli._run_spec_audit``):
|
|
20
|
+
|
|
21
|
+
- 0 (ready) — auditor outcome
|
|
22
|
+
- 10 (needs-clarification) — auditor outcome
|
|
23
|
+
- 40 (subprocess error) — auditor outcome
|
|
24
|
+
- 70 (parse failure) — auditor outcome
|
|
25
|
+
- 50 (config load failure) — CLI mode handler; emitted before
|
|
26
|
+
``run_spec_audit`` is called
|
|
27
|
+
- 60 (path validation failure) — CLI mode handler; PR_DOC doesn't
|
|
28
|
+
exist or isn't readable
|
|
29
|
+
|
|
30
|
+
The 50/60 split follows the CLI mode handler convention shared with
|
|
31
|
+
``_run_selfcheck`` and ``_run_auth_check`` — see CLAUDE.md's
|
|
32
|
+
"Exit-code convention for CLI mode handlers" subsection.
|
|
33
|
+
|
|
34
|
+
Structural invariants this module enforces:
|
|
35
|
+
|
|
36
|
+
- *Process isolation.* The auditor is a fresh subprocess, same as
|
|
37
|
+
reviewers and producer. No shared context with the operator's interactive
|
|
38
|
+
session.
|
|
39
|
+
- *Cold inputs.* The auditor receives only the PR brief text. No diff, no
|
|
40
|
+
reviewer outputs, no test results.
|
|
41
|
+
- *Cannot invent.* The ``SpecAuditOutput`` schema requires findings to
|
|
42
|
+
cite a ``section`` and ``line`` (per the "cannot-invent" spirit of the
|
|
43
|
+
synthesizer). The schema uses ``extra="forbid"`` to reject aliases.
|
|
44
|
+
- *Configurable provider.* Resolved from ``[auditor]`` in ``.syncade/config.toml``
|
|
45
|
+
(PR-v2-23); defaults to ``openai``/``gpt-5.5``, ``thinking=xhigh``,
|
|
46
|
+
``permissions=trusted-execute``.
|
|
47
|
+
|
|
48
|
+
Workspace setup mirrors :mod:`syncade.synthesizer`: isolated tempdir,
|
|
49
|
+
git-init, copy of the PR doc, env scrub of repo_root-leaking variables.
|
|
50
|
+
Reuses :func:`~syncade.synthesizer._init_workspace_git` and
|
|
51
|
+
:func:`~syncade.synthesizer._scrub_env_for_cold_synth` directly — same
|
|
52
|
+
logic, no duplication.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import shutil
|
|
58
|
+
import tempfile
|
|
59
|
+
import time
|
|
60
|
+
from dataclasses import dataclass, field
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Literal
|
|
63
|
+
|
|
64
|
+
from syncade.adapters.base import ReviewerAdapter, ReviewerInvocationError
|
|
65
|
+
from syncade.adapters.registry import get_adapter
|
|
66
|
+
from syncade.config import ReviewerConfig
|
|
67
|
+
from syncade.config_cold import (
|
|
68
|
+
AUDITOR_MODEL as AUDITOR_MODEL,
|
|
69
|
+
)
|
|
70
|
+
from syncade.config_cold import (
|
|
71
|
+
AUDITOR_PERMISSIONS as AUDITOR_PERMISSIONS,
|
|
72
|
+
)
|
|
73
|
+
from syncade.config_cold import (
|
|
74
|
+
AUDITOR_PROVIDER as AUDITOR_PROVIDER,
|
|
75
|
+
)
|
|
76
|
+
from syncade.config_cold import (
|
|
77
|
+
AUDITOR_THINKING as AUDITOR_THINKING,
|
|
78
|
+
)
|
|
79
|
+
from syncade.config_cold import (
|
|
80
|
+
AuditorConfig,
|
|
81
|
+
)
|
|
82
|
+
from syncade.findings import Severity as Severity
|
|
83
|
+
from syncade.process import (
|
|
84
|
+
SubprocessError,
|
|
85
|
+
SubprocessNotFoundError,
|
|
86
|
+
SubprocessResult,
|
|
87
|
+
SubprocessTimeoutError,
|
|
88
|
+
run_subprocess,
|
|
89
|
+
)
|
|
90
|
+
from syncade.prompts import load_spec_audit_template, render_spec_audit_prompt
|
|
91
|
+
|
|
92
|
+
# the pydantic schema + parser moved to spec_audit_schema; re-exported
|
|
93
|
+
# here so syncade.spec_audit.<name> import paths are unchanged.
|
|
94
|
+
from syncade.spec_audit_schema import (
|
|
95
|
+
AuditVerdict,
|
|
96
|
+
IssueClass,
|
|
97
|
+
SpecAuditFinding,
|
|
98
|
+
SpecAuditOutput,
|
|
99
|
+
SpecAuditOutputError,
|
|
100
|
+
get_spec_audit_schema_string,
|
|
101
|
+
parse_spec_audit_output,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Reuse the synthesizer's cold-workspace helpers rather than duplicating.
|
|
105
|
+
# Both modules provision an isolated tempdir, git-init it, and scrub env —
|
|
106
|
+
# identical logic, intentional reuse within the same package.
|
|
107
|
+
from syncade.synthesizer import _init_workspace_git, _scrub_env_for_cold_synth
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"AUDITOR_MODEL",
|
|
111
|
+
"AUDITOR_NAME",
|
|
112
|
+
"AUDITOR_PERMISSIONS",
|
|
113
|
+
"AUDITOR_PROVIDER",
|
|
114
|
+
"AUDITOR_THINKING",
|
|
115
|
+
"DEFAULT_SPEC_AUDIT_TIMEOUT_SECONDS",
|
|
116
|
+
"AuditVerdict",
|
|
117
|
+
"IssueClass",
|
|
118
|
+
"Severity",
|
|
119
|
+
"SpecAuditFinding",
|
|
120
|
+
"SpecAuditOutput",
|
|
121
|
+
"SpecAuditOutputError",
|
|
122
|
+
"SpecAuditResult",
|
|
123
|
+
"get_spec_audit_schema_string",
|
|
124
|
+
"parse_spec_audit_output",
|
|
125
|
+
"run_spec_audit",
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Auditor knobs
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# The four model knobs are now the DEFAULTS of the [auditor] config block and live
|
|
132
|
+
# in `syncade.config_cold` (PR-v2-23); re-exported below (see the import block) so
|
|
133
|
+
# existing importers keep working. Anything wanting the values in effect for THIS
|
|
134
|
+
# run reads `SyncadeConfig.auditor`, not these.
|
|
135
|
+
|
|
136
|
+
AUDITOR_NAME = "spec-auditor"
|
|
137
|
+
"""Persistence basename for the auditor's artifacts. Not a knob — stays a real
|
|
138
|
+
constant."""
|
|
139
|
+
|
|
140
|
+
DEFAULT_SPEC_AUDIT_TIMEOUT_SECONDS: float = 300.0
|
|
141
|
+
"""Default timeout for the spec audit subprocess. Generous — the auditor
|
|
142
|
+
reads a brief (usually <2KB) and emits a structured JSON verdict. Healthy
|
|
143
|
+
runs complete in 30–90s; the 300s ceiling is a safety margin."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Result dataclass
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True) # SLOTS_OK: result shape is persisted and kept stable.
|
|
152
|
+
class SpecAuditResult:
|
|
153
|
+
"""Outcome of one spec audit subprocess run.
|
|
154
|
+
|
|
155
|
+
Mirrors :class:`~syncade.synthesizer.SynthesizerResult` /
|
|
156
|
+
:class:`~syncade.test_runner.TestRunResult` /
|
|
157
|
+
:class:`~syncade.producer.ProducerResult` shape so persistence and the
|
|
158
|
+
CLI can treat all subprocess-result types uniformly.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
outcome: ``"ready"`` iff the audit ran cleanly AND found no
|
|
162
|
+
blocker-severity findings. ``"needs_clarification"`` iff the
|
|
163
|
+
audit ran cleanly AND found at least one blocker-severity
|
|
164
|
+
finding. ``"subprocess_error"`` iff the audit subprocess itself
|
|
165
|
+
failed (or parse-failed — check ``isinstance(error,
|
|
166
|
+
SpecAuditOutputError)`` to distinguish exit-70 from exit-40).
|
|
167
|
+
output: The parsed :class:`SpecAuditOutput` on success;
|
|
168
|
+
``None`` on ``subprocess_error``.
|
|
169
|
+
error: The exception that fired on ``subprocess_error``
|
|
170
|
+
(:class:`SpecAuditOutputError` for parse failures → exit 70;
|
|
171
|
+
:class:`~syncade.adapters.base.ReviewerInvocationError` or
|
|
172
|
+
:class:`~syncade.process.SubprocessError` subclass for
|
|
173
|
+
subprocess failures → exit 40). ``None`` on success.
|
|
174
|
+
duration_seconds: Wall-clock duration of the auditor subprocess.
|
|
175
|
+
raw_subprocess_result: The :class:`SubprocessResult` from the
|
|
176
|
+
auditor subprocess, preserved so persistence can write artifacts
|
|
177
|
+
even on timeouts and parse failures. ``None`` only when the
|
|
178
|
+
subprocess never started.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
outcome: Literal["ready", "needs_clarification", "subprocess_error"]
|
|
182
|
+
output: SpecAuditOutput | None
|
|
183
|
+
error: Exception | None
|
|
184
|
+
duration_seconds: float
|
|
185
|
+
raw_subprocess_result: SubprocessResult | None = field(default=None)
|
|
186
|
+
|
|
187
|
+
def __post_init__(self) -> None:
|
|
188
|
+
"""Enforce the consistency table before persistence sees it."""
|
|
189
|
+
if self.outcome == "subprocess_error":
|
|
190
|
+
if self.output is not None:
|
|
191
|
+
raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
|
|
192
|
+
"SpecAuditResult(outcome='subprocess_error') requires output=None; "
|
|
193
|
+
f"got output={self.output!r}"
|
|
194
|
+
)
|
|
195
|
+
if self.error is None:
|
|
196
|
+
raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
|
|
197
|
+
"SpecAuditResult(outcome='subprocess_error') requires error to be non-None"
|
|
198
|
+
)
|
|
199
|
+
elif self.outcome in ("ready", "needs_clarification"):
|
|
200
|
+
if self.output is None:
|
|
201
|
+
raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
|
|
202
|
+
f"SpecAuditResult(outcome={self.outcome!r}) requires output to be non-None"
|
|
203
|
+
)
|
|
204
|
+
if self.error is not None:
|
|
205
|
+
raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
|
|
206
|
+
f"SpecAuditResult(outcome={self.outcome!r}) requires error=None; "
|
|
207
|
+
f"got error={self.error!r}"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
raise ValueError( # GENERIC_ERR_OK: dataclass invariant preserves existing API.
|
|
211
|
+
f"SpecAuditResult: unknown outcome {self.outcome!r}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Schema string
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Main entry point
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _classify_outcome(output: SpecAuditOutput) -> Literal["ready", "needs_clarification"]:
|
|
226
|
+
"""Derive the SpecAuditResult outcome from the parsed SpecAuditOutput.
|
|
227
|
+
|
|
228
|
+
``"needs_clarification"`` iff any finding has severity ``"blocker"``.
|
|
229
|
+
The ``verdict`` field carries the auditor's own judgment, but the
|
|
230
|
+
outcome classification is mechanical — same philosophy as the
|
|
231
|
+
synthesizer's mechanical verdict.
|
|
232
|
+
"""
|
|
233
|
+
if any(f.severity == "blocker" for f in output.findings):
|
|
234
|
+
return "needs_clarification"
|
|
235
|
+
return "ready"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_spec_audit(
|
|
239
|
+
*,
|
|
240
|
+
pr_doc_path: Path,
|
|
241
|
+
repo_root: Path,
|
|
242
|
+
timeout_seconds: float = DEFAULT_SPEC_AUDIT_TIMEOUT_SECONDS,
|
|
243
|
+
config: AuditorConfig | None = None,
|
|
244
|
+
adapter: ReviewerAdapter | None = None,
|
|
245
|
+
) -> SpecAuditResult:
|
|
246
|
+
"""Audit the PR brief at ``pr_doc_path`` for spec-level issues.
|
|
247
|
+
|
|
248
|
+
Lifecycle:
|
|
249
|
+
|
|
250
|
+
1. Provision an isolated cold workspace (tempdir, copy of the PR
|
|
251
|
+
doc, git-init for trusted-execute).
|
|
252
|
+
2. Render the spec audit prompt from the template at
|
|
253
|
+
``<repo_root>/.syncade/templates/spec_audit.md`` (or the packaged
|
|
254
|
+
default).
|
|
255
|
+
3. Build the invocation from the ``[auditor]`` config block through the
|
|
256
|
+
``adapter`` — resolved from the ADAPTER REGISTRY by ``config.provider``
|
|
257
|
+
when not injected, so no ``codex`` on PATH is required (PR-v2-23).
|
|
258
|
+
4. Run the subprocess via :func:`syncade.process.run_subprocess`.
|
|
259
|
+
5. Extract the model's final text via
|
|
260
|
+
:meth:`~syncade.adapters.base.ReviewerAdapter.extract_final_text`.
|
|
261
|
+
6. Parse via :func:`parse_spec_audit_output`.
|
|
262
|
+
7. Classify :attr:`SpecAuditResult.outcome` based on blocker findings.
|
|
263
|
+
|
|
264
|
+
Never raises; all failure modes map to
|
|
265
|
+
``outcome="subprocess_error"``. The caller (CLI) inspects
|
|
266
|
+
``isinstance(result.error, SpecAuditOutputError)`` to distinguish
|
|
267
|
+
exit 70 (parse failure) from exit 40 (subprocess failure).
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
pr_doc_path: Absolute path to the PR brief to audit.
|
|
271
|
+
repo_root: The git repo root. Used for (a) per-repo template-
|
|
272
|
+
override lookup and (b) env-scrub substring check. NOT
|
|
273
|
+
passed to the auditor subprocess as cwd/-C/--add-dir.
|
|
274
|
+
timeout_seconds: Wall-clock timeout for the auditor subprocess.
|
|
275
|
+
config: The ``[auditor]`` block. Defaults to
|
|
276
|
+
:class:`~syncade.config_cold.AuditorConfig` defaults, which reproduce
|
|
277
|
+
the pre-PR-v2-23 hardcoded constants exactly.
|
|
278
|
+
adapter: Optional :class:`~syncade.adapters.base.ReviewerAdapter`.
|
|
279
|
+
Defaults to ``get_adapter(config.provider)``. Tests pass a
|
|
280
|
+
:class:`~syncade.adapters.fake.FakeAuditorAdapter`.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
:class:`SpecAuditResult` carrying either the parsed output or the
|
|
284
|
+
captured exception, plus the raw subprocess result for persistence.
|
|
285
|
+
"""
|
|
286
|
+
run_start = time.monotonic()
|
|
287
|
+
audit_cfg = config if config is not None else AuditorConfig()
|
|
288
|
+
adapter = adapter if adapter is not None else get_adapter(audit_cfg.provider)
|
|
289
|
+
|
|
290
|
+
# --- Cold workspace ----------------------------------------------------
|
|
291
|
+
with tempfile.TemporaryDirectory(prefix="syncade-audit-") as workspace_str:
|
|
292
|
+
workspace = Path(workspace_str)
|
|
293
|
+
|
|
294
|
+
# git-init so trusted-execute codex accepts the workspace.
|
|
295
|
+
try:
|
|
296
|
+
_init_workspace_git(workspace)
|
|
297
|
+
except SubprocessError as exc:
|
|
298
|
+
return SpecAuditResult(
|
|
299
|
+
outcome="subprocess_error",
|
|
300
|
+
output=None,
|
|
301
|
+
error=exc,
|
|
302
|
+
duration_seconds=time.monotonic() - run_start,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Copy the PR doc into the workspace.
|
|
306
|
+
pr_doc_subdir = workspace / "pr-doc"
|
|
307
|
+
try:
|
|
308
|
+
pr_doc_subdir.mkdir()
|
|
309
|
+
workspace_pr_doc = pr_doc_subdir / pr_doc_path.name
|
|
310
|
+
shutil.copy2(pr_doc_path, workspace_pr_doc)
|
|
311
|
+
except (OSError, shutil.SameFileError) as exc:
|
|
312
|
+
return SpecAuditResult(
|
|
313
|
+
outcome="subprocess_error",
|
|
314
|
+
output=None,
|
|
315
|
+
error=SubprocessError(
|
|
316
|
+
f"spec audit: failed to set up cold workspace at "
|
|
317
|
+
f"{workspace}: could not copy {pr_doc_path} — {exc}"
|
|
318
|
+
),
|
|
319
|
+
duration_seconds=time.monotonic() - run_start,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# --- Build the prompt ------------------------------------------
|
|
323
|
+
template = load_spec_audit_template(repo_root)
|
|
324
|
+
prompt = render_spec_audit_prompt(
|
|
325
|
+
template,
|
|
326
|
+
pr_doc_path=str(workspace_pr_doc),
|
|
327
|
+
json_schema=get_spec_audit_schema_string(),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# --- Build the invocation --------------------------------
|
|
331
|
+
audit_config = ReviewerConfig(
|
|
332
|
+
name=AUDITOR_NAME,
|
|
333
|
+
provider=audit_cfg.provider,
|
|
334
|
+
model=audit_cfg.model,
|
|
335
|
+
thinking=audit_cfg.thinking,
|
|
336
|
+
permissions=audit_cfg.permissions,
|
|
337
|
+
# PR-v2-24: carry the auth declaration onto the synthetic config the
|
|
338
|
+
# adapter sees. Without this the cold actors would silently run
|
|
339
|
+
# unenforced -- the classic 'guarded four of five actors' leak.
|
|
340
|
+
auth=audit_cfg.auth,
|
|
341
|
+
api_key_env=audit_cfg.api_key_env,
|
|
342
|
+
)
|
|
343
|
+
try:
|
|
344
|
+
invocation = adapter.build_invocation(audit_config, workspace, prompt)
|
|
345
|
+
except ValueError as exc:
|
|
346
|
+
return SpecAuditResult(
|
|
347
|
+
outcome="subprocess_error",
|
|
348
|
+
output=None,
|
|
349
|
+
error=exc,
|
|
350
|
+
duration_seconds=time.monotonic() - run_start,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# --- Run the subprocess ----------------------------------------
|
|
354
|
+
subprocess_result: SubprocessResult | None = None
|
|
355
|
+
try:
|
|
356
|
+
subprocess_result = run_subprocess(
|
|
357
|
+
invocation.argv,
|
|
358
|
+
cwd=workspace,
|
|
359
|
+
env=_scrub_env_for_cold_synth(invocation.env, repo_root),
|
|
360
|
+
timeout=timeout_seconds,
|
|
361
|
+
input_text=invocation.stdin_text,
|
|
362
|
+
)
|
|
363
|
+
except SubprocessTimeoutError as exc:
|
|
364
|
+
elapsed = time.monotonic() - run_start
|
|
365
|
+
return SpecAuditResult(
|
|
366
|
+
outcome="subprocess_error",
|
|
367
|
+
output=None,
|
|
368
|
+
error=exc,
|
|
369
|
+
duration_seconds=elapsed,
|
|
370
|
+
raw_subprocess_result=SubprocessResult(
|
|
371
|
+
returncode=-1,
|
|
372
|
+
stdout=exc.stdout,
|
|
373
|
+
stderr=exc.stderr,
|
|
374
|
+
duration_seconds=elapsed,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
except SubprocessNotFoundError as exc:
|
|
378
|
+
return SpecAuditResult(
|
|
379
|
+
outcome="subprocess_error",
|
|
380
|
+
output=None,
|
|
381
|
+
error=exc,
|
|
382
|
+
duration_seconds=time.monotonic() - run_start,
|
|
383
|
+
)
|
|
384
|
+
except SubprocessError as exc:
|
|
385
|
+
return SpecAuditResult(
|
|
386
|
+
outcome="subprocess_error",
|
|
387
|
+
output=None,
|
|
388
|
+
error=exc,
|
|
389
|
+
duration_seconds=time.monotonic() - run_start,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# --- Parse the output ------------------------------------------
|
|
393
|
+
try:
|
|
394
|
+
final_text = adapter.extract_final_text(
|
|
395
|
+
subprocess_result,
|
|
396
|
+
empty_output_exception_class=SpecAuditOutputError,
|
|
397
|
+
)
|
|
398
|
+
except (ReviewerInvocationError, SpecAuditOutputError) as exc:
|
|
399
|
+
return SpecAuditResult(
|
|
400
|
+
outcome="subprocess_error",
|
|
401
|
+
output=None,
|
|
402
|
+
error=exc,
|
|
403
|
+
duration_seconds=time.monotonic() - run_start,
|
|
404
|
+
raw_subprocess_result=subprocess_result,
|
|
405
|
+
)
|
|
406
|
+
except Exception as exc: # noqa: BLE001 # BROAD_EXCEPT_OK: boundary converts parser surprises.
|
|
407
|
+
# Same broad-catch rationale as synthesizer.run_synthesizer:
|
|
408
|
+
# unexpected exceptions from the extractor (AttributeError,
|
|
409
|
+
# IndexError on bizarre JSONL) are more plausibly model-output
|
|
410
|
+
# variation than programming bugs; map to polite failure so
|
|
411
|
+
# persistence writes every artifact the operator needs.
|
|
412
|
+
return SpecAuditResult(
|
|
413
|
+
outcome="subprocess_error",
|
|
414
|
+
output=None,
|
|
415
|
+
error=exc,
|
|
416
|
+
duration_seconds=time.monotonic() - run_start,
|
|
417
|
+
raw_subprocess_result=subprocess_result,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
output = parse_spec_audit_output(final_text)
|
|
422
|
+
except SpecAuditOutputError as exc:
|
|
423
|
+
return SpecAuditResult(
|
|
424
|
+
outcome="subprocess_error",
|
|
425
|
+
output=None,
|
|
426
|
+
error=exc,
|
|
427
|
+
duration_seconds=time.monotonic() - run_start,
|
|
428
|
+
raw_subprocess_result=subprocess_result,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return SpecAuditResult(
|
|
432
|
+
outcome=_classify_outcome(output),
|
|
433
|
+
output=output,
|
|
434
|
+
error=None,
|
|
435
|
+
duration_seconds=time.monotonic() - run_start,
|
|
436
|
+
raw_subprocess_result=subprocess_result,
|
|
437
|
+
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Spec-audit pydantic schema + parser.
|
|
2
|
+
|
|
3
|
+
:class:`SpecAuditOutputError`, the :class:`SpecAuditFinding` / :class:`SpecAuditOutput`
|
|
4
|
+
models (with the strict ``extra="forbid"`` discipline + permutation validator),
|
|
5
|
+
``get_spec_audit_schema_string`` (the prompt's ``{json_schema}`` source), and
|
|
6
|
+
the parser (``parse_spec_audit_output`` + its helper). ``spec_audit.py``
|
|
7
|
+
re-exports these for the ``syncade.spec_audit.<name>`` import paths.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
from syncade.findings import Severity
|
|
17
|
+
from syncade.findings_json import decode_and_validate, validate_dropping_forbidden_extras
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SpecAuditOutputError(Exception):
|
|
21
|
+
"""Raised when the auditor's stdout can't be parsed as
|
|
22
|
+
:class:`SpecAuditOutput`.
|
|
23
|
+
|
|
24
|
+
Distinct from :class:`~syncade.findings.ReviewerOutputError` and
|
|
25
|
+
:class:`~syncade.synthesis.SynthesizerOutputError` so the CLI can
|
|
26
|
+
route to exit 70 and name the spec-audit phase in the diagnostic.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
AuditVerdict = Literal["READY", "NEEDS-CLARIFICATION"]
|
|
31
|
+
"""Top-level spec audit verdict. ``READY`` requires affirmative verification
|
|
32
|
+
that the brief is free of every issue class; ``NEEDS-CLARIFICATION`` is the
|
|
33
|
+
default when any blocker-severity finding is present or the audit is
|
|
34
|
+
incomplete."""
|
|
35
|
+
|
|
36
|
+
IssueClass = Literal[
|
|
37
|
+
"unverified_claim",
|
|
38
|
+
"internal_contradiction",
|
|
39
|
+
"ambiguous_acceptance_criteria",
|
|
40
|
+
"missing_reference",
|
|
41
|
+
"scope_drift",
|
|
42
|
+
"missing_structural_sections",
|
|
43
|
+
]
|
|
44
|
+
"""Enumeration of the six spec-audit issue classes.
|
|
45
|
+
|
|
46
|
+
Single source of truth — the template, schema string, and pydantic model
|
|
47
|
+
all derive from this. Constraining to a ``Literal`` means malformed auditor
|
|
48
|
+
output (e.g. ``"typo_class"``) is rejected by pydantic before it reaches
|
|
49
|
+
the caller, raising ``ValidationError`` and ultimately ``SpecAuditOutputError``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SpecAuditFinding(BaseModel):
|
|
54
|
+
"""A single spec-level finding from the auditor.
|
|
55
|
+
|
|
56
|
+
Uses ``section`` and ``line`` (not ``file`` and ``line`` like
|
|
57
|
+
:class:`~syncade.findings.Finding`) — spec issues live in the brief
|
|
58
|
+
document, not in source files.
|
|
59
|
+
|
|
60
|
+
``extra="forbid"`` rejects any field not listed here. Schema
|
|
61
|
+
strictness applies: ``location``, ``path``, ``where``,
|
|
62
|
+
and any alias are forbidden.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="forbid")
|
|
66
|
+
|
|
67
|
+
severity: Severity
|
|
68
|
+
section: str = Field(..., min_length=1)
|
|
69
|
+
line: int | None = None
|
|
70
|
+
issue_class: IssueClass
|
|
71
|
+
finding: str = Field(..., min_length=1)
|
|
72
|
+
suggested_remediation: str | None = None
|
|
73
|
+
|
|
74
|
+
@field_validator("section", "finding")
|
|
75
|
+
@classmethod
|
|
76
|
+
def _validate_nonblank(cls, v: str) -> str:
|
|
77
|
+
if not v.strip():
|
|
78
|
+
raise ValueError("must contain non-whitespace content; got an all-whitespace value")
|
|
79
|
+
return v
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SpecAuditOutput(BaseModel):
|
|
83
|
+
"""Top-level output of one spec audit subprocess run.
|
|
84
|
+
|
|
85
|
+
Mirrors :class:`~syncade.findings.ReviewerOutput` with the same
|
|
86
|
+
strict-fields-only discipline and the same ``summary``,
|
|
87
|
+
``priority_order``, ``coverage_gaps``, ``dismissed_concerns`` surface. The
|
|
88
|
+
``verdict`` field is present here because the spec audit is advisory and
|
|
89
|
+
its verdict drives the CLI exit code, not the loop's mechanical verdict.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
verdict: AuditVerdict
|
|
95
|
+
findings: list[SpecAuditFinding] = Field(default_factory=list)
|
|
96
|
+
summary: str = Field(..., min_length=1)
|
|
97
|
+
priority_order: list[int] = Field(...)
|
|
98
|
+
coverage_gaps: list[str] = Field(...)
|
|
99
|
+
dismissed_concerns: list[str] = Field(...)
|
|
100
|
+
|
|
101
|
+
@field_validator("summary")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _validate_summary_nonblank(cls, v: str) -> str:
|
|
104
|
+
if not v.strip():
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"summary must contain non-whitespace content; got an "
|
|
107
|
+
"all-whitespace value which provides no audit narrative"
|
|
108
|
+
)
|
|
109
|
+
return v
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="after")
|
|
112
|
+
def _validate_priority_order(self) -> SpecAuditOutput:
|
|
113
|
+
"""``priority_order`` must be a complete permutation of
|
|
114
|
+
``range(len(findings))``. Empty list iff findings is empty."""
|
|
115
|
+
expected = list(range(len(self.findings)))
|
|
116
|
+
if sorted(self.priority_order) != expected:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"priority_order must be a complete permutation of "
|
|
119
|
+
f"range({len(self.findings)}); got {self.priority_order}"
|
|
120
|
+
)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_spec_audit_schema_string() -> str:
|
|
125
|
+
"""Return the JSON schema string for :class:`SpecAuditOutput`,
|
|
126
|
+
formatted for inclusion in the spec audit prompt template's
|
|
127
|
+
``{json_schema}`` placeholder.
|
|
128
|
+
|
|
129
|
+
Single source of truth — the prompt template pulls from here. Parallel
|
|
130
|
+
to :func:`syncade.findings.get_findings_schema_string` and
|
|
131
|
+
:func:`syncade.synthesis.get_synthesizer_schema_string`.
|
|
132
|
+
|
|
133
|
+
The ``section`` and ``line`` fields are the spec-audit-specific
|
|
134
|
+
location anchor (not ``file`` and ``line`` like reviewer findings).
|
|
135
|
+
Schema strictness is explicit in the comment.
|
|
136
|
+
"""
|
|
137
|
+
return (
|
|
138
|
+
"{\n"
|
|
139
|
+
' "verdict": "READY" | "NEEDS-CLARIFICATION",\n'
|
|
140
|
+
' "findings": [\n'
|
|
141
|
+
" {\n"
|
|
142
|
+
' "severity": "blocker"|"minor"|"nit",\n'
|
|
143
|
+
' "section": "string (brief section name, required)",\n'
|
|
144
|
+
' "line": int|null, '
|
|
145
|
+
"// specific line in the brief if known\n"
|
|
146
|
+
' "issue_class": "unverified_claim"|"internal_contradiction"|'
|
|
147
|
+
'"ambiguous_acceptance_criteria"|"missing_reference"|'
|
|
148
|
+
'"scope_drift"|"missing_structural_sections",\n'
|
|
149
|
+
' "finding": "string (description with citation, required)",\n'
|
|
150
|
+
' "suggested_remediation": "string"|null\n'
|
|
151
|
+
" }\n"
|
|
152
|
+
" ],\n"
|
|
153
|
+
' "summary": "string (required, non-empty audit narrative)",\n'
|
|
154
|
+
' "priority_order": [int], '
|
|
155
|
+
"// indices into findings, complete permutation; [] when findings is []\n"
|
|
156
|
+
' "coverage_gaps": [string], // required; [] if none\n'
|
|
157
|
+
' "dismissed_concerns": [string] // required; [] if none\n'
|
|
158
|
+
"}\n"
|
|
159
|
+
"\n"
|
|
160
|
+
"SCHEMA FIELD NAMES ARE EXACT. Do NOT use 'location', 'path', 'file',\n"
|
|
161
|
+
"'where', or any alias for 'section'. Do NOT use 'line_number' or 'lineno'\n"
|
|
162
|
+
"for 'line'. The parser uses extra='forbid' and will reject your response\n"
|
|
163
|
+
"if you use non-schema field names."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_spec_audit_output(raw: str) -> SpecAuditOutput:
|
|
168
|
+
"""Parse an auditor's raw stdout text into a :class:`SpecAuditOutput`.
|
|
169
|
+
|
|
170
|
+
Selects exactly ONE verdict block via
|
|
171
|
+
:func:`syncade.findings_json._decode_verdict_object` (last
|
|
172
|
+
``json``/unlabeled fence, else the whole response) and validates it. No
|
|
173
|
+
fallback to an earlier block — see :mod:`syncade.findings_json`.
|
|
174
|
+
|
|
175
|
+
Raises :class:`SpecAuditOutputError` on failure so the CLI can route to
|
|
176
|
+
exit 70 and name the spec-audit phase.
|
|
177
|
+
"""
|
|
178
|
+
return decode_and_validate(
|
|
179
|
+
raw,
|
|
180
|
+
# Same repair the reviewer gets (PR-h-field-05 item 2): a verdict whose ONLY
|
|
181
|
+
# defect is a forbidden extra key is repaired, not discarded. Cheaper leg than
|
|
182
|
+
# a reviewer, but the failure mode and the eligibility rule are identical.
|
|
183
|
+
validate=lambda payload: validate_dropping_forbidden_extras(
|
|
184
|
+
payload, SpecAuditOutput.model_validate, label="spec audit"
|
|
185
|
+
),
|
|
186
|
+
error=SpecAuditOutputError,
|
|
187
|
+
label="spec audit",
|
|
188
|
+
model_name="SpecAuditOutput",
|
|
189
|
+
artifact="spec-auditor.stdout in the run directory",
|
|
190
|
+
)
|