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/prompts.py ADDED
@@ -0,0 +1,448 @@
1
+ """Prompt template loading and rendering.
2
+
3
+ Templates ship with the package under ``syncade/templates/`` and may
4
+ be overridden per-repo by dropping a same-named file under
5
+ ``<repo_root>/.syncade/templates/``. The override mechanism exists so
6
+ a project with specialized verification needs (custom test harness,
7
+ unusual schema requirements, an explicit list of "do not skim these
8
+ subsystems") can tighten any prompt without forking syncade.
9
+
10
+ Rendering is intentionally minimal — :func:`str.format_map` with a
11
+ strict mapping. No Jinja, no conditionals, no loops. Adding a template
12
+ engine here is out of scope; if the prompt needs more structure,
13
+ write the override as a flat string with the placeholders the renderer
14
+ knows about.
15
+
16
+ The per-template loaders are thin wrappers
17
+ around :func:`load_template`, which takes the template basename. The
18
+ parameterized loader is the canonical entry point; the per-template
19
+ wrappers remain for clarity and back-compat with existing callers.
20
+ Each renderer keeps its own signature because the placeholder set
21
+ differs per template (reviewer needs ``diff``; synthesizer needs
22
+ ``reviewer_outputs_json``); a unified renderer would either be untyped
23
+ or take an unwieldy union, which buys nothing.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+ # the template loader + the adversarial-lens block moved to
31
+ # prompts_loader; re-exported here so syncade.prompts.<name> is unchanged.
32
+ from syncade.prompts_loader import ADVERSARIAL_LENS_BLOCK, BUG_CLASS_BLOCK, load_template
33
+
34
+ DEFAULT_TEMPLATE_PATH = "templates/reviewer.md"
35
+ """Package-relative path of the bundled reviewer template. Kept as a
36
+ module-level constant for back-compat with any external caller that
37
+ referenced it before the parameterized loader; new code should
38
+ prefer :func:`load_template` with the template basename."""
39
+
40
+
41
+ def load_reviewer_template(repo_root: Path) -> str:
42
+ """Load the generic reviewer prompt template.
43
+
44
+ Thin wrapper around :func:`load_template` with the reviewer
45
+ basename. Kept for clarity at call sites and for back-compat with
46
+ callers that imported it before the parameterized loader. This is
47
+ the fallback template for any provider without a dedicated one (see
48
+ :func:`load_reviewer_template_for_provider`).
49
+ """
50
+ return load_template(repo_root, "reviewer.md")
51
+
52
+
53
+ # Provider-specific reviewer templates. The two default reviewers get
54
+ # differentiated, hand-tuned adversarial prompts; any other provider
55
+ # (custom configs, the test fakes) falls back to the generic reviewer.md.
56
+ _REVIEWER_TEMPLATE_BY_PROVIDER = {
57
+ "anthropic": "reviewer_adversarial.md",
58
+ "openai": "reviewer_codex.md",
59
+ }
60
+
61
+
62
+ def reviewer_template_name_for_provider(provider: str) -> str:
63
+ """Resolve the reviewer template basename for ``provider``.
64
+
65
+ Returns the provider's dedicated template when one exists
66
+ (``anthropic`` → ``reviewer_adversarial.md``, ``openai`` →
67
+ ``reviewer_codex.md``), else the generic ``reviewer.md``.
68
+ """
69
+ return _REVIEWER_TEMPLATE_BY_PROVIDER.get(provider, "reviewer.md")
70
+
71
+
72
+ def load_reviewer_template_for_provider(repo_root: Path, provider: str) -> str:
73
+ """Load the reviewer template for a specific provider.
74
+
75
+ Resolution order:
76
+
77
+ 1. ``<repo_root>/.syncade/templates/<provider-template>`` — provider-specific
78
+ per-repo override (e.g. ``reviewer_adversarial.md`` for ``anthropic``).
79
+ 2. ``<repo_root>/.syncade/templates/reviewer.md`` — generic per-repo override.
80
+ A project that wants the same custom rules for all providers without
81
+ creating provider-specific override files gets picked up here.
82
+ 3. Packaged provider-specific default (e.g. ``syncade/templates/reviewer_adversarial.md``).
83
+
84
+ An unknown provider (any value other than ``"anthropic"`` and ``"openai"``)
85
+ maps to ``reviewer.md`` via :func:`reviewer_template_name_for_provider`, so
86
+ steps 1 and 3 both reference the generic template and step 2 is skipped.
87
+ """
88
+ provider_template = reviewer_template_name_for_provider(provider)
89
+ provider_override = repo_root / ".syncade" / "templates" / provider_template
90
+ if provider_override.is_file():
91
+ return load_template(repo_root, provider_template)
92
+ if provider_template != "reviewer.md":
93
+ generic_override = repo_root / ".syncade" / "templates" / "reviewer.md"
94
+ if generic_override.is_file():
95
+ return load_template(repo_root, "reviewer.md")
96
+ return load_template(repo_root, provider_template)
97
+
98
+
99
+ def load_reviewer_template_for(
100
+ repo_root: Path, *, provider: str, template: str | None = None
101
+ ) -> str:
102
+ """Load a reviewer's prompt template, honoring a per-reviewer override.
103
+
104
+ When ``template`` is set (a plain basename validated on
105
+ :class:`~syncade.config.ReviewerConfig`), it overrides provider-based
106
+ selection and is resolved via :func:`load_template` (per-repo
107
+ ``.syncade/templates/<name>`` override → packaged default). When unset,
108
+ falls back to :func:`load_reviewer_template_for_provider`.
109
+ """
110
+ if template:
111
+ return load_template(repo_root, template)
112
+ return load_reviewer_template_for_provider(repo_root, provider)
113
+
114
+
115
+ def load_synthesizer_template(repo_root: Path) -> str:
116
+ """Load the synthesizer prompt template.
117
+
118
+ Thin wrapper around :func:`load_template` with the synthesizer
119
+ basename. Parallel to :func:`load_reviewer_template`; both
120
+ resolve a per-repo ``.syncade/templates/<name>`` override before
121
+ falling back to the packaged default.
122
+ """
123
+ return load_template(repo_root, "synthesizer.md")
124
+
125
+
126
+ def load_producer_template(repo_root: Path) -> str:
127
+ """Load the producer prompt template.
128
+
129
+ Thin wrapper around :func:`load_template` with the producer
130
+ basename. Parallel to :func:`load_reviewer_template` and
131
+ :func:`load_synthesizer_template`; the per-repo override path is
132
+ ``<repo_root>/.syncade/templates/producer.md`` and the packaged default
133
+ lives at ``syncade/templates/producer.md``.
134
+
135
+ Path-traversal protection on the basename is shared via :func:`load_template`.
136
+ """
137
+ return load_template(repo_root, "producer.md")
138
+
139
+
140
+ _NO_PRIOR_ROUND_SENTINEL = "(no prior round — this is round 0)"
141
+ """the literal substituted into the reviewer + producer
142
+ templates' ``{prior_round_output}`` placeholder on the first round
143
+ (``round_idx == 0``), when no prior-round artifact exists to replay.
144
+ A bare ``None`` would either KeyError under strict ``format_map`` or
145
+ silently render as the string ``"None"``; the sentinel is the
146
+ documented "no prior context" signal so the prompt still reads cleanly
147
+ on round 0 AND the reviewer / producer prose explicitly tells the
148
+ model what the sentinel means.
149
+
150
+ Renderers default this kwarg to the sentinel so callers without cross-round
151
+ context, plus explicit round-0 dispatch in the orchestrator, work unchanged."""
152
+
153
+
154
+ def render_reviewer_prompt(
155
+ template: str,
156
+ *,
157
+ pr_doc_path: str,
158
+ diff: str,
159
+ master_plan_path: str | None = None,
160
+ json_schema: str,
161
+ prior_round_output: str = _NO_PRIOR_ROUND_SENTINEL,
162
+ adversarial_lens: bool = False,
163
+ bug_class_sweep: bool = False,
164
+ ) -> str:
165
+ """Substitute placeholders into the reviewer template.
166
+
167
+ Known placeholders:
168
+
169
+ - ``{pr_doc_path}``: path to the PR doc the reviewer should read
170
+ - ``{diff}``: the actual diff text the reviewer is judging
171
+ - ``{master_plan_path}``: optional path to the master plan; when
172
+ ``None``, the placeholder is rendered as ``"(none)"`` so the
173
+ prompt still reads cleanly
174
+ - ``{json_schema}``: the JSON schema text the reviewer must emit
175
+ its output against
176
+ - ``{prior_round_output}``: the reviewer's OWN prior-round
177
+ response text. ``round_idx == 0`` callers omit this kwarg and the
178
+ renderer substitutes :data:`_NO_PRIOR_ROUND_SENTINEL`
179
+ (``"(no prior round — this is round 0)"``); ``round_idx > 0``
180
+ callers pass the extracted prior-round text. Cross-PR isolation:
181
+ the orchestrator passes only the prior round's stdout within the
182
+ SAME ``syncade <pr-doc>`` invocation; a new run starts with the
183
+ sentinel again. Per-reviewer isolation: round-1 claude-reviewer
184
+ sees claude-reviewer's round-0 output, NOT codex-reviewer's.
185
+ - ``{adversarial_lens_block}``: the adversarial edge-enumeration
186
+ block (:data:`ADVERSARIAL_LENS_BLOCK`) when ``adversarial_lens``
187
+ is True, else the empty string. A reviewer not flagged renders this as
188
+ ``""``.
189
+ - ``{bug_class_block}``: the directed bug-class sweep
190
+ (:data:`BUG_CLASS_BLOCK`) when ``bug_class_sweep`` is True, else the
191
+ empty string. OPT-IN, like the adversarial lens — a reviewer that does
192
+ not set it renders this as ``""``.
193
+
194
+ The template is rendered with :meth:`str.format_map` against a
195
+ strict mapping — any placeholder in the template that isn't one of
196
+ the seven above raises :class:`KeyError`, so a typo in a custom
197
+ override surfaces loudly instead of silently producing an
198
+ unsubstituted prompt.
199
+ """
200
+ mapping = {
201
+ "pr_doc_path": pr_doc_path,
202
+ "diff": diff,
203
+ "master_plan_path": master_plan_path if master_plan_path else "(none)",
204
+ "json_schema": json_schema,
205
+ "prior_round_output": prior_round_output,
206
+ "adversarial_lens_block": ADVERSARIAL_LENS_BLOCK if adversarial_lens else "",
207
+ "bug_class_block": BUG_CLASS_BLOCK if bug_class_sweep else "",
208
+ }
209
+ return template.format_map(mapping)
210
+
211
+
212
+ def render_synthesizer_prompt(
213
+ template: str,
214
+ *,
215
+ pr_doc_path: str,
216
+ reviewer_outputs_json: str,
217
+ master_plan_path: str | None = None,
218
+ json_schema: str,
219
+ ) -> str:
220
+ """Substitute placeholders into the synthesizer template.
221
+
222
+ Known placeholders:
223
+
224
+ - ``{pr_doc_path}``: path to the PR doc — the synthesizer reads
225
+ it for context on what the producer was meant to ship (its only
226
+ window into the spec, since it does NOT see the diff)
227
+ - ``{reviewer_outputs_json}``: a single string containing the two
228
+ reviewers' structured outputs serialized as JSON (typically
229
+ formatted as labeled blocks so the model can attribute findings
230
+ back to each reviewer by name)
231
+ - ``{master_plan_path}``: optional path to the master plan; same
232
+ ``None`` → ``"(none)"`` convention as :func:`render_reviewer_prompt`
233
+ - ``{json_schema}``: the :class:`~syncade.synthesis.SynthesizerOutput` schema string
234
+ pulled from :func:`~syncade.synthesis.get_synthesizer_schema_string`
235
+
236
+ Strict :meth:`str.format_map` — any placeholder the template uses
237
+ that isn't one of the four above raises :class:`KeyError`. Same
238
+ surface-typos-loudly contract as the reviewer renderer.
239
+
240
+ The synthesizer is NOT given a ``{diff}`` placeholder. That's a
241
+ deliberate architecture choice: the synthesizer is the cold consolidator. It
242
+ sees what the
243
+ reviewers surfaced about the diff, not the diff itself. If a
244
+ future override template tries to reference ``{diff}``,
245
+ :meth:`str.format_map` will raise :class:`KeyError`, which is the right
246
+ outcome.
247
+ """
248
+ mapping = {
249
+ "pr_doc_path": pr_doc_path,
250
+ "reviewer_outputs_json": reviewer_outputs_json,
251
+ "master_plan_path": master_plan_path if master_plan_path else "(none)",
252
+ "json_schema": json_schema,
253
+ }
254
+ return template.format_map(mapping)
255
+
256
+
257
+ _NO_TEST_FAILURE_SENTINEL = "(no test failure this round)"
258
+ """Literal substituted into the producer template's
259
+ ``{test_run_stdout_path}`` placeholder when the test leg either
260
+ didn't run this round or passed (i.e. there's no failure trace
261
+ to show). The renderer handles ``None`` directly so callers don't
262
+ have to pre-convert."""
263
+
264
+
265
+ _NO_PRIOR_COMMITS_SENTINEL = "(no prior commits)"
266
+ """the literal substituted into the producer template's
267
+ ``{prior_round_commits}`` placeholder on the first round
268
+ (``round_idx == 0``), when no prior-round artifact exists to replay.
269
+ Parallel to :data:`_NO_PRIOR_ROUND_SENTINEL` but specific to the
270
+ commit-subjects section of the producer's prior-round context. The
271
+ two sentinels exist as distinct strings so the producer prose can
272
+ explicitly name each — the round-0 producer sees both "(no prior
273
+ round)" and "(no prior commits)" rather than a single ambiguous
274
+ "(none)"."""
275
+
276
+
277
+ _NO_OPERATOR_DECISION_SENTINEL = "(no operator decision — this is not a resumed escalation round)"
278
+ """the literal substituted into the producer template's
279
+ ``{operator_decision}`` placeholder on every NON-resumed round. Only a
280
+ round resumed after a producer escalation (``syncade --resume`` reading
281
+ ``decision.txt``) substitutes the operator's recorded decision; every
282
+ other producer run sees this sentinel. Parallel to the prior-round
283
+ sentinels."""
284
+
285
+
286
+ def load_spec_audit_template(repo_root: Path) -> str:
287
+ """Load the spec audit prompt template.
288
+
289
+ Thin wrapper around :func:`load_template` with the spec_audit
290
+ basename. Per-repo override path is
291
+ ``<repo_root>/.syncade/templates/spec_audit.md``; packaged default
292
+ lives at ``syncade/templates/spec_audit.md``.
293
+ """
294
+ return load_template(repo_root, "spec_audit.md")
295
+
296
+
297
+ def render_spec_audit_prompt(
298
+ template: str,
299
+ *,
300
+ pr_doc_path: str,
301
+ json_schema: str,
302
+ ) -> str:
303
+ """Substitute placeholders into the spec audit template.
304
+
305
+ Known placeholders:
306
+
307
+ - ``{pr_doc_path}``: path to the PR brief to audit — the sole input
308
+ to the cold auditor subprocess
309
+ - ``{json_schema}``: the :class:`~syncade.spec_audit.SpecAuditOutput`
310
+ schema string pulled from :func:`~syncade.spec_audit.get_spec_audit_schema_string`
311
+
312
+ Strict :meth:`str.format_map` — any placeholder the template uses
313
+ that isn't one of the two above raises :class:`KeyError`. The
314
+ auditor intentionally receives no diff, no reviewer outputs, and no
315
+ test results — only the brief. If a custom override template tries
316
+ to reference ``{diff}``, :meth:`str.format_map` raises immediately.
317
+ """
318
+ mapping = {
319
+ "pr_doc_path": pr_doc_path,
320
+ "json_schema": json_schema,
321
+ }
322
+ return template.format_map(mapping)
323
+
324
+
325
+ def load_spec_draft_template(repo_root: Path) -> str:
326
+ """Load the spec draft prompt template.
327
+
328
+ Thin wrapper around :func:`load_template` with the spec_draft basename.
329
+ Per-repo override path is ``<repo_root>/.syncade/templates/spec_draft.md``;
330
+ packaged default lives at ``syncade/templates/spec_draft.md``.
331
+ """
332
+ return load_template(repo_root, "spec_draft.md")
333
+
334
+
335
+ def render_spec_draft_prompt(
336
+ template: str,
337
+ *,
338
+ dialogue_path: str,
339
+ diff_path: str,
340
+ json_schema: str,
341
+ ) -> str:
342
+ """Substitute placeholders into the spec draft template.
343
+
344
+ Known placeholders:
345
+
346
+ - ``{dialogue_path}``: path to the parsed session dialogue the cold drafter
347
+ reads for *intent*
348
+ - ``{diff_path}``: path to the diff of what was built (may be the empty/sentinel
349
+ file for a dialogue-only draft)
350
+ - ``{json_schema}``: the :class:`~syncade.spec_draft.SpecDraftOutput` schema
351
+ string from :func:`~syncade.spec_draft.get_spec_draft_schema_string`
352
+
353
+ Strict :meth:`str.format_map` — any placeholder the template uses that isn't one
354
+ of the three above raises :class:`KeyError`.
355
+ """
356
+ mapping = {
357
+ "dialogue_path": dialogue_path,
358
+ "diff_path": diff_path,
359
+ "json_schema": json_schema,
360
+ }
361
+ return template.format_map(mapping)
362
+
363
+
364
+ def render_producer_prompt(
365
+ template: str,
366
+ *,
367
+ pr_doc_path: str,
368
+ findings_md_path: str,
369
+ test_run_stdout_path: str | None,
370
+ worktree_path: str,
371
+ round_number: int,
372
+ max_rounds: int,
373
+ prior_round_output: str = _NO_PRIOR_ROUND_SENTINEL,
374
+ prior_round_commits: str = _NO_PRIOR_COMMITS_SENTINEL,
375
+ operator_decision: str = _NO_OPERATOR_DECISION_SENTINEL,
376
+ ) -> str:
377
+ """Substitute placeholders into the producer template.
378
+
379
+ Known placeholders:
380
+
381
+ - ``{pr_doc_path}``: path to the PR spec — the contract the
382
+ producer is implementing
383
+ - ``{findings_md_path}``: path to the just-completed round's
384
+ ``findings.md`` — the consolidated review output (with
385
+ provenance + per-reviewer summaries) the producer reads to
386
+ know what to fix
387
+ - ``{test_run_stdout_path}``: path to the just-completed
388
+ round's ``test-run.stdout`` when the test leg ran and
389
+ failed; the literal string
390
+ ``"(no test failure this round)"`` otherwise. The renderer
391
+ accepts ``None`` directly and substitutes the sentinel itself.
392
+ - ``{worktree_path}``: the producer worktree on disk — the
393
+ starting point for ``git log`` / ``git diff``, and where
394
+ file edits should land
395
+ - ``{round_number}``: 0-indexed round this producer is for
396
+ (so the first producer-after-round-0 receives 0)
397
+ - ``{max_rounds}``: configured ``[loop] max_rounds`` so the
398
+ producer can budget effort ("you're in round 1 of 3")
399
+ - ``{prior_round_output}``: the producer's OWN prior-round
400
+ response text. ``round_idx == 0`` callers omit this kwarg and the
401
+ renderer substitutes :data:`_NO_PRIOR_ROUND_SENTINEL`; ``round_idx > 0``
402
+ callers (the orchestrator's producer-phase wiring) pass the
403
+ extracted prior-round text. Cross-PR isolation: the orchestrator
404
+ passes only the prior round's stdout within the SAME
405
+ ``syncade <pr-doc>`` invocation; a new run starts with the
406
+ sentinel again.
407
+ - ``{prior_round_commits}``: the commit subjects of
408
+ round-(N-1)'s producer commits, derived via ``git log -1
409
+ --format='%s' <sha>`` in the operator's repo (NOT the producer
410
+ worktree — branch advance has promoted the prior commits onto the
411
+ operator's branch by the time round-N producer runs). ``round_idx
412
+ == 0`` callers omit this kwarg and the renderer substitutes
413
+ :data:`_NO_PRIOR_COMMITS_SENTINEL`.
414
+
415
+ The template is rendered with :meth:`str.format_map` against a
416
+ strict mapping — any placeholder in the template that isn't one
417
+ of the eight above raises :class:`KeyError`. Surface-typos-loudly
418
+ contract, same as the reviewer and synthesizer renderers.
419
+
420
+ The producer is intentionally NOT given a ``{diff}`` placeholder
421
+ even though it sees the diff via the worktree. The diff
422
+ materializes via the producer running ``git log`` / ``git diff``
423
+ in the worktree — explicit tool calls, not a prompt-embedded
424
+ blob. This keeps the prompt size bounded (large diffs would
425
+ blow the prompt budget) and lets the producer focus its
426
+ attention on the consolidated findings rather than re-reading
427
+ the diff blob.
428
+ """
429
+ # tolerate None for test_run_stdout_path. The brief's
430
+ # acceptance: "``{test_run_stdout_path}`` substitution works
431
+ # with None → renders the literal '(no test failure this round)'
432
+ # string". Moved from caller-side (orchestrator) to renderer-
433
+ # side so the public API contract handles None directly.
434
+ resolved_test_path = (
435
+ test_run_stdout_path if test_run_stdout_path is not None else _NO_TEST_FAILURE_SENTINEL
436
+ )
437
+ mapping = {
438
+ "pr_doc_path": pr_doc_path,
439
+ "findings_md_path": findings_md_path,
440
+ "test_run_stdout_path": resolved_test_path,
441
+ "worktree_path": worktree_path,
442
+ "round_number": round_number,
443
+ "max_rounds": max_rounds,
444
+ "prior_round_output": prior_round_output,
445
+ "prior_round_commits": prior_round_commits,
446
+ "operator_decision": operator_decision,
447
+ }
448
+ return template.format_map(mapping)