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/billing.py ADDED
@@ -0,0 +1,141 @@
1
+ """The ONE place that decides whether a dollar is money.
2
+
3
+ **This module exists because I got the same thing wrong eight times.**
4
+
5
+ ``cost_usd`` is an API-EQUIVALENT VALUATION, not spend. It is fiction whenever the call
6
+ rode a subscription, and that is orthogonal to ``cost_source`` (``claude`` reports
7
+ ``total_cost_usd`` even on an OAuth session). Only ``auth_mode`` knows.
8
+
9
+ Syncade renders that judgement on TWO surfaces — ``--metrics`` and each round's
10
+ ``summary.md`` — and every single time I fixed one, I left the other telling the original
11
+ lie. Not once: repeatedly, over four review rounds, in the same PR:
12
+
13
+ round 5 --metrics learned billed-vs-valuation. summary.md still said "Total: $0.14".
14
+ round 7 --metrics learned unpriced API = lower bound. summary.md did not.
15
+ round 8 --metrics learned unpriced+unknown != free. summary.md did not.
16
+
17
+ Each fix was correct. Each was applied to one of two twins. The bug was never the logic —
18
+ it was that the logic lived in two places, so "fixed" only ever meant "fixed here".
19
+
20
+ So the rules and the WORDS both live here now, and both surfaces call this. They cannot
21
+ disagree, because there is no longer anywhere for them to disagree from.
22
+
23
+ The rules, once:
24
+
25
+ - **billed** — ``auth_mode == "api"``. Money that actually left the account.
26
+ - **api_equiv** — every priced token, however it was paid for. What the traffic would
27
+ cost at API list price; on a subscription that is what the plan is
28
+ worth, not what was spent.
29
+ - **unclassified** — auth mode unrecorded. NEVER folded into either: we do not know, and
30
+ guessing is what produced the original $71.18-of-nothing.
31
+ - **unpriced tokens** are tracked PER CLASS, because ``SUM()`` coalesces a NULL cost to 0
32
+ and a zero here is a claim, not a fact. Unpriced API tokens make ``billed`` a LOWER
33
+ BOUND. Unpriced unknown tokens must not read as free.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Iterable
39
+ from typing import NamedTuple, Protocol
40
+
41
+ _KNOWN = ("api", "subscription")
42
+
43
+
44
+ class _HasUsage(Protocol):
45
+ auth_mode: str
46
+ cost_usd: float | None
47
+ total_tokens: int
48
+
49
+
50
+ class Billing(NamedTuple):
51
+ """The numbers that must never be conflated, plus the two that qualify them."""
52
+
53
+ billed: float = 0.0
54
+ api_equiv: float = 0.0
55
+ unclassified: float = 0.0
56
+ billed_unpriced_tokens: int = 0
57
+ unclassified_unpriced_tokens: int = 0
58
+ # ALL unpriced tokens, every auth class. billed_unpriced (api) and
59
+ # unclassified_unpriced (unknown) are subsets; the remainder is SUBSCRIPTION-mode
60
+ # unpriced, which was tracked by neither and so vanished from the report entirely.
61
+ total_unpriced_tokens: int = 0
62
+
63
+
64
+ def from_rows(rows: Iterable[tuple[str, float, int]]) -> Billing:
65
+ """``(auth_mode, summed_cost, summed_unpriced_tokens)`` → :class:`Billing`.
66
+
67
+ The shape ``--metrics`` gets from SQL (``GROUP BY auth_mode``, never from display
68
+ aggregates — grouping for display once merged two modes into "mixed" and made real API
69
+ spend vanish).
70
+ """
71
+ rows = list(rows)
72
+ return Billing(
73
+ billed=sum(c for mode, c, _t in rows if mode == "api"),
74
+ api_equiv=sum(c for _mode, c, _t in rows),
75
+ unclassified=sum(c for mode, c, _t in rows if mode not in _KNOWN),
76
+ billed_unpriced_tokens=sum(t for mode, _c, t in rows if mode == "api"),
77
+ unclassified_unpriced_tokens=sum(t for mode, _c, t in rows if mode not in _KNOWN),
78
+ total_unpriced_tokens=sum(t for _mode, _c, t in rows),
79
+ )
80
+
81
+
82
+ def from_usages(usages: Iterable[_HasUsage]) -> Billing:
83
+ """Live :class:`~syncade.usage.Usage` records → :class:`Billing`.
84
+
85
+ The shape ``summary.md`` gets. Same rules as :func:`from_rows` — because they ARE the
86
+ same rules, and keeping them in one function is the entire point of this module.
87
+ """
88
+ # Read Usage.cost_incomplete_tokens -- the SAME rule the DB column is written from --
89
+ # rather than re-deriving "priced" here. A non-null cost_usd is NOT sufficient: a retry
90
+ # (_add_usage) can carry a partial cost with cost_source="unknown", and treating that as
91
+ # fully priced dropped the lower-bound hedge on summary.md.
92
+ rows = [(u.auth_mode, u.cost_usd or 0.0, u.cost_incomplete_tokens) for u in usages]
93
+ return from_rows(rows)
94
+
95
+
96
+ def render(b: Billing, *, indent: str = " ", bullet: bool = False) -> list[str]:
97
+ """The billing block, worded once.
98
+
99
+ Both surfaces render from here. When the wording of "what is money" changes, it changes
100
+ in one place — which is what stops ``summary.md`` from quietly saying "Total: $0.14"
101
+ for a run that cost nothing while ``--metrics`` says $0.00 billed.
102
+ """
103
+
104
+ # `bullet` = markdown context (summary.md). Plain = terminal (--metrics). Only the
105
+ # decoration differs; the NUMBERS and the CLAIMS are identical by construction.
106
+ def label(text: str) -> str:
107
+ return f"{indent}- **{text}:**" if bullet else f"{indent}{text + ':':<11}"
108
+
109
+ lines: list[str] = []
110
+
111
+ billed_note = "(money that left your account)"
112
+ if b.billed_unpriced_tokens:
113
+ # A lower bound is never presented as a total.
114
+ billed_note = (
115
+ f"(AT LEAST — {b.billed_unpriced_tokens} API tokens are unpriced, "
116
+ f"so real spend is HIGHER)"
117
+ )
118
+ lines.append(f"{label('billed')} ${b.billed:.4f} {billed_note}")
119
+
120
+ # Unpriced tokens that are neither api (surfaced on `billed`) nor unknown (surfaced on
121
+ # `unclassed`) -- i.e. SUBSCRIPTION-mode unpriced. billed is genuinely $0 for these
122
+ # (subscription is $0 marginal), but their API-EQUIVALENT valuation is unknown, so
123
+ # API-equiv is a lower bound. This was the dropped case: billed $0, no signal at all.
124
+ sub_unpriced = (
125
+ b.total_unpriced_tokens - b.billed_unpriced_tokens - b.unclassified_unpriced_tokens
126
+ )
127
+ if b.api_equiv > b.billed or sub_unpriced:
128
+ api_note = "(same traffic at API list price; subscription traffic is $0 marginal"
129
+ if sub_unpriced:
130
+ api_note += f"; {sub_unpriced} tokens unpriced, so this is a LOWER BOUND"
131
+ lines.append(f"{label('API-equiv')} ${b.api_equiv:.4f} {api_note})")
132
+
133
+ if b.unclassified or b.unclassified_unpriced_tokens:
134
+ detail = f"${b.unclassified:.4f}"
135
+ if b.unclassified_unpriced_tokens:
136
+ detail += f" + {b.unclassified_unpriced_tokens} unpriced tokens"
137
+ lines.append(
138
+ f"{label('unclassed')} {detail} "
139
+ f"(auth mode not recorded — cannot say whether this was billed)"
140
+ )
141
+ return lines
@@ -0,0 +1,113 @@
1
+ """User-defined mechanical-check config.
2
+
3
+ :class:`CheckConfig` models one ``[[checks]]`` block — a mechanical gate the
4
+ orchestrator runs itself (a shell command + exit code), tagged ``blocking`` or
5
+ ``advisory``. It lives in its OWN module because ``config.py`` is already over
6
+ the project's 400–500 LOC discipline; :class:`SyncadeConfig` imports this model
7
+ plus :func:`validate_check_names` and adds only the ``checks`` field and a thin
8
+ delegating validator. Empty ``checks`` list = today's loop, byte-identical.
9
+
10
+ Two-lane wall: a check is a command-with-an-exit-code ONLY. Anything needing
11
+ LLM judgment stays in ``reviewer.md`` — never a mechanical check.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
20
+
21
+ from syncade.worktree import TEST_WORKTREE_NAME
22
+
23
+ CheckSeverity = Literal["blocking", "advisory"]
24
+ """``blocking`` → failure ORs into NO-SHIP exactly like a failing test leg (and
25
+ the producer, which runs on NO-SHIP, can fix it). ``advisory`` → failure is
26
+ surfaced but NEVER gates the verdict. Default is ``advisory`` — fail-safe, so a
27
+ forgotten tag cannot silently block a ship."""
28
+
29
+
30
+ class CheckConfig(BaseModel):
31
+ """One mechanical check: a named shell command + a severity."""
32
+
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ name: str = Field(
36
+ min_length=1,
37
+ description="Stable identifier used in run artifacts and as the "
38
+ "check's worktree basename. Must be unique and must not collide with a "
39
+ "reviewer name or the reserved 'tests' worktree basename.",
40
+ )
41
+ command: str = Field(
42
+ min_length=1,
43
+ description="Shell command run verbatim via ``sh -c`` in a fresh "
44
+ "stripped worktree — the same mechanism as ``[loop] test_command``. "
45
+ "Non-zero exit = the check failed.",
46
+ )
47
+ severity: CheckSeverity = Field(
48
+ default="advisory",
49
+ description="``blocking`` folds a failure into the mechanical verdict "
50
+ "(NO-SHIP, like a failing test); ``advisory`` surfaces it without "
51
+ "gating. Defaults to ``advisory`` so a forgotten tag never gates.",
52
+ )
53
+
54
+ @field_validator("name", "command")
55
+ @classmethod
56
+ def _not_whitespace(cls, value: str) -> str:
57
+ """Reject ``" "`` / ``"\\n\\t"`` — ``min_length`` passes them through
58
+ (length is non-zero), but a whitespace command would silently SIGKILL
59
+ the check leg and a whitespace name would make an unusable worktree
60
+ basename. Mirrors ``LoopConfig._test_command_not_whitespace``."""
61
+ if not value.strip():
62
+ raise ValueError("must not be empty or whitespace-only (got a blank string)")
63
+ return value
64
+
65
+ @field_validator("name")
66
+ @classmethod
67
+ def _name_plain_basename(cls, value: str) -> str:
68
+ """The name is used as a worktree basename AND an artifact filename, so
69
+ reject anything that isn't a plain basename — a check name must never be
70
+ able to escape the round directory. Mirrors
71
+ ``WorktreeManager._validate_reviewer_name``."""
72
+ if value in (".", "..") or "/" in value or "\\" in value or Path(value).is_absolute():
73
+ raise ValueError(
74
+ f"check name {value!r} must be a plain basename "
75
+ f"(no '/', '\\', '.', '..', or absolute paths)"
76
+ )
77
+ return value
78
+
79
+
80
+ def validate_check_names(reviewer_names: list[str], check_names: list[str]) -> None:
81
+ """Raise ``ValueError`` if any check name collides on the per-round worktree
82
+ path. Each configured check provisions a worktree at ``round-N/<name>/``, so
83
+ a name must be unique among checks and must not equal (case-insensitively)
84
+ any reviewer name or the reserved ``tests`` worktree basename.
85
+
86
+ UNCONDITIONAL (unlike ``SyncadeConfig``'s reviewer-vs-``tests`` validator,
87
+ which is gated on ``test_command``): checks always provision worktrees when
88
+ configured, so reserving ``tests`` here also forecloses the latent collision
89
+ the moment ``test_command`` is set. ``casefold()`` matches the existing
90
+ validator — case-insensitive filesystems resolve ``Tests``/``tests`` to one
91
+ path.
92
+ """
93
+ if not check_names:
94
+ return
95
+ seen: set[str] = set()
96
+ reviewer_fold = {n.casefold() for n in reviewer_names}
97
+ reserved = TEST_WORKTREE_NAME.casefold()
98
+ for name in check_names:
99
+ fold = name.casefold()
100
+ if fold in seen:
101
+ raise ValueError(f"duplicate check name {name!r} (check names must be unique)")
102
+ seen.add(fold)
103
+ if fold in reviewer_fold:
104
+ raise ValueError(
105
+ f"check name {name!r} collides with a reviewer name "
106
+ f"(both provision a worktree at round-N/{name}/); rename the check"
107
+ )
108
+ if fold == reserved:
109
+ raise ValueError(
110
+ f"check name {name!r} collides with the reserved "
111
+ f"{TEST_WORKTREE_NAME!r} test-re-run worktree basename "
112
+ f"(case-insensitive); rename the check"
113
+ )