system-diff 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (56) hide show
  1. {system_diff-0.4.2 → system_diff-0.4.3}/PKG-INFO +5 -3
  2. {system_diff-0.4.2 → system_diff-0.4.3}/README.md +4 -2
  3. {system_diff-0.4.2 → system_diff-0.4.3}/docs/diagrams/system.json +20 -11
  4. {system_diff-0.4.2 → system_diff-0.4.3}/docs/diagrams/system.svg +3 -1
  5. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/.claude-plugin/plugin.json +1 -1
  6. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/hooks/register.tsx +1 -1
  7. {system_diff-0.4.2 → system_diff-0.4.3}/pyproject.toml +1 -1
  8. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/__init__.py +1 -1
  9. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/agent.py +20 -14
  10. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/agents.py +20 -3
  11. {system_diff-0.4.2 → system_diff-0.4.3}/tests/test_design_and_pane.py +33 -0
  12. {system_diff-0.4.2 → system_diff-0.4.3}/tests/test_terminal_and_learning.py +18 -6
  13. {system_diff-0.4.2 → system_diff-0.4.3}/.claude-plugin/marketplace.json +0 -0
  14. {system_diff-0.4.2 → system_diff-0.4.3}/.gitattributes +0 -0
  15. {system_diff-0.4.2 → system_diff-0.4.3}/.github/workflows/ci.yml +0 -0
  16. {system_diff-0.4.2 → system_diff-0.4.3}/.github/workflows/release.yml +0 -0
  17. {system_diff-0.4.2 → system_diff-0.4.3}/.github/workflows/system-diff.yml +0 -0
  18. {system_diff-0.4.2 → system_diff-0.4.3}/.gitignore +0 -0
  19. {system_diff-0.4.2 → system_diff-0.4.3}/LICENSE +0 -0
  20. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/hooks/hooks.json +0 -0
  21. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/hooks/page.tsx +0 -0
  22. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/hooks/pane.test.tsx +0 -0
  23. {system_diff-0.4.2 → system_diff-0.4.3}/mods/system-diff/types/index.d.ts +0 -0
  24. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/__main__.py +0 -0
  25. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/activity.py +0 -0
  26. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/architecture.py +0 -0
  27. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/canvas.py +0 -0
  28. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/cli.py +0 -0
  29. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/companion.py +0 -0
  30. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/compare.py +0 -0
  31. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/config.py +0 -0
  32. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/design.py +0 -0
  33. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/diff.py +0 -0
  34. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/extract.py +0 -0
  35. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/graph.py +0 -0
  36. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/inside.py +0 -0
  37. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/learn.py +0 -0
  38. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/mcp_server.py +0 -0
  39. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/narrate.py +0 -0
  40. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/pane.py +0 -0
  41. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/panel.py +0 -0
  42. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/pictures.py +0 -0
  43. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/png.py +0 -0
  44. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/profile.py +0 -0
  45. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/readme.py +0 -0
  46. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/sources.py +0 -0
  47. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/statusline.py +0 -0
  48. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/textmap.py +0 -0
  49. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/topics.py +0 -0
  50. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/tui.py +0 -0
  51. {system_diff-0.4.2 → system_diff-0.4.3}/src/system_diff/watch.py +0 -0
  52. {system_diff-0.4.2 → system_diff-0.4.3}/system-diff.toml +0 -0
  53. {system_diff-0.4.2 → system_diff-0.4.3}/tests/conftest.py +0 -0
  54. {system_diff-0.4.2 → system_diff-0.4.3}/tests/test_capture.py +0 -0
  55. {system_diff-0.4.2 → system_diff-0.4.3}/tests/test_checkpoint_watch_mcp.py +0 -0
  56. {system_diff-0.4.2 → system_diff-0.4.3}/tests/test_system_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: system-diff
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -132,8 +132,8 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
132
132
  - **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
133
133
  depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
