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/teamfile.py ADDED
@@ -0,0 +1,571 @@
1
+ """Team-profile persistence — the tomlkit ``syncmill.toml`` write path (P3 TEAM pane).
2
+
3
+ The first (and only) toml-*writing* module: `team save` / `team use` and the
4
+ dashboard TEAM pane all funnel through these helpers, while reads stay on
5
+ pydantic-settings' ``TomlConfigSettingsSource``. A flat top-level module
6
+ (gateway/monitor/session/board/dashboard precedent): two consumers (cli +
7
+ dashboard) share one genuinely new concern, and putting the writers in
8
+ ``config.py`` would mix declarative reads with imperative writes and force a
9
+ ``config → agents`` import cycle (the unknown-agent check needs
10
+ ``_AGENT_CONSTRUCTORS``). Import graph stays acyclic:
11
+ ``teamfile → config, agents``; ``cli → teamfile``; ``dashboard → teamfile``.
12
+
13
+ Design rules this module owns:
14
+
15
+ - **Comment-preserving round-trip.** Every write parses the existing document
16
+ with tomlkit, mutates the smallest span, and dumps — comments/whitespace
17
+ everywhere else are preserved byte-for-byte. Replacing ``[teams.<name>]`` is
18
+ deliberately *wholesale* (flags define the full profile; a partial merge
19
+ could not express "clear goals"), so only that table's own comments are lost.
20
+ - **Validate against the RESOLVED config, then write toml only.** A save must
21
+ never produce a file that makes the next ``SyncmillConfig()`` construction
22
+ die (which would brick every command): ``validate_team_save`` runs the shared
23
+ ``check_team_goals`` over ``{**config.teams, name: team}`` — the env+toml
24
+ merged view with the candidate substituted. For every other name the merged
25
+ view is unchanged by the write; for the saved name either env doesn't define
26
+ it (merged == the validated candidate) or env wins the deep-merge (already
27
+ valid, since the resolved config loaded before the save).
28
+ - **Atomic writes** — temp file + ``os.replace`` (the breaker/board recipe).
29
+ - **Serialized read-modify-write.** Atomicity alone only guarantees no reader
30
+ ever sees a half-written file; it does NOT stop two writers from each parsing
31
+ the same document and the second replace discarding the first's table (a lost
32
+ update — `team save` racing the dashboard/TUI TEAM pane, or `init --force`).
33
+ Every write therefore runs inside :func:`config_write_lock`, a BLOCKING
34
+ cross-process lock kept in syncmill's own state root, keyed by a digest of
35
+ the config's real path — DERIVED, never probed, so every writer picks the
36
+ same file, and never in the user's working tree (see :func:`_lock_path_for`
37
+ for what both of those cost to learn). Blocking, not
38
+ fail-fast: the critical section is a sub-millisecond parse-mutate-dump, so a
39
+ waiter serializes invisibly, where a busy error would turn a benign micro-race
40
+ into a user-visible failure. The lock is re-entrant per thread, so a caller
41
+ that spans validate→save (closing the stale-validation window) costs the
42
+ inner acquisitions nothing.
43
+ - ``TeamFileError`` is the single error type at the seam (``BoardError``
44
+ precedent): the CLI converts to ``BadParameter``/``ClickException``, the
45
+ dashboard mirrors it into ``last_team_error`` — this module never imports
46
+ click.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import hashlib
52
+ import os
53
+ import stat
54
+ from collections.abc import Iterator, Mapping, Sequence
55
+ from contextlib import ExitStack, contextmanager
56
+ from pathlib import Path
57
+ from uuid import uuid4
58
+
59
+ import tomlkit
60
+ from pydantic import ValidationError
61
+ from pydantic_settings import (
62
+ BaseSettings,
63
+ PydanticBaseSettingsSource,
64
+ TomlConfigSettingsSource,
65
+ )
66
+ from tomlkit.items import Table
67
+
68
+ from syncmill.agents import _AGENT_CONSTRUCTORS
69
+ from syncmill.artifacts import ArtifactError, _cross_process_lock, printable_name
70
+ from syncmill.config import SyncmillConfig, TeamConfig, check_team_goals
71
+
72
+
73
+ class TeamFileError(Exception):
74
+ """A team-profile write/validation failure (parse error, invalid profile,
75
+ unwritable file, unobtainable lock). The single error type at this seam."""
76
+
77
+
78
+ #: Where config write locks live — syncmill's own state root, like the board db
79
+ #: and the breaker's state file. NOT in the repository: see :func:`_lock_path_for`.
80
+ _LOCK_DIR = "~/.syncmill/locks"
81
+
82
+
83
+ def _lock_path_for(path: Path) -> Path:
84
+ """Where ``path``'s write lock lives: syncmill's state root, keyed by config.
85
+
86
+ Two properties this has to have, which cost two earlier revisions to learn.
87
+
88
+ **Derived, never probed.** The first revision asked
89
+ ``git rev-parse --git-common-dir`` so the lock would sit in ``.git``. That
90
+ made the lock's identity depend on a subprocess: a writer that cannot run
91
+ ``git``, or a repository whose common directory is read-only, picks a
92
+ different file or fails outright — and two writers holding two different
93
+ locks serialize nothing at all. A lock whose whole purpose is that everyone
94
+ picks the SAME file cannot be chosen by something that can answer
95
+ differently per process. The key here is a digest of the config's real path
96
+ (``realpath``, so two routes to one file — a symlinked checkout, a bind
97
+ mount — share one lock).
98
+
99
+ **Out of the working tree.** The second revision put the lock beside the
100
+ config and had ``syncmill init`` add it to ``.git/info/exclude``. But that
101
+ file is machine-local: anyone cloning a repository initialized elsewhere,
102
+ or upgrading a project without re-running ``init``, has no such rule, and
103
+ their first ``team save`` drops an untracked file into ``git status`` — and
104
+ into every agent worktree, since snapshots capture untracked files. Nothing
105
+ the tool writes for its own bookkeeping belongs in the user's tree.
106
+
107
+ The tradeoff taken knowingly: this is a per-USER path, so two different OS
108
+ users editing one shared config are not serialized against each other. That
109
+ is the rare case, it degrades to the behavior that shipped before any lock
110
+ existed, and it buys universal correctness for the case that actually
111
+ happens — one person's CLI racing their own dashboard or TUI.
112
+
113
+ Raises ``TeamFileError`` when the state root cannot be resolved or created
114
+ (an unresolvable ``~``, an unwritable home) — the ``Board`` precedent for
115
+ the same state root.
116
+ """
117
+ try:
118
+ target = os.path.realpath(path)
119
+ root = Path(_LOCK_DIR).expanduser()
120
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
121
+ except (OSError, RuntimeError) as exc:
122
+ raise TeamFileError(f"cannot prepare the config lock directory: {exc}")
123
+ digest = hashlib.sha256(target.encode("utf-8")).hexdigest()
124
+ return root / f"{digest}.lock"
125
+
126
+
127
+ @contextmanager
128
+ def config_write_lock(path: Path) -> Iterator[None]:
129
+ """Serialize read-modify-write cycles on ``path`` across processes.
130
+
131
+ The lock file (see :func:`_lock_path_for`) is never unlinked: removing it
132
+ would let a later acquirer create a *different* inode and hold a lock nobody
133
+ else contends on (the ``JobStore`` lock-file rule).
134
+
135
+ Acquisition BLOCKS — every writer holds it for one parse-mutate-dump, so a
136
+ waiter serializes invisibly, and failing fast would report contention as an
137
+ error the user can do nothing about. Re-entrant per thread (the underlying
138
+ :func:`syncmill.artifacts._cross_process_lock`), so a caller may wrap a
139
+ whole validate→save sequence and the writers' own acquisitions are free.
140
+
141
+ Only the ACQUISITION is wrapped into ``TeamFileError`` — the body's own
142
+ exceptions propagate unchanged, so a write failure is never relabelled as a
143
+ locking one (every existing converter, CLI → ``ClickException``, dashboard →
144
+ ``last_team_error``, TUI → notify, ``project.py`` → ``ProjectError``, keeps
145
+ working because the new failure is the same error type).
146
+ """
147
+ stack = ExitStack()
148
+ try:
149
+ stack.enter_context(_cross_process_lock(_lock_path_for(path)))
150
+ except (ArtifactError, OSError) as exc:
151
+ raise TeamFileError(f"cannot lock {path}: {exc}") from exc
152
+ with stack:
153
+ yield
154
+
155
+
156
+ def toml_path() -> Path:
157
+ """The file every helper reads and writes — derived from
158
+ ``SyncmillConfig.model_config["toml_file"]`` so the write path is
159
+ read/write-symmetric with discovery by construction (``syncmill.toml``
160
+ discovered cwd-relative in production; the hermetic suite's autouse
161
+ ``isolate_toml_discovery`` repoints it into ``tmp_path``, so a save test
162
+ can never touch a real repo-root file even if it forgets to repoint).
163
+
164
+ ABSOLUTIZED against the cwd at call time: consumers hold the returned path
165
+ across their lifetime (the dashboard/TUI anchor ``_toml_path.parent`` for
166
+ project roots and worker launches), and a relative ``Path("syncmill.toml")``
167
+ would silently re-resolve against whatever the process cwd has drifted to
168
+ by then — the "resolved once at launch" claim those comments make is only
169
+ true because this pins an absolute path. Deliberately NOT ``resolve()``:
170
+ a ``syncmill.toml`` that is a symlink to a shared config elsewhere must
171
+ keep the LINK's directory as the anchor — dereferencing it would point
172
+ ``_toml_path.parent`` (project root, jobs, room workers) at the symlink
173
+ target's directory instead of the checkout holding the link.
174
+ ``Path.cwd()`` is already symlink-free on POSIX (getcwd resolves)."""
175
+ path = Path(
176
+ SyncmillConfig.model_config.get("toml_file") or "syncmill.toml"
177
+ ).expanduser()
178
+ return path if path.is_absolute() else Path.cwd() / path
179
+
180
+
181
+ def load_document(path: Path) -> tomlkit.TOMLDocument:
182
+ """Parse ``path`` into a tomlkit document; a missing file is an empty one.
183
+
184
+ Raises ``TeamFileError`` on unreadable/unparseable content or an existing
185
+ top-level ``teams`` key that isn't a table (writing under it would corrupt
186
+ the file).
187
+ """
188
+ try:
189
+ text = path.read_text(encoding="utf-8")
190
+ except FileNotFoundError:
191
+ return tomlkit.document()
192
+ except OSError as exc:
193
+ raise TeamFileError(f"cannot read {path}: {exc}")
194
+ try:
195
+ doc = tomlkit.parse(text)
196
+ except Exception as exc: # tomlkit raises its own ParseError hierarchy
197
+ raise TeamFileError(f"cannot parse {path}: {exc}")
198
+ teams = doc.get("teams")
199
+ if teams is not None and not isinstance(teams, (Table, dict)):
200
+ raise TeamFileError(
201
+ f"{path} has a top-level 'teams' key that is not a table — "
202
+ f"fix the file before saving a team profile"
203
+ )
204
+ return doc
205
+
206
+
207
+ def build_team(
208
+ *,
209
+ agents: Sequence[str],
210
+ strategy: str | None = None,
211
+ goals: Sequence[str] = (),
212
+ capabilities: Mapping[str, Sequence[str]] | None = None,
213
+ ) -> TeamConfig:
214
+ """Construct a validated ``TeamConfig``, converting the pydantic
215
+ ``ValidationError`` into a one-line ``TeamFileError`` (the CLI
216
+ ``_load_config()`` conversion pattern)."""
217
+ try:
218
+ return TeamConfig(
219
+ agents=list(agents),
220
+ strategy=strategy,
221
+ goals=list(goals),
222
+ capabilities={k: list(v) for k, v in (capabilities or {}).items()},
223
+ )
224
+ except ValidationError as exc:
225
+ raise TeamFileError(f"invalid team profile:\n{exc}")
226
+
227
+
228
+ def validate_team_save(
229
+ config: SyncmillConfig, name: str, team: TeamConfig, *, path: Path | None = None
230
+ ) -> None:
231
+ """Pre-write validation for ``save_team`` — raises ``TeamFileError``.
232
+
233
+ Checks the candidate against TWO views (codex design-review Major — each
234
+ can mask a conflict the other sees):
235
+
236
+ - the RESOLVED (env+toml) config with the new team substituted under its
237
+ name — guarantees the next config construction under the current env
238
+ can't die (see the module docstring);
239
+ - when ``path`` is given, the FILE-only teams with the same substitution —
240
+ an env override (env > file, per-field deep-merge) can hide a dup-goal
241
+ that would remain in the file once that env is removed.
242
+
243
+ Plus the same unknown-agent-id check ``cli._apply_team`` applies at
244
+ selection time — catching the typo at save time instead of first use.
245
+ Free-form capability *tokens* are never validated (the matcher's
246
+ graceful-fallback contract).
247
+ """
248
+ if not name:
249
+ raise TeamFileError("team name must be a non-empty string")
250
+ unknown = [a for a in team.agents if a not in _AGENT_CONSTRUCTORS]
251
+ if unknown:
252
+ raise TeamFileError(
253
+ f"team {name!r} names unknown agent(s): {', '.join(unknown)}. "
254
+ f"Valid agents: {', '.join(sorted(_AGENT_CONSTRUCTORS))}"
255
+ )
256
+ try:
257
+ check_team_goals({**config.teams, name: team})
258
+ except ValueError as exc:
259
+ raise TeamFileError(str(exc))
260
+ if path is not None:
261
+ _validate_file_standalone(path, name, team)
262
+
263
+
264
+ def _validate_file_standalone(path: Path, name: str, team: TeamConfig) -> None:
265
+ """Reject a save that would leave ``path`` unloadable once env overrides are
266
+ dropped (codex post-code review Major). The resolved-view check above can be
267
+ satisfied by an env override that MASKS a broken file team — so validate the
268
+ file STANDING ALONE: build a real ``SyncmillConfig`` from **only** the toml
269
+ file (env/dotenv sources dropped), which validates every file team's
270
+ structure AND its dup-goals **exactly** as ``_load_config()`` would minus
271
+ env (crucially env-free — a standalone ``TeamConfig(**table)`` would instead
272
+ read bare ``AGENTS``/``GOALS`` env for any field the table omits, silently
273
+ re-introducing the very mask this guards against). Then run the shared
274
+ dup-goal rule over the file teams plus the candidate. Any malformed file
275
+ team surfaces as ``TeamFileError`` — a save can't paper over a file that
276
+ won't load without env.
277
+
278
+ (A bad top-level toml key can't reach here: it isn't env-maskable, so the
279
+ caller's own ``_load_config()`` would already have failed the command.)
280
+ """
281
+
282
+ class _FileOnly(SyncmillConfig):
283
+ model_config = {**SyncmillConfig.model_config, "toml_file": str(path)}
284
+
285
+ @classmethod
286
+ def settings_customise_sources(
287
+ cls,
288
+ settings_cls: type[BaseSettings],
289
+ init_settings: PydanticBaseSettingsSource,
290
+ env_settings: PydanticBaseSettingsSource,
291
+ dotenv_settings: PydanticBaseSettingsSource,
292
+ file_secret_settings: PydanticBaseSettingsSource,
293
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
294
+ # Deliberately DROP env_settings/dotenv_settings — the file alone.
295
+ return (
296
+ init_settings,
297
+ TomlConfigSettingsSource(settings_cls),
298
+ file_secret_settings,
299
+ )
300
+
301
+ try:
302
+ file_cfg = _FileOnly()
303
+ except ValidationError as exc:
304
+ raise TeamFileError(
305
+ f"{path} has a team profile that is invalid standing alone (an "
306
+ f"environment override currently masks it):\n{exc}"
307
+ )
308
+ try:
309
+ check_team_goals({**file_cfg.teams, name: team})
310
+ except ValueError as exc:
311
+ raise TeamFileError(
312
+ f"{exc} (the conflict is inside {path} standing alone — an "
313
+ f"environment override currently masks it)"
314
+ )
315
+
316
+
317
+ def save_team(path: Path, name: str, team: TeamConfig) -> None:
318
+ """Create/replace ``[teams.<name>]`` in ``path``, preserving everything else.
319
+
320
+ The named table is replaced WHOLESALE (flags define the full profile —
321
+ partial merge can't express "clear goals"); ``strategy`` is emitted only
322
+ when set, ``goals`` and the ``[teams.<name>.capabilities]`` sub-table only
323
+ when non-empty, so the file mirrors what ``team show`` reports as unset.
324
+ The table name and capability keys are verbatim (tomlkit quotes as needed).
325
+ Atomic (temp + ``os.replace``) and serialized against every other writer by
326
+ :func:`config_write_lock`, so a concurrent save of a DIFFERENT team can no
327
+ longer be discarded by this one's replace. Raises ``TeamFileError``.
328
+ """
329
+ with config_write_lock(path):
330
+ doc = load_document(path)
331
+ teams = doc.get("teams")
332
+ if teams is None:
333
+ # Super-table: renders as bare `[teams.<name>]` headers, no `[teams]`.
334
+ teams = tomlkit.table(is_super_table=True)
335
+ doc["teams"] = teams
336
+ entry = tomlkit.table()
337
+ entry["agents"] = list(team.agents)
338
+ if team.strategy is not None:
339
+ entry["strategy"] = team.strategy
340
+ if team.goals:
341
+ entry["goals"] = list(team.goals)
342
+ if team.capabilities:
343
+ caps = tomlkit.table()
344
+ for agent_id, tokens in team.capabilities.items():
345
+ caps[agent_id] = list(tokens)
346
+ entry["capabilities"] = caps
347
+ teams[name] = entry
348
+ _atomic_write(path, tomlkit.dumps(doc))
349
+
350
+
351
+ def set_default_team(path: Path, name: str | None) -> None:
352
+ """Upsert (or, with ``None``, remove) the top-level ``default_team`` key.
353
+
354
+ Clearing an absent key is a no-op and never creates a missing file (so
355
+ ``team use --clear`` stays a safe fix path). tomlkit places a new top-level
356
+ scalar BEFORE the first table (regression-tested — a value appended after
357
+ ``[teams.x]`` would re-parse as that table's member and corrupt the file).
358
+ Serialized by :func:`config_write_lock`. The "no file to clear" check stays
359
+ OUTSIDE the lock: taking it would create the lock file (and, on a missing
360
+ parent directory, fail) for a call that is defined to touch nothing.
361
+ Raises ``TeamFileError``.
362
+ """
363
+ if name is None and not path.exists():
364
+ return
365
+ with config_write_lock(path):
366
+ if name is None:
367
+ doc = load_document(path)
368
+ if "default_team" not in doc:
369
+ return
370
+ del doc["default_team"]
371
+ else:
372
+ doc = load_document(path)
373
+ doc["default_team"] = name
374
+ _atomic_write(path, tomlkit.dumps(doc))
375
+
376
+
377
+ def _fsync_dir(directory: Path) -> None:
378
+ """Best-effort fsync of a directory, so the rename itself is durable.
379
+
380
+ Not every platform lets you open a directory for this; where it fails, the file
381
+ contents are still fsynced and the rename is still atomic — only the ordering
382
+ guarantee across a power loss is weaker."""
383
+ try:
384
+ fd = os.open(str(directory), os.O_RDONLY)
385
+ except OSError:
386
+ return
387
+ try:
388
+ os.fsync(fd)
389
+ except OSError:
390
+ pass
391
+ finally:
392
+ os.close(fd)
393
+
394
+
395
+ def _restore_mode(fd: int, tmp: Path, mode: int) -> None:
396
+ """Put back the bits ``os.open``'s umask masking cleared.
397
+
398
+ ``os.fchmod`` is Unix-only, so on Windows it would raise ``AttributeError`` —
399
+ outside the ``OSError`` boundary this module converts — and fail every write to
400
+ an existing config. Fall back to chmod-by-path there; the temp is ours and
401
+ freshly created, and Windows only honours the read-only bit anyway.
402
+
403
+ The fallback's pathname is not a meaningful race: it runs only where
404
+ ``fchmod`` is absent (Windows), and there a file this process holds open
405
+ cannot be renamed or deleted by another process unless it was opened with
406
+ delete sharing, which CPython does not request."""
407
+ fchmod = getattr(os, "fchmod", None)
408
+ if fchmod is not None:
409
+ fchmod(fd, mode)
410
+ else: # pragma: no cover - exercised on Windows
411
+ os.chmod(tmp, mode)
412
+
413
+
414
+ def _atomic_write(path: Path, text: str) -> None:
415
+ """Temp file + ``os.replace`` in ``path.parent`` (the breaker/board recipe),
416
+ so a failed write can never leave a truncated ``syncmill.toml``.
417
+
418
+ The destination's mode is PRESERVED. ``NamedTemporaryFile`` creates at 0600 and
419
+ ``os.replace`` carries that onto the destination, so writing a checked-in 0644
420
+ project config silently narrowed it — a spurious mode change in git and a break
421
+ for group-readable checkouts.
422
+
423
+ A file that does not exist yet is created with ``O_CREAT|O_EXCL`` and mode 0666,
424
+ so the KERNEL applies the process umask — exactly what a plain ``open()`` would
425
+ produce. Reading the umask in userspace means setting it to 0 and restoring it,
426
+ which is process-global: any thread (the dashboard runs Textual) or fork inside
427
+ that window would create its files world-writable.
428
+
429
+ The temp is created with the DESTINATION's mode, not widened-then-narrowed: a
430
+ private 0600 config written into a 0644 temp is world-readable for as long as it
431
+ takes to write it. ``os.open``'s mode is umask-masked, so the following
432
+ ``fchmod`` restores any bit the umask cleared — by then the file has only ever
433
+ been at most as permissive as its destination.
434
+
435
+ Durability order is fchmod → fsync → replace → fsync(parent): the mode is set
436
+ before the fsync that has to cover it, and the parent sync is what makes the
437
+ rename survive a power loss rather than leaving a zero-length config.
438
+ """
439
+ try:
440
+ mode: int | None = stat.S_IMODE(path.stat().st_mode)
441
+ except FileNotFoundError:
442
+ mode = None # genuinely new: let the umask decide, via O_CREAT below
443
+ except OSError as exc:
444
+ # Only "the file is not there" means "new file". Treating any stat failure
445
+ # that way makes a transient inspection error (EIO, a stale NFS handle)
446
+ # silently republish a PRIVATE config at the umask default. Refuse before
447
+ # creating anything.
448
+ raise TeamFileError(f"cannot inspect {path}: {exc}")
449
+ tmp = path.parent / f".{path.name}.{uuid4().hex}.tmp"
450
+ created = False
451
+ try:
452
+ fd = os.open(
453
+ str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666 if mode is None else mode
454
+ )
455
+ created = True # only now do we own this path, and may remove it
456
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
457
+ fh.write(text)
458
+ if mode is not None:
459
+ _restore_mode(fh.fileno(), tmp, mode)
460
+ fh.flush()
461
+ os.fsync(fh.fileno())
462
+ os.replace(tmp, path)
463
+ created = False # renamed away; nothing of ours is left at `tmp`
464
+ _fsync_dir(path.parent)
465
+ except OSError as exc:
466
+ raise TeamFileError(f"cannot write {path}: {exc}")
467
+ finally:
468
+ # Every exit path, not just OSError: a KeyboardInterrupt or an encoding
469
+ # failure mid-write would otherwise strand a partial temp. Guarded by
470
+ # `created` so an O_EXCL collision never unlinks a file we did not make.
471
+ if created:
472
+ try:
473
+ tmp.unlink(missing_ok=True)
474
+ except OSError:
475
+ pass
476
+
477
+
478
+ def write_document(path: Path, doc: tomlkit.TOMLDocument) -> None:
479
+ """Dump ``doc`` over ``path`` atomically, preserving mode and comments.
480
+
481
+ Public so ``project.py`` can refresh a project config through the one module
482
+ that owns toml writing, rather than hand-serializing a second time — which
483
+ also puts `init`'s writes under the same :func:`config_write_lock` as the
484
+ team writers, so an `init --force` repair and a `team save` cannot interleave
485
+ into a lost update. The caller read ``doc`` before this call, so it should
486
+ hold the lock across that read whenever the two must be one transaction."""
487
+ with config_write_lock(path):
488
+ _atomic_write(path, tomlkit.dumps(doc))
489
+
490
+
491
+ def file_team_names(path: Path) -> set[str]:
492
+ """The team names defined in ``path`` ITSELF (not the resolved view) —
493
+ `team use` warns when the chosen name resolves only via ``SYNCMILL_TEAMS``,
494
+ since the persisted default would dangle without that env (codex
495
+ design-review Major). Raises ``TeamFileError`` like ``load_document``."""
496
+ return set(load_document(path).get("teams") or {})
497
+
498
+
499
+ def load_config_at(path: Path) -> SyncmillConfig:
500
+ """Construct the resolved config with the toml source pointed at ``path``.
501
+
502
+ The dashboard's post-write reload seam (codex design-review Major): a plain
503
+ ``SyncmillConfig()`` reads ``model_config["toml_file"]`` relative to the
504
+ *current* cwd, which need not be the file the TEAM pane just wrote — this
505
+ pins the toml layer to the exact injected path while preserving the normal
506
+ env > file > defaults precedence (a one-off subclass, so the global
507
+ ``SyncmillConfig.model_config`` is never mutated). Raises pydantic's
508
+ ``ValidationError`` like any construction; callers degrade best-effort.
509
+ """
510
+
511
+ class _ConfigAt(SyncmillConfig):
512
+ model_config = {**SyncmillConfig.model_config, "toml_file": str(path)}
513
+
514
+ return _ConfigAt()
515
+
516
+
517
+ def parse_capability_specs(specs: Sequence[str]) -> dict[str, list[str]]:
518
+ """Parse ``"agent=tok1,tok2"`` specs (the ``--capability`` flag / TEAM-pane
519
+ field shape) into a capabilities map. Raises ``TeamFileError`` on a missing
520
+ ``=``, an empty agent id, no non-empty tokens, or a duplicate agent key."""
521
+ caps: dict[str, list[str]] = {}
522
+ for spec in specs:
523
+ agent_id, sep, raw = spec.partition("=")
524
+ agent_id = agent_id.strip()
525
+ if not sep or not agent_id:
526
+ raise TeamFileError(
527
+ f"invalid capability spec {spec!r} — expected 'agent=tok1,tok2'"
528
+ )
529
+ tokens = [t.strip() for t in raw.split(",") if t.strip()]
530
+ if not tokens:
531
+ raise TeamFileError(
532
+ f"capability spec {spec!r} declares no tokens — "
533
+ f"expected 'agent=tok1,tok2'"
534
+ )
535
+ if agent_id in caps:
536
+ raise TeamFileError(f"duplicate capability spec for agent {agent_id!r}")
537
+ caps[agent_id] = tokens
538
+ return caps
539
+
540
+
541
+ def render_team(name: str, team: TeamConfig, config: SyncmillConfig) -> str:
542
+ """One team profile as plain text — the shared renderer `team show` echoes
543
+ and the dashboard TEAM pane displays verbatim, so the two surfaces stay
544
+ byte-identical (the ``render_item``/``render_board_stats`` precedent).
545
+
546
+ Names, agent ids, capabilities, strategies and goals come from configuration
547
+ and an unknown agent id is shown as such, so each is displayed through
548
+ ``printable_name``."""
549
+ p = printable_name
550
+ lines = [f"Team: {p(name)}", "", "Agents (team priority order):", ""]
551
+ for i, agent_id in enumerate(team.agents, 1):
552
+ if agent_id not in _AGENT_CONSTRUCTORS:
553
+ status = "unknown agent"
554
+ elif config.agent_config(agent_id).enabled:
555
+ status = "enabled"
556
+ else:
557
+ status = "disabled"
558
+ line = f" {i}. {p(agent_id)} [{status}]"
559
+ caps = team.capabilities.get(agent_id)
560
+ if caps:
561
+ line += f" capabilities: {', '.join(p(c) for c in caps)}"
562
+ lines.append(line)
563
+ strategy = (
564
+ p(team.strategy)
565
+ if team.strategy
566
+ else f"(inherits default_strategy: {p(str(config.default_strategy))})"
567
+ )
568
+ lines.append("")
569
+ lines.append(f"strategy: {strategy}")
570
+ lines.append(f"goals: {', '.join(p(g) for g in team.goals) if team.goals else '—'}")
571
+ return "\n".join(lines)