system-diff 0.3.4__py3-none-any.whl → 0.4.2__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.
system_diff/__init__.py CHANGED
@@ -5,4 +5,4 @@
5
5
  bp = extract.extract(DirSource(Path(".")), cfg)
6
6
  svg = graph.system_svg(bp, cfg)
7
7
  """
8
- __version__ = "0.3.4"
8
+ __version__ = "0.4.2"
system_diff/agent.py CHANGED
@@ -18,7 +18,7 @@ import sys
18
18
  from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
- from . import activity, companion, config, design, learn
21
+ from . import activity, agents, companion, config, design, learn
22
22
  from .compare import compare_worktree, mark_turn, turn_sha
23
23
  from .extract import Code
24
24
  from .sources import git
@@ -121,18 +121,22 @@ def on_start(root: Path, payload: Dict[str, Any], env=os.environ) -> Optional[st
121
121
  return companion.open_beside(root, env) or None
122
122
 
123
123
 
124
- def on_prompt(root: Path, payload: Dict[str, Any]) -> None:
125
- """The person sent a prompt: a new turn starts. A notice Claude Code sends as a prompt (a background task done)
124
+ def on_prompt(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
125
+ """The person sent a prompt: a new turn starts, and Claude is told to plan the work for a team of agents (unless
126
+ the config says `[agents] orchestrate = false`). A notice Claude Code sends as a prompt (a background task done)
126
127
  is not one: the turn goes on."""
127
128
  if activity.is_system(payload.get("prompt") or ""):
128
- return
129
+ return None
129
130
  mark_turn(root)
130
131
  activity.new_turn(root)
131
132
  activity.record(root, {"event": activity.PROMPT, "text": _clip(payload.get("prompt"), 300),
132
- "session": payload.get("session_id", "")})
133
+ "session": payload.get("session_id", ""), "agent": payload.get("agent_id") or ""})
134
+ if (config.lenient(root).get("agents") or {}).get("orchestrate", True) is False or payload.get("agent_id"):
135
+ return None
136
+ return {"context": agents.ORCHESTRATE}
133
137
 
134
138
 
135
- def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[str]:
139
+ def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
136
140
  """Note the file and keep a brief of its part for the status line. After an edit to code, look at what the turn
137
141
  has done to the system so far, and say what this edit added to it (one hook at a time: they can overlap)."""
138
142
  tool = payload.get("tool_name", "")
@@ -143,8 +147,9 @@ def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[str]:
143
147
  if not path:
144
148
  return None
145
149
  edit = tool in EDIT_TOOLS
150
+ agent_id = payload.get("agent_id") or ""
146
151
  activity.record(root, {"event": activity.EDIT if edit else activity.READ, "path": path, "tool": tool,
147
- "session": payload.get("session_id", "")})
152
+ "session": payload.get("session_id", ""), "agent": agent_id})
148
153
  cfg = config.lenient(root)
149
154
  part = activity.part_of(path, Code(cfg))
150
155
  if part is None:
@@ -160,9 +165,13 @@ def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[str]:
160
165
  before = last.get("told") or [s["text"] for s in last.get("story") or []]
161
166
  since, turn = compare_worktree(root, ["checkpoint", "turn"])
162
167
  notes = design.Reading(turn).notes()
163
- record_step(root, path, part, last.get("noted") or [], notes)
168
+ noted = set(last.get("noted") or [])
169
+ record_step(root, path, part, noted, notes)
164
170
  activity.write_state(root, since.diff, since.base, turn.diff, noted=[n["text"] for n in notes])
165
- return step_message(before, learn.story(turn.diff))
171
+ said = step_message(before, learn.story(turn.diff))
172
+ team = agents.after_edit(root, agent_id, part, [n for n in notes if n["text"] not in noted], turn.after)
173
+ message = "; ".join(m for m in (said, f"System diff: {team['warn']}" if team["warn"] else "") if m)
174
+ return {"message": message, "context": team["context"]}
166
175
 
167
176
 
168
177
  def record_step(root: Path, path: str, part: str, before: Iterable[str], notes: List[Dict[str, Any]]) -> None:
@@ -206,9 +215,19 @@ def run_hook(stdin_text: str, emit: Callable[[str], None] = print) -> int:
206
215
  if event == "SessionStart":
207
216
  message = on_start(root, payload)
208
217
  elif event == "UserPromptSubmit":
209
- on_prompt(root, payload)
218
+ said = on_prompt(root, payload) or {}
219
+ if said.get("context"):
220
+ emit(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
221
+ "additionalContext": said["context"]}}))
210
222
  elif event == "PostToolUse":