134
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.
135
+ (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
136
+ of any kind (a read, an edit, a shell command, a search), once: nobody waits on anybody.
137
137
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
138
138
  under Needs your attention.
139
139
  - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
@@ -146,6 +146,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
146
146
 
147
147
  - **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
148
148
  /health · a new way in: it needs the same checks as the others". Edits that change no structure stay quiet.
149
+ - **Messages from other agents** on the bus arrive with an agent's next tool result of any kind, not only after
150
+ an edit.
149
151
  - **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
150
152
  structure stayed the same; nothing for a turn that only talked.
151
153
  - **Ask Claude** "what is the API part?" or "what does this change do?": the MCP server gives it `explain` (the facts
@@ -100,8 +100,8 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
100
100
  - **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
101
101
  depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
102
102
  ("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
103
- (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result:
104
- nobody waits on anybody.
103
+ (to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
104
+ of any kind (a read, an edit, a shell command, a search), once: nobody waits on anybody.
105
105
  - **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
106
106
  under Needs your attention.
107
107
  - **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
@@ -114,6 +114,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
114
114
 
115
115
  - **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
116
116
  /health · a new way in: it needs the same checks as the others". Edits that change no structure stay quiet.
117
+ - **Messages from other agents** on the bus arrive with an agent's next tool result of any kind, not only after
118
+ an edit.
117
119
  - **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
118
120
  structure stayed the same; nothing for a turn that only talked.
119
121
  - **Ask Claude** "what is the API part?" or "what does this change do?": the MCP server gives it `explain` (the facts
@@ -9,7 +9,7 @@
9
9
  "container": "",
10
10
  "declared": true,
11
11
  "files": 3,
12
- "lines": 313,
12
+ "lines": 314,
13
13
  "hub": false,
14
14
  "quiet": false
15
15
  },
@@ -33,7 +33,7 @@
33
33
  "container": "",
34
34
  "declared": true,
35
35
  "files": 1,
36
- "lines": 456,
36
+ "lines": 462,
37
37
  "hub": false,
38
38
  "quiet": false
39
39
  },
@@ -45,7 +45,7 @@
45
45
  "container": "",
46
46
  "declared": true,
47
47
  "files": 1,
48
- "lines": 296,
48
+ "lines": 368,
49
49
  "hub": false,
50
50
  "quiet": false
51
51
  },
@@ -69,7 +69,7 @@
69
69
  "container": "",
70
70
  "declared": true,
71
71
  "files": 1,
72
- "lines": 521,
72
+ "lines": 531,
73
73
  "hub": false,
74
74
  "quiet": false
75
75
  },
@@ -201,7 +201,7 @@
201
201
  "container": "",
202
202
  "declared": true,
203
203
  "files": 2,
204
- "lines": 732,
204
+ "lines": 745,
205
205
  "hub": false,
206
206
  "quiet": false
207
207
  },
@@ -317,8 +317,10 @@
317
317
  "agent>learn": 1,
318
318
  "agent>sources": 1,
319
319
  "agents>activity": 1,
320
- "agents>architecture": 1,
320
+ "agents>architecture": 2,
321
+ "agents>config": 1,
321
322
  "agents>design": 1,
323
+ "agents>extract": 1,
322
324
  "agents>sources": 1,
323
325
  "architecture>design": 2,
324
326
  "architecture>diff": 1,
@@ -433,7 +435,8 @@
433
435
  ],
434
436
  "agent>agents": [
435
437
  "ORCHESTRATE",
436
- "after_edit"
438
+ "after_edit",
439
+ "deliver"
437
440
  ],
438
441
  "agent>companion": [
439
442
  "add_vscode_task",
@@ -466,22 +469,28 @@
466
469
  ],
467
470
  "agents>activity": [
468
471
  "EDIT",
469
- "_replace",
470
472
  "PROMPT",
471
- "READ",
473
+ "_replace",
472
474
  "is_system",
473
- "part_of"
475
+ "part_of",
476
+ "READ"
474
477
  ],
475
478
  "agents>architecture": [
476
479
  "groups_of"
477
480
  ],
