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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/config.py
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
"""Configuration system for syncmill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
11
|
+
from pydantic_settings import (
|
|
12
|
+
BaseSettings,
|
|
13
|
+
PydanticBaseSettingsSource,
|
|
14
|
+
SettingsConfigDict,
|
|
15
|
+
TomlConfigSettingsSource,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentConfig(BaseModel):
|
|
20
|
+
"""Per-agent configuration.
|
|
21
|
+
|
|
22
|
+
Env overrides flow through the parent ``SyncmillConfig`` prefix, e.g.
|
|
23
|
+
``SYNCMILL_CLAUDE__TIMEOUT_SECONDS``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="ignore")
|
|
27
|
+
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
# ge=1: a zero/negative timeout makes every agent "time out" instantly with
|
|
30
|
+
# empty output and no explanation (asyncio.wait_for(…, 0) fires immediately).
|
|
31
|
+
timeout_seconds: int = Field(default=300, ge=1)
|
|
32
|
+
max_turns: int = 10
|
|
33
|
+
# Combined raw stdout + stderr per provider invocation.
|
|
34
|
+
output_max_bytes: int = Field(default=32 * 1024 * 1024, ge=1)
|
|
35
|
+
workflow_env_allowlist: list[str] = Field(default_factory=list, max_length=64)
|
|
36
|
+
|
|
37
|
+
@field_validator("workflow_env_allowlist")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _valid_workflow_env_allowlist(cls, values: list[str]) -> list[str]:
|
|
40
|
+
"""Workflow workers accept exact environment names, never patterns.
|
|
41
|
+
|
|
42
|
+
The ordinary (non-workflow) agent environment remains unchanged. Exact
|
|
43
|
+
names keep the security boundary reviewable and avoid a permissive
|
|
44
|
+
``AWS_*``/``*_TOKEN`` entry silently exposing unrelated credentials.
|
|
45
|
+
"""
|
|
46
|
+
if len(values) != len(set(values)):
|
|
47
|
+
raise ValueError("workflow_env_allowlist entries must be unique")
|
|
48
|
+
invalid = [
|
|
49
|
+
value
|
|
50
|
+
for value in values
|
|
51
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value)
|
|
52
|
+
]
|
|
53
|
+
if invalid:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"workflow_env_allowlist entries must be exact environment names: "
|
|
56
|
+
+ ", ".join(invalid)
|
|
57
|
+
)
|
|
58
|
+
return values
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProjectConfig(BaseModel):
|
|
62
|
+
"""Stable project identity and project-local memory boundary."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="ignore")
|
|
65
|
+
|
|
66
|
+
id: str | None = None
|
|
67
|
+
memory_dir: str = ".syncmill/memory"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WorkspaceConfig(BaseModel):
|
|
71
|
+
"""Private workspace used to snapshot the user's complete Git state.
|
|
72
|
+
|
|
73
|
+
``stale_seconds`` (``SYNCMILL_WORKSPACE__STALE_SECONDS``) is the age
|
|
74
|
+
backstop in ``sweep_orphaned_workspaces``, NOT its ordinary criterion: a
|
|
75
|
+
crashed run is collected as soon as its recorded pid is gone, and the
|
|
76
|
+
backstop only covers the case a pid cannot decide — the owner is gone but
|
|
77
|
+
its pid has been reused, which would otherwise protect the leak forever.
|
|
78
|
+
Because it OVERRIDES a live pid, lowering it is the setting that can delete
|
|
79
|
+
a running run's workspace, so the floor is an hour. Raise it on a machine
|
|
80
|
+
whose runs legitimately go quiet for days; lower it (toward the floor) when
|
|
81
|
+
a short-lived CI worker recycles pids fast and leaked workspaces cost disk.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="ignore")
|
|
85
|
+
|
|
86
|
+
root: str = "~/.syncmill/workspaces"
|
|
87
|
+
retain_on_failure: bool = True
|
|
88
|
+
stale_seconds: int = Field(default=7 * 24 * 3600, ge=3600)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RunsConfig(BaseModel):
|
|
92
|
+
"""Durable run artifacts. These are correctness state, not telemetry."""
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(extra="ignore")
|
|
95
|
+
|
|
96
|
+
root: str = "~/.syncmill/projects"
|
|
97
|
+
#: Terminal run artifacts kept per project (#118): after every successful
|
|
98
|
+
#: save the Supervisor best-effort prunes older TERMINAL runs beyond this
|
|
99
|
+
#: (pending/applying/recovery_required are never candidates — the same
|
|
100
|
+
#: `runs prune` semantics, whose --keep default this mirrors).
|
|
101
|
+
keep_last: int = Field(default=50, ge=1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class WorkflowConfig(BaseModel):
|
|
105
|
+
"""Bounded native workflow planning and execution.
|
|
106
|
+
|
|
107
|
+
A workflow is declarative data, never an executable provider-generated
|
|
108
|
+
script. The limits stay finite at both the friendly default and validation
|
|
109
|
+
layers so a malformed plan cannot turn into unbounded fanout.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
model_config = ConfigDict(extra="forbid")
|
|
113
|
+
|
|
114
|
+
planner_agent: Literal["claude", "codex"] = "claude"
|
|
115
|
+
max_parallel: int = Field(default=4, ge=1, le=16)
|
|
116
|
+
max_agent_calls: int = Field(default=32, ge=2, le=256)
|
|
117
|
+
max_depth: int = Field(default=6, ge=1, le=12)
|
|
118
|
+
deadline_seconds: int = Field(default=60 * 60, ge=60, le=4 * 60 * 60)
|
|
119
|
+
max_attempts_per_node: int = Field(default=2, ge=1, le=3)
|
|
120
|
+
retention_days: int = Field(default=30, ge=1, le=3650)
|
|
121
|
+
keep_workflows: int = Field(default=50, ge=0, le=10000)
|
|
122
|
+
max_output_bytes: int = Field(
|
|
123
|
+
default=1024 * 1024, ge=4096, le=16 * 1024 * 1024
|
|
124
|
+
)
|
|
125
|
+
# The durable copy of a node's diff. max_output_bytes bounds what an agent
|
|
126
|
+
# SAYS; this bounds what it leaves behind, which is a separate way for one
|
|
127
|
+
# node to fill the state root.
|
|
128
|
+
max_patch_bytes: int = Field(
|
|
129
|
+
default=32 * 1024 * 1024, ge=4096, le=512 * 1024 * 1024
|
|
130
|
+
)
|
|
131
|
+
# How long a claim may go unrefreshed before `workflow resume` may take it
|
|
132
|
+
# over — and, divided down, how often a running workflow beats. It decides
|
|
133
|
+
# ONE case: an owner this host cannot ask about (another host, or an
|
|
134
|
+
# unreadable owner field) whose heartbeat is present but old. A live local
|
|
135
|
+
# process is asked directly and always wins, and a claim with no recorded
|
|
136
|
+
# owner or heartbeat at all is never taken over, because nothing about it
|
|
137
|
+
# distinguishes abandoned from working. Both of those are released with
|
|
138
|
+
# `workflow cancel`.
|
|
139
|
+
lease_timeout_seconds: int = Field(default=1800, ge=60, le=24 * 60 * 60)
|
|
140
|
+
max_prompt_bytes: int = Field(
|
|
141
|
+
default=2 * 1024 * 1024, ge=4096, le=16 * 1024 * 1024
|
|
142
|
+
)
|
|
143
|
+
max_changed_files: int = Field(default=512, ge=1, le=10000)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RoomsConfig(BaseModel):
|
|
147
|
+
"""Durable, human-gated multi-agent comparison rooms.
|
|
148
|
+
|
|
149
|
+
Rooms are deliberately opt-in while the state-machine surface settles. The
|
|
150
|
+
first shipping slice supports one active room per project and at most the
|
|
151
|
+
three built-in providers. The upper bounds already match the persistent
|
|
152
|
+
room design so expanding the roster later is a configuration change rather
|
|
153
|
+
than an on-disk schema rewrite.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
model_config = ConfigDict(extra="ignore")
|
|
157
|
+
|
|
158
|
+
enabled: bool = False
|
|
159
|
+
max_members: int = Field(default=3, ge=1, le=8)
|
|
160
|
+
max_parallel: int = Field(default=3, ge=1, le=4)
|
|
161
|
+
max_rounds: int = Field(default=20, ge=1, le=100)
|
|
162
|
+
retention_days: int = Field(default=30, ge=1)
|
|
163
|
+
keep_rooms: int = Field(default=50, ge=1)
|
|
164
|
+
output_max_bytes: int = Field(default=10 * 1024 * 1024, ge=1024)
|
|
165
|
+
lease_timeout_seconds: int = Field(default=900, ge=30)
|
|
166
|
+
|
|
167
|
+
@model_validator(mode="after")
|
|
168
|
+
def _parallel_not_above_members(self) -> "RoomsConfig":
|
|
169
|
+
if self.max_parallel > self.max_members:
|
|
170
|
+
raise ValueError("rooms.max_parallel cannot exceed rooms.max_members")
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ConversationConfig(BaseModel):
|
|
175
|
+
"""Provider-native conversation continuation."""
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="ignore")
|
|
178
|
+
|
|
179
|
+
enabled: bool = True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SelectionConfig(BaseModel):
|
|
183
|
+
"""Winner selection policy for concurrent strategies."""
|
|
184
|
+
|
|
185
|
+
model_config = ConfigDict(extra="ignore")
|
|
186
|
+
|
|
187
|
+
mode: Literal["verified", "priority", "judge"] = "verified"
|
|
188
|
+
judge_agent: str | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Mem0Config(BaseModel):
|
|
192
|
+
"""Settings for the optional mem0 reference backend.
|
|
193
|
+
|
|
194
|
+
``config`` is passed through to mem0 after syncmill adds its local-first
|
|
195
|
+
defaults and validates the Qdrant lifecycle requirements. Like ``TeamConfig``,
|
|
196
|
+
this nested settings object is populated only by its parent so ambient
|
|
197
|
+
unprefixed ``CONFIG``/``DATA_DIR`` variables cannot reconfigure it.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
model_config = ConfigDict(extra="ignore")
|
|
201
|
+
|
|
202
|
+
data_dir: str = "~/.syncmill/mem0"
|
|
203
|
+
telemetry: bool = False
|
|
204
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class LangGraphConfig(BaseModel):
|
|
208
|
+
"""Settings for the optional local LangGraph BaseStore adapter."""
|
|
209
|
+
|
|
210
|
+
model_config = ConfigDict(extra="ignore")
|
|
211
|
+
|
|
212
|
+
db_path: str = "~/.syncmill/langgraph.db"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class MemoryConfig(BaseModel):
|
|
216
|
+
"""Long-term memory settings (memtomem by default)."""
|
|
217
|
+
|
|
218
|
+
model_config = ConfigDict(extra="ignore")
|
|
219
|
+
|
|
220
|
+
backend: Literal["memtomem", "mem0", "langgraph"] = "memtomem"
|
|
221
|
+
db_path: str = "~/.memtomem/memtomem.db"
|
|
222
|
+
# None preserves memtomem's ambient/default memory directory. Isolated
|
|
223
|
+
# runtimes should set this together with db_path so Markdown writes and the
|
|
224
|
+
# SQLite index share the same explicit boundary.
|
|
225
|
+
memory_dir: str | None = None
|
|
226
|
+
mem0: Mem0Config = Field(default_factory=Mem0Config)
|
|
227
|
+
langgraph: LangGraphConfig = Field(default_factory=LangGraphConfig)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class BreakerConfig(BaseModel):
|
|
231
|
+
"""Circuit-breaker settings — reactive back-off from repeatedly-failing
|
|
232
|
+
agents (#8).
|
|
233
|
+
|
|
234
|
+
DISABLED by default (``enabled=False``): ``build_breaker`` returns ``None``
|
|
235
|
+
and strategies skip breaker logic entirely, so out-of-the-box behavior is
|
|
236
|
+
unchanged. Enable + tune via env::
|
|
237
|
+
|
|
238
|
+
SYNCMILL_BREAKER__ENABLED=true
|
|
239
|
+
SYNCMILL_BREAKER__FAILURE_THRESHOLD=3
|
|
240
|
+
SYNCMILL_BREAKER__COOLDOWN_SECONDS=300
|
|
241
|
+
SYNCMILL_BREAKER__STATE_PATH=~/.syncmill/breaker.json
|
|
242
|
+
|
|
243
|
+
State persists to ``state_path`` (JSON) so consecutive failures accumulate
|
|
244
|
+
*across* separate ``syncmill run`` invocations — the only way "fails N times
|
|
245
|
+
in a row" is observable, since each run executes an agent at most once.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
model_config = ConfigDict(extra="ignore")
|
|
249
|
+
|
|
250
|
+
enabled: bool = False
|
|
251
|
+
failure_threshold: int = Field(default=3, ge=1)
|
|
252
|
+
cooldown_seconds: int = Field(default=300, ge=0)
|
|
253
|
+
state_path: str = "~/.syncmill/breaker.json"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class GateConfig(BaseModel):
|
|
257
|
+
"""Quality-gate settings — commands run against an agent's worktree before
|
|
258
|
+
its result is accepted (#10).
|
|
259
|
+
|
|
260
|
+
Each entry in ``commands`` is a single command (parsed with ``shlex.split``,
|
|
261
|
+
run without a shell), so chain checks by adding more entries rather than with
|
|
262
|
+
``&&``. An empty ``commands`` (the default) **disables** gating, so default
|
|
263
|
+
behavior is unchanged. Override via env, e.g.::
|
|
264
|
+
|
|
265
|
+
SYNCMILL_GATE__COMMANDS='["pytest -q", "ruff check ."]'
|
|
266
|
+
SYNCMILL_GATE__TIMEOUT_SECONDS=120
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
model_config = ConfigDict(extra="ignore")
|
|
270
|
+
|
|
271
|
+
commands: list[str] = Field(default_factory=list)
|
|
272
|
+
timeout_seconds: int = 300
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class CapabilityConfig(BaseModel):
|
|
276
|
+
"""Declarative per-agent capability descriptors — capability matching (#9).
|
|
277
|
+
|
|
278
|
+
DISABLED by default: ``agents`` is an empty map, so ``build_matcher`` returns
|
|
279
|
+
``None`` and agent ordering stays at raw priority — behavior is unchanged out
|
|
280
|
+
of the box (mirrors ``GateConfig``/``BreakerConfig``).
|
|
281
|
+
|
|
282
|
+
``agents`` maps an agent id to the capability tokens it declares (a language,
|
|
283
|
+
a task type, …). A task carrying matching tokens in its ``Task.tags`` routes a
|
|
284
|
+
matching agent *ahead of* raw ``agent_priority``; nothing matching falls back
|
|
285
|
+
to priority order. Purely DECLARATIVE — config only, never a live probe of the
|
|
286
|
+
binary (respects "no ``health_check``"). The map is keyed by the **real agent
|
|
287
|
+
id**, so a hyphenated id like ``kimi-code`` is a verbatim key (unlike the
|
|
288
|
+
per-agent ``AgentConfig`` fields, a dict key has no identifier restriction).
|
|
289
|
+
Override via env (a JSON object), e.g.::
|
|
290
|
+
|
|
291
|
+
SYNCMILL_CAPABILITIES__AGENTS='{"codex": ["python", "refactor"], "kimi-code": ["rust"]}'
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
model_config = ConfigDict(extra="ignore")
|
|
295
|
+
|
|
296
|
+
agents: dict[str, list[str]] = Field(default_factory=dict)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TeamConfig(BaseModel):
|
|
300
|
+
"""A named team profile — an ordered agent subset + capability/strategy
|
|
301
|
+
bundle, selectable per run (Phase 4 of the agent-team platform).
|
|
302
|
+
|
|
303
|
+
Declared as ``[teams.<name>]`` tables in ``syncmill.toml``; the table name is
|
|
304
|
+
the team name and, like ``[capabilities.agents]`` keys, is a **verbatim dict
|
|
305
|
+
key** (hyphens allowed — no identifier restriction). The env path is the JSON
|
|
306
|
+
object form (dict fields don't ride the ``__`` nested delimiter)::
|
|
307
|
+
|
|
308
|
+
SYNCMILL_TEAMS='{"python-team": {"agents": ["codex", "claude"],
|
|
309
|
+
"strategy": "compete", "goals": ["backend"]}}'
|
|
310
|
+
|
|
311
|
+
env teams deep-merge with toml teams like every other nested table.
|
|
312
|
+
|
|
313
|
+
Selecting a team (``run --team`` / ``board run --team`` / a ``board run
|
|
314
|
+
--goal`` whose goal a team declares) overlays the profile onto the resolved
|
|
315
|
+
config **before** the ``Supervisor`` is constructed: ``agents`` replaces
|
|
316
|
+
``agent_priority`` (subset + order), a non-empty ``capabilities`` replaces
|
|
317
|
+
``capabilities.agents``, and a set ``strategy`` replaces ``default_strategy``
|
|
318
|
+
(an explicit ``--strategy`` flag still wins). The registry, matcher, and
|
|
319
|
+
strategy all derive from those fields in ``Supervisor.__init__``, so the
|
|
320
|
+
whole stack follows with no strategy/agent-layer changes.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
model_config = ConfigDict(extra="ignore")
|
|
324
|
+
|
|
325
|
+
agents: list[str] = Field(
|
|
326
|
+
min_length=1,
|
|
327
|
+
description="Ordered agent subset — becomes this team's agent_priority "
|
|
328
|
+
"(required, at least one agent).",
|
|
329
|
+
)
|
|
330
|
+
capabilities: dict[str, list[str]] = Field(
|
|
331
|
+
default_factory=dict,
|
|
332
|
+
description="Team-scoped capability map (same shape/keys as "
|
|
333
|
+
"[capabilities.agents]); empty keeps the global declaration.",
|
|
334
|
+
)
|
|
335
|
+
strategy: str | None = Field(
|
|
336
|
+
default=None,
|
|
337
|
+
description="Team default strategy; unset keeps the resolved "
|
|
338
|
+
"default_strategy. An explicit --strategy flag beats it.",
|
|
339
|
+
)
|
|
340
|
+
goals: list[str] = Field(
|
|
341
|
+
default_factory=list,
|
|
342
|
+
description="Board goals this team owns — `board run --goal <g>` "
|
|
343
|
+
"auto-selects the team declaring g. A goal may belong to at most one team.",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@field_validator("agents", "goals")
|
|
347
|
+
@classmethod
|
|
348
|
+
def _no_empty_or_duplicate_entries(cls, value: list[str]) -> list[str]:
|
|
349
|
+
"""Reject empty-string and duplicate entries.
|
|
350
|
+
|
|
351
|
+
An empty goal ``""`` is the un-goaled board default — a team declaring it
|
|
352
|
+
would silently own every goal-less item, so it is rejected outright.
|
|
353
|
+
"""
|
|
354
|
+
if any(not entry for entry in value):
|
|
355
|
+
raise ValueError("entries must be non-empty strings")
|
|
356
|
+
if len(set(value)) != len(value):
|
|
357
|
+
raise ValueError("entries must be unique")
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def check_team_goals(teams: Mapping[str, TeamConfig]) -> None:
|
|
362
|
+
"""Reject a structurally broken ``teams`` map (raises ``ValueError``).
|
|
363
|
+
|
|
364
|
+
An empty team name is meaningless (nothing to select by), and a goal
|
|
365
|
+
declared by two teams would make ``board run --goal`` team selection depend
|
|
366
|
+
on dict insertion order — both are config typos, rejected hard (the
|
|
367
|
+
``_make_strategy`` hard-fail house style, never first-wins). Shared by the
|
|
368
|
+
``SyncmillConfig`` model validator and ``teamfile.validate_team_save`` (the
|
|
369
|
+
pre-write dup-goal check for ``team save``) — both pass fully-constructed
|
|
370
|
+
``TeamConfig`` values, so the rule can't drift between the two.
|
|
371
|
+
"""
|
|
372
|
+
goal_owner: dict[str, str] = {}
|
|
373
|
+
for name, team in teams.items():
|
|
374
|
+
if not name:
|
|
375
|
+
raise ValueError("team name must be a non-empty string")
|
|
376
|
+
for goal in team.goals:
|
|
377
|
+
other = goal_owner.get(goal)
|
|
378
|
+
if other is not None:
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"goal {goal!r} is declared by two teams: "
|
|
381
|
+
f"{other!r} and {name!r} — a goal may belong to at "
|
|
382
|
+
f"most one team"
|
|
383
|
+
)
|
|
384
|
+
goal_owner[goal] = name
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class CouncilConfig(BaseModel):
|
|
388
|
+
"""CouncilStrategy settings — multi-round deliberation (#13).
|
|
389
|
+
|
|
390
|
+
The council runs three kinds of phase: a concurrent **propose** phase (every
|
|
391
|
+
agent drafts a solution in its own worktree), ``rounds`` concurrent
|
|
392
|
+
**critique** phases (each member refines its solution after reading the
|
|
393
|
+
council's proposals + prior critiques from ``shared``), and a concurrent
|
|
394
|
+
**synthesize** phase (every member produces a final solution from the whole
|
|
395
|
+
deliberation, and the winner is picked by the same priority/gate rule as
|
|
396
|
+
Compete). ``rounds`` counts only the critique phases, so the total phases per
|
|
397
|
+
council = propose(1) + critique(``rounds``) + synthesize(1): the default
|
|
398
|
+
``rounds=1`` is a genuine multi-round council (propose → critique → synthesize).
|
|
399
|
+
|
|
400
|
+
There is nothing to disable — ``council`` is only consulted when the council
|
|
401
|
+
strategy is selected (``default_strategy="council"`` / ``--strategy council``),
|
|
402
|
+
so the defaults are inert for the other strategies. Override via env, e.g.::
|
|
403
|
+
|
|
404
|
+
SYNCMILL_COUNCIL__ROUNDS=2
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
model_config = ConfigDict(extra="ignore")
|
|
408
|
+
|
|
409
|
+
rounds: int = Field(
|
|
410
|
+
default=1,
|
|
411
|
+
ge=1,
|
|
412
|
+
description="Number of critique rounds between propose and synthesize "
|
|
413
|
+
"(>=1). Total phases per council = 1 propose + rounds critique + 1 synthesize.",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class DecomposeConfig(BaseModel):
|
|
418
|
+
"""DecomposeStrategy settings — task decomposition (#14).
|
|
419
|
+
|
|
420
|
+
``max_subtasks`` caps how many subtasks the planner's breakdown may contain:
|
|
421
|
+
the planner is asked to stay within it and ``_parse_subtasks`` truncates any
|
|
422
|
+
overflow (logging what it drops — no silent cap). It bounds the fan-out cost,
|
|
423
|
+
since N subtasks become N concurrent subtask runs plus one synthesis run per
|
|
424
|
+
runnable agent.
|
|
425
|
+
|
|
426
|
+
There is nothing to disable — ``decompose`` is only consulted when the decompose
|
|
427
|
+
strategy is selected (``default_strategy="decompose"`` / ``--strategy
|
|
428
|
+
decompose``), so the defaults are inert for the other strategies. Override via
|
|
429
|
+
env, e.g.::
|
|
430
|
+
|
|
431
|
+
SYNCMILL_DECOMPOSE__MAX_SUBTASKS=6
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
model_config = ConfigDict(extra="ignore")
|
|
435
|
+
|
|
436
|
+
max_subtasks: int = Field(
|
|
437
|
+
default=8,
|
|
438
|
+
ge=1,
|
|
439
|
+
description="Maximum number of subtasks the planner may produce (>=1); the "
|
|
440
|
+
"breakdown is truncated to this many.",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class BoardConfig(BaseModel):
|
|
445
|
+
"""Persistent work-board settings (Phase 1).
|
|
446
|
+
|
|
447
|
+
DISABLED by default (``enabled=False``): ``build_board`` returns ``None`` and
|
|
448
|
+
the board CLI errors with a clear "enable it" message, so plain ``syncmill
|
|
449
|
+
run`` and every existing command are unchanged out of the box (mirrors
|
|
450
|
+
``BreakerConfig``).
|
|
451
|
+
|
|
452
|
+
Unlike memory/breaker, the store is **LOAD-BEARING** when enabled (the source
|
|
453
|
+
of work + system of record), so it does NOT degrade silently — a corrupt db
|
|
454
|
+
raises ``BoardError``. The Supervisor builds it LAZILY (not in ``__init__``),
|
|
455
|
+
so even an enabled-but-corrupt board never breaks a plain ``run`` that never
|
|
456
|
+
touches it. Enable + tune via env::
|
|
457
|
+
|
|
458
|
+
SYNCMILL_BOARD__ENABLED=true
|
|
459
|
+
SYNCMILL_BOARD__DB_PATH=~/.syncmill/board.db
|
|
460
|
+
SYNCMILL_BOARD__LEASE_TIMEOUT_SECONDS=1800
|
|
461
|
+
|
|
462
|
+
``lease_timeout_seconds`` bounds how long a claimed (``running``) item may sit
|
|
463
|
+
before ``recover_stale_running`` reclaims it to ``planned`` — the SIGKILL /
|
|
464
|
+
crashed-run recovery analogue of ``cleanup_orphaned_worktrees``.
|
|
465
|
+
|
|
466
|
+
``stall_seconds`` is the aging window for ``board stats`` (Phase 2): a blocked /
|
|
467
|
+
human-required item, or an item whose handoff baton was never picked up, counts
|
|
468
|
+
as a *stall* once it has sat untouched longer than this. Tunable via
|
|
469
|
+
``SYNCMILL_BOARD__STALL_SECONDS`` (it only affects the read-only stats report,
|
|
470
|
+
never dispatch).
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
model_config = ConfigDict(extra="ignore")
|
|
474
|
+
|
|
475
|
+
enabled: bool = False
|
|
476
|
+
db_path: str = "~/.syncmill/board.db"
|
|
477
|
+
lease_timeout_seconds: int = Field(default=1800, ge=1)
|
|
478
|
+
stall_seconds: int = Field(default=3600, ge=1)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class TuiConfig(BaseModel):
|
|
482
|
+
"""Persistent state for the optional full-screen TUI command center."""
|
|
483
|
+
|
|
484
|
+
model_config = ConfigDict(extra="ignore")
|
|
485
|
+
|
|
486
|
+
state_dir: str = "~/.syncmill/tui"
|
|
487
|
+
keep_jobs: int = Field(default=20, ge=1)
|
|
488
|
+
log_max_bytes: int = Field(default=10 * 1024 * 1024, ge=1024)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class MonitorConfig(BaseModel):
|
|
492
|
+
"""tmux live-monitoring settings — a live view of a concurrent run (#16).
|
|
493
|
+
|
|
494
|
+
DISABLED by default (``enabled=False``): the CLI's ``--monitor`` flag flips it
|
|
495
|
+
on for a single run (set post-construction, like ``--strategy``). When on AND
|
|
496
|
+
the selected strategy is concurrent (compete/council/decompose) AND stdout is a
|
|
497
|
+
TTY AND tmux is installed, the run is launched inside a tmux session with one
|
|
498
|
+
live pane per agent; otherwise it falls through to a normal run with a notice.
|
|
499
|
+
Out of the box nothing changes. Override via env, e.g.::
|
|
500
|
+
|
|
501
|
+
SYNCMILL_MONITOR__ENABLED=true
|
|
502
|
+
SYNCMILL_MONITOR__SESSION_PREFIX=syncmill
|
|
503
|
+
|
|
504
|
+
Note: the internal outer→inner handshake uses the ``SYNCMILL_MONITOR_DIR`` env
|
|
505
|
+
var + a sentinel file (see ``monitor.inner_dir``); that is launcher-set IPC, NOT
|
|
506
|
+
user config, and deliberately sits outside this ``SYNCMILL_MONITOR__*`` table.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
model_config = ConfigDict(extra="ignore")
|
|
510
|
+
|
|
511
|
+
enabled: bool = False
|
|
512
|
+
session_prefix: str = "syncmill"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class ReportConfig(BaseModel):
|
|
516
|
+
"""Run-report (envelope) settings — the ecosystem join artifact (S3).
|
|
517
|
+
|
|
518
|
+
DISABLED by default (``enabled=False``): ``build_reporter`` returns ``None``
|
|
519
|
+
and the run writes no report, so out of the box every run is byte-identical
|
|
520
|
+
to before (mirrors ``BreakerConfig``/``BoardConfig``). ``runs_dir`` may still
|
|
521
|
+
be touched while disabled if tracing is on — the tracer's default output dir
|
|
522
|
+
is that same tree, so the Supervisor sweeps it under ``keep_last`` rather
|
|
523
|
+
than letting trace-only run dirs accumulate forever. When
|
|
524
|
+
enabled, every run writes ``{runs_dir}/{run_id}/envelope.json`` (plus a
|
|
525
|
+
``result.json`` copy) per ``contracts/run-envelope.schema.json`` —
|
|
526
|
+
best-effort/degrade like memory: a report failure warns and the run,
|
|
527
|
+
result output, and cleanup continue unchanged. Enable + tune via env::
|
|
528
|
+
|
|
529
|
+
SYNCMILL_INTEGRATION__REPORT__ENABLED=true
|
|
530
|
+
SYNCMILL_INTEGRATION__REPORT__RUNS_DIR=~/.syncmill/runs
|
|
531
|
+
SYNCMILL_INTEGRATION__REPORT__KEEP_LAST=50
|
|
532
|
+
|
|
533
|
+
``keep_last`` bounds the owned run directories retained at run start (the
|
|
534
|
+
current run always survives and counts toward the total, so ``0`` would be
|
|
535
|
+
meaningless — hence ``ge=1``). Foreign directories are untouched and do not
|
|
536
|
+
count. It governs the trace-only sweep too, so one setting bounds owned
|
|
537
|
+
``runs_dir`` entries whichever producer is filling them.
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
model_config = ConfigDict(extra="ignore")
|
|
541
|
+
|
|
542
|
+
enabled: bool = False
|
|
543
|
+
runs_dir: str = "~/.syncmill/runs"
|
|
544
|
+
keep_last: int = Field(default=50, ge=1)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def normalize_phoenix_endpoint(value: str) -> str:
|
|
548
|
+
"""Validate and normalize a Phoenix OTLP/HTTP traces endpoint."""
|
|
549
|
+
if not value:
|
|
550
|
+
return value
|
|
551
|
+
parsed = urlsplit(value)
|
|
552
|
+
if (
|
|
553
|
+
parsed.scheme not in {"http", "https"}
|
|
554
|
+
or not parsed.hostname
|
|
555
|
+
or parsed.username is not None
|
|
556
|
+
or parsed.password is not None
|
|
557
|
+
or parsed.query
|
|
558
|
+
or parsed.fragment
|
|
559
|
+
):
|
|
560
|
+
raise ValueError(
|
|
561
|
+
"trace endpoint must be an absolute http(s) URL without "
|
|
562
|
+
"credentials, query, or fragment"
|
|
563
|
+
)
|
|
564
|
+
path = parsed.path.rstrip("/")
|
|
565
|
+
if not path.endswith("/v1/traces"):
|
|
566
|
+
path += "/v1/traces"
|
|
567
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class TraceConfig(BaseModel):
|
|
571
|
+
"""Trace-exporter settings — OTLP-JSON spans for tracegraph (S3′).
|
|
572
|
+
|
|
573
|
+
DISABLED by default (``enabled=False``): ``build_tracer`` returns ``None``
|
|
574
|
+
and the run is byte-identical to before (the ``build_gate``/``build_breaker``
|
|
575
|
+
pattern). When enabled, each run buffers span records and atomically writes
|
|
576
|
+
``{dir}/{run_id}/trace.otlp.json`` — an OTLP protobuf-JSON document
|
|
577
|
+
tracegraph ingests as-is (``ingest-otlp``). Best-effort like memory: an
|
|
578
|
+
exporter failure warns and the run, result, and cleanup continue. Enable via::
|
|
579
|
+
|
|
580
|
+
SYNCMILL_INTEGRATION__TRACE__ENABLED=true
|
|
581
|
+
|
|
582
|
+
``exporter="file"`` preserves the durable OTLP-JSON artifact only.
|
|
583
|
+
``exporter="otlp-http"`` keeps that artifact and additionally streams the
|
|
584
|
+
same body-free spans to Phoenix over OTLP/HTTP protobuf. ``endpoint`` and
|
|
585
|
+
``project_name`` are optional SyncMill overrides; empty values defer to the
|
|
586
|
+
official ``PHOENIX_COLLECTOR_ENDPOINT`` / ``PHOENIX_PROJECT_NAME`` env vars,
|
|
587
|
+
then local/syncmill defaults. Authentication is intentionally not modeled
|
|
588
|
+
here: the exporter reads ``PHOENIX_API_KEY`` without persisting the secret in
|
|
589
|
+
project configuration. ``dir`` empty means "the report ``runs_dir``" so the
|
|
590
|
+
trace lands beside the run envelope — and is retention-swept under
|
|
591
|
+
``report.keep_last`` even when reporting is off. A non-empty ``dir`` is
|
|
592
|
+
user-owned and NEVER swept: syncmill won't delete inside a directory the
|
|
593
|
+
operator chose, which may hold more than its run dirs.
|
|
594
|
+
``include_output`` remains warn+ignored
|
|
595
|
+
— spans are body-free by contract (digests/counts only).
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
model_config = ConfigDict(extra="ignore")
|
|
599
|
+
|
|
600
|
+
enabled: bool = False
|
|
601
|
+
exporter: str = "file"
|
|
602
|
+
dir: str = ""
|
|
603
|
+
include_output: bool = False
|
|
604
|
+
endpoint: str = ""
|
|
605
|
+
project_name: str = ""
|
|
606
|
+
batch_delay_seconds: float = Field(default=0.5, gt=0)
|
|
607
|
+
timeout_seconds: int = Field(default=5, ge=1)
|
|
608
|
+
tool_calls_enabled: bool = False
|
|
609
|
+
tool_name_map: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
610
|
+
|
|
611
|
+
@field_validator("endpoint")
|
|
612
|
+
@classmethod
|
|
613
|
+
def _safe_otlp_http_endpoint(cls, value: str) -> str:
|
|
614
|
+
return normalize_phoenix_endpoint(value)
|
|
615
|
+
|
|
616
|
+
@field_validator("project_name")
|
|
617
|
+
@classmethod
|
|
618
|
+
def _nonblank_project_name(cls, value: str) -> str:
|
|
619
|
+
if value and not value.strip():
|
|
620
|
+
raise ValueError("trace project_name must be empty or non-blank")
|
|
621
|
+
return value
|
|
622
|
+
|
|
623
|
+
@field_validator("tool_name_map")
|
|
624
|
+
@classmethod
|
|
625
|
+
def _qualified_tool_name_map(
|
|
626
|
+
cls, value: dict[str, dict[str, str]]
|
|
627
|
+
) -> dict[str, dict[str, str]]:
|
|
628
|
+
bad: list[str] = []
|
|
629
|
+
for provider, mapping in value.items():
|
|
630
|
+
if not provider or not isinstance(mapping, dict):
|
|
631
|
+
bad.append(repr(provider))
|
|
632
|
+
continue
|
|
633
|
+
for raw_name, tool_key in mapping.items():
|
|
634
|
+
if (
|
|
635
|
+
not raw_name
|
|
636
|
+
or not tool_key
|
|
637
|
+
or tool_key.count("::") != 1
|
|
638
|
+
or any(not part for part in tool_key.split("::"))
|
|
639
|
+
or any(ch.isspace() for ch in tool_key)
|
|
640
|
+
):
|
|
641
|
+
bad.append(f"{provider}:{raw_name!r}->{tool_key!r}")
|
|
642
|
+
if bad:
|
|
643
|
+
raise ValueError(
|
|
644
|
+
"trace tool_name_map values must be qualified 'server::tool' keys: "
|
|
645
|
+
+ ", ".join(bad)
|
|
646
|
+
)
|
|
647
|
+
return value
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class ToolgraphConfig(BaseModel):
|
|
651
|
+
"""toolgraph advisory-preflight settings (S5).
|
|
652
|
+
|
|
653
|
+
DISABLED by default (``enabled=False``): ``run_preflight`` returns ``None``
|
|
654
|
+
and no subprocess runs, so behavior is byte-identical to before. When
|
|
655
|
+
enabled, the Supervisor runs ``toolgraph preflight`` once (for the lead
|
|
656
|
+
matched agent) BEFORE dispatching the strategy — advisory only, so any
|
|
657
|
+
failure (binary absent, timeout, non-zero exit, bad JSON, unknown major)
|
|
658
|
+
is a yellow warning and the run CONTINUES; the outcome is recorded in the
|
|
659
|
+
envelope ``preflight`` block. Enable + tune via env::
|
|
660
|
+
|
|
661
|
+
SYNCMILL_INTEGRATION__TOOLGRAPH__ENABLED=true
|
|
662
|
+
SYNCMILL_INTEGRATION__TOOLGRAPH__PROFILE=review
|
|
663
|
+
SYNCMILL_INTEGRATION__TOOLGRAPH__AGENT_MAP='{"codex": "codex", "kimi-code": "kimi"}'
|
|
664
|
+
|
|
665
|
+
``mode="advisory"`` preserves the S5 lead-only fail-open behavior;
|
|
666
|
+
``mode="strict"`` enables P3.1 all-identity enforcement. Strict requires a
|
|
667
|
+
non-empty qualified candidate surface and agent map. Unknown identity/tool
|
|
668
|
+
and unavailability block by default while drift warns; the three policy
|
|
669
|
+
fields accept explicit ``block|warn`` overrides. ``agent_map``
|
|
670
|
+
maps a syncmill agent id to a toolgraph ``Agent.id``, keyed by the **real**
|
|
671
|
+
agent id (``kimi-code`` verbatim — the ``[capabilities.agents]`` rule);
|
|
672
|
+
``command`` is the invocation seam tests override. ``candidate_tools`` are
|
|
673
|
+
server-qualified ``server::tool`` keys (the ambient MCP surface — memtomem,
|
|
674
|
+
the ``serve`` gateway — an operator wants policy-checked).
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
model_config = ConfigDict(extra="ignore")
|
|
678
|
+
|
|
679
|
+
enabled: bool = False
|
|
680
|
+
mode: str = "advisory"
|
|
681
|
+
profile: str = "review"
|
|
682
|
+
unknown_policy: str = "block"
|
|
683
|
+
drift_policy: str = "warn"
|
|
684
|
+
unavailable_policy: str = "block"
|
|
685
|
+
timeout_seconds: int = Field(default=5, ge=1)
|
|
686
|
+
command: list[str] = Field(default_factory=lambda: ["toolgraph", "preflight"])
|
|
687
|
+
control_plan_enabled: bool = False
|
|
688
|
+
control_plan_command: list[str] = Field(
|
|
689
|
+
default_factory=lambda: ["toolgraph", "control-preflight"]
|
|
690
|
+
)
|
|
691
|
+
candidate_tools: list[str] = Field(default_factory=list)
|
|
692
|
+
agent_map: dict[str, str] = Field(default_factory=dict)
|
|
693
|
+
|
|
694
|
+
@field_validator("command", "control_plan_command")
|
|
695
|
+
@classmethod
|
|
696
|
+
def _nonempty_command(cls, value: list[str]) -> list[str]:
|
|
697
|
+
if not value or any(
|
|
698
|
+
not token
|
|
699
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in token)
|
|
700
|
+
for token in value
|
|
701
|
+
):
|
|
702
|
+
raise ValueError("toolgraph commands require non-empty control-free argv tokens")
|
|
703
|
+
return value
|
|
704
|
+
|
|
705
|
+
@field_validator("mode")
|
|
706
|
+
@classmethod
|
|
707
|
+
def _known_mode(cls, value: str) -> str:
|
|
708
|
+
if value not in {"advisory", "strict"}:
|
|
709
|
+
raise ValueError("toolgraph mode must be 'advisory' or 'strict'")
|
|
710
|
+
return value
|
|
711
|
+
|
|
712
|
+
@field_validator("profile")
|
|
713
|
+
@classmethod
|
|
714
|
+
def _known_profile(cls, value: str) -> str:
|
|
715
|
+
allowed = {"strict", "review", "explore"}
|
|
716
|
+
if value not in allowed:
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"toolgraph profile {value!r} must be one of {sorted(allowed)}"
|
|
719
|
+
)
|
|
720
|
+
return value
|
|
721
|
+
|
|
722
|
+
@field_validator("unknown_policy", "drift_policy", "unavailable_policy")
|
|
723
|
+
@classmethod
|
|
724
|
+
def _known_enforcement_policy(cls, value: str) -> str:
|
|
725
|
+
if value not in {"block", "warn"}:
|
|
726
|
+
raise ValueError("toolgraph enforcement policies must be 'block' or 'warn'")
|
|
727
|
+
return value
|
|
728
|
+
|
|
729
|
+
@field_validator("candidate_tools")
|
|
730
|
+
@classmethod
|
|
731
|
+
def _shaped_candidate_tools(cls, value: list[str]) -> list[str]:
|
|
732
|
+
# Mode-INDEPENDENT shape check: candidate_tools are spliced verbatim
|
|
733
|
+
# into the toolgraph argv in advisory mode too, so a token like
|
|
734
|
+
# "--out" or one with whitespace/control chars would be re-parsed as
|
|
735
|
+
# an option and redirect toolgraph's own output/profile flags (argv
|
|
736
|
+
# position confusion — no shell involved). Strict-only rules
|
|
737
|
+
# (non-empty, unique, agent_map present) stay in the model validator.
|
|
738
|
+
bad = [
|
|
739
|
+
tool
|
|
740
|
+
for tool in value
|
|
741
|
+
if not tool
|
|
742
|
+
or tool.startswith("-")
|
|
743
|
+
or any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in tool)
|
|
744
|
+
or tool.count("::") != 1
|
|
745
|
+
or any(not part for part in tool.split("::"))
|
|
746
|
+
]
|
|
747
|
+
if bad:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
"toolgraph candidate_tools must use qualified 'server::tool' keys "
|
|
750
|
+
"(no leading '-', no whitespace/control chars): "
|
|
751
|
+
+ ", ".join(repr(tool) for tool in bad)
|
|
752
|
+
)
|
|
753
|
+
return value
|
|
754
|
+
|
|
755
|
+
@field_validator("agent_map")
|
|
756
|
+
@classmethod
|
|
757
|
+
def _shaped_agent_map(cls, value: dict[str, str]) -> dict[str, str]:
|
|
758
|
+
# Same rule, same reason as candidate_tools above — and the same argv:
|
|
759
|
+
# the mapped identity occupies the token immediately BEFORE them, so an
|
|
760
|
+
# identity like "--out" redirects toolgraph's own flags exactly as a
|
|
761
|
+
# malformed candidate would. Whitespace does NOT redirect anything on
|
|
762
|
+
# its own (an argv element stays one element however many spaces it
|
|
763
|
+
# holds); it is rejected because an identity is a single token by
|
|
764
|
+
# grammar, and a value with spaces or control characters is a config
|
|
765
|
+
# mistake worth failing loudly rather than passing through. Keys are real agent
|
|
766
|
+
# ids (verbatim, hyphens kept — the [capabilities.agents] rule) and are
|
|
767
|
+
# never spliced into argv, so only the values are shape-checked.
|
|
768
|
+
bad = [
|
|
769
|
+
identity
|
|
770
|
+
for identity in value.values()
|
|
771
|
+
if not identity
|
|
772
|
+
or identity.startswith("-")
|
|
773
|
+
or any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in identity)
|
|
774
|
+
]
|
|
775
|
+
if bad:
|
|
776
|
+
raise ValueError(
|
|
777
|
+
"toolgraph agent_map identities must be single argv tokens "
|
|
778
|
+
"(no leading '-', no whitespace/control chars): "
|
|
779
|
+
+ ", ".join(repr(identity) for identity in bad)
|
|
780
|
+
)
|
|
781
|
+
# The synthetic principal minted for an agent with NO mapping is
|
|
782
|
+
# `syncmill-unmapped-` followed by sixteen lowercase hex characters, and
|
|
783
|
+
# nothing else can be one. Refusing exactly that shape keeps a deliberate
|
|
784
|
+
# identity out of the namespace reserved for absent ones without
|
|
785
|
+
# confiscating the prefix: `syncmill-unmapped-production` cannot collide
|
|
786
|
+
# with any principal the helper generates, so it stays a legal name.
|
|
787
|
+
# A literal pattern rather than a shared constant, because config must
|
|
788
|
+
# not import planning.
|
|
789
|
+
reserved = [
|
|
790
|
+
identity for identity in value.values()
|
|
791
|
+
if re.fullmatch(r"syncmill-unmapped-[0-9a-f]{16}", identity)
|
|
792
|
+
]
|
|
793
|
+
if reserved:
|
|
794
|
+
raise ValueError(
|
|
795
|
+
"toolgraph agent_map identities must not use the synthetic "
|
|
796
|
+
"'syncmill-unmapped-<16 hex>' form (reserved for agents with no "
|
|
797
|
+
"mapping): " + ", ".join(repr(identity) for identity in reserved)
|
|
798
|
+
)
|
|
799
|
+
return value
|
|
800
|
+
|
|
801
|
+
@model_validator(mode="after")
|
|
802
|
+
def _validate_strict_surface(self) -> "ToolgraphConfig":
|
|
803
|
+
needs_surface = self.mode == "strict" or self.control_plan_enabled
|
|
804
|
+
if not self.enabled or not needs_surface:
|
|
805
|
+
return self
|
|
806
|
+
if not self.candidate_tools:
|
|
807
|
+
raise ValueError("toolgraph strict/control-plan mode requires candidate_tools")
|
|
808
|
+
if len(set(self.candidate_tools)) != len(self.candidate_tools):
|
|
809
|
+
raise ValueError("toolgraph strict/control-plan candidate_tools must be unique")
|
|
810
|
+
if not self.agent_map:
|
|
811
|
+
raise ValueError(
|
|
812
|
+
"toolgraph strict/control-plan mode requires at least one agent_map entry"
|
|
813
|
+
)
|
|
814
|
+
return self
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def valid_model_selector(value: object) -> bool:
|
|
818
|
+
"""The ONE selector-shape rule (config validation AND manifest inheritance):
|
|
819
|
+
a single 1-256-char argv token — no whitespace, no ASCII control chars.
|
|
820
|
+
``\\Z`` (not ``$``, which tolerates one trailing newline) keeps the regex
|
|
821
|
+
self-sufficient; the control-char clause covers what ``\\S`` can't (e.g.
|
|
822
|
+
DEL, and non-space controls inside the token)."""
|
|
823
|
+
return (
|
|
824
|
+
isinstance(value, str)
|
|
825
|
+
and re.match(r"^\S{1,256}\Z", value) is not None
|
|
826
|
+
and not any(ord(ch) < 32 or ord(ch) == 127 for ch in value)
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
class ModelRoutingConfig(BaseModel):
|
|
831
|
+
"""Difficulty-based provider model routing (``[model_routing]``).
|
|
832
|
+
|
|
833
|
+
DISABLED by default (``mode="off"``): no classifier runs, no decision is
|
|
834
|
+
built, no model flag is passed — behavior is byte-identical to before.
|
|
835
|
+
``mode="observe"`` classifies each top-level run and records the
|
|
836
|
+
recommendation in the run plan/manifest WITHOUT applying any model flag;
|
|
837
|
+
an explicit ``syncmill run --difficulty low|medium|high`` applies the
|
|
838
|
+
configured selector even under ``mode="off"``. ``mode="enforce"`` is
|
|
839
|
+
reserved for PR 2 (post-calibration) and rejected here.
|
|
840
|
+
|
|
841
|
+
``models`` maps tier → provider → selector::
|
|
842
|
+
|
|
843
|
+
[model_routing.models.low]
|
|
844
|
+
claude = "claude-haiku-4-5-20251001"
|
|
845
|
+
kimi-code = "kimi-k2"
|
|
846
|
+
|
|
847
|
+
Provider keys are the **real** agent ids (``kimi-code`` verbatim — the
|
|
848
|
+
``[capabilities.agents]`` key rule). Unlike the lenient per-agent tables
|
|
849
|
+
this model is ``extra="forbid"``: a typo inside a cost-control table must
|
|
850
|
+
be a config error, not a silently-ignored key (§2.2 invariant 5). Selector
|
|
851
|
+
strings are validated for shape only (one non-whitespace argv token, no
|
|
852
|
+
control characters) — syncmill never checks a provider catalog, so a
|
|
853
|
+
syntactically valid but unavailable selector remains an ordinary provider
|
|
854
|
+
execution failure. Env forms::
|
|
855
|
+
|
|
856
|
+
SYNCMILL_MODEL_ROUTING__MODE=observe
|
|
857
|
+
SYNCMILL_MODEL_ROUTING__MODELS='{"low":{"codex":"<selector>"}}'
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
model_config = ConfigDict(extra="forbid")
|
|
861
|
+
|
|
862
|
+
mode: str = "off"
|
|
863
|
+
classifier: str = "prompt-complexity-v1"
|
|
864
|
+
models: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
865
|
+
|
|
866
|
+
@field_validator("mode")
|
|
867
|
+
@classmethod
|
|
868
|
+
def _known_mode(cls, value: str) -> str:
|
|
869
|
+
if value == "enforce":
|
|
870
|
+
raise ValueError(
|
|
871
|
+
"model_routing mode 'enforce' is reserved for a later release "
|
|
872
|
+
"(calibration-gated); use 'off' or 'observe'"
|
|
873
|
+
)
|
|
874
|
+
if value not in {"off", "observe"}:
|
|
875
|
+
raise ValueError("model_routing mode must be 'off' or 'observe'")
|
|
876
|
+
return value
|
|
877
|
+
|
|
878
|
+
@field_validator("classifier")
|
|
879
|
+
@classmethod
|
|
880
|
+
def _known_classifier(cls, value: str) -> str:
|
|
881
|
+
if value != "prompt-complexity-v1":
|
|
882
|
+
raise ValueError(
|
|
883
|
+
f"unknown model_routing classifier {value!r} "
|
|
884
|
+
"(supported: 'prompt-complexity-v1')"
|
|
885
|
+
)
|
|
886
|
+
return value
|
|
887
|
+
|
|
888
|
+
@field_validator("models")
|
|
889
|
+
@classmethod
|
|
890
|
+
def _valid_models(
|
|
891
|
+
cls, value: dict[str, dict[str, str]]
|
|
892
|
+
) -> dict[str, dict[str, str]]:
|
|
893
|
+
allowed_tiers = {"low", "medium", "high"}
|
|
894
|
+
allowed_providers = {"claude", "codex", "kimi-code"}
|
|
895
|
+
for tier, table in value.items():
|
|
896
|
+
if tier not in allowed_tiers:
|
|
897
|
+
raise ValueError(
|
|
898
|
+
f"model_routing.models has unknown tier {tier!r} "
|
|
899
|
+
f"(allowed: {sorted(allowed_tiers)})"
|
|
900
|
+
)
|
|
901
|
+
for provider, selector in table.items():
|
|
902
|
+
if provider not in allowed_providers:
|
|
903
|
+
raise ValueError(
|
|
904
|
+
f"model_routing.models.{tier} has unknown provider "
|
|
905
|
+
f"{provider!r} (allowed: {sorted(allowed_providers)})"
|
|
906
|
+
)
|
|
907
|
+
if not valid_model_selector(selector):
|
|
908
|
+
raise ValueError(
|
|
909
|
+
f"model_routing.models.{tier}.{provider} selector "
|
|
910
|
+
f"{selector!r} must be 1-256 non-whitespace characters "
|
|
911
|
+
"with no control characters"
|
|
912
|
+
)
|
|
913
|
+
return value
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
class IntegrationConfig(BaseModel):
|
|
917
|
+
"""Ecosystem-integration settings — the ``[integration.*]`` table family.
|
|
918
|
+
|
|
919
|
+
``report`` (S3), ``trace`` (S3′), and ``toolgraph`` (S5) are the members;
|
|
920
|
+
each is disabled by default, so out of the box the integration surface is
|
|
921
|
+
inert.
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
model_config = ConfigDict(extra="ignore")
|
|
925
|
+
|
|
926
|
+
report: ReportConfig = Field(default_factory=ReportConfig)
|
|
927
|
+
trace: TraceConfig = Field(default_factory=TraceConfig)
|
|
928
|
+
toolgraph: ToolgraphConfig = Field(default_factory=ToolgraphConfig)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
class SyncmillConfig(BaseSettings):
|
|
932
|
+
"""Root configuration, resolved from three layers (highest priority first):
|
|
933
|
+
|
|
934
|
+
1. ``SYNCMILL_*`` environment variables (nested delimiter ``__``).
|
|
935
|
+
2. A project ``syncmill.toml`` in the current working directory.
|
|
936
|
+
3. Field defaults.
|
|
937
|
+
|
|
938
|
+
Env always wins over the file and the file over defaults, so **every field
|
|
939
|
+
stays env-overridable** (the long-standing invariant) while a checked-in
|
|
940
|
+
``syncmill.toml`` gives a project baseline. Explicit constructor kwargs sit
|
|
941
|
+
above all three (the programmatic/test path). The CLI flags ``--repo`` /
|
|
942
|
+
``--strategy`` / ``--timeout`` don't use that init source — they mutate the
|
|
943
|
+
built config post-construction (only when passed), which likewise overrides
|
|
944
|
+
every source. Unknown *top-level* keys in the file are rejected (typo
|
|
945
|
+
protection); per-agent tables (``extra="ignore"``) are lenient.
|
|
946
|
+
|
|
947
|
+
TOML mirrors the env naming exactly: per-agent tables use the **field** name
|
|
948
|
+
(``[kimi_code]``, underscored — like ``SYNCMILL_KIMI_CODE__…``), while
|
|
949
|
+
``[capabilities.agents]`` is keyed by the **real agent id** (``kimi-code``,
|
|
950
|
+
verbatim — like the ``SYNCMILL_CAPABILITIES__AGENTS`` JSON) and
|
|
951
|
+
``[teams.<name>]`` is keyed by the verbatim team name (like the
|
|
952
|
+
``SYNCMILL_TEAMS`` JSON).
|
|
953
|
+
"""
|
|
954
|
+
|
|
955
|
+
model_config = SettingsConfigDict(
|
|
956
|
+
env_prefix="SYNCMILL_",
|
|
957
|
+
env_nested_delimiter="__",
|
|
958
|
+
toml_file="syncmill.toml",
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
@classmethod
|
|
962
|
+
def settings_customise_sources(
|
|
963
|
+
cls,
|
|
964
|
+
settings_cls: type[BaseSettings],
|
|
965
|
+
init_settings: PydanticBaseSettingsSource,
|
|
966
|
+
env_settings: PydanticBaseSettingsSource,
|
|
967
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
968
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
969
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
970
|
+
"""Slot ``syncmill.toml`` below env vars and above field defaults.
|
|
971
|
+
|
|
972
|
+
The returned tuple is precedence order (first = highest): explicit init
|
|
973
|
+
kwargs, then env, then the toml file, then defaults — keeping the
|
|
974
|
+
env > file > defaults invariant without dropping any default source.
|
|
975
|
+
Nested values deep-merge across sources, so an env override of a single
|
|
976
|
+
nested field (e.g. ``SYNCMILL_CLAUDE__TIMEOUT_SECONDS``) leaves the rest
|
|
977
|
+
of that table from the file intact.
|
|
978
|
+
"""
|
|
979
|
+
return (
|
|
980
|
+
init_settings,
|
|
981
|
+
env_settings,
|
|
982
|
+
dotenv_settings,
|
|
983
|
+
TomlConfigSettingsSource(settings_cls),
|
|
984
|
+
file_secret_settings,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
agent_priority: list[str] = Field(
|
|
988
|
+
default=["claude", "codex", "kimi-code"],
|
|
989
|
+
description="Agent order — RouteStrategy fallback order, "
|
|
990
|
+
"PipelineStrategy stage order, CompeteStrategy winner priority, "
|
|
991
|
+
"CouncilStrategy member/synthesis-winner priority, and DecomposeStrategy "
|
|
992
|
+
"planner + subtask round-robin + synthesis-winner priority. "
|
|
993
|
+
"Capability matching (#9) re-orders agents OVER this baseline per task; "
|
|
994
|
+
"with no capability hint (or no match) the order is exactly this priority.",
|
|
995
|
+
)
|
|
996
|
+
config_version: int = Field(default=2, ge=1)
|
|
997
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
998
|
+
workspace: WorkspaceConfig = Field(default_factory=WorkspaceConfig)
|
|
999
|
+
runs: RunsConfig = Field(default_factory=RunsConfig)
|
|
1000
|
+
workflow: WorkflowConfig = Field(default_factory=WorkflowConfig)
|
|
1001
|
+
rooms: RoomsConfig = Field(default_factory=RoomsConfig)
|
|
1002
|
+
conversations: ConversationConfig = Field(default_factory=ConversationConfig)
|
|
1003
|
+
selection: SelectionConfig = Field(default_factory=SelectionConfig)
|
|
1004
|
+
claude: AgentConfig = Field(default_factory=AgentConfig)
|
|
1005
|
+
codex: AgentConfig = Field(default_factory=AgentConfig)
|
|
1006
|
+
kimi_code: AgentConfig = Field(default_factory=AgentConfig)
|
|
1007
|
+
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
|
1008
|
+
gate: GateConfig = Field(default_factory=GateConfig)
|
|
1009
|
+
breaker: BreakerConfig = Field(default_factory=BreakerConfig)
|
|
1010
|
+
capabilities: CapabilityConfig = Field(default_factory=CapabilityConfig)
|
|
1011
|
+
council: CouncilConfig = Field(default_factory=CouncilConfig)
|
|
1012
|
+
decompose: DecomposeConfig = Field(default_factory=DecomposeConfig)
|
|
1013
|
+
monitor: MonitorConfig = Field(default_factory=MonitorConfig)
|
|
1014
|
+
board: BoardConfig = Field(default_factory=BoardConfig)
|
|
1015
|
+
tui: TuiConfig = Field(default_factory=TuiConfig)
|
|
1016
|
+
integration: IntegrationConfig = Field(default_factory=IntegrationConfig)
|
|
1017
|
+
model_routing: ModelRoutingConfig = Field(default_factory=ModelRoutingConfig)
|
|
1018
|
+
teams: dict[str, TeamConfig] = Field(default_factory=dict)
|
|
1019
|
+
applied_team: str | None = Field(
|
|
1020
|
+
default=None,
|
|
1021
|
+
description="INTERNAL/runtime-only: the team profile actually applied to "
|
|
1022
|
+
"this process (stamped by `apply_team`, read by the run-report "
|
|
1023
|
+
"envelope's `team` field). The `_scrub_applied_team` validator forces "
|
|
1024
|
+
"it to None at construction, so a stray SYNCMILL_APPLIED_TEAM env var "
|
|
1025
|
+
"or toml key can never forge it — only `apply_team` sets it.",
|
|
1026
|
+
)
|
|
1027
|
+
default_team: str | None = Field(
|
|
1028
|
+
default=None,
|
|
1029
|
+
description="Team profile auto-applied by `run`/`board run` when no "
|
|
1030
|
+
"explicit --team is passed (precedence: --team > goal→team mapping > "
|
|
1031
|
+
"default_team). Persisted by `team use` as a top-level syncmill.toml "
|
|
1032
|
+
"key; rides env as SYNCMILL_DEFAULT_TEAM. Deliberately NOT validated "
|
|
1033
|
+
"against `teams` at construction — a dangling value must not brick "
|
|
1034
|
+
"every command including its own fix (`team use --clear`); the apply "
|
|
1035
|
+
"sites raise a clean error instead. An empty string is treated as "
|
|
1036
|
+
'unset at apply sites (the `-r ""` house rule).',
|
|
1037
|
+
)
|
|
1038
|
+
repo_root: str = "."
|
|
1039
|
+
default_strategy: str = "route"
|
|
1040
|
+
|
|
1041
|
+
@model_validator(mode="after")
|
|
1042
|
+
def _validate_teams(self) -> "SyncmillConfig":
|
|
1043
|
+
"""Reject a structurally broken ``teams`` map at construction.
|
|
1044
|
+
|
|
1045
|
+
Delegates to the shared :func:`check_team_goals` — the same rule
|
|
1046
|
+
``teamfile.validate_team_save`` enforces before writing a profile.
|
|
1047
|
+
"""
|
|
1048
|
+
check_team_goals(self.teams)
|
|
1049
|
+
return self
|
|
1050
|
+
|
|
1051
|
+
@model_validator(mode="after")
|
|
1052
|
+
def _validate_strict_reporting(self) -> "SyncmillConfig":
|
|
1053
|
+
if (
|
|
1054
|
+
self.integration.toolgraph.enabled
|
|
1055
|
+
and self.integration.toolgraph.mode == "strict"
|
|
1056
|
+
and not self.integration.report.enabled
|
|
1057
|
+
):
|
|
1058
|
+
raise ValueError("toolgraph strict mode requires integration.report.enabled=true")
|
|
1059
|
+
return self
|
|
1060
|
+
|
|
1061
|
+
@model_validator(mode="after")
|
|
1062
|
+
def _scrub_applied_team(self) -> "SyncmillConfig":
|
|
1063
|
+
"""Force ``applied_team`` to ``None`` at construction — it is RUNTIME
|
|
1064
|
+
state only ever stamped by :func:`apply_team` (codex review, S3): a
|
|
1065
|
+
declared field necessarily rides the env/toml sources, so without this
|
|
1066
|
+
scrub a stray ``SYNCMILL_APPLIED_TEAM`` or toml key could forge a team
|
|
1067
|
+
name (arbitrary text) into every run-envelope's ``team`` field."""
|
|
1068
|
+
self.applied_team = None
|
|
1069
|
+
return self
|
|
1070
|
+
|
|
1071
|
+
def agent_config(self, agent_id: str) -> AgentConfig:
|
|
1072
|
+
"""Get config for a specific agent by id.
|
|
1073
|
+
|
|
1074
|
+
Hyphenated ids (e.g. ``kimi-code``) map to their underscore field name
|
|
1075
|
+
(``kimi_code``) — Python identifiers can't contain hyphens, but agent
|
|
1076
|
+
ids can, so the env var is ``SYNCMILL_KIMI_CODE__TIMEOUT_SECONDS``.
|
|
1077
|
+
"""
|
|
1078
|
+
return getattr(self, agent_id.replace("-", "_"), AgentConfig())
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def apply_team(config: SyncmillConfig, team_name: str) -> TeamConfig:
|
|
1082
|
+
"""Overlay the named team profile onto the resolved config IN PLACE.
|
|
1083
|
+
|
|
1084
|
+
``agent_priority`` ← ``team.agents`` (subset + order); ``capabilities.agents``
|
|
1085
|
+
← ``team.capabilities`` only when non-empty (an unset map keeps the global
|
|
1086
|
+
declaration); ``default_strategy`` ← ``team.strategy`` only when set. This is
|
|
1087
|
+
post-construction mutation — the same mechanism as the ``--repo``/``--strategy``
|
|
1088
|
+
CLI flags — so it trivially overrides every config source; apply it BEFORE
|
|
1089
|
+
constructing the ``Supervisor``, which derives the registry, matcher, and
|
|
1090
|
+
strategy from these fields in ``__init__``.
|
|
1091
|
+
|
|
1092
|
+
Raises ``KeyError`` on an unknown team name (the CLI wraps it with a friendly
|
|
1093
|
+
listing). Returns the applied profile.
|
|
1094
|
+
"""
|
|
1095
|
+
team = config.teams[team_name]
|
|
1096
|
+
config.agent_priority = list(team.agents)
|
|
1097
|
+
if team.capabilities:
|
|
1098
|
+
config.capabilities.agents = dict(team.capabilities)
|
|
1099
|
+
if team.strategy is not None:
|
|
1100
|
+
config.default_strategy = team.strategy
|
|
1101
|
+
# Record WHICH profile was applied — the run-report envelope's `team` field
|
|
1102
|
+
# reads it. Stamped here (not threaded through Supervisor.run) so all three
|
|
1103
|
+
# apply paths — direct --team/default-team, the monitored inner's forwarded
|
|
1104
|
+
# --team, and `board run` — are covered by construction.
|
|
1105
|
+
config.applied_team = team_name
|
|
1106
|
+
return team
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def resolve_goal_team(config: SyncmillConfig, goal: str) -> str | None:
|
|
1110
|
+
"""Name of the team whose profile declares ``goal``, or ``None``.
|
|
1111
|
+
|
|
1112
|
+
Deterministic by construction: ``_validate_teams`` rejects a goal declared
|
|
1113
|
+
by two teams, so at most one team can match.
|
|
1114
|
+
"""
|
|
1115
|
+
for name, team in config.teams.items():
|
|
1116
|
+
if goal in team.goals:
|
|
1117
|
+
return name
|
|
1118
|
+
return None
|