syncmill 0.2.0__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 (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/models.py ADDED
@@ -0,0 +1,244 @@
1
+ """Data models for tasks and results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from uuid import UUID, uuid4
8
+
9
+
10
+ class TaskStatus(StrEnum):
11
+ PENDING = "pending"
12
+ RUNNING = "running"
13
+ COMPLETED = "completed"
14
+ FAILED = "failed"
15
+
16
+
17
+ class ToolOutcome(StrEnum):
18
+ """Body-free terminal outcome for one observed MCP tool call."""
19
+
20
+ SUCCEEDED = "succeeded"
21
+ FAILED = "failed"
22
+ UNKNOWN = "unknown"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ToolCallRecord:
27
+ """The minimum safe projection of an observed MCP call."""
28
+
29
+ sequence: int
30
+ tool_key: str
31
+ outcome: ToolOutcome
32
+ # Explicit producer-authored retry causality. Built-in parsers leave this unset.
33
+ retry_of_sequence: int | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ToolTelemetry:
38
+ """Provider capture summary attached in-memory to a ``TaskResult`` only."""
39
+
40
+ source: str
41
+ complete: bool
42
+ calls: tuple[ToolCallRecord, ...] = ()
43
+ malformed_count: int = 0
44
+ unqualified_count: int = 0
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TokenUsage:
49
+ """Best-effort provider token/cost telemetry parsed from a CLI's output.
50
+
51
+ Normalized across providers so display is uniform: ``total_input_tokens`` is
52
+ the FULL prompt size including cached tokens (Claude reports cache tokens
53
+ beside ``input_tokens``, so they are summed in; codex's ``input_tokens``
54
+ already includes ``cached_input_tokens``). Every field is optional — a
55
+ provider that emits no usage (kimi) or malformed usage yields ``None`` at
56
+ the ``TaskResult.usage`` level, never a partial guess.
57
+ """
58
+
59
+ total_input_tokens: int | None = None
60
+ output_tokens: int | None = None
61
+ cache_read_tokens: int | None = None
62
+ cache_creation_tokens: int | None = None
63
+ total_cost_usd: float | None = None
64
+
65
+ @classmethod
66
+ def from_mapping(cls, raw: object) -> "TokenUsage | None":
67
+ """Validated construction from an untrusted usage dict (a manifest's
68
+ ``usage`` block, provider telemetry). Each field is dropped
69
+ individually when malformed — negative, boolean, non-int (non-finite
70
+ or negative for cost), or ``cache_read_tokens`` exceeding
71
+ ``total_input_tokens`` — so bad telemetry renders as omitted, never
72
+ as a misleading number. ``None`` when ``raw`` is not a dict."""
73
+ import math
74
+
75
+ if not isinstance(raw, dict):
76
+ return None
77
+
78
+ def _count(value: object) -> int | None:
79
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
80
+ return None
81
+ return value
82
+
83
+ counts = {
84
+ key: _count(raw.get(key))
85
+ for key in (
86
+ "total_input_tokens",
87
+ "output_tokens",
88
+ "cache_read_tokens",
89
+ "cache_creation_tokens",
90
+ )
91
+ }
92
+ if (
93
+ counts["cache_read_tokens"] is not None
94
+ and counts["total_input_tokens"] is not None
95
+ and counts["cache_read_tokens"] > counts["total_input_tokens"]
96
+ ):
97
+ counts["cache_read_tokens"] = None
98
+ cost = raw.get("total_cost_usd")
99
+ try:
100
+ if (
101
+ isinstance(cost, bool)
102
+ or not isinstance(cost, (int, float))
103
+ or not math.isfinite(cost) # OverflowError on an absurd int
104
+ or cost < 0
105
+ ):
106
+ cost = None
107
+ else:
108
+ cost = float(cost)
109
+ except OverflowError:
110
+ cost = None
111
+ return cls(**counts, total_cost_usd=cost)
112
+
113
+ @property
114
+ def cache_hit_ratio(self) -> float | None:
115
+ """Fraction of the input the provider served from its prompt cache
116
+ (None when either side of the ratio is unknown or the input is empty)."""
117
+ if self.cache_read_tokens is None or not self.total_input_tokens:
118
+ return None
119
+ return self.cache_read_tokens / self.total_input_tokens
120
+
121
+ def format_compact(self) -> str | None:
122
+ """Short human string for tables — e.g. ``cache 85% (34.2k/40.1k)`` —
123
+ or ``None`` when there is nothing meaningful to show."""
124
+
125
+ def _k(n: int) -> str:
126
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
127
+
128
+ ratio = self.cache_hit_ratio
129
+ if ratio is not None:
130
+ assert self.cache_read_tokens is not None # implied by ratio
131
+ assert self.total_input_tokens is not None
132
+ return (
133
+ f"cache {ratio:.0%} "
134
+ f"({_k(self.cache_read_tokens)}/{_k(self.total_input_tokens)})"
135
+ )
136
+ if self.total_input_tokens:
137
+ return f"in {_k(self.total_input_tokens)}"
138
+ return None
139
+
140
+
141
+ @dataclass
142
+ class Task:
143
+ """A unit of work to be executed by an agent."""
144
+
145
+ prompt: str
146
+ id: UUID = field(default_factory=uuid4)
147
+ status: TaskStatus = TaskStatus.PENDING
148
+ tags: list[str] = field(default_factory=list)
149
+
150
+
151
+ @dataclass
152
+ class GateCheck:
153
+ """Result of one quality-gate command run against a worktree."""
154
+
155
+ command: str
156
+ passed: bool
157
+ exit_code: int
158
+ output: str = ""
159
+ duration_seconds: float = 0.0
160
+
161
+
162
+ @dataclass
163
+ class GateReport:
164
+ """Outcome of running the quality gate against an agent's worktree.
165
+
166
+ ``passed`` is the accept/reject signal (every check passed); ``score`` is the
167
+ fraction that passed, the ranking signal CompeteStrategy's ``_select_winner``
168
+ can order candidates by (currently pass/priority; ``score`` is the seam for a
169
+ finer-grained future ranking).
170
+ """
171
+
172
+ passed: bool
173
+ score: float
174
+ checks: list[GateCheck] = field(default_factory=list)
175
+
176
+ @classmethod
177
+ def from_checks(cls, checks: list[GateCheck]) -> GateReport:
178
+ """Aggregate per-command checks. No checks → trivially passed (score 1.0),
179
+ so an empty gate never blocks (defensive; ``build_gate`` returns ``None``
180
+ for the disabled case instead of an empty gate)."""
181
+ if not checks:
182
+ return cls(passed=True, score=1.0, checks=[])
183
+ n_pass = sum(1 for c in checks if c.passed)
184
+ return cls(
185
+ passed=n_pass == len(checks),
186
+ score=round(n_pass / len(checks), 2),
187
+ checks=list(checks),
188
+ )
189
+
190
+ def summary(self) -> str:
191
+ """One-line human summary, naming the failing commands — used in gate
192
+ failure errors and CLI output."""
193
+ n_pass = sum(1 for c in self.checks if c.passed)
194
+ head = f"{n_pass}/{len(self.checks)} checks passed"
195
+ failed = [c.command for c in self.checks if not c.passed]
196
+ if failed:
197
+ head += "; failed: " + ", ".join(failed)
198
+ return head
199
+
200
+
201
+ @dataclass
202
+ class TaskResult:
203
+ """Outcome of an agent executing a task.
204
+
205
+ ``agent_id`` is normally a real agent id, but Supervisor-level failures use
206
+ sentinel values that are NOT agents: ``"none"`` (no agents available),
207
+ ``"artifact"`` (mandatory run-artifact persistence failed), ``"routing"``
208
+ (model-routing validation failed — all-unmapped explicit tier or corrupt
209
+ inherited metadata), and ``"preflight"`` (strict preflight blocked the
210
+ run). Consumers keying on ``agent_id`` must not treat these as agents.
211
+ """
212
+
213
+ task_id: UUID
214
+ agent_id: str
215
+ status: TaskStatus
216
+ output: str
217
+ exit_code: int
218
+ duration_seconds: float
219
+ files_changed: list[str] = field(default_factory=list)
220
+ error: str | None = None
221
+ # Quality-gate verdict, attached by the strategy when a gate ran against this
222
+ # result's worktree (None when no gate is configured). Like files_changed,
223
+ # it's an annotation set post-hoc by the strategy, not produced by the agent.
224
+ gate: GateReport | None = None
225
+ # Full applyable diff of the winning worktree vs HEAD (untracked files
226
+ # included), collected alongside files_changed BEFORE the Supervisor tears the
227
+ # worktree down — the recovery path `run --emit-patch` exports. Like
228
+ # files_changed/gate, an annotation set post-hoc by the strategy; None for a
229
+ # failed result, a non-git workspace, or a diff-collection failure ("" means
230
+ # a clean worktree: the agent changed nothing).
231
+ patch: str | None = None
232
+ # Deliberately omitted from result.json/the envelope; strategies project it
233
+ # into body-free OTLP TOOL spans instead. Kept last to preserve TaskResult's
234
+ # established positional constructor contract.
235
+ tool_telemetry: ToolTelemetry | None = None
236
+ # Durable orchestration metadata. Kept optional and last for compatibility
237
+ # with integrations constructing TaskResult positionally.
238
+ run_id: str | None = None
239
+ artifact_path: str | None = None
240
+ conversation_id: str | None = None
241
+ provider_session_id: str | None = None
242
+ # Best-effort provider token/cache telemetry (None when the CLI emitted none
243
+ # or it was malformed). Appended last to preserve the positional contract.
244
+ usage: TokenUsage | None = None