481
+ "agents>config": [
482
+ "lenient"
483
+ ],
478
484
  "agents>design": [
479
485
  "depends",
486
+ "kinds",
480
487
  "and_list",
481
488
  "fan_in",
482
- "kinds",
483
489
  "summarize"
484
490
  ],
491
+ "agents>extract": [
492
+ "Code"
493
+ ],
485
494
  "agents>sources": [
486
495
  "git_dir"
487
496
  ],
@@ -64,9 +64,11 @@ text { font-family: 'Segoe UI', FreeSans, 'Helvetica Neue', Arial, sans-serif; }
64
64
  <path class="be k-call s-same" d="M214,172 C243,172 243,188 272,188" marker-end="url(#ar-call-same)"><title>agent → extract</title></path>
65
65
  <path class="be k-call s-same" d="M214,172 C243,172 243,788 272,788" marker-end="url(#ar-call-same)"><title>agent → learn</title></path>
66
66
  <path class="be k-call s-same" d="M214,172 C246,172 246,99 278,99 L478,99 C507,99 507,52 536,52" marker-end="url(#ar-call-same)"><title>agent → sources</title></path>
67
- <path class="be k-call s-same" d="M478,488 C507,488 507,112 536,112" marker-end="url(#ar-call-same)"><title>agents → activity</title></path>
68
67
  <path class="be k-call s-same" d="M478,488 C514,488 514,428 484,428" marker-end="url(#ar-call-same)"><title>agents → architecture</title></path>
68
+ <path class="be k-call s-same" d="M478,488 C507,488 507,112 536,112" marker-end="url(#ar-call-same)"><title>agents → activity</title></path>
69
+ <path class="be k-call s-same" d="M478,488 C544,488 544,128 484,128" marker-end="url(#ar-call-same)"><title>agents → config</title></path>
69
70
  <path class="be k-call s-same" d="M478,488 C514,488 514,548 484,548" marker-end="url(#ar-call-same)"><title>agents → design</title></path>
71
+ <path class="be k-call s-same" d="M478,488 C544,488 544,188 484,188" marker-end="url(#ar-call-same)"><title>agents → extract</title></path>
70
72
  <path class="be k-call s-same" d="M478,488 C507,488 507,52 536,52" marker-end="url(#ar-call-same)"><title>agents → sources</title></path>
71
73
  <path class="be k-call s-same" d="M478,428 C524,428 524,548 484,548" marker-end="url(#ar-call-same)"><title>architecture → design</title></path>
72
74
  <path class="be k-call s-same" d="M478,428 C524,428 524,308 484,308" marker-end="url(#ar-call-same)"><title>architecture → graph</title></path>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "system-diff",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "A pane beside the chat: what Claude is working on in your system, zoomed in, what just happened and why it matters, and a topic to learn (system-diff)",
5
5
  "types": "./types/index.d.ts",