211
- message = on_tool(root, payload)
223
+ said = on_tool(root, payload) or {}
224
+ message = said.get("message") or None
225
+ if said.get("context"): # what other agents published, for this agent to read with its tool result
226
+ out = {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": said["context"]}}
227
+ if message:
228
+ out["systemMessage"] = message
229
+ emit(json.dumps(out))
230
+ message = None
212
231
  elif event == "Stop":
213
232
  message = on_stop(root, payload)
214
233
  if message:
@@ -225,7 +244,8 @@ HOOKS = ( # event, matcher, how it runs, what it does
225
244
  ("SessionStart", "startup|resume", {"timeout": 10},
226
245
  "when a session starts, opens the terminal view beside Claude where the terminal can split (tmux, zellij, "
227
246
  "WezTerm, kitty, Windows Terminal)"),
228
- ("UserPromptSubmit", None, {"async": True}, "marks where each turn starts (in the background)"),
247
+ ("UserPromptSubmit", None, {"timeout": 10},
248
+ "marks where each turn starts, and asks Claude to plan the work for a team of agents"),
229
249
  ("PostToolUse", "|".join(READ_TOOLS), {"async": True},
230
250
  "notes the files Claude reads, for the terminal view and the status line (in the background)"),
231
251
  # Not in the background: only a hook Claude Code waits for can show you a line in the conversation. A timeout
system_diff/agents.py ADDED
@@ -0,0 +1,351 @@
1
+ """Many agents on one system: a PLAN that splits a task by the system's blocks, one agent per area, each with its brief;
2
+ CLAIMS, so agents keep to their own blocks; and a BUS, an async publish/subscribe channel whose topics are the system's
3
+ blocks: an agent subscribes to the blocks its own depend on, every structural change to a block is published on that
4
+ block's topic (by the hooks, as it happens), agents publish their own notes too, and each agent's unread messages
5
+ reach it with its next tool call, without waiting on anyone.
6
+
7
+ Who is who: Claude Code names each subagent by an id (`agent_id` in its hook events; the main conversation has none,
8
+ "main" here). A plan names agents by area ("billing"); an id is tied to a name when it first edits a block that name
9
+ claimed, or when it claims or publishes under that name.
10
+
11
+ Kept in .git/system-diff/ (never committed): agents.json (plan, claims, names, subscriptions, read cursors) and
12
+ bus.jsonl (the messages, newest last).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any, Dict, Iterable, List, Optional
21
+
22
+ from . import activity, design
23
+ from .sources import git_dir
24
+
25
+ STATE, BUS = "agents.json", "bus.jsonl"
26
+ MAIN = "main"
27
+ SYSTEM = "system-diff" # the sender of what system-diff itself publishes
28
+ KEEP = 2000 # messages kept on the bus
29
+ MOST_AGENTS = 4 # a plan's agents at most, unless asked for more
30
+
31
+
32
+ def _load(root: Path) -> Dict[str, Any]:
33
+ try:
34
+ data = json.loads((git_dir(root) / STATE).read_text(encoding="utf-8"))
35
+ if isinstance(data, dict):
36
+ return {"plan": data.get("plan") or {}, "claims": data.get("claims") or {},
37
+ "agents": data.get("agents") or {}, "ids": data.get("ids") or {},
38
+ "warned": data.get("warned") or {}}
39
+ except (OSError, ValueError):
40
+ pass
41
+ return {"plan": {}, "claims": {}, "agents": {}, "ids": {}, "warned": {}}
42
+
43
+
44
+ def _save(root: Path, data: Dict[str, Any]) -> None:
45
+ activity._replace(git_dir(root) / STATE, json.dumps(data, ensure_ascii=False, indent=1) + "\n")
46
+
47
+
48
+ def state(root: Path) -> Dict[str, Any]:
49
+ return _load(root)
50
+
51
+
52
+ def _agent(data: Dict[str, Any], name: str) -> Dict[str, Any]:
53
+ return data["agents"].setdefault(name, {"topics": [], "read": 0, "blocks": [], "brief": ""})
54
+
55
+
56
+ def name_of(root: Path, agent_id: str) -> str:
57
+ """The name an agent goes by: its plan name once tied to one, else "main" or a short form of its id."""
58
+ if not agent_id or agent_id == MAIN:
59
+ return MAIN
60
+ return _load(root)["ids"].get(agent_id) or "agent-" + agent_id[:6]
61
+
62
+
63
+ def tie(root: Path, agent_id: str, name: str) -> None:
64
+ if agent_id and agent_id != MAIN and name:
65
+ data = _load(root)
66
+ if data["ids"].get(agent_id) != name:
67
+ data["ids"][agent_id] = name
68
+ _save(root, data)
69
+
70
+
71
+ # ── claims ────────────────────────────────────────────────────────────────────
72
+
73
+ def claim(root: Path, agent: str, blocks: Iterable[str]) -> Dict[str, str]:
74
+ """`agent` takes `blocks`; returns the blocks another agent had, by that agent."""
75
+ data = _load(root)
76
+ taken = {}
77
+ for b in blocks:
78
+ had = data["claims"].get(b)
79
+ if had and had != agent:
80
+ taken[b] = had
81
+ data["claims"][b] = agent
82
+ a = _agent(data, agent)
83
+ if b not in a["blocks"]:
84
+ a["blocks"].append(b)
85
+ _save(root, data)
86
+ return taken
87
+
88
+
89
+ def owner(root: Path, block: str) -> Optional[str]:
90
+ return _load(root)["claims"].get(block)
91
+
92
+
93
+ # ── the bus ───────────────────────────────────────────────────────────────────
94
+
95
+ def publish(root: Path, topic: str, text: str, sender: str = MAIN, why: str = "", kind: str = "note") -> Dict[str, Any]:
96
+ """A message on a topic (a block's id, "all", or "agent:<name>" for one agent)."""
97
+ path = git_dir(root) / BUS
98
+ try:
99
+ last = int(json.loads(path.read_text(encoding="utf-8").splitlines()[-1])["n"])
100
+ except (OSError, ValueError, IndexError, KeyError):
101
+ last = 0
102
+ msg = {"n": last + 1, "at": round(time.time(), 3), "topic": topic, "from": sender, "kind": kind,
103
+ "text": text, "why": why}
104
+ with open(path, "a", encoding="utf-8") as f:
105
+ f.write(json.dumps(msg, ensure_ascii=False) + "\n")
106
+ if msg["n"] % 200 == 0: # trimmed now and then
107
+ lines = path.read_text(encoding="utf-8").splitlines()[-KEEP:]
108
+ activity._replace(path, "\n".join(lines) + "\n")
109
+ return msg
110
+
111
+
112
+ def messages(root: Path, after: int = 0) -> List[Dict[str, Any]]:
113
+ try:
114
+ lines = (git_dir(root) / BUS).read_text(encoding="utf-8").splitlines()
115
+ except OSError:
116
+ return []
117
+ out = []
118
+ for line in lines:
119
+ try:
120
+ m = json.loads(line)
121
+ except ValueError:
122
+ continue
123
+ if m.get("n", 0) > after:
124
+ out.append(m)
125
+ return out
126
+
127
+
128
+ def subscribe(root: Path, agent: str, topics: Iterable[str]) -> List[str]:
129
+ data = _load(root)
130
+ a = _agent(data, agent)
131
+ for t in topics:
132
+ if t and t not in a["topics"]:
133
+ a["topics"].append(t)
134
+ _save(root, data)
135
+ return a["topics"]
136
+
137
+
138
+ def inbox(root: Path, agent: str, mark: bool = True) -> List[Dict[str, Any]]:
139
+ """`agent`'s unread messages: on the topics it subscribes to, "all", and its own; never its own sends."""
140
+ data = _load(root)
141
+ a = _agent(data, agent)
142
+ want = set(a["topics"]) | {"all", f"agent:{agent}"}
143
+ new = [m for m in messages(root, a["read"]) if m["topic"] in want and m["from"] != agent]
144
+ if mark:
145
+ latest = messages(root, a["read"])
146
+ if latest:
147
+ a["read"] = latest[-1]["n"]
148
+ _save(root, data)
149
+ return new
150
+
151
+
152
+ def as_context(msgs: List[Dict[str, Any]], most: int = 8) -> str:
153
+ """Messages as a few lines an agent reads with its next tool result."""
154
+ if not msgs:
155
+ return ""
156
+ lines = [f"[system-diff bus] {len(msgs)} new message{'' if len(msgs) == 1 else 's'} from other agents:"]
157
+ for m in msgs[-most:]:
158
+ why = f" ({m['why']})" if m.get("why") else ""
159
+ lines.append(f"- {m['from']} on {m['topic']}: {m['text']}{why}")
160
+ lines.append("Adjust your work if it depends on these; publish what others need to know with the publish tool.")
161
+ return "\n".join(lines)
162
+
163
+
164
+ # ── what the hooks do for each agent ─────────────────────────────────────────
165
+
166
+ def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[str, Any]], bp: dict) -> Dict[str, str]:
167
+ """After an agent's edit: tie it to a plan name if it edited a claimed block nobody else is tied to, subscribe it
168
+ to its block and the blocks that block depends on, publish what the edit changed in the structure on the topics
169
+ of the blocks it changed, and return {"context": unread messages for the agent, "warn": a claim it stepped on}."""
170
+ data = _load(root)
171
+ name = data["ids"].get(agent_id) if agent_id and agent_id != MAIN else MAIN
172
+ claimed = data["claims"].get(part or "")
173
+ if not name and claimed and claimed not in data["ids"].values():
174
+ tie(root, agent_id, claimed)
175
+ name = claimed
176
+ name = name or ("agent-" + agent_id[:6])
177
+ if part:
178
+ deps = sorted(design.depends(bp).get(part, ()))
179
+ subscribe(root, name, [part] + deps)
180
+ for n in design.summarize(notes):
181
+ topic = (n.get("parts") or [part or "all"])[0] or "all"
182
+ publish(root, topic, n["text"], sender=name, why=n.get("why", ""), kind="change")
183
+ warn = ""
184
+ if part and claimed and claimed != name:
185
+ warn = f"{part} is claimed by agent {claimed}; {name} just edited it"
186
+ publish(root, f"agent:{claimed}", f"{name} edited your block {part}", sender=name, kind="warning")
187
+ from . import config
188
+ wide = spread(root, name, agent_id, bp, config.lenient(root))
189
+ # spread warnings reach the agent they are about directly; on the bus they are a record for the inbox tool
190
+ unread = [m for m in inbox(root, name) if not (m.get("from") == SYSTEM and m.get("kind") == "warning")]
191
+ context = "\n".join(t for t in (as_context(unread), wide) if t)
192
+ warn = "; ".join(t for t in (warn, wide) if t)
193
+ return {"context": context, "warn": warn, "name": name}
194
+
195
+
196
+ def areas_of(root: Path, bp: dict, cfg: Dict[str, Any]) -> Dict[str, str]:
197
+ """Each block's area: the agent the saved plan gave it to, when there is a plan; else its container, or the
198
+ block itself when it is in none."""
199
+ agents = (_load(root)["plan"] or {}).get("agents") or []
200
+ if agents:
201
+ return {b: g["name"] for g in agents for b in g.get("blocks") or []}
202
+ from .architecture import groups_of
203
+ group = groups_of(bp, cfg, design.kinds(bp, cfg))
204
+ return {cid: (group[cid][2:] if group.get(cid, "").startswith("g:") else cid) for cid in bp["components"]}
205
+
206
+
207
+ def spread(root: Path, name: str, agent_id: str, bp: dict, cfg: Dict[str, Any]) -> str:
208
+ """When one agent has edited blocks in two or more areas this turn: a message telling it to plan a team and hand
209
+ the rest on, once per agent per turn (also published to all); else ""."""
210
+ from .extract import Code
211
+ code = Code(cfg)
212
+ me = "" if agent_id in ("", None, MAIN) else agent_id
213
+ start, parts = 0.0, []
214
+ for e in activity.events(root, 400):
215
+ if e.get("event") == activity.PROMPT and not activity.is_system(e.get("text", "")):
216
+ start, parts = e["at"], []
217
+ elif e.get("event") == activity.EDIT and (e.get("agent") or "") == me and e.get("path"):
218
+ part = activity.part_of(e["path"], code)
219
+ if part and part not in parts:
220
+ parts.append(part)
221
+ area = areas_of(root, bp, cfg)
222
+ seen: List[str] = []
223
+ for p in parts:
224
+ a = area.get(p, p)
225
+ if a not in seen:
226
+ seen.append(a)
227
+ if len(seen) < 2:
228
+ return ""
229
+ data = _load(root)
230
+ turn = str(start)
231
+ told = data["warned"].get(turn) or []
232
+ if name in told:
233
+ return ""
234
+ data["warned"] = {turn: told + [name]} # only the latest turn is kept
235
+ _save(root, data)
236
+ text = (f"{name} has edited blocks in {len(seen)} areas this turn ({', '.join(seen)}). system-diff runs work "
237
+ "that spans areas as a team: call plan_agents with what is left, launch its agents in parallel, and "
238
+ "keep to one area yourself.")
239
+ publish(root, "all", text, sender=SYSTEM, kind="warning")
240
+ return text
241
+
242
+
243
+ def who_worked(events: Iterable[dict], code) -> Dict[str, Dict[str, Any]]:
244
+ """Each agent seen in the activity log since the last prompt: the parts it edited and read, when it last acted."""
245
+ out: Dict[str, Dict[str, Any]] = {}
246
+ for e in events:
247
+ if e.get("event") == activity.PROMPT and not activity.is_system(e.get("text", "")):
248
+ out = {}
249
+ continue
250
+ if e.get("event") not in (activity.EDIT, activity.READ) or not e.get("path"):
251
+ continue
252
+ a = out.setdefault(e.get("agent") or MAIN, {"edits": [], "reads": [], "at": 0.0})
253
+ part = activity.part_of(e["path"], code)
254
+ if part:
255
+ key = "edits" if e["event"] == activity.EDIT else "reads"
256
+ if part not in a[key]:
257
+ a[key].append(part)
258
+ a["at"] = max(a["at"], e["at"])
259
+ return out
260
+
261
+
262
+ # ── the plan ──────────────────────────────────────────────────────────────────
263
+
264
+ WORD = re.compile(r"[a-z0-9]+")
265
+
266
+
267
+ def _areas(c) -> List[Dict[str, Any]]:
268
+ """Where work can be split: each container (its parts together), else each part on its own."""
269
+ from .architecture import groups_of
270
+ bp, cfg = c.after, c.cfg
271
+ kind = design.kinds(bp, cfg)
272
+ group = groups_of(bp, cfg, kind)
273
+ areas: Dict[str, Dict[str, Any]] = {}
274
+ for cid, comp in bp["components"].items():
275
+ if comp.get("quiet"):
276
+ continue
277
+ key = group.get(cid, cid)
278
+ a = areas.setdefault(key, {"id": key, "title": key[2:] if key.startswith("g:") else comp["label"],
279
+ "blocks": []})
280
+ a["blocks"].append(cid)
281
+ return list(areas.values())
282
+
283
+
284
+ def plan(root: Path, c, task: str, most: int = MOST_AGENTS, focus: Iterable[str] = ()) -> Dict[str, Any]:
285
+ """Split `task` across agents by the system's blocks: the areas (containers, else parts) the task names or that
286
+ `focus` gives, one agent each (at most `most`, the rest joining the closest), each with the blocks it claims, the
287
+ blocks it depends on (its subscriptions) and a brief; and how to run them. Saved, and the claims made."""
288
+ bp = c.after
289
+ words = set(WORD.findall(task.lower()))
290
+ deps = design.depends(bp)
291
+ scored = []
292
+ for a in _areas(c):
293
+ text = " ".join([a["title"]] + [bp["components"][b]["label"] + " " + b for b in a["blocks"]]).lower()
294
+ hit = len(words & set(WORD.findall(text))) + 3 * len(set(focus) & set(a["blocks"]))
295
+ if hit:
296
+ scored.append((hit, a))
297
+ if not scored: # nothing named: the areas the task's likely to touch are the ones most depended on
298
+ into = design.fan_in(deps)
299
+ scored = sorted(((sum(into.get(b, 0) for b in a["blocks"]) + 1, a) for a in _areas(c)), key=lambda s: -s[0])
300
+ areas = [a for _s, a in sorted(scored, key=lambda s: (-s[0], s[1]["id"]))]
301
+ picked, rest = areas[:most], areas[most:]
302
+ for a in rest: # joins the picked area it is most connected to
303
+ best = max(picked, key=lambda p: sum(1 for b in a["blocks"] for d in deps.get(b, ()) if d in p["blocks"])
304
+ + sum(1 for b in p["blocks"] for d in deps.get(b, ()) if d in a["blocks"]))
305
+ best["blocks"] += a["blocks"]
306
+ agents = []
307
+ owned = {b: a["id"] for a in picked for b in a["blocks"]}
308
+ for a in picked:
309
+ name = re.sub(r"[^a-z0-9]+", "-", a["title"].lower()).strip("-") or "agent"
310
+ needs = sorted({d for b in a["blocks"] for d in deps.get(b, ()) if d not in a["blocks"]})
311
+ needed_by = sorted({b2 for b2, ds in deps.items() for d in ds if d in a["blocks"] and b2 not in a["blocks"]})
312
+ talks_to = sorted({re.sub(r"[^a-z0-9]+", "-", next(p["title"] for p in picked if p["id"] == owned[x]).lower())
313
+ for x in needs + needed_by if x in owned and owned[x] != a["id"]})
314
+ label = lambda ids: design.and_list([bp["components"][i]["label"] for i in ids if i in bp["components"]], 6)
315
+ brief = (f"You are the {a['title']} agent in a team working on: {task}\n"
316
+ f"Work only in these blocks (claimed for you): {label(a['blocks'])}. "
317
+ f"First call the system-diff `claim` tool with agent \"{name}\" and these blocks.\n"
318
+ + (f"Your blocks use {label(needs)}: you are subscribed to them, so changes other agents make there "
319
+ "reach you with your next tool result.\n" if needs else "")
320
+ + (f"{label(needed_by)} use your blocks: keep their interfaces stable, and when you must change one, "
321
+ f"say so with the `publish` tool (topic: the block's id) before you do.\n" if needed_by else "")
322
+ + (f"Coordinate with: {', '.join(talks_to)}.\n" if talks_to else "")
323
+ + "Finish with a short summary of what you changed in the system's structure.")
324
+ agents.append({"name": name, "area": a["title"], "blocks": a["blocks"], "subscribes": needs,
325
+ "talks_to": talks_to, "brief": brief})
326
+ data = _load(root)
327
+ data["plan"] = {"task": task, "at": round(time.time(), 3), "agents": [{k: v for k, v in g.items() if k != "brief"}
328
+ for g in agents]}
329
+ for g in agents:
330
+ a = _agent(data, g["name"])
331
+ a["brief"] = g["brief"]
332
+ a["blocks"] = list(g["blocks"])
333
+ a["topics"] = sorted(set(a["topics"]) | set(g["subscribes"]) | {f"agent:{g['name']}"})
334
+ for b in g["blocks"]:
335
+ data["claims"][b] = g["name"]
336
+ _save(root, data)
337
+ how = ("Launch these agents now, in parallel, each as its own subagent with its brief as the prompt (one message "
338
+ "with several Task/Agent tool calls). Do not do their work yourself. When they finish, read the system-diff "
339
+ "`inbox` tool as \"main\" and integrate: resolve what they published, then check the whole with "
340
+ "`system_diff`." if len(agents) > 1 else
341
+ "One area: do it yourself; no team needed.")
342
+ publish(root, "all", f"Plan: {len(agents)} agent{'' if len(agents) == 1 else 's'} for: {task}", sender=MAIN,
343
+ kind="plan")
344
+ return {"task": task, "agents": agents, "how": how}
345
+
346
+
347
+ ORCHESTRATE = ("system-diff: this repository works with a team of agents. Before you start on a task that touches "
348
+ "more than one part of the system, call the system-diff `plan_agents` tool with the task; when it "
349
+ "returns two or more agents, launch them in parallel as subagents with their briefs and coordinate "
350
+ "through its bus (`inbox`, `publish`) instead of doing every part yourself. Small, single-part tasks "
351
+ "need no team.")
system_diff/canvas.py CHANGED
@@ -138,8 +138,12 @@ def title_style(n: dict) -> str:
138
138
  return f"title bold {st}" if st != "same" else "title"
139
139
 
140
140
 
141
+ ZOOMS = {0: (20, 32, 2, 6, 4), 1: (12, 22, 0, 5, 3), 2: (8, 16, 0, 5, 3)} # min box, max box, rows, channel, gap
142
+ # by zoom: full, compact, tiny
143
+
144
+
141
145
  def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]], *, width: int = 100,
