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/gates.py ADDED
@@ -0,0 +1,179 @@
1
+ """Quality gates (#10) — validate an agent's work in its worktree before it's
2
+ accepted.
3
+
4
+ A gate runs *after* an agent finishes, against the diff it produced in its own
5
+ worktree — never as a pre-run liveness probe (that would violate the "no
6
+ ``health_check`` on agents" invariant). It's reactive, like the rest of
7
+ syncmill's fault handling: run, then judge the output.
8
+
9
+ The gate is **plumbed through the strategy layer, never the agent layer** —
10
+ agents stay pure ``execute(prompt, workdir, timeout)`` functions and know nothing
11
+ about gates. The Supervisor builds the gate from config and injects it into the
12
+ strategy, which decides what a gate verdict means for its topology: RouteStrategy
13
+ falls back on failure, PipelineStrategy fails the chain, CompeteStrategy downranks
14
+ failers when picking a winner.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import shlex
21
+ import subprocess
22
+ from pathlib import Path
23
+ from time import monotonic
24
+ from typing import Protocol, runtime_checkable
25
+
26
+ from syncmill.agents import _isolated_process_env, _sync_run
27
+ from syncmill.config import GateConfig
28
+ from syncmill.models import GateCheck, GateReport
29
+ from syncmill.worktree import Worktree
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Per-check captured output is tailed to this many characters: a failing test run
34
+ # can emit megabytes, but only the tail (where the failure is) needs to reach the
35
+ # gate error / CLI without bloating a TaskResult.
36
+ _OUTPUT_LIMIT = 4000
37
+
38
+
39
+ def _gate_env(workdir: Path) -> dict[str, str]:
40
+ """The child environment for a gate check — the agents' isolation verbatim.
41
+
42
+ Reused rather than rebuilt so "isolated" can only ever mean one thing: git
43
+ routing and config injection stripped, syncmill's own run-identity IPC
44
+ stripped, ``PWD`` pinned to the worktree. A private cross-import (the
45
+ ``session.py`` → ``monitor.py`` precedent); ``agents`` imports nothing from
46
+ here, so the graph stays acyclic.
47
+ """
48
+ return _isolated_process_env(Path(workdir))
49
+
50
+
51
+ @runtime_checkable
52
+ class QualityGate(Protocol):
53
+ """Validates an agent's work in its worktree. Pure verdict: worktree in,
54
+ ``GateReport`` out — like agents, it knows nothing about the Supervisor."""
55
+
56
+ def evaluate(self, worktree: Worktree) -> GateReport: ...
57
+
58
+
59
+ class CommandGate:
60
+ """Runs a fixed list of commands in the worktree; passes iff every one exits 0.
61
+
62
+ Each command is parsed with ``shlex.split`` and run **without a shell** —
63
+ matching how the rest of syncmill shells out (argv lists, no ``shell=True``).
64
+ Chain checks by configuring multiple commands, not with ``&&``/pipes. A
65
+ command that can't run (missing binary, bad syntax, timeout) counts as a
66
+ failed check rather than crashing the gate — an unverifiable result must not
67
+ be silently accepted, but it also must not abort the whole task.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ commands: list[str],
73
+ timeout_seconds: int,
74
+ *,
75
+ total_timeout_seconds: float | None = None,
76
+ ):
77
+ self._commands = list(commands)
78
+ self._timeout = timeout_seconds
79
+ self._total_timeout = total_timeout_seconds
80
+ self._deadline: float | None = None
81
+
82
+ def evaluate(self, worktree: Worktree) -> GateReport:
83
+ self._deadline = (
84
+ monotonic() + self._total_timeout
85
+ if self._total_timeout is not None
86
+ else None
87
+ )
88
+ try:
89
+ checks = [self._run_one(cmd, worktree.path) for cmd in self._commands]
90
+ finally:
91
+ self._deadline = None
92
+ report = GateReport.from_checks(checks)
93
+ logger.debug(
94
+ "Gate on %s (%s): %s", worktree.agent_id, worktree.path, report.summary()
95
+ )
96
+ return report
97
+
98
+ def _run_one(self, command: str, workdir: Path) -> GateCheck:
99
+ start = monotonic()
100
+
101
+ def failed(output: str) -> GateCheck:
102
+ # Every non-clean-exit path reports the same shape — a failed check
103
+ # with the elapsed time — so a launch/decode error is observable, not
104
+ # a zero-duration mystery, and never crashes the gate.
105
+ return GateCheck(
106
+ command, passed=False, exit_code=-1, output=output,
107
+ duration_seconds=round(monotonic() - start, 2),
108
+ )
109
+
110
+ try:
111
+ argv = shlex.split(command)
112
+ except ValueError as exc:
113
+ return failed(f"invalid command: {exc}")
114
+ if not argv:
115
+ return failed("empty command")
116
+ timeout = self._timeout
117
+ if self._deadline is not None:
118
+ remaining = self._deadline - monotonic()
119
+ if remaining <= 0:
120
+ return failed("workflow deadline exceeded before gate command")
121
+ timeout = min(timeout, remaining)
122
+ try:
123
+ # _sync_run: the owned-process-group runner — a timeout kills the
124
+ # command's WHOLE tree, not just the direct child (a test runner's
125
+ # spawned workers used to survive as orphans).
126
+ proc = _sync_run(
127
+ argv,
128
+ capture_output=True,
129
+ tail_chars=_OUTPUT_LIMIT,
130
+ # errors="replace": a gate command may emit invalid UTF-8 (test
131
+ # runners, binary diffs). Decoding must degrade to replacement
132
+ # chars, never raise UnicodeDecodeError and abort the task — the
133
+ # same policy the agent layer uses (agents.py).
134
+ text=True,
135
+ errors="replace",
136
+ cwd=str(workdir),
137
+ timeout=timeout,
138
+ # A gate check is usually a test/lint/build command that reads the
139
+ # repository, so an ambient GIT_DIR/GIT_WORK_TREE would have it
140
+ # validate a different tree than the worktree we are judging, and
141
+ # an ambient core.hooksPath would run someone else's script. Same
142
+ # isolation the agent subprocesses get — run-identity IPC
143
+ # included, since a gate command that invokes syncmill would
144
+ # otherwise adopt the parent's monitor identity and overwrite its
145
+ # envelope and result.json.
146
+ env=_gate_env(workdir),
147
+ )
148
+ except subprocess.TimeoutExpired:
149
+ return failed(f"timed out after {timeout:g}s")
150
+ except FileNotFoundError:
151
+ # FileNotFoundError covers both a missing binary and a missing cwd;
152
+ # check the workdir directly so the verdict names the real cause.
153
+ if not Path(workdir).exists():
154
+ return failed(f"working directory not found: {workdir}")
155
+ return failed(f"command not found: {argv[0]}")
156
+ except ValueError as exc:
157
+ # An invalid argument (e.g. an embedded NUL byte) makes subprocess.run
158
+ # raise ValueError, NOT OSError — it would otherwise escape every
159
+ # handler and crash the gate. A bad command is a failed check.
160
+ return failed(f"invalid command argument: {exc}")
161
+ except OSError as exc:
162
+ return failed(f"failed to launch: {exc}")
163
+ combined = (proc.stdout + proc.stderr).strip()
164
+ return GateCheck(
165
+ command,
166
+ passed=proc.returncode == 0,
167
+ exit_code=proc.returncode,
168
+ output=combined[-_OUTPUT_LIMIT:],
169
+ duration_seconds=round(monotonic() - start, 2),
170
+ )
171
+
172
+
173
+ def build_gate(config: GateConfig, *, total_timeout_seconds: float | None = None) -> QualityGate | None:
174
+ """The gate described by ``config``, or ``None`` when no commands are
175
+ configured — the disabled default, where strategies skip gating entirely and
176
+ behavior is unchanged."""
177
+ if not config.commands:
178
+ return None
179
+ return CommandGate(config.commands, config.timeout_seconds, total_timeout_seconds=total_timeout_seconds)
syncmill/gateway.py ADDED
@@ -0,0 +1,234 @@
1
+ """Read-only MCP gateway over syncmill's ``shared`` memory + work-board (#15, Phase 2).
2
+
3
+ A *one-way* (read-only) MCP surface that lets external/interactive MCP clients
4
+ (Claude Code/Desktop, Cursor, …) READ syncmill's accumulated ``shared`` knowledge
5
+ AND its work-board WITHOUT making syncmill's agents MCP-aware. The core invariant is
6
+ untouched: agents never touch MCP and the Supervisor remains the sole *writer*; this
7
+ gateway is a separate, non-agent, read-only *consumer*.
8
+
9
+ Six read-only tools, no write/delete/index tool: ``shared_search`` / ``shared_read``
10
+ / ``shared_stats`` are locked to the ``shared`` namespace (never an agent's private
11
+ ``agent-runtime:*`` scope — see the isolation guard in ``SyncmillMemory.get_shared``);
12
+ ``board_list`` / ``board_get`` / ``board_stats`` (Phase 2) read the work-board when it
13
+ is enabled, and report disabled-vs-unavailable when it is not.
14
+
15
+ All memtomem access flows through ``SyncmillMemory`` and all board access through the
16
+ ``Board`` store (both advisory/best-effort here), so a broken or unavailable backend
17
+ degrades gracefully — the server still starts and the tools report "currently
18
+ unavailable" rather than crashing.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from mcp.server.mcpserver import MCPServer
24
+
25
+ from syncmill.board import (
26
+ Board,
27
+ BoardError,
28
+ WorkState,
29
+ render_board,
30
+ render_board_stats,
31
+ render_item,
32
+ )
33
+ from syncmill.memory import SyncmillMemory
34
+
35
+ SERVER_NAME = "syncmill-shared"
36
+ INSTRUCTIONS = (
37
+ "Read-only gateway over syncmill's shared agent memory and work-board. Use "
38
+ "shared_search / shared_read / shared_stats for shared knowledge, and "
39
+ "board_list / board_get / board_stats to READ the work-board (items, an item's "
40
+ "detail + audit history, and team-health stats). The surface is read-only — it "
41
+ "cannot write memory or the board, and cannot read any agent's private scope."
42
+ )
43
+
44
+ DEFAULT_TOP_K = 10
45
+ MAX_TOP_K = 50
46
+ DEFAULT_BOARD_LIMIT = 50
47
+ MAX_BOARD_LIMIT = 200
48
+ #: The valid `state` filter values for board_list — derived from the work-item state
49
+ #: machine so a client-supplied bad state is a clear ARGUMENT error, not a board outage.
50
+ _VALID_BOARD_STATES = tuple(s.value for s in WorkState)
51
+
52
+ _UNAVAILABLE = "shared memory is currently unavailable"
53
+ #: The board_* tools always exist (a stable 6-tool surface), but report this when
54
+ #: the server was started without the board enabled — distinct from a backend error.
55
+ _BOARD_DISABLED = "the work-board is not enabled on this server"
56
+ _BOARD_UNAVAILABLE = "the work-board is currently unavailable"
57
+
58
+
59
+ def build_gateway(
60
+ memory: SyncmillMemory,
61
+ *,
62
+ board: Board | None = None,
63
+ board_error: str | None = None,
64
+ ) -> MCPServer:
65
+ """Construct the read-only MCP server (without running it).
66
+
67
+ Host/port are NOT construction-time concerns on the mcp 2.0 SDK: they are
68
+ ``mcp.run(transport="streamable-http", host=..., port=...)`` kwargs, and the
69
+ SDK computes its DNS-rebinding ``transport_security`` policy from the host
70
+ actually passed at run time (loopback hosts get automatic protection), so the
71
+ old stale-policy hazard of mutating settings after construction is structurally
72
+ gone. The CLI owns transport selection.
73
+
74
+ ``memory`` is injected (not built here) so tests can pass a fake-store-backed
75
+ ``SyncmillMemory`` with no monkeypatching, and so the CLI owns its lifetime.
76
+ The tools read memory's *per-call* return value (``None`` == backend failure)
77
+ to choose their message — they never consult the sticky ``memory.degraded``
78
+ flag, so a healthy-empty result is never mislabeled "unavailable".
79
+
80
+ ``board`` is the optional Phase-2 work-board (read-only). The board_* tools are
81
+ registered UNCONDITIONALLY — the surface is a stable six tools so the exact-set
82
+ regression guard never depends on whether the board is enabled. When ``board is
83
+ None`` they report ``_BOARD_DISABLED`` (genuinely off) UNLESS ``board_error`` is
84
+ set — i.e. the board WAS enabled but failed to open — in which case they report
85
+ ``_BOARD_UNAVAILABLE: <error>``, preserving the disabled-vs-broken distinction for
86
+ clients (mirrors the shared tools' healthy-empty-vs-unavailable split) while
87
+ keeping the shared-memory gateway alive. Like the shared tools they preserve
88
+ read-only + no-auth (no board-write tool exists). The board is sqlite-backed and
89
+ SYNCHRONOUS; its reads are quick, so calling them directly inside the async MCP
90
+ handlers is fine (no thread offload needed)."""
91
+ mcp = MCPServer(SERVER_NAME, instructions=INSTRUCTIONS)
92
+
93
+ def _board_or_message() -> Board | str:
94
+ """The board_* tools' shared guard: the usable ``Board`` (a narrowed
95
+ reference type checkers can see, unlike a bare None-check on the closed-over
96
+ ``Board | None``), else the right message — ``_BOARD_UNAVAILABLE`` for an
97
+ enabled-but-broken board (an error was reported), ``_BOARD_DISABLED`` for a
98
+ genuinely-off one."""
99
+ if board is not None:
100
+ return board
101
+ return f"{_BOARD_UNAVAILABLE}: {board_error}" if board_error else _BOARD_DISABLED
102
+
103
+ @mcp.tool()
104
+ async def shared_search(query: str, top_k: int = DEFAULT_TOP_K) -> str:
105
+ """Search syncmill's shared memory and return ranked excerpts.
106
+
107
+ Args:
108
+ query: Natural-language search query.
109
+ top_k: Maximum number of results to return (clamped to 1..50).
110
+ """
111
+ # Clamp untrusted input — an unauthenticated client must not be able to
112
+ # request an unbounded / oversized search.
113
+ top_k = max(1, min(int(top_k), MAX_TOP_K))
114
+ hits = await memory.search_shared(query, top_k=top_k)
115
+ if hits is None:
116
+ return _UNAVAILABLE
117
+ if not hits:
118
+ return "No matches in shared memory."
119
+ lines = [
120
+ f"{i}. [score {r.get('score', 0):.2f}] {r.get('source', 'unknown')} "
121
+ f"(id: {r.get('id', '?')})\n {r.get('content', '')[:300]}"
122
+ for i, r in enumerate(hits, 1)
123
+ ]
124
+ return "\n".join(lines)
125
+
126
+ @mcp.tool()
127
+ async def shared_read(chunk_id: str) -> str:
128
+ """Read the full content of one shared-memory chunk by its id.
129
+
130
+ Args:
131
+ chunk_id: The chunk id (as shown in shared_search results).
132
+ """
133
+ read = await memory.get_shared(chunk_id)
134
+ if read.status == "unavailable":
135
+ # A backend failure on THIS call (or a refused scoped view) — never
136
+ # reported as data absence (#169).
137
+ return _UNAVAILABLE
138
+ chunk = read.chunk
139
+ if read.status != "found" or chunk is None:
140
+ # Covers missing, malformed, and non-shared ids alike — all "not
141
+ # found" from a read-only shared consumer's point of view (the
142
+ # privacy contract: a private chunk's existence is never disclosed).
143
+ return f"Chunk {chunk_id} not found in shared memory."
144
+ header = [
145
+ f"## Chunk {chunk.get('id', chunk_id)}",
146
+ f"- Source: {chunk.get('source', 'unknown')}",
147
+ ]
148
+ tags = ", ".join(chunk.get("tags") or [])
149
+ if tags:
150
+ header.append(f"- Tags: {tags}")
151
+ return "\n".join(header) + f"\n\n{chunk.get('content', '')}"
152
+
153
+ @mcp.tool()
154
+ async def shared_stats() -> str:
155
+ """Report how many chunks are in syncmill's shared memory."""
156
+ stats = await memory.shared_stats()
157
+ if stats is None:
158
+ return _UNAVAILABLE
159
+ return f"shared: {stats['chunk_count']} chunks"
160
+
161
+ @mcp.tool()
162
+ async def board_list(
163
+ state: str | None = None,
164
+ goal: str | None = None,
165
+ limit: int = DEFAULT_BOARD_LIMIT,
166
+ ) -> str:
167
+ """List work-board items (read-only), MOST-RECENT-first by creation order.
168
+
169
+ Args:
170
+ state: Optional state filter (planned/running/blocked/review/done).
171
+ goal: Optional goal filter.
172
+ limit: Maximum items to return (clamped to 1..200).
173
+ """
174
+ bd = _board_or_message()
175
+ if isinstance(bd, str):
176
+ return bd
177
+ # A bad `state` filter is a CLIENT argument error, not a backend outage —
178
+ # validate it up front and return a corrective message (with the valid set),
179
+ # so the LLM fixes the argument rather than concluding the board is down.
180
+ if state is not None and state not in _VALID_BOARD_STATES:
181
+ return (
182
+ f"Unknown state {state!r}. Valid states: "
183
+ f"{', '.join(_VALID_BOARD_STATES)}."
184
+ )
185
+ limit = max(1, min(int(limit), MAX_BOARD_LIMIT))
186
+ try:
187
+ # Sorted newest-first and bounded IN SQL: only `limit` rows are
188
+ # fetched and converted (#167 — the old reversed-FIFO slice
189
+ # materialized the whole board on the event loop to answer
190
+ # limit=1). Newest-first because an LLM reading "current board
191
+ # state" needs recent work; a truncated read keeps the newest, never
192
+ # the oldest, items.
193
+ items = bd.list_recent(state=state, goal=goal, limit=limit)
194
+ except BoardError as exc:
195
+ # A genuine backend/db error (the state was already validated above).
196
+ return f"{_BOARD_UNAVAILABLE}: {exc}"
197
+ if not items:
198
+ return "No board items match."
199
+ return render_board(items)
200
+
201
+ @mcp.tool()
202
+ async def board_get(item_id: str) -> str:
203
+ """Read one work-board item in full — state, evidence, handoff, audit history.
204
+
205
+ Args:
206
+ item_id: The item id (as shown in board_list).
207
+ """
208
+ bd = _board_or_message()
209
+ if isinstance(bd, str):
210
+ return bd
211
+ try:
212
+ # `get` validates the id (a malformed/guessed id is not-found, not a probe)
213
+ # and returns None for a missing item; only a real db error raises.
214
+ item = bd.get(item_id)
215
+ if item is None:
216
+ return f"Board item {item_id} not found."
217
+ return render_item(item, bd.transitions(item_id))
218
+ except BoardError as exc:
219
+ return f"{_BOARD_UNAVAILABLE}: {exc}"
220
+
221
+ @mcp.tool()
222
+ async def board_stats() -> str:
223
+ """Report work-board team-health stats — state breakdown, redispatched work,
224
+ stale-lease reclaims, false-done, and stalled (aged blocked / human-required /
225
+ dropped-handoff) items."""
226
+ bd = _board_or_message()
227
+ if isinstance(bd, str):
228
+ return bd
229
+ try:
230
+ return render_board_stats(bd.stats())
231
+ except BoardError as exc:
232
+ return f"{_BOARD_UNAVAILABLE}: {exc}"
233
+
234
+ return mcp
syncmill/git_env.py ADDED
@@ -0,0 +1,99 @@
1
+ """Safe child environments for Git operations bound to an explicit checkout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+
9
+ # Git documents these variables as repository, worktree, index, ref namespace,
10
+ # shallow-boundary, or object-database routing controls. An ambient value can
11
+ # make a command with an explicit cwd read or mutate a different repository.
12
+ GIT_REPOSITORY_ROUTING_ENV = frozenset(
13
+ {
14
+ "GIT_DIR",
15
+ "GIT_WORK_TREE",
16
+ "GIT_COMMON_DIR",
17
+ "GIT_INDEX_FILE",
18
+ "GIT_OBJECT_DIRECTORY",
19
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
20
+ "GIT_NAMESPACE",
21
+ "GIT_SHALLOW_FILE",
22
+ "GIT_REPLACE_REF_BASE",
23
+ "GIT_CEILING_DIRECTORIES",
24
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
25
+ }
26
+ )
27
+
28
+
29
+ # Git also accepts configuration through the environment, and configuration can
30
+ # execute code: core.hooksPath points at scripts git runs itself, include.path
31
+ # pulls in an arbitrary config file, and alias.<name> entries beginning with "!"
32
+ # are shell commands. An ambient value therefore survives an explicit cwd the way
33
+ # the routing variables above do — it does not redirect *which* repository is
34
+ # read, it redirects what running a command in that repository *does*. syncmill
35
+ # runs git inside worktrees an agent can write to, so these are stripped too.
36
+ #
37
+ # GIT_CONFIG_KEY_<n>/GIT_CONFIG_VALUE_<n> are numbered, hence the prefix match in
38
+ # is_git_routing_var. Variables that merely *configure* an external helper the
39
+ # user legitimately relies on (GIT_SSH_COMMAND, GIT_ASKPASS, GIT_EXEC_PATH) are
40
+ # deliberately kept: stripping them breaks real authentication and custom git
41
+ # installs, and unlike the above they are not a syncmill-introduced surface.
42
+ GIT_CONFIG_INJECTION_ENV = frozenset(
43
+ {
44
+ "GIT_CONFIG",
45
+ "GIT_CONFIG_GLOBAL",
46
+ "GIT_CONFIG_SYSTEM",
47
+ "GIT_CONFIG_PARAMETERS",
48
+ "GIT_CONFIG_COUNT",
49
+ }
50
+ )
51
+
52
+ # Git also lets the environment redefine what a PATHSPEC means, which silently
53
+ # changes the answer to a question rather than where it is asked. syncmill asks
54
+ # git two security-relevant pathspec questions — is this state root ignored
55
+ # (`check-ignore`), is this identity marker tracked (`ls-files`) — and both
56
+ # read as a benign "no" when the pathspec stops meaning what the caller wrote:
57
+ # GIT_LITERAL_PATHSPECS turns `:(icase)…` magic into a filename nothing
58
+ # matches, and the glob/icase toggles reinterpret a plain path. Stripped for
59
+ # the same reason as the routing variables above.
60
+ GIT_PATHSPEC_SEMANTICS_ENV = frozenset(
61
+ {
62
+ "GIT_LITERAL_PATHSPECS",
63
+ "GIT_GLOB_PATHSPECS",
64
+ "GIT_NOGLOB_PATHSPECS",
65
+ "GIT_ICASE_PATHSPECS",
66
+ }
67
+ )
68
+
69
+ _NUMBERED_CONFIG_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
70
+
71
+
72
+ def is_git_routing_var(key: str) -> bool:
73
+ """True if ``key`` routes a Git command's repository, injects its config, or
74
+ redefines pathspec semantics.
75
+
76
+ The single predicate behind every isolated child environment syncmill builds
77
+ (git calls here, agent and gate subprocesses in ``agents.py``/``gates.py``),
78
+ so the three can't drift on what "isolated" means.
79
+ """
80
+ return (
81
+ key in GIT_REPOSITORY_ROUTING_ENV
82
+ or key in GIT_CONFIG_INJECTION_ENV
83
+ or key in GIT_PATHSPEC_SEMANTICS_ENV
84
+ or key.startswith(_NUMBERED_CONFIG_PREFIXES)
85
+ )
86
+
87
+
88
+ def isolated_git_env(
89
+ overrides: Mapping[str, str] | None = None,
90
+ ) -> dict[str, str]:
91
+ """Copy the process environment without ambient Git routing or config injection."""
92
+ env = {
93
+ key: value
94
+ for key, value in os.environ.items()
95
+ if not is_git_routing_var(key)
96
+ }
97
+ if overrides:
98
+ env.update(overrides)
99
+ return env