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 +2 -0
- cli_bridge/__main__.py +4 -0
- cli_bridge/buildloop.py +379 -0
- cli_bridge/cli.py +438 -0
- cli_bridge/config.py +470 -0
- cli_bridge/conversations.py +111 -0
- cli_bridge/council.py +340 -0
- cli_bridge/detect.py +17 -0
- cli_bridge/eval.py +610 -0
- cli_bridge/findings.py +316 -0
- cli_bridge/guards.py +98 -0
- cli_bridge/jobs.py +167 -0
- cli_bridge/lanes.py +669 -0
- cli_bridge/orchestrate.py +587 -0
- cli_bridge/preamble.py +99 -0
- cli_bridge/router.py +158 -0
- cli_bridge/runner.py +371 -0
- cli_bridge/server.py +2414 -0
- cli_bridge/telemetry.py +745 -0
- cli_bridge/workflows.py +1312 -0
- cli_bridge/worktrees.py +480 -0
- cli_bridge_mcp-0.1.0.dist-info/METADATA +437 -0
- cli_bridge_mcp-0.1.0.dist-info/RECORD +26 -0
- cli_bridge_mcp-0.1.0.dist-info/WHEEL +4 -0
- cli_bridge_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- cli_bridge_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
cli_bridge/__init__.py
ADDED
cli_bridge/__main__.py
ADDED
cli_bridge/buildloop.py
ADDED
|
@@ -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)
|