142
- centre: bool = False, most_lines: int = 2) -> Grid:
146
+ centre: bool = False, most_lines: int = 2, zoom: int = 0) -> Grid:
143
147
  """A system diagram: `nodes` (id, col, title, sub, status, type: actor|component|group|store|host; `agent` where
144
148
  the agent works, `focus` for the box zoomed in on, `items` for the components a container holds, `lines` for
145
149
  rows inside a box) in `columns` order, joined by `edges` (from, to, kind, status, label; `from_item`/`to_item`:
@@ -150,6 +154,12 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
150
154
  present = [k for k, _h in columns if any(n["col"] == k for n in nodes)]
151
155
  if not present:
152
156
  return g
157
+ least, most_w, rows_in, chan_min, chan_pad = ZOOMS.get(zoom, ZOOMS[0])
158
+ most_lines = min(most_lines, rows_in)
159
+ if zoom: # compact: no labels on flows (a picked box's detail names them), tiny: no subtitles either
160
+ edges = [{**e, "label": ""} for e in edges]
161
+ if zoom >= 2:
162
+ nodes = [{**n, "sub": ""} for n in nodes]
153
163
  heading = {k: h for k, h in columns}
154
164
  ci = {k: i for i, k in enumerate(present)}
155
165
  col_nodes = [[n for n in nodes if n["col"] == k] for k in present]
@@ -181,13 +191,13 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
181
191
  item_row = {it["id"]: (n["id"], 3 + j) for n in nodes for j, it in enumerate(n.get("items") or [])}
182
192
 
183
193
  # widths: as wide as the longest row wants, narrowed together when the whole is wider than the pane
184
- want = [min(32, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
185
- + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
186
- + [len(heading[present[i]]) + 1, 14]))
194
+ want = [min(most_w, max([len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][:1]]
195
+ + [len(t) + 4 for n in col for t, _s, _b in contents[n["id"]][2:]]
196
+ + [min(len(heading[present[i]]) + 1, most_w), least]))
187
197
  for i, col in enumerate(col_nodes)]
188
198
  label_w = max([len(e.get("label") or "") for e in edges] + [0])
189
- gaps = (8 + label_w) * len(present)
190
- while sum(want) + gaps > width and max(want) > 20:
199
+ gaps = (chan_min + 2 + label_w) * len(present)
200
+ while sum(want) + gaps > width and max(want) > least:
191
201
  biggest = max(range(len(want)), key=lambda i: want[i])
192
202
  want[biggest] -= 1
193
203
  heights = {n["id"]: 2 + len(contents[n["id"]]) for n in nodes}
@@ -320,7 +330,7 @@ def layered(nodes: List[dict], edges: List[dict], columns: List[Sequence[str]],
320
330
  if e.get("label") and cs != ct:
321
331
  label_in[hops[-1][0]] = max(label_in[hops[-1][0]], len(e["label"]) + 2)
322
332
  widths = want
323
- chan = [max(6, len(lanes[k]) + 4 + label_in[k]) for k in range(len(present))]
333
+ chan = [max(chan_min, len(lanes[k]) + chan_pad + label_in[k]) for k in range(len(present))]
324
334
  if not lanes[len(present) - 1]:
325
335
  chan[-1] = 0
326
336
  xs, x = [], 0
system_diff/cli.py CHANGED
@@ -193,8 +193,9 @@ def parser() -> argparse.ArgumentParser:
193
193
  "the end of a turn one line on what it changed in the system")
194
194
  pn = sub.add_parser("panel", help="what a pane beside the agent shows, as JSON (for the Claude Code mod)")
195
195
  pn.add_argument("--width", type=int, default=80)
196
- pn.add_argument("--view", default="now", choices=["now", "map", "changes", "topics"])
196
+ pn.add_argument("--view", default="now", choices=["now", "map", "changes", "topics", "agents"])
197
197
  pn.add_argument("--part", default="", help="the part to zoom in on (default: the one the agent works on)")
198
+ pn.add_argument("--zoom", type=int, default=-1, help="diagrams 0 full, 1 compact, 2 tiny; -1 by the width")
198
199
  pn.add_argument("--protocol", type=int, default=1, help="2: a page of runs and pickable blocks (the mod's painter)")
199
200
  pf = sub.add_parser("profile", help="what you want explained: technologies marked new to you")
200
201
  pf.add_argument("--learn", metavar="TECH", help="mark a technology new to you, or unmark it")
@@ -254,7 +255,7 @@ def main(argv: Optional[List[str]] = None) -> int:
254
255
  return agent.run_hook(sys.stdin.read() if not sys.stdin.isatty() else "")
255
256
  if args.cmd == "panel":
256
257
  from . import panel
257
- return panel.main(root, args.width, args.view, args.part, args.protocol)
258
+ return panel.main(root, args.width, args.view, args.part, args.protocol, args.zoom)
258
259
  if args.cmd == "profile":
259
260
  from . import profile
260
261
  if args.learn:
system_diff/mcp_server.py CHANGED
@@ -16,7 +16,7 @@ try: # the MCP SDK's 2.x name; 1.x called it FastMCP, with the same constructor
16
16
  except ImportError: # pragma: no cover - mcp 1.x
17
17
  from mcp.server.fastmcp import FastMCP as Server
18
18
 
19
- from . import activity, config, graph, learn, pictures, textmap
19
+ from . import activity, agents, config, graph, learn, pictures, textmap
20
20
  from . import compare as cmp
21
21
  from .extract import Code as CodeOf # the spec's own Code, below, is a line of a picture
22
22
  from .extract import extract
@@ -29,7 +29,9 @@ INSTRUCTIONS = (
29
29
  "this turn changed. Use explain when the person asks what a part is or does, or about a technology in it: it "
30
30
  "gives the facts to teach from and a diagram to paste in a code block. Use system_map for the whole system, and "
31
31
  "draw for a picture of something else (a process, a sequence). Call checkpoint only when the person says they "
32
- "have seen and understood the current state."
32
+ "have seen and understood the current state. For work that touches more than one part, plan a team with "
33
+ "plan_agents and launch its agents in parallel as subagents; agents claim their blocks with claim, tell others "
34
+ "about changes with publish (topics are block ids), and read what others published with inbox."
33
35
  )
34
36
  WIDTH = 100 # the columns a diagram is drawn for
35
37
 
@@ -233,6 +235,46 @@ def build() -> Server:
233
235
  sha = cmp.checkpoint(repo_root(), ref or None)
234
236
  return {"checkpoint": sha[:9], "of": ref or "the working tree"}
235
237
 
238
+ @server.tool()
239
+ def plan_agents(task: str, most: int = 4, focus: List[str] = []) -> Dict[str, Any]: # noqa: B006
240
+ """Split a task across a team of agents by the system's blocks: one agent per area the task touches (a
241
+ container, else a part), at most `most`, each with the blocks it owns, the blocks it depends on (it is
242
+ subscribed to them on the bus) and a brief to launch it with. `focus`: block ids to include. Claims the
243
+ blocks. Then launch the agents in parallel as subagents, each with its brief as its prompt."""
244
+ root = repo_root()
245
+ c = cmp.compare(root, "checkpoint", ".")
246
+ return agents.plan(root, c, task, most, focus)
247
+
248
+ @server.tool()
249
+ def claim(agent: str, blocks: List[str]) -> Dict[str, Any]:
250
+ """Take blocks (part ids) for `agent` (its name in the plan), so other agents keep out of them. Returns the
251
+ blocks another agent had."""
252
+ return {"agent": agent, "taken_from_others": agents.claim(repo_root(), agent, blocks)}
253
+
254
+ @server.tool()
255
+ def publish(agent: str, topic: str, text: str) -> Dict[str, Any]:
256
+ """Tell the other agents something, async: on a block's topic (its id: those working on what depends on it
257
+ read it with their next tool result), "all", or "agent:<name>" for one agent. Changes to a block's structure
258
+ are published by system-diff itself; publish plans, contracts and questions."""
259
+ return agents.publish(repo_root(), topic, text, sender=agent)
260
+
261
+ @server.tool()
262
+ def subscribe(agent: str, topics: List[str]) -> Dict[str, Any]:
263
+ """Follow more topics (block ids). An agent already follows its own blocks and the blocks they depend on."""
264
+ return {"agent": agent, "topics": agents.subscribe(repo_root(), agent, topics)}
265
+
266
+ @server.tool()
267
+ def inbox(agent: str = "main") -> Dict[str, Any]:
268
+ """`agent`'s unread messages from the other agents (and marks them read)."""
269
+ return {"agent": agent, "messages": agents.inbox(repo_root(), agent)}
270
+
271
+ @server.tool()
272
+ def team() -> Dict[str, Any]:
273
+ """The plan, who claims which block, and the latest messages on the bus."""
274
+ root = repo_root()
275
+ st = agents.state(root)
276
+ return {"plan": st["plan"], "claims": st["claims"], "messages": agents.messages(root)[-20:]}
277
+
236
278
  return server
