cli-bridge-mcp 0.1.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.
cli_bridge/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """cli-bridge — consult a council of AI CLIs from inside any MCP client."""
2
+ __version__ = "0.1.0"
cli_bridge/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,379 @@
1
+ """Steerable multi-turn direct builds.
2
+
3
+ `ask_build(mode=direct, async=true)` starts a background build JOB that runs the delegate over
4
+ several turns in the SAME target dir, so the host can watch it (`job_tail`) and steer it
5
+ (`build_steer`) the way a human would, while doing other work in parallel.
6
+
7
+ Design (each point co-decided with the user, not assumed):
8
+ • Continuity is the FILESYSTEM, not a transcript. The delegate writes into the real
9
+ target_dir every turn, so turn N>1 just tells it "your previous work is on disk, continue".
10
+ No transcript replay needed (that was an isolated-worktree concern).
11
+ • Steering between turns: `build_steer` queues instructions; the next turn folds them into a
12
+ DELIMITED block (`<<<HOST_STEERING>>> … <<<END>>>`) so the delegate treats them as commands,
13
+ not as file content to write.
14
+ • Interrupt: `build_steer(interrupt=true)` cancels the CURRENT turn (kills the delegate's
15
+ process group via the runner). Files already written are KEPT (the user's explicit choice);
16
+ the rest of the turn is lost. The loop then continues, applying any queued steering.
17
+ • Definition of Done, tested for real: an OPTIONAL `dod_cmd` (a list[str] argv, NEVER a shell
18
+ string) runs after each turn. Pass → done. Fail → the stderr is fed back as the next turn's
19
+ steering, up to `max_fail_retries` CONSECUTIVE failures (default 3). A separate `max_turns`
20
+ (default 12) bounds total turns so a build that keeps churning still stops.
21
+ • Plan-leak signal: a build turn that changes 0 files in the zone is flagged (the delegate may
22
+ have planned instead of acting) — a WARNING in the log, not a hard kill (factual, not lexical).
23
+ • Safety reuses the direct-build guards (worktrees): per-zone lock for the whole job, zone-scoped
24
+ revert, and the mandatory post-turn GLOBAL porcelain zone-violation check.
25
+
26
+ git ops + the agent run are injected/real so the loop is testable with a fake run_lane and a real
27
+ temp repo (no AI CLI, no network).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import contextlib
33
+ import os
34
+ import subprocess
35
+ import time
36
+ from dataclasses import dataclass, field
37
+
38
+ from . import worktrees
39
+
40
+ _DOD_TIMEOUT_S = 600
41
+ DEFAULT_MAX_TURNS = 12
42
+ DEFAULT_MAX_FAIL_RETRIES = 3
43
+ DEFAULT_STEER_GRACE_S = 90 # after a no-DoD turn with nothing queued, wait this long for a steer
44
+
45
+
46
+ @dataclass
47
+ class BuildState:
48
+ """Live state of a running build, reachable by job_id for steering / status / tail."""
49
+ target_dir: str = ""
50
+ root: str = ""
51
+ zone_rel: str = ""
52
+ zone_label: str = ""
53
+ lane_display: str = ""
54
+ log_path: str = ""
55
+ pre_build_ref: str = ""
56
+ max_turns: int = DEFAULT_MAX_TURNS
57
+ max_fail_retries: int = DEFAULT_MAX_FAIL_RETRIES
58
+ turn: int = 0
59
+ files_changed: int = 0
60
+ note: str = "starting"
61
+ steer_q: list[str] = field(default_factory=list)
62
+ interrupt_requested: bool = False
63
+ turn_task: asyncio.Task | None = field(default=None, repr=False)
64
+
65
+
66
+ _BUILDS: dict[str, BuildState] = {}
67
+
68
+
69
+ def register(job_id: str, state: BuildState) -> None:
70
+ _BUILDS[job_id] = state
71
+
72
+
73
+ def steer(job_id: str, instruction: str, interrupt: bool = False) -> str:
74
+ """Queue an instruction for the next turn and/or interrupt the current one. Returns a short
75
+ human status. 'unknown' if the id isn't a live build in THIS process."""
76
+ st = _BUILDS.get(job_id)
77
+ if st is None:
78
+ return "unknown"
79
+ instruction = (instruction or "").strip()
80
+ if instruction:
81
+ st.steer_q.append(instruction)
82
+ if interrupt:
83
+ st.interrupt_requested = True
84
+ if st.turn_task is not None and not st.turn_task.done():
85
+ st.turn_task.cancel()
86
+ return ("interrupting the current turn; files written so far are kept"
87
+ + (" · steering queued for the next turn" if instruction else ""))
88
+ if not instruction:
89
+ return "nothing to do (no instruction, no interrupt)"
90
+ return f"steering queued ({len(st.steer_q)} pending) — applied on the next turn"
91
+
92
+
93
+ def snapshot(job_id: str) -> dict | None:
94
+ """Build-specific status fields, merged into job_status for kind=build jobs."""
95
+ st = _BUILDS.get(job_id)
96
+ if st is None:
97
+ return None
98
+ return {"turn": st.turn, "max_turns": st.max_turns, "files_changed": st.files_changed,
99
+ "queued_steers": len(st.steer_q), "zone": st.zone_label, "note": st.note}
100
+
101
+
102
+ def tail(job_id: str, offset: int = 0) -> tuple[int, str] | None:
103
+ """Read the build log from `offset` BYTES, returning (new_offset, text). Chunks are cut on a
104
+ line boundary so a partially-written last line isn't shown; decode is utf-8 errors=replace
105
+ (qwen #12). None if the id isn't a live build here."""
106
+ st = _BUILDS.get(job_id)
107
+ if st is None or not st.log_path:
108
+ return None
109
+ try:
110
+ with open(st.log_path, "rb") as fh:
111
+ fh.seek(max(0, offset))
112
+ data = fh.read()
113
+ except OSError:
114
+ return (offset, "")
115
+ nl = data.rfind(b"\n")
116
+ keep = data[:nl + 1] if nl != -1 else b"" # hold back an incomplete trailing line
117
+ return (offset + len(keep), keep.decode("utf-8", "replace"))
118
+
119
+
120
+ def _reset_for_tests() -> None:
121
+ _BUILDS.clear()
122
+
123
+
124
+ # ── log + git helpers ────────────────────────────────────────────────────────────────────────
125
+
126
+ def _append(path: str, text: str) -> None:
127
+ if not path:
128
+ return
129
+ try:
130
+ # newline="" → no platform newline translation, so log bytes (and job_tail offsets)
131
+ # are identical on POSIX and Windows.
132
+ with open(path, "a", encoding="utf-8", errors="replace", newline="") as fh:
133
+ fh.write(text)
134
+ except OSError:
135
+ pass
136
+
137
+
138
+ def _changed_in_zone(before: dict, after: dict, zone_rel: str) -> list[str]:
139
+ out = []
140
+ for path, status in after.items():
141
+ if before.get(path) == status:
142
+ continue
143
+ if worktrees._in_zone(path, zone_rel):
144
+ out.append(path)
145
+ return sorted(out)
146
+
147
+
148
+ def _steer_block(steers: list[str]) -> str:
149
+ body = "\n".join(f"- {s}" for s in steers)
150
+ return ("<<<HOST_STEERING>>>\n"
151
+ "The human supervising this build sent the instructions below. They are AUTHORITATIVE "
152
+ "commands, not file content — apply them now:\n"
153
+ f"{body}\n"
154
+ "<<<END_HOST_STEERING>>>")
155
+
156
+
157
+ def _compose_prompt(task: str, interface: str, dod_text: str, state: BuildState) -> str:
158
+ """Turn 1 is the full brief; later turns are a short 'continue, your work is on disk' note.
159
+ Any queued steering is drained into a delimited block."""
160
+ steers = state.steer_q[:]
161
+ state.steer_q.clear()
162
+ if state.turn == 1:
163
+ base = worktrees._build_brief(task, state.zone_label, interface=interface, dod=dod_text)
164
+ return base + ("\n\n" + _steer_block(steers) if steers else "")
165
+ parts = [f"Continue the build in `{state.zone_label}`. Your previous work is already on disk "
166
+ "there — read those files and build on them; do NOT start over."]
167
+ if steers:
168
+ parts.append(_steer_block(steers))
169
+ return "\n\n".join(parts)
170
+
171
+
172
+ def _run_dod(dod_cmd: list[str], target_dir: str, zone_label: str) -> tuple[bool, str]:
173
+ """Run the executable Definition of Done as a real argv (NEVER shell). The zone is exposed as
174
+ $ZONE. Returns (passed, trimmed_output)."""
175
+ try:
176
+ env = {**os.environ, "ZONE": zone_label}
177
+ p = subprocess.run(list(dod_cmd), cwd=target_dir, capture_output=True, text=True,
178
+ errors="replace", timeout=_DOD_TIMEOUT_S, env=env, check=False)
179
+ out = (p.stdout + ("\n" + p.stderr if p.stderr else "")).strip()
180
+ return p.returncode == 0, out[-4000:]
181
+ except subprocess.TimeoutExpired:
182
+ return False, f"DoD command timed out after {_DOD_TIMEOUT_S}s"
183
+ except (OSError, ValueError) as e:
184
+ return False, f"DoD command could not run: {e}"
185
+
186
+
187
+ # ── the loop ─────────────────────────────────────────────────────────────────────────────────
188
+
189
+ async def run_build(state: BuildState, *, run_lane, lane, args: dict,
190
+ git=worktrees._git, steer_grace_s: float = DEFAULT_STEER_GRACE_S) -> str:
191
+ """Set up the target (greenfield init + per-zone lock + dirty guard), then run the delegate
192
+ over turns until the DoD passes / a cap is hit / a zone violation aborts. Returns a markdown
193
+ report (the job result). Fills `state` as it goes so steer/status/tail see live progress."""
194
+ if "agent" not in lane.caps:
195
+ return f"[error] lane '{lane.key}' has no write/build mode."
196
+ task = (args.get("task") or "").strip()
197
+ if not task:
198
+ return "[error] task is required"
199
+
200
+ raw_target = (args.get("target_dir") or args.get("cwd") or ".").strip()
201
+ target_dir = os.path.abspath(os.path.expanduser(raw_target))
202
+ os.makedirs(target_dir, exist_ok=True)
203
+ scaffold_git = args.get("scaffold_git", True)
204
+ confirm_dirty = bool(args.get("confirm_dirty", False))
205
+
206
+ root, err = worktrees._repo_root(target_dir)
207
+ scaffold_note = ""
208
+ if err:
209
+ if not scaffold_git:
210
+ return (f"[error] {target_dir} is not a git repository and scaffold_git=false; a "
211
+ "direct build needs a git net. Enable scaffold_git or run inside a repo.")
212
+ rc, _o, ierr = git(["-C", target_dir, "init"])
213
+ if rc != 0:
214
+ return f"[error] could not git-init {target_dir}: {ierr.strip()}"
215
+ root, scaffold_note = target_dir, "git-initialised greenfield"
216
+
217
+ raw_zone = (args.get("zone") or "").strip()
218
+ zone_abs = os.path.abspath(os.path.join(target_dir, raw_zone)) if raw_zone else target_dir
219
+ zone_rel = worktrees._relposix(os.path.relpath(zone_abs, root))
220
+ zone_label = os.path.relpath(zone_abs, target_dir)
221
+ if zone_label == ".":
222
+ zone_label = raw_target if raw_target != "." else target_dir
223
+ os.makedirs(zone_abs, exist_ok=True)
224
+
225
+ state.target_dir, state.root, state.zone_rel, state.zone_label = (
226
+ target_dir, root, zone_rel, zone_label)
227
+ state.lane_display = lane.display
228
+ state.max_turns = int(args.get("max_turns") or DEFAULT_MAX_TURNS)
229
+ state.max_fail_retries = int(args.get("max_fail_retries") or DEFAULT_MAX_FAIL_RETRIES)
230
+ _rc, head, _e = git(["-C", root, "rev-parse", "--verify", "HEAD"])
231
+ state.pre_build_ref = head.strip() if _rc == 0 else "" # empty in a fresh (no-commit) repo
232
+
233
+ interface = str(args.get("interface") or "")
234
+ dod_text = str(args.get("dod") or "")
235
+ dod_cmd = args.get("dod_cmd") or None
236
+ if dod_cmd is not None and (not isinstance(dod_cmd, list)
237
+ or not all(isinstance(x, str) for x in dod_cmd)):
238
+ return "[error] dod_cmd must be a list of strings (argv), never a shell string."
239
+ model, effort = args.get("model"), args.get("effort")
240
+ timeout_s = args.get("timeout_s")
241
+
242
+ try:
243
+ with worktrees._zone_lock(target_dir, zone_rel):
244
+ before0 = worktrees._porcelain(root)
245
+ dirty = sorted(p for p, st in before0.items()
246
+ if st != "??" and worktrees._in_zone(p, zone_rel))
247
+ if dirty and not confirm_dirty:
248
+ return ("[error] zone has uncommitted TRACKED changes; commit/stash them or pass "
249
+ f"confirm_dirty=true. Dirty: {', '.join(dirty[:20])}")
250
+ _append(state.log_path,
251
+ f"# build start · lane={lane.display} · zone={zone_label}"
252
+ f"{' · ' + scaffold_note if scaffold_note else ''}\n")
253
+ return await _loop(state, run_lane=run_lane, lane=lane, task=task, interface=interface,
254
+ dod_text=dod_text, dod_cmd=dod_cmd, model=model, effort=effort,
255
+ timeout_s=timeout_s, before0=before0, scaffold_note=scaffold_note,
256
+ steer_grace_s=steer_grace_s)
257
+ except worktrees._BuildLocked as e:
258
+ return f"[error] {e}"
259
+
260
+
261
+ async def _loop(state, *, run_lane, lane, task, interface, dod_text, dod_cmd, model, effort,
262
+ timeout_s, before0, scaffold_note, steer_grace_s) -> str:
263
+ consecutive_fails = 0
264
+ last_res = None
265
+ while state.turn < state.max_turns:
266
+ state.turn += 1
267
+ state.note = f"running turn {state.turn}"
268
+ prompt = _compose_prompt(task, interface, dod_text, state)
269
+ before = worktrees._porcelain(state.root)
270
+ _append(state.log_path, f"\n=== turn {state.turn}/{state.max_turns} ===\n")
271
+
272
+ state.turn_task = asyncio.create_task(run_lane(
273
+ lane, {"task": prompt, "agent": "build", "cwd": state.target_dir,
274
+ "model": model, "effort": effort, "timeout_s": timeout_s}, tool="ask_build"))
275
+ try:
276
+ last_res = await state.turn_task
277
+ except asyncio.CancelledError:
278
+ if not state.turn_task.done():
279
+ state.turn_task.cancel()
280
+ with contextlib.suppress(asyncio.CancelledError, Exception):
281
+ await state.turn_task
282
+ if state.interrupt_requested: # interrupt: keep files, go to next turn
283
+ state.interrupt_requested = False
284
+ _append(state.log_path,
285
+ f"--- turn {state.turn} interrupted by host (files kept) ---\n")
286
+ state.note = "interrupted; awaiting next turn"
287
+ continue
288
+ _append(state.log_path, f"--- build cancelled during turn {state.turn} ---\n")
289
+ raise # genuine job cancel
290
+ finally:
291
+ state.turn_task = None
292
+
293
+ _append(state.log_path, (last_res.render().strip() or "(no output)") + "\n")
294
+ after = worktrees._porcelain(state.root)
295
+
296
+ violations = worktrees._zone_violations(before, after, state.zone_rel)
297
+ if violations:
298
+ worktrees._revert_zone(state.root, state.zone_rel)
299
+ _append(state.log_path, f"!!! zone violation: {', '.join(violations[:20])} — reverted\n")
300
+ state.note = "zone violation"
301
+ return _report(state, last_res, "zone_violation", scaffold_note, violations=violations)
302
+
303
+ changed_total = _changed_in_zone(before0, after, state.zone_rel)
304
+ state.files_changed = len(changed_total)
305
+ if not _changed_in_zone(before, after, state.zone_rel):
306
+ _append(state.log_path,
307
+ "warning: this turn changed 0 files in the zone (planned instead of acting?)\n")
308
+
309
+ if dod_cmd:
310
+ ok, dod_out = _run_dod(dod_cmd, state.target_dir, state.zone_label)
311
+ _append(state.log_path, f"DoD {'PASS' if ok else 'FAIL'}:\n{dod_out}\n")
312
+ if ok:
313
+ state.note = "done (DoD passed)"
314
+ return _report(state, last_res, "done", scaffold_note, dod_out=dod_out)
315
+ consecutive_fails += 1
316
+ if consecutive_fails >= state.max_fail_retries:
317
+ state.note = "stopped (DoD kept failing)"
318
+ return _report(state, last_res, "dod_failed", scaffold_note, dod_out=dod_out)
319
+ state.steer_q.append(f"The Definition-of-Done check failed. Fix this, then it must "
320
+ f"pass:\n{dod_out}")
321
+ continue
322
+ consecutive_fails = 0
323
+
324
+ if state.steer_q: # more steering queued → another turn
325
+ continue
326
+ if await _wait_for_steer(state, steer_grace_s): # give the user a window to react
327
+ continue
328
+ state.note = "built (no DoD)"
329
+ return _report(state, last_res, "built", scaffold_note)
330
+
331
+ state.note = "stopped (max turns)"
332
+ return _report(state, last_res, "max_turns", scaffold_note)
333
+
334
+
335
+ async def _wait_for_steer(state: BuildState, grace_s: float) -> bool:
336
+ """After a no-DoD turn with nothing queued, poll briefly for a late steer/interrupt so the
337
+ user can react to what they just watched. Returns True if something arrived."""
338
+ if grace_s <= 0:
339
+ return bool(state.steer_q) or state.interrupt_requested
340
+ state.note = "idle — send build_steer to continue, or it finishes shortly"
341
+ deadline = time.monotonic() + grace_s
342
+ while time.monotonic() < deadline:
343
+ if state.steer_q or state.interrupt_requested:
344
+ return True
345
+ await asyncio.sleep(0.2)
346
+ return False
347
+
348
+
349
+ def _report(state: BuildState, res, outcome: str, scaffold_note: str, *,
350
+ dod_out: str = "", violations: list[str] | None = None) -> str:
351
+ titles = {"done": "✅ done (Definition of Done passed)",
352
+ "built": "✅ built",
353
+ "dod_failed": "⚠️ stopped — Definition of Done kept failing",
354
+ "max_turns": "⚠️ stopped — hit the turn cap",
355
+ "zone_violation": "⛔ rejected — zone violation"}
356
+ head = (f"# Direct build (steered) — {titles.get(outcome, outcome)}\n"
357
+ f"_Agent: {state.lane_display} · repo: `{state.root}` · zone: `{state.zone_label}` · "
358
+ f"turns: {state.turn}/{state.max_turns} · files changed in zone: {state.files_changed}"
359
+ f"{' · ' + scaffold_note if scaffold_note else ''}_\n")
360
+ lines = [head]
361
+
362
+ if violations:
363
+ lines += ["The delegate wrote OUTSIDE its zone; the in-zone work was reverted. Left for "
364
+ "you to inspect (not auto-deleted):\n", "```\n" + "\n".join(violations[:30]) + "\n```"]
365
+ lines += ["\n## Last agent output\n", res.render().strip() if res else "_(none)_"]
366
+ return "\n".join(lines)
367
+
368
+ if dod_out:
369
+ lines += ["## Definition of Done — last result\n", "```\n" + dod_out + "\n```"]
370
+ lines += ["\n## Changes in the zone (real, unstaged — review then commit or revert)\n"]
371
+ diff = worktrees._zone_diff(state.root, state.zone_rel)
372
+ lines.append("```diff\n" + diff.rstrip() + "\n```" if diff.strip()
373
+ else "_No file changes in the zone._")
374
+ lines += ["\n## Revert (zone-scoped — leaves work outside the zone untouched)",
375
+ f"```\ngit -C {state.root} checkout -- {state.zone_label}\n"
376
+ f"git -C {state.root} clean -fd {state.zone_label}\n```"]
377
+ if state.pre_build_ref:
378
+ lines.append(f"_Pre-build commit was `{state.pre_build_ref[:12]}` (for a full rollback)._")
379
+ return "\n".join(lines)