6
6
  "author": {
@@ -14,7 +14,7 @@ import type { Act, Page, Row } from '../types'
14
14
  // green what is new, done or to learn, white what to read, gray the background, red only what was taken away.
15
15
 
16
16
  const PANE = 'system-diff'
17
- const VERSION = '0.4.2' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
17
+ const VERSION = '0.4.3' // the plugin's own, as .claude-plugin/plugin.json says (a test keeps them one)
18
18
  const WIDEST = 999 // as wide as Claude Code docks a pane: 45% of the terminal
19
19
  const COMMANDS = ['system-diff', 'system-diff-pane'] as const
20
20
  const VIEWS = [['now', 'Now', 'n'], ['map', 'Map', 'm'], ['agents', 'Agents', 'a'], ['changes', 'Changes', 'c'],
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "system-diff"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.4.2"
8
+ __version__ = "0.4.3"
@@ -137,30 +137,36 @@ def on_prompt(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
137
137
 
138
138
 
139
139
  def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
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
141
- has done to the system so far, and say what this edit added to it (one hook at a time: they can overlap)."""
140
+ """After every tool: hand the agent what other agents published since its last tool. For a read or an edit of
141
+ a file in a part, also note the file and keep a brief of its part for the status line; after an edit to code,
142
+ look at what the turn has done to the system so far, and say what this edit added to it (one hook at a time:
143
+ they can overlap)."""
142
144
  tool = payload.get("tool_name", "")
145
+ agent_id = payload.get("agent_id") or ""
146
+
147
+ def unread() -> Dict[str, Any]:
148
+ return {"message": None, "context": agents.deliver(root, agent_id)}
149
+
143
150
  if tool not in EDIT_TOOLS + READ_TOOLS:
144
- return None
151
+ return unread()
145
152
  given = payload.get("tool_input") or {}
146
153
  path = activity.relative(root, given.get("file_path") or given.get("notebook_path") or "")
147
154
  if not path:
148
- return None
155
+ return unread()
149
156
  edit = tool in EDIT_TOOLS
150
- agent_id = payload.get("agent_id") or ""
151
157
  activity.record(root, {"event": activity.EDIT if edit else activity.READ, "path": path, "tool": tool,
152
158
  "session": payload.get("session_id", ""), "agent": agent_id})
153
159
  cfg = config.lenient(root)
154
160
  part = activity.part_of(path, Code(cfg))
155
161
  if part is None:
156
- return None
162
+ return unread()
157
163
  if not activity.load_brief(root, part, max_age=600):
158
164
  activity.save_brief(root, learn.brief(root, cfg, part))
159
165
  if not (edit and turn_sha(root)):
160
- return None
166
+ return unread()
161
167
  with activity.only_one(root, wait=10) as mine:
162
168
  if not mine:
163
- return None
169
+ return unread()
164
170
  last = activity.read_state(root).get("turn") or {}
165
171
  before = last.get("told") or [s["text"] for s in last.get("story") or []]
166
172
  since, turn = compare_worktree(root, ["checkpoint", "turn"])
@@ -246,12 +252,12 @@ HOOKS = ( # event, matcher, how it runs, what it does
246
252
  "WezTerm, kitty, Windows Terminal)"),
247
253
  ("UserPromptSubmit", None, {"timeout": 10},
248
254
  "marks where each turn starts, and asks Claude to plan the work for a team of agents"),
249
- ("PostToolUse", "|".join(READ_TOOLS), {"async": True},
250
- "notes the files Claude reads, for the terminal view and the status line (in the background)"),
251
- # Not in the background: only a hook Claude Code waits for can show you a line in the conversation. A timeout
252
- # (seconds) so a stuck git never holds up the agent; Claude Code's own default is 600.
253
- ("PostToolUse", "|".join(EDIT_TOOLS), {"timeout": 15},
254
- "after an edit that changes the system's structure, one line on what it changed and why it matters"),
255
+ # One entry for every tool, and not in the background: only a hook Claude Code waits for can show you a line in
256
+ # the conversation or hand the agent its messages. A timeout (seconds) so a stuck git never holds up the agent;
257
+ # Claude Code's own default is 600.
258
+ ("PostToolUse", None, {"timeout": 15},
259
+ "notes the files Claude reads and, after an edit that changes the system's structure, one line on what it "
260
+ "changed; and hands each agent what other agents published"),
255
261
  ("Stop", None, {"timeout": 30}, "after each turn, one line on what the turn changed in the system"),
256
262
  )
257
263
 
@@ -2,7 +2,7 @@
2
2
  CLAIMS, so agents keep to their own blocks; and a BUS, an async publish/subscribe channel whose topics are the system's
3
3
  blocks: an agent subscribes to the blocks its own depend on, every structural change to a block is published on that
4
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.
5
+ reach it with its next tool result of any kind, without waiting on anyone.
6
6
 
7
7
  Who is who: Claude Code names each subagent by an id (`agent_id` in its hook events; the main conversation has none,
8
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
@@ -65,6 +65,10 @@ def tie(root: Path, agent_id: str, name: str) -> None:
65
65
  data = _load(root)
66
66
  if data["ids"].get(agent_id) != name:
67
67
  data["ids"][agent_id] = name
68
+ short = data["agents"].get("agent-" + agent_id[:6]) # what it read before it had a name stays read
69
+ if short:
70
+ a = _agent(data, name)
71
+ a["read"] = max(a["read"], short["read"])
68
72
  _save(root, data)
69
73
 
70
74
 
@@ -163,6 +167,20 @@ def as_context(msgs: List[Dict[str, Any]], most: int = 8) -> str:
163
167
 
164
168
  # ── what the hooks do for each agent ─────────────────────────────────────────
165
169
 
170
+ def _for_context(msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
171
+ """The messages worth putting in an agent's context: spread warnings reach the agent they are about directly; on
172
+ the bus they are a record for the inbox tool."""
173
+ return [m for m in msgs if not (m.get("from") == SYSTEM and m.get("kind") == "warning")]
174
+
175
+
176
+ def deliver(root: Path, agent_id: str) -> str:
177
+ """The unread messages for the agent with this Claude Code id ("" or "main": the main conversation), as context
178
+ text, marked read; "" without writing anything when there is no bus yet."""
179
+ if not (git_dir(root) / BUS).exists():
180
+ return ""
181
+ return as_context(_for_context(inbox(root, name_of(root, agent_id))))
182
+
183
+
166
184
  def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[str, Any]], bp: dict) -> Dict[str, str]:
167
185
  """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
186
  to its block and the blocks that block depends on, publish what the edit changed in the structure on the topics
@@ -186,8 +204,7 @@ def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[
186
204
  publish(root, f"agent:{claimed}", f"{name} edited your block {part}", sender=name, kind="warning")
187
205
  from . import config
188
206
  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")]
207
+ unread = _for_context(inbox(root, name))
191
208
  context = "\n".join(t for t in (as_context(unread), wide) if t)
192
209
  warn = "; ".join(t for t in (warn, wide) if t)
193
210
  return {"context": context, "warn": warn, "name": name}
@@ -270,6 +270,31 @@ def test_the_agents_tab_shows_each_agent_and_warns_when_two_edit_one_block(repo,
270
270
  assert "● API @api @st" in "\n".join(text) # who works where, on the diagram
271
271
 
272
272
 
273
+ def test_messages_reach_an_agent_with_its_next_tool_result_of_any_kind_once(repo):
274
+ from system_diff import agent, agents
275
+ from system_diff.sources import git_dir
276
+
277
+ def tool(name, **given):
278
+ said: list = []
279
+ agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "PostToolUse", "tool_name": name,
280
+ "agent_id": "a1", "tool_input": given}), emit=said.append)
281
+ return [json.loads(s) for s in said]
282
+
283
+ bus = git_dir(repo) / agents.BUS
284
+ assert not bus.exists() and tool("Bash", command="ls") == [] # no bus: nothing said, nothing written
285
+ assert not bus.exists()
286
+ agents.tie(repo, "a1", "api")
287
+ agents.publish(repo, "agent:api", "please keep GET /api/orders", sender="web")
288
+ out = tool("Bash", command="ls") # not a file tool: it still gets it
289
+ assert "web on agent:api: please keep GET /api/orders" in out[0]["hookSpecificOutput"]["additionalContext"]
290
+ assert out[0]["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
291
+ assert tool("Bash", command="ls") == [] # once only
292
+ agents.publish(repo, "agent:api", "store moved to app/db", sender="store")
293
+ out = tool("Read", file_path=str(repo / "app/core/rules.py"))
294
+ assert "store on agent:api: store moved to app/db" in out[0]["hookSpecificOutput"]["additionalContext"]
295
+ assert tool("Read", file_path=str(repo / "app/core/rules.py")) == []
296
+
297
+
273
298
  def test_one_agent_editing_two_areas_in_a_turn_is_told_once_to_plan_a_team(repo, files):
274
299
  from system_diff import agent
275
300
 
@@ -316,3 +341,11 @@ def test_a_narrow_pane_gets_compact_diagrams_unless_asked_otherwise(repo, capsys
316
341
  def _run(capsys, repo, *args):
317
342
  assert main(["--repo", str(repo), "panel", "--protocol", "2", *args]) == 0
318
343
  return capsys.readouterr().out
344
+
345
+
346
+ def test_what_an_agent_read_before_it_had_a_name_stays_read(repo):
347
+ from system_diff import agents
348
+ agents.publish(repo, "all", "hello", sender="main")
349
+ assert len(agents.inbox(repo, "agent-a1b2c3")) == 1
350
+ agents.tie(repo, "a1b2c3d4", "api")
351
+ assert agents.inbox(repo, "api") == []
@@ -332,10 +332,8 @@ def test_setup_wires_three_hooks_and_a_status_line_and_leaves_someone_elses_alon
332
332
  agent.setup_claude(repo, "local", out=said.append)
333
333
  path = repo / ".claude" / "settings.local.json"
334
334
  data = json.loads(path.read_text())
335
- assert data["hooks"]["PostToolUse"] == [ # reads noted in the background; an edit waited for, so it can speak
336
- {"matcher": "Read", "hooks": [{"type": "command", "command": "system-diff hook", "async": True}]},
337
- {"matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [
338
- {"type": "command", "command": "system-diff hook", "timeout": 15}]}]
335
+ assert data["hooks"]["PostToolUse"] == [ # every tool, waited for: it can speak and hand over messages
336
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}]
339
337
  assert data["hooks"]["UserPromptSubmit"][0]["hooks"][0]["timeout"] == 10 # waited for: it tells Claude
340
338
  assert data["statusLine"] == {"type": "command", "command": "system-diff statusline"}
341
339
  agent.setup_claude(repo, "local", undo=True, out=said.append)
@@ -347,11 +345,25 @@ def test_setup_wires_three_hooks_and_a_status_line_and_leaves_someone_elses_alon
347
345
  said.clear()
348
346
  agent.setup_claude(repo, "local", out=said.append)
349
347
  post = json.loads(path.read_text())["hooks"]["PostToolUse"]
350
- assert post[0] == {"hooks": [{"command": "mine"}]} and [e["matcher"] for e in post[1:]] == [
351
- "Read", "Edit|Write|MultiEdit|NotebookEdit"]
348
+ assert post == [{"hooks": [{"command": "mine"}]},
349
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}]
352
350
  assert any(s.startswith("updated the PostToolUse hook") for s in said)
353
351
  agent.setup_claude(repo, "local", undo=True, out=said.append)
354
352
 
353
+ two = {"hooks": {"PostToolUse": [ # the two entries the setup before this one wrote
354
+ {"matcher": "Read", "hooks": [{"type": "command", "command": "system-diff hook", "async": True}]},
355
+ {"matcher": "Edit|Write|MultiEdit|NotebookEdit", "hooks": [
356
+ {"type": "command", "command": "system-diff hook", "timeout": 15}]}]}}
357
+ path.write_text(json.dumps(two))
358
+ said.clear()
359
+ agent.setup_claude(repo, "local", out=said.append)
360
+ assert json.loads(path.read_text())["hooks"]["PostToolUse"] == [
361
+ {"hooks": [{"type": "command", "command": "system-diff hook", "timeout": 15}]}] # one, not three
362
+ assert sum(s.startswith("updated the PostToolUse hook") for s in said) == 1
363
+ agent.setup_claude(repo, "local", out=said.append)
364
+ assert len(json.loads(path.read_text())["hooks"]["PostToolUse"]) == 1 # and it stays one
365
+ agent.setup_claude(repo, "local", undo=True, out=said.append)
366
+
355
367
  user = home / ".claude" / "settings.json"
356
368
  user.parent.mkdir(parents=True)
357
369
  user.write_text(json.dumps({"statusLine": {"type": "command", "command": "my-line"}}))
File without changes
File without changes
File without changes