237
279
 
238
280
 
system_diff/pane.py CHANGED
@@ -16,7 +16,7 @@ from collections import Counter, defaultdict
16
16
  from pathlib import Path
17
17
  from typing import Any, Dict, List, Optional
18
18
 
19
- from . import activity, architecture, canvas, config, design, graph, inside, profile
19
+ from . import activity, agents, architecture, canvas, config, design, graph, inside, profile
20
20
  from .compare import compare, turn_sha
21
21
  from .extract import Code, first_party, outside_imports
22
22
  from .learn import STDLIB, BASIC
@@ -35,7 +35,7 @@ def _name(nid: str, *bps: dict) -> str:
35
35
  def _diagram(page: canvas.Page, c, model: Dict[str, Any], width: int, centre: bool = False) -> canvas.Grid:
36
36
  """The diagram on the page, every box a block: Enter zooms in on it; its detail says what it uses and what uses
37
37
  it, all of it (the diagram draws each box's main flows)."""
38
- g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre)
38
+ g = canvas.layered(model["nodes"], model["edges"], model["columns"], width=width, centre=centre, zoom=ZOOM)
39
39
  conns = graph.connections(c.after)
40
40
  names = {n["id"]: n["title"] for n in model["nodes"]}
41
41
  names.update({i["id"]: i["title"] for n in model["nodes"] for i in n.get("items") or []})
