Learn Claude Code
Back to Worktree + Task Isolation

Autonomous AgentsWorktree + Task Isolation

s11 (499 LOC) → s12 (694 LOC)

LOC Delta

+195lines

New Tools

12

task_createtask_listtask_gettask_updatetask_bind_worktreeworktree_createworktree_listworktree_statusworktree_runworktree_removeworktree_keepworktree_events
New Classes

3

EventBusTaskManagerWorktreeManager
New Functions

6

detect_repo_rootsafe_pathrun_bashrun_readrun_writerun_edit

Autonomous Agents

Scan Board, Claim Tasks

499 LOC

14 tools: bash, read_file, write_file, edit_file, send_message, read_inbox, shutdown_response, plan_approval, idle, claim_task, spawn_teammate, list_teammates, broadcast, shutdown_request

collaboration

Worktree + Task Isolation

Isolate by Directory

694 LOC

16 tools: bash, read_file, write_file, edit_file, task_create, task_list, task_get, task_update, task_bind_worktree, worktree_create, worktree_list, worktree_status, worktree_run, worktree_remove, worktree_keep, worktree_events

collaboration

Source Code Diff

s11 (s11_autonomous_agents.py) -> s12 (s12_worktree_task_isolation.py)
11#!/usr/bin/env python3
2-# Harness: autonomy -- models that find work without being told.
2+# Harness: directory isolation -- parallel execution lanes that never collide.
33"""
4-s11_autonomous_agents.py - Autonomous Agents
4+s12_worktree_task_isolation.py - Worktree + Task Isolation
55
6-Idle cycle with task board polling, auto-claiming unclaimed tasks, and
7-identity re-injection after context compression. Builds on s10's protocols.
6+Directory-level isolation for parallel task execution.
7+Tasks are the control plane and worktrees are the execution plane.
88
9- Teammate lifecycle:
10- +-------+
11- | spawn |
12- +---+---+
13- |
14- v
15- +-------+ tool_use +-------+
16- | WORK | <----------- | LLM |
17- +---+---+ +-------+
18- |
19- | stop_reason != tool_use
20- v
21- +--------+
22- | IDLE | poll every 5s for up to 60s
23- +---+----+
24- |
25- +---> check inbox -> message? -> resume WORK
26- |
27- +---> scan .tasks/ -> unclaimed? -> claim -> resume WORK
28- |
29- +---> timeout (60s) -> shutdown
9+ .tasks/task_12.json
10+ {
11+ "id": 12,
12+ "subject": "Implement auth refactor",
13+ "status": "in_progress",
14+ "worktree": "auth-refactor"
15+ }
3016
31- Identity re-injection after compression:
32- messages = [identity_block, ...remaining...]
33- "You are 'coder', role: backend, team: my-team"
17+ .worktrees/index.json
18+ {
19+ "worktrees": [
20+ {
21+ "name": "auth-refactor",
22+ "path": ".../.worktrees/auth-refactor",
23+ "branch": "wt/auth-refactor",
24+ "task_id": 12,
25+ "status": "active"
26+ }
27+ ]
28+ }
3429
35-Key insight: "The agent finds work itself."
30+Key insight: "Isolate by directory, coordinate by task ID."
3631"""
3732
3833import json
3934import os
35+import re
4036import subprocess
41-import threading
4237import time
43-import uuid
4438from pathlib import Path
4539
4640from anthropic import Anthropic
4741from dotenv import load_dotenv
4842
4943load_dotenv(override=True)
44+
5045if os.getenv("ANTHROPIC_BASE_URL"):
5146 os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
5247
5348WORKDIR = Path.cwd()
5449client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
5550MODEL = os.environ["MODEL_ID"]
56-TEAM_DIR = WORKDIR / ".team"
57-INBOX_DIR = TEAM_DIR / "inbox"
58-TASKS_DIR = WORKDIR / ".tasks"
5951
60-POLL_INTERVAL = 5
61-IDLE_TIMEOUT = 60
6252
63-SYSTEM = f"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves."
53+def detect_repo_root(cwd: Path) -> Path | None:
54+ """Return git repo root if cwd is inside a repo, else None."""
55+ try:
56+ r = subprocess.run(
57+ ["git", "rev-parse", "--show-toplevel"],
58+ cwd=cwd,
59+ capture_output=True,
60+ text=True,
61+ timeout=10,
62+ )
63+ if r.returncode != 0:
64+ return None
65+ root = Path(r.stdout.strip())
66+ return root if root.exists() else None
67+ except Exception:
68+ return None
6469
65-VALID_MSG_TYPES = {
66- "message",
67- "broadcast",
68- "shutdown_request",
69- "shutdown_response",
70- "plan_approval_response",
71-}
7270
73-# -- Request trackers --
74-shutdown_requests = {}
75-plan_requests = {}
76-_tracker_lock = threading.Lock()
77-_claim_lock = threading.Lock()
71+REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR
7872
73+SYSTEM = (
74+ f"You are a coding agent at {WORKDIR}. "
75+ "Use task + worktree tools for multi-task work. "
76+ "For parallel or risky changes: create tasks, allocate worktree lanes, "
77+ "run commands in those lanes, then choose keep/remove for closeout. "
78+ "Use worktree_events when you need lifecycle visibility."
79+)
7980
80-# -- MessageBus: JSONL inbox per teammate --
81-class MessageBus:
82- def __init__(self, inbox_dir: Path):
83- self.dir = inbox_dir
81+
82+# -- EventBus: append-only lifecycle events for observability --
83+class EventBus:
84+ def __init__(self, event_log_path: Path):
85+ self.path = event_log_path
86+ self.path.parent.mkdir(parents=True, exist_ok=True)
87+ if not self.path.exists():
88+ self.path.write_text("")
89+
90+ def emit(
91+ self,
92+ event: str,
93+ task: dict | None = None,
94+ worktree: dict | None = None,
95+ error: str | None = None,
96+ ):
97+ payload = {
98+ "event": event,
99+ "ts": time.time(),
100+ "task": task or {},
101+ "worktree": worktree or {},
102+ }
103+ if error:
104+ payload["error"] = error
105+ with self.path.open("a", encoding="utf-8") as f:
106+ f.write(json.dumps(payload) + "\n")
107+
108+ def list_recent(self, limit: int = 20) -> str:
109+ n = max(1, min(int(limit or 20), 200))
110+ lines = self.path.read_text(encoding="utf-8").splitlines()
111+ recent = lines[-n:]
112+ items = []
113+ for line in recent:
114+ try:
115+ items.append(json.loads(line))
116+ except Exception:
117+ items.append({"event": "parse_error", "raw": line})
118+ return json.dumps(items, indent=2)
119+
120+
121+# -- TaskManager: persistent task board with optional worktree binding --
122+class TaskManager:
123+ def __init__(self, tasks_dir: Path):
124+ self.dir = tasks_dir
84125 self.dir.mkdir(parents=True, exist_ok=True)
126+ self._next_id = self._max_id() + 1
85127
86- def send(self, sender: str, to: str, content: str,
87- msg_type: str = "message", extra: dict = None) -> str:
88- if msg_type not in VALID_MSG_TYPES:
89- return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
90- msg = {
91- "type": msg_type,
92- "from": sender,
93- "content": content,
94- "timestamp": time.time(),
128+ def _max_id(self) -> int:
129+ ids = []
130+ for f in self.dir.glob("task_*.json"):
131+ try:
132+ ids.append(int(f.stem.split("_")[1]))
133+ except Exception:
134+ pass
135+ return max(ids) if ids else 0
136+
137+ def _path(self, task_id: int) -> Path:
138+ return self.dir / f"task_{task_id}.json"
139+
140+ def _load(self, task_id: int) -> dict:
141+ path = self._path(task_id)
142+ if not path.exists():
143+ raise ValueError(f"Task {task_id} not found")
144+ return json.loads(path.read_text())
145+
146+ def _save(self, task: dict):
147+ self._path(task["id"]).write_text(json.dumps(task, indent=2))
148+
149+ def create(self, subject: str, description: str = "") -> str:
150+ task = {
151+ "id": self._next_id,
152+ "subject": subject,
153+ "description": description,
154+ "status": "pending",
155+ "owner": "",
156+ "worktree": "",
157+ "blockedBy": [],
158+ "created_at": time.time(),
159+ "updated_at": time.time(),
95160 }
96- if extra:
97- msg.update(extra)
98- inbox_path = self.dir / f"{to}.jsonl"
99- with open(inbox_path, "a") as f:
100- f.write(json.dumps(msg) + "\n")
101- return f"Sent {msg_type} to {to}"
161+ self._save(task)
162+ self._next_id += 1
163+ return json.dumps(task, indent=2)
102164
103- def read_inbox(self, name: str) -> list:
104- inbox_path = self.dir / f"{name}.jsonl"
105- if not inbox_path.exists():
106- return []
107- messages = []
108- for line in inbox_path.read_text().strip().splitlines():
109- if line:
110- messages.append(json.loads(line))
111- inbox_path.write_text("")
112- return messages
165+ def get(self, task_id: int) -> str:
166+ return json.dumps(self._load(task_id), indent=2)
113167
114- def broadcast(self, sender: str, content: str, teammates: list) -> str:
115- count = 0
116- for name in teammates:
117- if name != sender:
118- self.send(sender, name, content, "broadcast")
119- count += 1
120- return f"Broadcast to {count} teammates"
168+ def exists(self, task_id: int) -> bool:
169+ return self._path(task_id).exists()
121170
171+ def update(self, task_id: int, status: str = None, owner: str = None) -> str:
172+ task = self._load(task_id)
173+ if status:
174+ if status not in ("pending", "in_progress", "completed"):
175+ raise ValueError(f"Invalid status: {status}")
176+ task["status"] = status
177+ if owner is not None:
178+ task["owner"] = owner
179+ task["updated_at"] = time.time()
180+ self._save(task)
181+ return json.dumps(task, indent=2)
122182
123-BUS = MessageBus(INBOX_DIR)
183+ def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
184+ task = self._load(task_id)
185+ task["worktree"] = worktree
186+ if owner:
187+ task["owner"] = owner
188+ if task["status"] == "pending":
189+ task["status"] = "in_progress"
190+ task["updated_at"] = time.time()
191+ self._save(task)
192+ return json.dumps(task, indent=2)
124193
194+ def unbind_worktree(self, task_id: int) -> str:
195+ task = self._load(task_id)
196+ task["worktree"] = ""
197+ task["updated_at"] = time.time()
198+ self._save(task)
199+ return json.dumps(task, indent=2)
125200
126-# -- Task board scanning --
127-def scan_unclaimed_tasks() -> list:
128- TASKS_DIR.mkdir(exist_ok=True)
129- unclaimed = []
130- for f in sorted(TASKS_DIR.glob("task_*.json")):
131- task = json.loads(f.read_text())
132- if (task.get("status") == "pending"
133- and not task.get("owner")
134- and not task.get("blockedBy")):
135- unclaimed.append(task)
136- return unclaimed
201+ def list_all(self) -> str:
202+ tasks = []
203+ for f in sorted(self.dir.glob("task_*.json")):
204+ tasks.append(json.loads(f.read_text()))
205+ if not tasks:
206+ return "No tasks."
207+ lines = []
208+ for t in tasks:
209+ marker = {
210+ "pending": "[ ]",
211+ "in_progress": "[>]",
212+ "completed": "[x]",
213+ }.get(t["status"], "[?]")
214+ owner = f" owner={t['owner']}" if t.get("owner") else ""
215+ wt = f" wt={t['worktree']}" if t.get("worktree") else ""
216+ lines.append(f"{marker} #{t['id']}: {t['subject']}{owner}{wt}")
217+ return "\n".join(lines)
137218
138219
139-def claim_task(task_id: int, owner: str) -> str:
140- with _claim_lock:
141- path = TASKS_DIR / f"task_{task_id}.json"
142- if not path.exists():
143- return f"Error: Task {task_id} not found"
144- task = json.loads(path.read_text())
145- task["owner"] = owner
146- task["status"] = "in_progress"
147- path.write_text(json.dumps(task, indent=2))
148- return f"Claimed task #{task_id} for {owner}"
220+TASKS = TaskManager(REPO_ROOT / ".tasks")
221+EVENTS = EventBus(REPO_ROOT / ".worktrees" / "events.jsonl")
149222
150223
151-# -- Identity re-injection after compression --
152-def make_identity_block(name: str, role: str, team_name: str) -> dict:
153- return {
154- "role": "user",
155- "content": f"<identity>You are '{name}', role: {role}, team: {team_name}. Continue your work.</identity>",
156- }
224+# -- WorktreeManager: create/list/run/remove git worktrees + lifecycle index --
225+class WorktreeManager:
226+ def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus):
227+ self.repo_root = repo_root
228+ self.tasks = tasks
229+ self.events = events
230+ self.dir = repo_root / ".worktrees"
231+ self.dir.mkdir(parents=True, exist_ok=True)
232+ self.index_path = self.dir / "index.json"
233+ if not self.index_path.exists():
234+ self.index_path.write_text(json.dumps({"worktrees": []}, indent=2))
235+ self.git_available = self._is_git_repo()
157236
237+ def _is_git_repo(self) -> bool:
238+ try:
239+ r = subprocess.run(
240+ ["git", "rev-parse", "--is-inside-work-tree"],
241+ cwd=self.repo_root,
242+ capture_output=True,
243+ text=True,
244+ timeout=10,
245+ )
246+ return r.returncode == 0
247+ except Exception:
248+ return False
158249
159-# -- Autonomous TeammateManager --
160-class TeammateManager:
161- def __init__(self, team_dir: Path):
162- self.dir = team_dir
163- self.dir.mkdir(exist_ok=True)
164- self.config_path = self.dir / "config.json"
165- self.config = self._load_config()
166- self.threads = {}
250+ def _run_git(self, args: list[str]) -> str:
251+ if not self.git_available:
252+ raise RuntimeError("Not in a git repository. worktree tools require git.")
253+ r = subprocess.run(
254+ ["git", *args],
255+ cwd=self.repo_root,
256+ capture_output=True,
257+ text=True,
258+ timeout=120,
259+ )
260+ if r.returncode != 0:
261+ msg = (r.stdout + r.stderr).strip()
262+ raise RuntimeError(msg or f"git {' '.join(args)} failed")
263+ return (r.stdout + r.stderr).strip() or "(no output)"
167264
168- def _load_config(self) -> dict:
169- if self.config_path.exists():
170- return json.loads(self.config_path.read_text())
171- return {"team_name": "default", "members": []}
265+ def _load_index(self) -> dict:
266+ return json.loads(self.index_path.read_text())
172267
173- def _save_config(self):
174- self.config_path.write_text(json.dumps(self.config, indent=2))
268+ def _save_index(self, data: dict):
269+ self.index_path.write_text(json.dumps(data, indent=2))
175270
176- def _find_member(self, name: str) -> dict:
177- for m in self.config["members"]:
178- if m["name"] == name:
179- return m
271+ def _find(self, name: str) -> dict | None:
272+ idx = self._load_index()
273+ for wt in idx.get("worktrees", []):
274+ if wt.get("name") == name:
275+ return wt
180276 return None
181277
182- def _set_status(self, name: str, status: str):
183- member = self._find_member(name)
184- if member:
185- member["status"] = status
186- self._save_config()
278+ def _validate_name(self, name: str):
279+ if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""):
280+ raise ValueError(
281+ "Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -"
282+ )
187283
188- def spawn(self, name: str, role: str, prompt: str) -> str:
189- member = self._find_member(name)
190- if member:
191- if member["status"] not in ("idle", "shutdown"):
192- return f"Error: '{name}' is currently {member['status']}"
193- member["status"] = "working"
194- member["role"] = role
195- else:
196- member = {"name": name, "role": role, "status": "working"}
197- self.config["members"].append(member)
198- self._save_config()
199- thread = threading.Thread(
200- target=self._loop,
201- args=(name, role, prompt),
202- daemon=True,
203- )
204- self.threads[name] = thread
205- thread.start()
206- return f"Spawned '{name}' (role: {role})"
284+ def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str:
285+ self._validate_name(name)
286+ if self._find(name):
287+ raise ValueError(f"Worktree '{name}' already exists in index")
288+ if task_id is not None and not self.tasks.exists(task_id):
289+ raise ValueError(f"Task {task_id} not found")
207290
208- def _loop(self, name: str, role: str, prompt: str):
209- team_name = self.config["team_name"]
210- sys_prompt = (
211- f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
212- f"Use idle tool when you have no more work. You will auto-claim new tasks."
291+ path = self.dir / name
292+ branch = f"wt/{name}"
293+ self.events.emit(
294+ "worktree.create.before",
295+ task={"id": task_id} if task_id is not None else {},
296+ worktree={"name": name, "base_ref": base_ref},
213297 )
214- messages = [{"role": "user", "content": prompt}]
215- tools = self._teammate_tools()
298+ try:
299+ self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
216300
217- while True:
218- # -- WORK PHASE: standard agent loop --
219- for _ in range(50):
220- inbox = BUS.read_inbox(name)
221- for msg in inbox:
222- if msg.get("type") == "shutdown_request":
223- self._set_status(name, "shutdown")
224- return
225- messages.append({"role": "user", "content": json.dumps(msg)})
226- try:
227- response = client.messages.create(
228- model=MODEL,
229- system=sys_prompt,
230- messages=messages,
231- tools=tools,
232- max_tokens=8000,
233- )
234- except Exception:
235- self._set_status(name, "idle")
236- return
237- messages.append({"role": "assistant", "content": response.content})
238- if response.stop_reason != "tool_use":
239- break
240- results = []
241- idle_requested = False
242- for block in response.content:
243- if block.type == "tool_use":
244- if block.name == "idle":
245- idle_requested = True
246- output = "Entering idle phase. Will poll for new tasks."
247- else:
248- output = self._exec(name, block.name, block.input)
249- print(f" [{name}] {block.name}: {str(output)[:120]}")
250- results.append({
251- "type": "tool_result",
252- "tool_use_id": block.id,
253- "content": str(output),
254- })
255- messages.append({"role": "user", "content": results})
256- if idle_requested:
257- break
301+ entry = {
302+ "name": name,
303+ "path": str(path),
304+ "branch": branch,
305+ "task_id": task_id,
306+ "status": "active",
307+ "created_at": time.time(),
308+ }
258309
259- # -- IDLE PHASE: poll for inbox messages and unclaimed tasks --
260- self._set_status(name, "idle")
261- resume = False
262- polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1)
263- for _ in range(polls):
264- time.sleep(POLL_INTERVAL)
265- inbox = BUS.read_inbox(name)
266- if inbox:
267- for msg in inbox:
268- if msg.get("type") == "shutdown_request":
269- self._set_status(name, "shutdown")
270- return
271- messages.append({"role": "user", "content": json.dumps(msg)})
272- resume = True
273- break
274- unclaimed = scan_unclaimed_tasks()
275- if unclaimed:
276- task = unclaimed[0]
277- claim_task(task["id"], name)
278- task_prompt = (
279- f"<auto-claimed>Task #{task['id']}: {task['subject']}\n"
280- f"{task.get('description', '')}</auto-claimed>"
281- )
282- if len(messages) <= 3:
283- messages.insert(0, make_identity_block(name, role, team_name))
284- messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
285- messages.append({"role": "user", "content": task_prompt})
286- messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
287- resume = True
288- break
310+ idx = self._load_index()
311+ idx["worktrees"].append(entry)
312+ self._save_index(idx)
289313
290- if not resume:
291- self._set_status(name, "shutdown")
292- return
293- self._set_status(name, "working")
314+ if task_id is not None:
315+ self.tasks.bind_worktree(task_id, name)
294316
295- def _exec(self, sender: str, tool_name: str, args: dict) -> str:
296- # these base tools are unchanged from s02
297- if tool_name == "bash":
298- return _run_bash(args["command"])
299- if tool_name == "read_file":
300- return _run_read(args["path"])
301- if tool_name == "write_file":
302- return _run_write(args["path"], args["content"])
303- if tool_name == "edit_file":
304- return _run_edit(args["path"], args["old_text"], args["new_text"])
305- if tool_name == "send_message":
306- return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
307- if tool_name == "read_inbox":
308- return json.dumps(BUS.read_inbox(sender), indent=2)
309- if tool_name == "shutdown_response":
310- req_id = args["request_id"]
311- with _tracker_lock:
312- if req_id in shutdown_requests:
313- shutdown_requests[req_id]["status"] = "approved" if args["approve"] else "rejected"
314- BUS.send(
315- sender, "lead", args.get("reason", ""),
316- "shutdown_response", {"request_id": req_id, "approve": args["approve"]},
317+ self.events.emit(
318+ "worktree.create.after",
319+ task={"id": task_id} if task_id is not None else {},
320+ worktree={
321+ "name": name,
322+ "path": str(path),
323+ "branch": branch,
324+ "status": "active",
325+ },
317326 )
318- return f"Shutdown {'approved' if args['approve'] else 'rejected'}"
319- if tool_name == "plan_approval":
320- plan_text = args.get("plan", "")
321- req_id = str(uuid.uuid4())[:8]
322- with _tracker_lock:
323- plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
324- BUS.send(
325- sender, "lead", plan_text, "plan_approval_response",
326- {"request_id": req_id, "plan": plan_text},
327+ return json.dumps(entry, indent=2)
328+ except Exception as e:
329+ self.events.emit(
330+ "worktree.create.failed",
331+ task={"id": task_id} if task_id is not None else {},
332+ worktree={"name": name, "base_ref": base_ref},
333+ error=str(e),
327334 )
328- return f"Plan submitted (request_id={req_id}). Waiting for approval."
329- if tool_name == "claim_task":
330- return claim_task(args["task_id"], sender)
331- return f"Unknown tool: {tool_name}"
335+ raise
332336
333- def _teammate_tools(self) -> list:
334- # these base tools are unchanged from s02
335- return [
336- {"name": "bash", "description": "Run a shell command.",
337- "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
338- {"name": "read_file", "description": "Read file contents.",
339- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
340- {"name": "write_file", "description": "Write content to file.",
341- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
342- {"name": "edit_file", "description": "Replace exact text in file.",
343- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
344- {"name": "send_message", "description": "Send message to a teammate.",
345- "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
346- {"name": "read_inbox", "description": "Read and drain your inbox.",
347- "input_schema": {"type": "object", "properties": {}}},
348- {"name": "shutdown_response", "description": "Respond to a shutdown request.",
349- "input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
350- {"name": "plan_approval", "description": "Submit a plan for lead approval.",
351- "input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
352- {"name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.",
353- "input_schema": {"type": "object", "properties": {}}},
354- {"name": "claim_task", "description": "Claim a task from the task board by ID.",
355- "input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
356- ]
357-
358337 def list_all(self) -> str:
359- if not self.config["members"]:
360- return "No teammates."
361- lines = [f"Team: {self.config['team_name']}"]
362- for m in self.config["members"]:
363- lines.append(f" {m['name']} ({m['role']}): {m['status']}")
338+ idx = self._load_index()
339+ wts = idx.get("worktrees", [])
340+ if not wts:
341+ return "No worktrees in index."
342+ lines = []
343+ for wt in wts:
344+ suffix = f" task={wt['task_id']}" if wt.get("task_id") else ""
345+ lines.append(
346+ f"[{wt.get('status', 'unknown')}] {wt['name']} -> "
347+ f"{wt['path']} ({wt.get('branch', '-')}){suffix}"
348+ )
364349 return "\n".join(lines)
365350
366- def member_names(self) -> list:
367- return [m["name"] for m in self.config["members"]]
351+ def status(self, name: str) -> str:
352+ wt = self._find(name)
353+ if not wt:
354+ return f"Error: Unknown worktree '{name}'"
355+ path = Path(wt["path"])
356+ if not path.exists():
357+ return f"Error: Worktree path missing: {path}"
358+ r = subprocess.run(
359+ ["git", "status", "--short", "--branch"],
360+ cwd=path,
361+ capture_output=True,
362+ text=True,
363+ timeout=60,
364+ )
365+ text = (r.stdout + r.stderr).strip()
366+ return text or "Clean worktree"
368367
368+ def run(self, name: str, command: str) -> str:
369+ dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
370+ if any(d in command for d in dangerous):
371+ return "Error: Dangerous command blocked"
369372
370-TEAM = TeammateManager(TEAM_DIR)
373+ wt = self._find(name)
374+ if not wt:
375+ return f"Error: Unknown worktree '{name}'"
376+ path = Path(wt["path"])
377+ if not path.exists():
378+ return f"Error: Worktree path missing: {path}"
371379
380+ try:
381+ r = subprocess.run(
382+ command,
383+ shell=True,
384+ cwd=path,
385+ capture_output=True,
386+ text=True,
387+ timeout=300,
388+ )
389+ out = (r.stdout + r.stderr).strip()
390+ return out[:50000] if out else "(no output)"
391+ except subprocess.TimeoutExpired:
392+ return "Error: Timeout (300s)"
372393
373-# -- Base tool implementations (these base tools are unchanged from s02) --
374-def _safe_path(p: str) -> Path:
394+ def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
395+ wt = self._find(name)
396+ if not wt:
397+ return f"Error: Unknown worktree '{name}'"
398+
399+ self.events.emit(
400+ "worktree.remove.before",
401+ task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
402+ worktree={"name": name, "path": wt.get("path")},
403+ )
404+ try:
405+ args = ["worktree", "remove"]
406+ if force:
407+ args.append("--force")
408+ args.append(wt["path"])
409+ self._run_git(args)
410+
411+ if complete_task and wt.get("task_id") is not None:
412+ task_id = wt["task_id"]
413+ before = json.loads(self.tasks.get(task_id))
414+ self.tasks.update(task_id, status="completed")
415+ self.tasks.unbind_worktree(task_id)
416+ self.events.emit(
417+ "task.completed",
418+ task={
419+ "id": task_id,
420+ "subject": before.get("subject", ""),
421+ "status": "completed",
422+ },
423+ worktree={"name": name},
424+ )
425+
426+ idx = self._load_index()
427+ for item in idx.get("worktrees", []):
428+ if item.get("name") == name:
429+ item["status"] = "removed"
430+ item["removed_at"] = time.time()
431+ self._save_index(idx)
432+
433+ self.events.emit(
434+ "worktree.remove.after",
435+ task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
436+ worktree={"name": name, "path": wt.get("path"), "status": "removed"},
437+ )
438+ return f"Removed worktree '{name}'"
439+ except Exception as e:
440+ self.events.emit(
441+ "worktree.remove.failed",
442+ task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
443+ worktree={"name": name, "path": wt.get("path")},
444+ error=str(e),
445+ )
446+ raise
447+
448+ def keep(self, name: str) -> str:
449+ wt = self._find(name)
450+ if not wt:
451+ return f"Error: Unknown worktree '{name}'"
452+
453+ idx = self._load_index()
454+ kept = None
455+ for item in idx.get("worktrees", []):
456+ if item.get("name") == name:
457+ item["status"] = "kept"
458+ item["kept_at"] = time.time()
459+ kept = item
460+ self._save_index(idx)
461+
462+ self.events.emit(
463+ "worktree.keep",
464+ task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
465+ worktree={
466+ "name": name,
467+ "path": wt.get("path"),
468+ "status": "kept",
469+ },
470+ )
471+ return json.dumps(kept, indent=2) if kept else f"Error: Unknown worktree '{name}'"
472+
473+
474+WORKTREES = WorktreeManager(REPO_ROOT, TASKS, EVENTS)
475+
476+
477+# -- Base tools (kept minimal, same style as previous sessions) --
478+def safe_path(p: str) -> Path:
375479 path = (WORKDIR / p).resolve()
376480 if not path.is_relative_to(WORKDIR):
377481 raise ValueError(f"Path escapes workspace: {p}")
378482 return path
379483
380484
381-def _run_bash(command: str) -> str:
382- dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
485+def run_bash(command: str) -> str:
486+ dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
383487 if any(d in command for d in dangerous):
384488 return "Error: Dangerous command blocked"
385489 try:
386490 r = subprocess.run(
387- command, shell=True, cwd=WORKDIR,
388- capture_output=True, text=True, timeout=120,
491+ command,
492+ shell=True,
493+ cwd=WORKDIR,
494+ capture_output=True,
495+ text=True,
496+ timeout=120,
389497 )
390498 out = (r.stdout + r.stderr).strip()
391499 return out[:50000] if out else "(no output)"
392500 except subprocess.TimeoutExpired:
393501 return "Error: Timeout (120s)"
394502
395503
396-def _run_read(path: str, limit: int = None) -> str:
504+def run_read(path: str, limit: int = None) -> str:
397505 try:
398- lines = _safe_path(path).read_text().splitlines()
506+ lines = safe_path(path).read_text().splitlines()
399507 if limit and limit < len(lines):
400508 lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
401509 return "\n".join(lines)[:50000]
402510 except Exception as e:
403511 return f"Error: {e}"
404512
405513
406-def _run_write(path: str, content: str) -> str:
514+def run_write(path: str, content: str) -> str:
407515 try:
408- fp = _safe_path(path)
516+ fp = safe_path(path)
409517 fp.parent.mkdir(parents=True, exist_ok=True)
410518 fp.write_text(content)
411519 return f"Wrote {len(content)} bytes"
412520 except Exception as e:
413521 return f"Error: {e}"
414522
415523
416-def _run_edit(path: str, old_text: str, new_text: str) -> str:
524+def run_edit(path: str, old_text: str, new_text: str) -> str:
417525 try:
418- fp = _safe_path(path)
526+ fp = safe_path(path)
419527 c = fp.read_text()
420528 if old_text not in c:
421529 return f"Error: Text not found in {path}"
422530 fp.write_text(c.replace(old_text, new_text, 1))
423531 return f"Edited {path}"
424532 except Exception as e:
425533 return f"Error: {e}"
426534
427535
428-# -- Lead-specific protocol handlers --
429-def handle_shutdown_request(teammate: str) -> str:
430- req_id = str(uuid.uuid4())[:8]
431- with _tracker_lock:
432- shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
433- BUS.send(
434- "lead", teammate, "Please shut down gracefully.",
435- "shutdown_request", {"request_id": req_id},
436- )
437- return f"Shutdown request {req_id} sent to '{teammate}'"
438-
439-
440-def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
441- with _tracker_lock:
442- req = plan_requests.get(request_id)
443- if not req:
444- return f"Error: Unknown plan request_id '{request_id}'"
445- with _tracker_lock:
446- req["status"] = "approved" if approve else "rejected"
447- BUS.send(
448- "lead", req["from"], feedback, "plan_approval_response",
449- {"request_id": request_id, "approve": approve, "feedback": feedback},
450- )
451- return f"Plan {req['status']} for '{req['from']}'"
452-
453-
454-def _check_shutdown_status(request_id: str) -> str:
455- with _tracker_lock:
456- return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
457-
458-
459-# -- Lead tool dispatch (14 tools) --
460536TOOL_HANDLERS = {
461- "bash": lambda **kw: _run_bash(kw["command"]),
462- "read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
463- "write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
464- "edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
465- "spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
466- "list_teammates": lambda **kw: TEAM.list_all(),
467- "send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
468- "read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
469- "broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
470- "shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
471- "shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
472- "plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
473- "idle": lambda **kw: "Lead does not idle.",
474- "claim_task": lambda **kw: claim_task(kw["task_id"], "lead"),
537+ "bash": lambda **kw: run_bash(kw["command"]),
538+ "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
539+ "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
540+ "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
541+ "task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
542+ "task_list": lambda **kw: TASKS.list_all(),
543+ "task_get": lambda **kw: TASKS.get(kw["task_id"]),
544+ "task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
545+ "task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")),
546+ "worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")),
547+ "worktree_list": lambda **kw: WORKTREES.list_all(),
548+ "worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
549+ "worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
550+ "worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
551+ "worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
552+ "worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
475553}
476554
477-# these base tools are unchanged from s02
478555TOOLS = [
479- {"name": "bash", "description": "Run a shell command.",
480- "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
481- {"name": "read_file", "description": "Read file contents.",
482- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
483- {"name": "write_file", "description": "Write content to file.",
484- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
485- {"name": "edit_file", "description": "Replace exact text in file.",
486- "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
487- {"name": "spawn_teammate", "description": "Spawn an autonomous teammate.",
488- "input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
489- {"name": "list_teammates", "description": "List all teammates.",
490- "input_schema": {"type": "object", "properties": {}}},
491- {"name": "send_message", "description": "Send a message to a teammate.",
492- "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
493- {"name": "read_inbox", "description": "Read and drain the lead's inbox.",
494- "input_schema": {"type": "object", "properties": {}}},
495- {"name": "broadcast", "description": "Send a message to all teammates.",
496- "input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
497- {"name": "shutdown_request", "description": "Request a teammate to shut down.",
498- "input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
499- {"name": "shutdown_response", "description": "Check shutdown request status.",
500- "input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
501- {"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
502- "input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
503- {"name": "idle", "description": "Enter idle state (for lead -- rarely used).",
504- "input_schema": {"type": "object", "properties": {}}},
505- {"name": "claim_task", "description": "Claim a task from the board by ID.",
506- "input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
556+ {
557+ "name": "bash",
558+ "description": "Run a shell command in the current workspace (blocking).",
559+ "input_schema": {
560+ "type": "object",
561+ "properties": {"command": {"type": "string"}},
562+ "required": ["command"],
563+ },
564+ },
565+ {
566+ "name": "read_file",
567+ "description": "Read file contents.",
568+ "input_schema": {
569+ "type": "object",
570+ "properties": {
571+ "path": {"type": "string"},
572+ "limit": {"type": "integer"},
573+ },
574+ "required": ["path"],
575+ },
576+ },
577+ {
578+ "name": "write_file",
579+ "description": "Write content to file.",
580+ "input_schema": {
581+ "type": "object",
582+ "properties": {
583+ "path": {"type": "string"},
584+ "content": {"type": "string"},
585+ },
586+ "required": ["path", "content"],
587+ },
588+ },
589+ {
590+ "name": "edit_file",
591+ "description": "Replace exact text in file.",
592+ "input_schema": {
593+ "type": "object",
594+ "properties": {
595+ "path": {"type": "string"},
596+ "old_text": {"type": "string"},
597+ "new_text": {"type": "string"},
598+ },
599+ "required": ["path", "old_text", "new_text"],
600+ },
601+ },
602+ {
603+ "name": "task_create",
604+ "description": "Create a new task on the shared task board.",
605+ "input_schema": {
606+ "type": "object",
607+ "properties": {
608+ "subject": {"type": "string"},
609+ "description": {"type": "string"},
610+ },
611+ "required": ["subject"],
612+ },
613+ },
614+ {
615+ "name": "task_list",
616+ "description": "List all tasks with status, owner, and worktree binding.",
617+ "input_schema": {"type": "object", "properties": {}},
618+ },
619+ {
620+ "name": "task_get",
621+ "description": "Get task details by ID.",
622+ "input_schema": {
623+ "type": "object",
624+ "properties": {"task_id": {"type": "integer"}},
625+ "required": ["task_id"],
626+ },
627+ },
628+ {
629+ "name": "task_update",
630+ "description": "Update task status or owner.",
631+ "input_schema": {
632+ "type": "object",
633+ "properties": {
634+ "task_id": {"type": "integer"},
635+ "status": {
636+ "type": "string",
637+ "enum": ["pending", "in_progress", "completed"],
638+ },
639+ "owner": {"type": "string"},
640+ },
641+ "required": ["task_id"],
642+ },
643+ },
644+ {
645+ "name": "task_bind_worktree",
646+ "description": "Bind a task to a worktree name.",
647+ "input_schema": {
648+ "type": "object",
649+ "properties": {
650+ "task_id": {"type": "integer"},
651+ "worktree": {"type": "string"},
652+ "owner": {"type": "string"},
653+ },
654+ "required": ["task_id", "worktree"],
655+ },
656+ },
657+ {
658+ "name": "worktree_create",
659+ "description": "Create a git worktree and optionally bind it to a task.",
660+ "input_schema": {
661+ "type": "object",
662+ "properties": {
663+ "name": {"type": "string"},
664+ "task_id": {"type": "integer"},
665+ "base_ref": {"type": "string"},
666+ },
667+ "required": ["name"],
668+ },
669+ },
670+ {
671+ "name": "worktree_list",
672+ "description": "List worktrees tracked in .worktrees/index.json.",
673+ "input_schema": {"type": "object", "properties": {}},
674+ },
675+ {
676+ "name": "worktree_status",
677+ "description": "Show git status for one worktree.",
678+ "input_schema": {
679+ "type": "object",
680+ "properties": {"name": {"type": "string"}},
681+ "required": ["name"],
682+ },
683+ },
684+ {
685+ "name": "worktree_run",
686+ "description": "Run a shell command in a named worktree directory.",
687+ "input_schema": {
688+ "type": "object",
689+ "properties": {
690+ "name": {"type": "string"},
691+ "command": {"type": "string"},
692+ },
693+ "required": ["name", "command"],
694+ },
695+ },
696+ {
697+ "name": "worktree_remove",
698+ "description": "Remove a worktree and optionally mark its bound task completed.",
699+ "input_schema": {
700+ "type": "object",
701+ "properties": {
702+ "name": {"type": "string"},
703+ "force": {"type": "boolean"},
704+ "complete_task": {"type": "boolean"},
705+ },
706+ "required": ["name"],
707+ },
708+ },
709+ {
710+ "name": "worktree_keep",
711+ "description": "Mark a worktree as kept in lifecycle state without removing it.",
712+ "input_schema": {
713+ "type": "object",
714+ "properties": {"name": {"type": "string"}},
715+ "required": ["name"],
716+ },
717+ },
718+ {
719+ "name": "worktree_events",
720+ "description": "List recent worktree/task lifecycle events from .worktrees/events.jsonl.",
721+ "input_schema": {
722+ "type": "object",
723+ "properties": {"limit": {"type": "integer"}},
724+ },
725+ },
507726]
508727
509728
510729def agent_loop(messages: list):
511730 while True:
512- inbox = BUS.read_inbox("lead")
513- if inbox:
514- messages.append({
515- "role": "user",
516- "content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
517- })
518- messages.append({
519- "role": "assistant",
520- "content": "Noted inbox messages.",
521- })
522731 response = client.messages.create(
523732 model=MODEL,
524733 system=SYSTEM,
525734 messages=messages,
526735 tools=TOOLS,
527736 max_tokens=8000,
528737 )
529738 messages.append({"role": "assistant", "content": response.content})
530739 if response.stop_reason != "tool_use":
531740 return
741+
532742 results = []
533743 for block in response.content:
534744 if block.type == "tool_use":
535745 handler = TOOL_HANDLERS.get(block.name)
536746 try:
537747 output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
538748 except Exception as e:
539749 output = f"Error: {e}"
540750 print(f"> {block.name}: {str(output)[:200]}")
541- results.append({
542- "type": "tool_result",
543- "tool_use_id": block.id,
544- "content": str(output),
545- })
751+ results.append(
752+ {
753+ "type": "tool_result",
754+ "tool_use_id": block.id,
755+ "content": str(output),
756+ }
757+ )
546758 messages.append({"role": "user", "content": results})
547759
548760
549761if __name__ == "__main__":
762+ print(f"Repo root for s12: {REPO_ROOT}")
763+ if not WORKTREES.git_available:
764+ print("Note: Not in a git repo. worktree_* tools will return errors.")
765+
550766 history = []
551767 while True:
552768 try:
553- query = input("\033[36ms11 >> \033[0m")
769+ query = input("\033[36ms12 >> \033[0m")
554770 except (EOFError, KeyboardInterrupt):
555771 break
556772 if query.strip().lower() in ("q", "exit", ""):
557773 break
558- if query.strip() == "/team":
559- print(TEAM.list_all())
560- continue
561- if query.strip() == "/inbox":
562- print(json.dumps(BUS.read_inbox("lead"), indent=2))
563- continue
564- if query.strip() == "/tasks":
565- TASKS_DIR.mkdir(exist_ok=True)
566- for f in sorted(TASKS_DIR.glob("task_*.json")):
567- t = json.loads(f.read_text())
568- marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
569- owner = f" @{t['owner']}" if t.get("owner") else ""
570- print(f" {marker} #{t['id']}: {t['subject']}{owner}")
571- continue
572774 history.append({"role": "user", "content": query})
573775 agent_loop(history)
574776 response_content = history[-1]["content"]
575777 if isinstance(response_content, list):
576778 for block in response_content:
577779 if hasattr(block, "text"):
578780 print(block.text)
579781 print()