syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/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
|