@@ -229,7 +229,7 @@ def view_now(root: Path, width: int, part: str) -> Dict[str, Any]:
229
229
  page = canvas.Page(width)
230
230
  _status(page, now, cfg)
231
231
 
232
- urgent = [n for n in notes if n["weight"] >= design.ATTENTION]
232
+ urgent = [n for n in notes if n["weight"] >= design.ATTENTION] + collisions(root, events, code, cfg)
233
233
  if urgent:
234
234
  page.heading("▲ Needs your attention", "worth a look before Claude goes on")
235
235
  page.rows[-1][0][1] = "attention"
@@ -253,6 +253,7 @@ def view_now(root: Path, width: int, part: str) -> Dict[str, Any]:
253
253
  if model is None:
254
254
  model = architecture.system(c, marks, agent)
255
255
  page.heading("System", "● Claude · + new · ~ changed · − gone · click, then ← ↑ ↓ → and ↵")
256
+ badges(model, root, events, code)
256
257
  g = _diagram(page, c, model, width, centre=bool(part))
257
258
  ids = {b["id"] for b in g.blocks}
258
259
  page.pick = next((b for b in ("b:" + (part or ""), "b:" + (agent or "")) if b in ids), "")
@@ -373,7 +374,123 @@ def view_topics(root: Path, width: int) -> Dict[str, Any]:
373
374
  return page.json(view="topics", part="")
