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 +1 -1
- system_diff/agent.py +32 -12
- system_diff/agents.py +351 -0
- system_diff/canvas.py +17 -7
- system_diff/cli.py +3 -2
- system_diff/mcp_server.py +44 -2
- system_diff/pane.py +121 -4
- system_diff/panel.py +3 -2
- {system_diff-0.3.4.dist-info → system_diff-0.4.2.dist-info}/METADATA +27 -1
- {system_diff-0.3.4.dist-info → system_diff-0.4.2.dist-info}/RECORD +13 -12
- {system_diff-0.3.4.dist-info → system_diff-0.4.2.dist-info}/WHEEL +0 -0
- {system_diff-0.3.4.dist-info → system_diff-0.4.2.dist-info}/entry_points.txt +0 -0
- {system_diff-0.3.4.dist-info → system_diff-0.4.2.dist-info}/licenses/LICENSE +0 -0
system_diff/__init__.py
CHANGED
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]) ->
|
|
125
|
-
"""The person sent a prompt: a new turn starts
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {"
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
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 = (
|
|
190
|
-
while sum(want) + gaps > width and max(want) >
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
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=
|
|
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=
|
|
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=
|
|
7
|
-
system_diff/cli.py,sha256=
|
|
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=
|
|
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=
|
|
20
|
-
system_diff/panel.py,sha256=
|
|
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.
|
|
32
|
-
system_diff-0.
|
|
33
|
-
system_diff-0.
|
|
34
|
-
system_diff-0.
|
|
35
|
-
system_diff-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|