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