374
375
 
375
376
 
376
- def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
377
+ # ── a team of agents ──────────────────────────────────────────────────────────
378
+
379
+ def team(root: Path, events: List[dict], code: Code) -> Dict[str, Dict[str, Any]]:
380
+ """Every agent: its plan entry (area, blocks, brief), the parts it edited and read this turn, when it last acted,
381
+ by the name it goes by."""
382
+ st = agents.state(root)
383
+ out: Dict[str, Dict[str, Any]] = {}
384
+ for g in (st["plan"].get("agents") or []):
385
+ out[g["name"]] = {"area": g.get("area", ""), "blocks": g.get("blocks", []), "edits": [], "reads": [], "at": 0.0}
386
+ for aid, seen in agents.who_worked(events, code).items():
387
+ name = st["ids"].get(aid) or (agents.MAIN if aid == agents.MAIN else "agent-" + aid[:6])
388
+ a = out.setdefault(name, {"area": "", "blocks": [], "edits": [], "reads": [], "at": 0.0})
389
+ a["edits"] = sorted(set(a["edits"]) | set(seen["edits"]))
390
+ a["reads"] = sorted(set(a["reads"]) | set(seen["reads"]))
391
+ a["at"] = max(a["at"], seen["at"])
392
+ return out
393
+
394
+
395
+ def badges(model: Dict[str, Any], root: Path, events: List[dict], code: Code) -> None:
396
+ """Who works where, on the diagram: each block's agents (those editing it, else those it is claimed for) after
397
+ its title, when more than one agent is about."""
398
+ who = team(root, events, code)
399
+ if len(who) < 2:
400
+ return
401
+ on: Dict[str, List[str]] = defaultdict(list)
402
+ for name, a in who.items():
403
+ for part in (a["edits"] or a["blocks"]):
404
+ if name not in on[part]:
405
+ on[part].append(name)
406
+ for n in model["nodes"]:
407
+ for box in [n] + list(n.get("items") or []):
408
+ if on.get(box["id"]):
409
+ box["title"] += " " + " ".join(f"@{t}" for t in on[box["id"]][:2])
410
+
411
+
412
+ def collisions(root: Path, events: List[dict], code: Code, cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
413
+ """Agents in each other's way this turn: two editing one block, or one editing a block claimed for another."""
414
+ who = team(root, events, code)
415
+ claims = agents.state(root)["claims"]
416
+ editors: Dict[str, List[str]] = defaultdict(list)
417
+ for name, a in who.items():
418
+ for part in a["edits"]:
419
+ editors[part].append(name)
420
+ out = []
421
+ for part, names in sorted(editors.items()):
422
+ P = activity.label(cfg, part)
423
+ if len(names) > 1:
424
+ out.append({"sign": "▲", "status": "warn", "weight": 9, "text": f"{design.and_list(names)} all edit {P}",
425
+ "why": "two agents changing one block can undo each other's work", "ask":
426
+ f"Should {P} be left to {claims.get(part) or names[0]}, the others publishing what they need?"})
427
+ elif claims.get(part) and claims[part] not in names and names != [agents.MAIN]:
428
+ out.append({"sign": "▲", "status": "warn", "weight": 8, "text": f"{names[0]} edits {P}, claimed for "
429
+ f"{claims[part]}", "why": "outside its own blocks", "ask": ""})
430
+ return out
431
+
432
+
433
+ def view_agents(root: Path, width: int) -> Dict[str, Any]:
434
+ cfg = config.lenient(root)
435
+ code = Code(cfg)
436
+ events = activity.events(root, 400)
437
+ c = compare(root, "turn" if turn_sha(root) else "checkpoint", ".")
438
+ st = agents.state(root)
439
+ who = team(root, events, code)
440
+ page = canvas.Page(width)
441
+ task = (st["plan"] or {}).get("task", "")
442
+ page.heading("The team", f"plan: {canvas.cut(task, width - 22)}" if task else
443
+ "no plan yet: Claude makes one with plan_agents")
444
+ clash = collisions(root, events, code, cfg)
445
+ if clash:
446
+ _notes(page, clash, 4, ask=True)
447
+ for name, a in sorted(who.items(), key=lambda kv: (kv[0] != agents.MAIN, -kv[1]["at"], kv[0])):
448
+ live = a["at"] and time.time() - a["at"] < 120
449
+ page.add(["● " if live else "○ ", "accent" if live else "muted"], [name, "bold"],
450
+ [f" {a['area']}" if a["area"] else "", "muted"],
451
+ [f" {time.strftime('%H:%M', time.localtime(a['at']))}" if a["at"] else " not started", "muted"])
452
+ mine = [activity.label(cfg, b) for b in a["blocks"]]
453
+ if mine:
454
+ page.para("owns " + design.and_list(mine, 5), "muted", indent=4)
455
+ if a["edits"]:
456
+ page.para("edited " + design.and_list([activity.label(cfg, b) for b in a["edits"]], 5), "", indent=4)
457
+ unread = len(agents.inbox(root, name, mark=False))
458
+ if unread:
459
+ page.add([f" {unread} unread on the bus", "attention"])
460
+ if not who:
461
+ page.add([" no agent has touched the code yet", "muted"])
462
+ page.heading("Who works where", "@name after a block: the agents on it")
463
+ model = architecture.system(c, None, activity.now(events, code)["part"])
464
+ badges(model, root, events, code)
465
+ _diagram(page, c, model, width)
466
+ page.heading("The bus", "what agents told each other, newest first")
467
+ msgs = agents.messages(root)[-10:][::-1]
468
+ for m in msgs:
469
+ style = {"change": "changed", "warning": "attention", "plan": "accent"}.get(m.get("kind", ""), "")
470
+ page.add([time.strftime("%H:%M ", time.localtime(m["at"])), "muted"], [m["from"], "bold"],
471
+ [f" → {m['topic']}: ", "muted"], [canvas.cut(m["text"], width - 20 - len(m["topic"])), style])
472
+ if m.get("why"):
473
+ page.add([" " + canvas.cut(m["why"], width - 9), "muted"])
474
+ if not msgs:
475
+ page.add([" nothing yet: changes to a block are published here as agents make them", "muted"])
476
+ return page.json(view="agents", part="")
477
+
478
+
479
+ ZOOM = 0 # how compact the diagrams are drawn: 0 full, 1 compact, 2 tiny (show sets it)
480
+
481
+
482
+ def show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None, zoom: int = -1) -> Dict[str, Any]:
483
+ """The page for a view. `zoom` -1: compact below 110 columns, full above."""
484
+ global ZOOM
485
+ ZOOM = (1 if width < 110 else 0) if zoom < 0 else max(0, min(2, zoom))
486
+ out = _show(root, width, view, part)
487
+ out["zoom"] = ZOOM
488
+ return out
489
+
490
+
491
+ def _show(root: Path, width: int = 80, view: str = "now", part: Optional[str] = None) -> Dict[str, Any]:
492
+ if view == "agents":
493
+ return view_agents(root, width)
377
494
  if view == "map":
