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/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