378
495
  return view_map(root, width)
379
496
  if view == "changes":
system_diff/panel.py CHANGED
@@ -232,10 +232,11 @@ def rows(root: Path, width: int = 80) -> List[textmap.Line]:
232
232
  return got["head"] + got["rows"]
233
233
 
234
234
 
235
- def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1) -> int:
235
+ def main(root: Path, width: int, view: str = "now", part: Optional[str] = None, protocol: int = 1,
236
+ zoom: int = -1) -> int:
236
237
  if protocol >= 2:
237
238
  from . import pane
238
- print(json.dumps(pane.show(root, width, view, part or None), ensure_ascii=False))
239
+ print(json.dumps(pane.show(root, width, view, part or None, zoom), ensure_ascii=False))
239
240
  return 0
240
241
  got = show(root, width, view, part or None)
241
242
  for key in ("head", "rows"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: system-diff
3
- Version: 0.3.4
3
+ Version: 0.4.2
4
4
  Summary: Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs.
5
5
  Project-URL: Homepage, https://github.com/simplifiedmachine/system-diff
6
6
  Project-URL: Documentation, https://github.com/simplifiedmachine/system-diff/wiki
@@ -92,6 +92,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
92
92
  - **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
93
93
  back, `f` follows Claude again.
94
94
  - **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
95
+ - **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
96
+ tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
95
97
  - **This turn**: each change to the design, and what it trades off.
96
98
  - **Learn**: how the part Claude works on is designed.
97
99
  - The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
@@ -100,6 +102,9 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
100
102
  - **What just happened**: the system diff, step by step. Each change an edit made is said in plain words, with why
101
103
  it matters and the file it came from. Edits that changed no structure are told once.
102
104
  - **Map**: the system diagram since your checkpoint, every box to pick and zoom into.
105
+ - **Agents**: the team at work. Each agent's area, the blocks it owns and edited, and unread messages; the system
106
+ diagram with `@agent` after each block it works on; the bus, newest first; and a warning when two agents edit one
107
+ block, or one edits a block claimed for another.
103
108
  - **Changes**: every change since your checkpoint, and this turn, read for design. Once you have understood it, tell
104
109
  Claude "mark it as seen" (or run `system-diff checkpoint`): from then on you see only what is new to you.
105
110
  - **Topics**: the stack, technology by technology. Pick one and the parts that use it light up in the diagram; space
@@ -116,6 +121,27 @@ One meaning per colour, so your eye goes where it should:
116
121
 
117
122
  `n` `m` `c` `t` switch views.
118
123
 
124
+ ### A team of agents, by the system's blocks
125
+
126
+ Claude does most work alone unless asked otherwise, so system-diff asks it to plan a team:
127
+
128
+ - **Planning:** at each prompt, the hook tells Claude to call `plan_agents` with the task. The plan splits the task by
129
+ the system's blocks, one agent per area it touches (a container, else a part). Each agent gets the blocks it owns
130
+ (claimed for it), the blocks it depends on, and a brief to launch it with. Claude then launches the agents in
131
+ parallel as subagents. Turn this off with `[agents] orchestrate = false` in `system-diff.toml`.
132
+ - **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
133
+ depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
134
+ ("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
135
+ (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result:
136
+ nobody waits on anybody.
137
+ - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
138
+ under Needs your attention.
139
+ - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
140
+ turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
141
+
142
+ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
143
+ (`agents.json`, `bus.jsonl`), never pushed.
144
+
119
145
  ### In the conversation
120
146
 
121
147
  - **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
@@ -1,10 +1,11 @@
1
- system_diff/__init__.py,sha256=LwrTgDCI80cQPYW3XYtWCV7ufUT5tiIrtOFBuCzutFU,289
1
+ system_diff/__init__.py,sha256=fKOVXpF3wt-lOcTcNVQIPxe_htHnXkVHWRPHX1wALbQ,289
2
2
  system_diff/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
3
  system_diff/activity.py,sha256=lq5_U-Ulz-mrMALSU8bGB0O4EQHBudSjL8dGALZXxaE,11851
4
- system_diff/agent.py,sha256=j-A5BWv4u9rSf2KHq7EGI7J2D-b4GWUygNflh0hu0no,21931
4
+ system_diff/agent.py,sha256=3GFqgPLs4-MUgtg0Fob57QA-QN-MJgInv2K7Y6AZ-EE,23382
5
+ system_diff/agents.py,sha256=PiaXDL_t-bY0nQLSu9n-uh2asdQlwK3eGhPOTSn2UWg,17546
5
6
  system_diff/architecture.py,sha256=P3Xln9LmdBziQim12KYQKsxLJKesZAHR_F73U-BqwCs,18165
6
- system_diff/canvas.py,sha256=Gg1LlhaRY0pWmAPAdJ8ooKIi0BJGpYbOULStuDglqnA,25052
7
- system_diff/cli.py,sha256=oD1YM9RoRX8wUH4AxrjFwtr3MVD8ukIWTUzoDRX2Kfg,16345
7
+ system_diff/canvas.py,sha256=P3TvtSv1p3WEp-SbLfQSff1ttR0x7M-Tiy_Ow_Z4w5M,25695
8
+ system_diff/cli.py,sha256=az2C90_uHcPkWUO_6-s8M73jgwfWSyeWD8BQsOLD9Ys,16478
8
9
  system_diff/companion.py,sha256=qmBlfLp0RMS_G0EfqiMBdUmfcvdFAxPk3a1ETTT0SbY,7615
9
10
  system_diff/compare.py,sha256=tX6-_xhrM5USlgnaMkMC4fVT4FKoyrjFDRx3r59Slqk,8355
10
11
  system_diff/config.py,sha256=zD4XSuUO0xJdxr4IZPeZE1faRpl5YKdHIrjJp3yCGi4,7491
@@ -14,10 +15,10 @@ system_diff/extract.py,sha256=aOeuJpE6POCq3U2LnbcO76M1aSU68qKL_OVH2S7Z65I,55950
14
15
  system_diff/graph.py,sha256=PGpIqPLJ6rlG2c6qENw_tKp9gFqnt1X_PC6du_Sl-Ds,18324
15
16
  system_diff/inside.py,sha256=thCeHc3mK6GDHFzTBB7_7eLH9CmivuPdLfETS7_A9Z4,11057
16
17
  system_diff/learn.py,sha256=0ogIXepNUqXOBFNO_uDwrkhVfMpVPemndPY9n7cBbcw,25088
17
- system_diff/mcp_server.py,sha256=XQnJuuFvnoiK-pioC7IWZv5kc2vkZlKpaLO8WPDLXYg,11638
18
+ system_diff/mcp_server.py,sha256=uKnEifapKirqTI-XaZNgrfk9E3kA2vmSsrh09H_ydOs,14253
18
19
  system_diff/narrate.py,sha256=lnRW9u03AjGVlWL7Zo-Rt2MsTpz569qfSaFu_8X3c8I,8048
19
- system_diff/pane.py,sha256=zjLhgLf3s1FbKjUhP4jTnLsStEdPpPcRZQLJmbHyTN8,21007
20
- system_diff/panel.py,sha256=kvIgxdVuaa_wFvV-QtnLYnUkNK7MbF-xRGjlTq_aVrM,11991
20
+ system_diff/pane.py,sha256=iToIErlLbN6QZscPsGVPJJ4z362hJQ50SKtMwUmghdU,27240
21
+ system_diff/panel.py,sha256=GOM0qd6kJ2j5QsKIZDvXCxQmqB7Wb5KLKknttcZjjnI,12022
21
22
  system_diff/pictures.py,sha256=G4a8DmvkOttuW962vcGm6CnsbeOTIN_WLrl6ZK0lQGQ,28479
22
23
  system_diff/png.py,sha256=HCxc23roQNxHdcsULmMQnvpqLA1Ejkkc1k9lR_Swa14,5775
23
24
  system_diff/profile.py,sha256=zW05E_IDpyWreAxdZpA37ZH6QYIHuEedcrgxD2yCavs,1361
@@ -28,8 +29,8 @@ system_diff/textmap.py,sha256=tVUywzuwxcGscLbUy-2etTRq-966kIFuxUDeF1e-lgE,15008
28
29
  system_diff/topics.py,sha256=2vbPccf5-jENBLb-P45KZsmaouODglZODYkabwzUXqM,21847
29
30
  system_diff/tui.py,sha256=H5jdM3suzgMUBnq2oi5dSOvul2mZTY0bjP3i5iVkgAc,17756
30
31
  system_diff/watch.py,sha256=ec3u_YBvfaSVsCXdqd_nDKAKWZzJvKLJm37VMpq6SFo,17066
31
- system_diff-0.3.4.dist-info/METADATA,sha256=hn-K-1QROUWBZLOp4tsRKLtm_VsDTOb4YUEjAqxnOhE,11232
32
- system_diff-0.3.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
33
- system_diff-0.3.4.dist-info/entry_points.txt,sha256=8fw6tkuFfZI2L7ScWUXVxj28LK9p6UiImnnB8E1om1E,84
34
- system_diff-0.3.4.dist-info/licenses/LICENSE,sha256=QHQ-0t-7_4_K7j92CTlQScHiJiDZ91DcpV6WIOIFYQE,1074
35
- system_diff-0.3.4.dist-info/RECORD,,
32
+ system_diff-0.4.2.dist-info/METADATA,sha256=XNsvSaLEGZ93rVehHu17Xc6-Hsq50pMFHJPXDRkKtNE,13352
33
+ system_diff-0.4.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
34
+ system_diff-0.4.2.dist-info/entry_points.txt,sha256=8fw6tkuFfZI2L7ScWUXVxj28LK9p6UiImnnB8E1om1E,84
35
+ system_diff-0.4.2.dist-info/licenses/LICENSE,sha256=QHQ-0t-7_4_K7j92CTlQScHiJiDZ91DcpV6WIOIFYQE,1074
36
+ system_diff-0.4.2.dist-info/RECORD,,