system-diff 0.4.0__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.
- {system_diff-0.4.0 → system_diff-0.4.3}/PKG-INFO +9 -3
- {system_diff-0.4.0 → system_diff-0.4.3}/README.md +8 -2
- {system_diff-0.4.0 → system_diff-0.4.3}/docs/diagrams/system.json +20 -11
- {system_diff-0.4.0 → system_diff-0.4.3}/docs/diagrams/system.svg +3 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/.claude-plugin/plugin.json +1 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/page.tsx +7 -4
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/pane.test.tsx +12 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/register.tsx +19 -3
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/types/index.d.ts +2 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/pyproject.toml +1 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/__init__.py +1 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/agent.py +20 -14
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/agents.py +76 -4
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/canvas.py +17 -7
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/cli.py +2 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/pane.py +14 -2
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/panel.py +3 -2
- {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_design_and_pane.py +82 -1
- {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_terminal_and_learning.py +18 -6
- {system_diff-0.4.0 → system_diff-0.4.3}/.claude-plugin/marketplace.json +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/.gitattributes +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/ci.yml +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/release.yml +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/.github/workflows/system-diff.yml +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/.gitignore +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/LICENSE +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/mods/system-diff/hooks/hooks.json +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/__main__.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/activity.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/architecture.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/companion.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/compare.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/config.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/design.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/diff.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/extract.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/graph.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/inside.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/learn.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/mcp_server.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/narrate.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/pictures.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/png.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/profile.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/readme.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/sources.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/statusline.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/textmap.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/topics.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/tui.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/src/system_diff/watch.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/system-diff.toml +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/tests/conftest.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_capture.py +0 -0
- {system_diff-0.4.0 → system_diff-0.4.3}/tests/test_checkpoint_watch_mcp.py +0 -0
- {system_diff-0.4.0 → 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.
|
|
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
|
|
@@ -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.
|
|
@@ -130,10 +132,12 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
|
|
|
130
132
|
- **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
|
|
131
133
|
depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
|
|
132
134
|
("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
|
|
133
|
-
(to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
|
|
134
|
-
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.
|
|
135
137
|
- **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
|
|
136
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.
|
|
137
141
|
|
|
138
142
|
The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
|
|
139
143
|
(`agents.json`, `bus.jsonl`), never pushed.
|
|
@@ -142,6 +146,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
|
|
|
142
146
|
|
|
143
147
|
- **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
|
|
144
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.
|
|
145
151
|
- **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
|
|
146
152
|
structure stayed the same; nothing for a turn that only talked.
|
|
147
153
|
- **Ask Claude** "what is the API part?" or "what does this change do?": the MCP server gives it `explain` (the facts
|
|
@@ -60,6 +60,8 @@ Claude is changing in it. `/system-diff close` (or "× close" in its tab row) hi
|
|
|
60
60
|
- **Zoom:** Enter (or a second click) zooms in on a container's components, with the lines between them. `b` goes
|
|
61
61
|
back, `f` follows Claude again.
|
|
62
62
|
- **Scroll:** `[` `]` scroll a wide diagram across; shift ↑ ↓, Page Up/Down or the wheel scroll a tall one.
|
|
63
|
+
- **Zoom:** ⊖ and ⊕ in the tab row (or `-` and `=`) draw the diagrams compact (narrower boxes, no flow labels) or
|
|
64
|
+
tiny (titles only), to fit more of the system in a narrow pane; below 110 columns it is compact by itself.
|
|
63
65
|
- **This turn**: each change to the design, and what it trades off.
|
|
64
66
|
- **Learn**: how the part Claude works on is designed.
|
|
65
67
|
- The principle its place calls for: an edge of the system, an adapter, a shared foundation, a coordinator.
|
|
@@ -98,10 +100,12 @@ Claude does most work alone unless asked otherwise, so system-diff asks it to pl
|
|
|
98
100
|
- **The bus:** an async publish/subscribe channel whose topics are the blocks. Each agent follows the blocks its own
|
|
99
101
|
depend on. Every change an agent makes to a block's structure is published on that block's topic as it happens
|
|
100
102
|
("API no longer answers DELETE /orders/{id}"), with why it matters. Agents publish their own notes with `publish`
|
|
101
|
-
(to a block, to "all", or to "agent:<name>"). Each agent's unread messages reach it with its next tool result
|
|
102
|
-
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.
|
|
103
105
|
- **Claims:** `claim` gives an agent its blocks. An edit to someone else's block is published to its owner and shown
|
|
104
106
|
under Needs your attention.
|
|
107
|
+
- **One agent, one area:** when an agent (the main conversation included) edits blocks in two or more areas in one
|
|
108
|
+
turn, the edit hook tells it, once that turn, to plan a team with `plan_agents` and hand the rest to agents.
|
|
105
109
|
|
|
106
110
|
The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`. It all stays in `.git/system-diff/`
|
|
107
111
|
(`agents.json`, `bus.jsonl`), never pushed.
|
|
@@ -110,6 +114,8 @@ The MCP tools: `plan_agents`, `claim`, `publish`, `subscribe`, `inbox`, `team`.
|
|
|
110
114
|
|
|
111
115
|
- **After an edit that changes the system's structure**, one line under it: "System diff: API now answers GET
|
|
112
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.
|
|
113
119
|
- **At the end of each turn**, one line on what the turn did to the system, or which parts it edited when the
|
|
114
120
|
structure stayed the same; nothing for a turn that only talked.
|
|
115
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
-
"
|
|
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.
|
|
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": {
|
|
@@ -12,7 +12,7 @@ import type { Page, PageBlock, Row, Run } from '../types'
|
|
|
12
12
|
// the rest keeps its colours: yellow what changed, green what is new, red what went, gray the background.
|
|
13
13
|
|
|
14
14
|
type Props = { page: Page; canGoBack: boolean }
|
|
15
|
-
type State = { sel: string; sx: number; mine: boolean; ticking?: boolean }
|
|
15
|
+
type State = { sel: string; sx: number; mine: boolean; ticking?: boolean; free?: boolean } // free: scrolled by hand
|
|
16
16
|
|
|
17
17
|
const COLORS: Record<string, string> = {
|
|
18
18
|
accent: 'blue', focus: 'blue', near: 'blue', data: 'blue',
|
|
@@ -88,7 +88,7 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
|
|
|
88
88
|
const state = surface.state
|
|
89
89
|
const sel = state?.mine && byId.has(state.sel) ? state.sel : byId.has(page.pick) ? page.pick : (page.blocks[0]?.id ?? '')
|
|
90
90
|
const picked = byId.get(sel)
|
|
91
|
-
const sx = across(page, cols, state?.mine ? state.sx : 0, picked)
|
|
91
|
+
const sx = across(page, cols, state?.mine ? state.sx : 0, state?.free ? undefined : picked)
|
|
92
92
|
|
|
93
93
|
// The pane was laid out for another width (the split moved): ask for this one once it holds, not for every
|
|
94
94
|
// width a drag passes on its way.
|
|
@@ -104,7 +104,8 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
|
|
|
104
104
|
surface.post({ width: askedWidth })
|
|
105
105
|
}
|
|
106
106
|
})
|
|
107
|
-
surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true
|
|
107
|
+
surface.setState({ sel: state?.sel ?? '', sx: state?.sx ?? 0, mine: state?.mine ?? false, ticking: true,
|
|
108
|
+
free: state?.free })
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const pick = (b: PageBlock | undefined, x = sx) => {
|
|
@@ -127,8 +128,10 @@ const PageView: ClientModule<JsonValue, State> = (raw, surface) => {
|
|
|
127
128
|
} else if (['up', 'down', 'left', 'right'].includes(e.key)) {
|
|
128
129
|
pick(move(page.blocks, picked, e.key))
|
|
129
130
|
} else if (e.key === '[' || e.key === ']') {
|
|
130
|
-
surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true,
|
|
131
|
+
surface.setState({ sel, sx: across(page, cols, sx + (e.key === '[' ? -STEP : STEP), undefined), mine: true, free: true,
|
|
131
132
|
ticking: true })
|
|
133
|
+
} else if (e.key === '-' || e.key === '=' || e.key === '+') {
|
|
134
|
+
surface.post({ zoomBy: e.key === '-' ? 1 : -1 }) // - shows more of the system, = bigger boxes
|
|
132
135
|
} else if (e.key === 'return') {
|
|
133
136
|
act(picked, 'enter')
|
|
134
137
|
} else if (e.key === ' ') {
|
|
@@ -185,3 +185,15 @@ test('/system-diff close closes the pane, and so does its close button', async (
|
|
|
185
185
|
expect(closed).toEqual(['system-diff', 'system-diff'])
|
|
186
186
|
await ui.unmount()
|
|
187
187
|
})
|
|
188
|
+
|
|
189
|
+
test('zoom out draws the diagrams compact, zoom in full again', async ($, on) => {
|
|
190
|
+
const asked: string[][] = []
|
|
191
|
+
answer(on, asked)
|
|
192
|
+
await $.command.run(RUN)
|
|
193
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
194
|
+
await ui.press({ key: 'zoom-out' })
|
|
195
|
+
expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 1')
|
|
196
|
+
await ui.key({ key: '=', in: 'page' })
|
|
197
|
+
expect(asked[asked.length - 1]?.join(' ')).toContain('--zoom 0')
|
|
198
|
+
await ui.unmount()
|
|
199
|
+
})
|
|
@@ -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.
|
|
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'],
|
|
@@ -25,6 +25,9 @@ const view = atom({ plugin: 'system-diff', key: 'view' } as const, 'now', { shap
|
|
|
25
25
|
const part = atom({ plugin: 'system-diff', key: 'part' } as const, '', { shape: 'part-1' })
|
|
26
26
|
const back = atom({ plugin: 'system-diff', key: 'back' } as const, [] as string[], { shape: 'back-2' })
|
|
27
27
|
const note = atom({ plugin: 'system-diff', key: 'note' } as const, 'Reading the system…', { shape: 'note-1' })
|
|
28
|
+
// How compact the diagrams are: -1 by the pane's width (compact below 110 columns), 0 full, 1 compact, 2 tiny.
|
|
29
|
+
const zoomLevel = atom({ plugin: 'system-diff', key: 'zoom' } as const, -1, { shape: 'zoom-1' })
|
|
30
|
+
const ZOOM_WORD = ['full', 'compact', 'tiny']
|
|
28
31
|
|
|
29
32
|
// How to run system-diff: the repository's own virtualenv first, then whatever is on PATH.
|
|
30
33
|
const RUNNERS: string[][] = [
|
|
@@ -73,6 +76,8 @@ async function refresh($: EngineInterface): Promise<void> {
|
|
|
73
76
|
again = false
|
|
74
77
|
const argv = ['panel', '--protocol', '2', '--width', String(columns), '--view', await read($, view),
|
|
75
78
|
'--part', await read($, part)]
|
|
79
|
+
const level = await read($, zoomLevel)
|
|
80
|
+
if (level >= 0) argv.push('--zoom', String(level)) // left out, the engine picks by the width (older ones too)
|
|
76
81
|
const got = await run($, argv)
|
|
77
82
|
if (got && !got.old) {
|
|
78
83
|
if (got.out !== last) {
|
|
@@ -155,6 +160,13 @@ async function scrollBy($: EngineInterface, rows: number): Promise<void> {
|
|
|
155
160
|
|
|
156
161
|
// As wide as Claude Code docks a pane, the first time: a width the person set (dragged, or with the pane's keys:
|
|
157
162
|
// ctrl+x ← →) is theirs from then on.
|
|
163
|
+
// More of the system in the pane (zoom out, +1) or bigger boxes (zoom in, -1), from where the page is drawn now.
|
|
164
|
+
async function zoomBy($: EngineInterface, by: number): Promise<void> {
|
|
165
|
+
const drawnAt = (await read($, page))?.zoom ?? 0
|
|
166
|
+
await update($, zoomLevel, () => Math.max(0, Math.min(2, drawnAt + by)))
|
|
167
|
+
await refresh($)
|
|
168
|
+
}
|
|
169
|
+
|
|
158
170
|
function open($: EngineInterface) {
|
|
159
171
|
return $.ui.open({ id: PANE, title: 'System diff', columns: WIDEST })
|
|
160
172
|
}
|
|
@@ -213,7 +225,7 @@ export const register: Register = on => {
|
|
|
213
225
|
// What the page (page.tsx) asks for.
|
|
214
226
|
on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
215
227
|
const data = (e.data ?? {}) as Act & { back?: boolean; follow?: boolean; width?: number; reveal?: number;
|
|
216
|
-
scrollBy?: number }
|
|
228
|
+
scrollBy?: number; zoomBy?: number }
|
|
217
229
|
if (typeof data.width === 'number' && data.width > 0 && data.width !== columns) {
|
|
218
230
|
columns = data.width
|
|
219
231
|
await refresh($)
|
|
@@ -223,6 +235,7 @@ export const register: Register = on => {
|
|
|
223
235
|
else if (typeof data.learn === 'string') await learn($, data.learn)
|
|
224
236
|
else if (data.back) await goBack($)
|
|
225
237
|
else if (data.follow) await follow($)
|
|
238
|
+
else if (typeof data.zoomBy === 'number') await zoomBy($, data.zoomBy)
|
|
226
239
|
else if (typeof data.view === 'string') await choose($, data.view)
|
|
227
240
|
return {}
|
|
228
241
|
})
|
|
@@ -278,11 +291,14 @@ export const register: Register = on => {
|
|
|
278
291
|
{behind.length > 0 && (
|
|
279
292
|
<Button key="back" hotkey="b" plain onPress={() => void goBack($)}><Text dimColor>← Back</Text></Button>
|
|
280
293
|
)}
|
|
294
|
+
<Button key="zoom-out" plain onPress={() => void zoomBy($, 1)}><Text dimColor>⊖</Text></Button>
|
|
295
|
+
<Text dimColor>{ZOOM_WORD[drawn?.zoom ?? 0] ?? 'full'}</Text>
|
|
296
|
+
<Button key="zoom-in" plain onPress={() => void zoomBy($, -1)}><Text dimColor>⊕</Text></Button>
|
|
281
297
|
<Button key="close" plain onPress={() => void $.ui.close({ id: PANE })}><Text dimColor>× close</Text></Button>
|
|
282
298
|
</Box>
|
|
283
299
|
{said !== '' && <Text dimColor>{said}</Text>}
|
|
284
300
|
{drawn !== null && Client !== undefined && (
|
|
285
|
-
<Client key="page" module="./page.tsx" props={{ page: drawn, canGoBack: behind.length > 0 }} />
|
|
301
|
+
<Client key="page" module="./page.tsx" width="100%" props={{ page: drawn, canGoBack: behind.length > 0 }} />
|
|
286
302
|
)}
|
|
287
303
|
{drawn !== null && Client === undefined && drawn.rows.map(line)}
|
|
288
304
|
{drawn !== null && Client === undefined && (
|
|
@@ -24,6 +24,7 @@ export type Page = {
|
|
|
24
24
|
lights: Record<string, string[]>
|
|
25
25
|
details: Record<string, Row[]>
|
|
26
26
|
pick: string
|
|
27
|
+
zoom?: number // how compact it is drawn: 0 full, 1 compact, 2 tiny
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
declare module 'claude-code' {
|
|
@@ -34,6 +35,7 @@ declare module 'claude-code' {
|
|
|
34
35
|
part: Shaped<string>
|
|
35
36
|
back: Shaped<string[]>
|
|
36
37
|
note: Shaped<string>
|
|
38
|
+
zoom: Shaped<number>
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "system-diff"
|
|
7
|
-
version = "0.4.
|
|
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"
|
|
@@ -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
|
-
"""
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
166
|
+
return unread()
|
|
161
167
|
with activity.only_one(root, wait=10) as mine:
|
|
162
168
|
if not mine:
|
|
163
|
-
return
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
#
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
"
|
|
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
|
|
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
|
|
@@ -24,6 +24,7 @@ from .sources import git_dir
|
|
|
24
24
|
|
|
25
25
|
STATE, BUS = "agents.json", "bus.jsonl"
|
|
26
26
|
MAIN = "main"
|
|
27
|
+
SYSTEM = "system-diff" # the sender of what system-diff itself publishes
|
|
27
28
|
KEEP = 2000 # messages kept on the bus
|
|
28
29
|
MOST_AGENTS = 4 # a plan's agents at most, unless asked for more
|
|
29
30
|
|
|
@@ -33,10 +34,11 @@ def _load(root: Path) -> Dict[str, Any]:
|
|
|
33
34
|
data = json.loads((git_dir(root) / STATE).read_text(encoding="utf-8"))
|
|
34
35
|
if isinstance(data, dict):
|
|
35
36
|
return {"plan": data.get("plan") or {}, "claims": data.get("claims") or {},
|
|
36
|
-
"agents": data.get("agents") or {}, "ids": data.get("ids") or {}
|
|
37
|
+
"agents": data.get("agents") or {}, "ids": data.get("ids") or {},
|
|
38
|
+
"warned": data.get("warned") or {}}
|
|
37
39
|
except (OSError, ValueError):
|
|
38
40
|
pass
|
|
39
|
-
return {"plan": {}, "claims": {}, "agents": {}, "ids": {}}
|
|
41
|
+
return {"plan": {}, "claims": {}, "agents": {}, "ids": {}, "warned": {}}
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
def _save(root: Path, data: Dict[str, Any]) -> None:
|
|
@@ -63,6 +65,10 @@ def tie(root: Path, agent_id: str, name: str) -> None:
|
|
|
63
65
|
data = _load(root)
|
|
64
66
|
if data["ids"].get(agent_id) != name:
|
|
65
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"])
|
|
66
72
|
_save(root, data)
|
|
67
73
|
|
|
68
74
|
|
|
@@ -161,6 +167,20 @@ def as_context(msgs: List[Dict[str, Any]], most: int = 8) -> str:
|
|
|
161
167
|
|
|
162
168
|
# ── what the hooks do for each agent ─────────────────────────────────────────
|
|
163
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
|
+
|
|
164
184
|
def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[str, Any]], bp: dict) -> Dict[str, str]:
|
|
165
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
|
|
166
186
|
to its block and the blocks that block depends on, publish what the edit changed in the structure on the topics
|
|
@@ -182,7 +202,59 @@ def after_edit(root: Path, agent_id: str, part: Optional[str], notes: List[Dict[
|
|
|
182
202
|
if part and claimed and claimed != name:
|
|
183
203
|
warn = f"{part} is claimed by agent {claimed}; {name} just edited it"
|
|
184
204
|
publish(root, f"agent:{claimed}", f"{name} edited your block {part}", sender=name, kind="warning")
|
|
185
|
-
|
|
205
|
+
from . import config
|
|
206
|
+
wide = spread(root, name, agent_id, bp, config.lenient(root))
|
|
207
|
+
unread = _for_context(inbox(root, name))
|
|
208
|
+
context = "\n".join(t for t in (as_context(unread), wide) if t)
|
|
209
|
+
warn = "; ".join(t for t in (warn, wide) if t)
|
|
210
|
+
return {"context": context, "warn": warn, "name": name}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def areas_of(root: Path, bp: dict, cfg: Dict[str, Any]) -> Dict[str, str]:
|
|
214
|
+
"""Each block's area: the agent the saved plan gave it to, when there is a plan; else its container, or the
|
|
215
|
+
block itself when it is in none."""
|
|
216
|
+
agents = (_load(root)["plan"] or {}).get("agents") or []
|
|
217
|
+
if agents:
|
|
218
|
+
return {b: g["name"] for g in agents for b in g.get("blocks") or []}
|
|
219
|
+
from .architecture import groups_of
|
|
220
|
+
group = groups_of(bp, cfg, design.kinds(bp, cfg))
|
|
221
|
+
return {cid: (group[cid][2:] if group.get(cid, "").startswith("g:") else cid) for cid in bp["components"]}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def spread(root: Path, name: str, agent_id: str, bp: dict, cfg: Dict[str, Any]) -> str:
|
|
225
|
+
"""When one agent has edited blocks in two or more areas this turn: a message telling it to plan a team and hand
|
|
226
|
+
the rest on, once per agent per turn (also published to all); else ""."""
|
|
227
|
+
from .extract import Code
|
|
228
|
+
code = Code(cfg)
|
|
229
|
+
me = "" if agent_id in ("", None, MAIN) else agent_id
|
|
230
|
+
start, parts = 0.0, []
|
|
231
|
+
for e in activity.events(root, 400):
|
|
232
|
+
if e.get("event") == activity.PROMPT and not activity.is_system(e.get("text", "")):
|
|
233
|
+
start, parts = e["at"], []
|
|
234
|
+
elif e.get("event") == activity.EDIT and (e.get("agent") or "") == me and e.get("path"):
|
|
235
|
+
part = activity.part_of(e["path"], code)
|
|
236
|
+
if part and part not in parts:
|
|
237
|
+
parts.append(part)
|
|
238
|
+
area = areas_of(root, bp, cfg)
|
|
239
|
+
seen: List[str] = []
|
|
240
|
+
for p in parts:
|
|
241
|
+
a = area.get(p, p)
|
|
242
|
+
if a not in seen:
|
|
243
|
+
seen.append(a)
|
|
244
|
+
if len(seen) < 2:
|
|
245
|
+
return ""
|
|
246
|
+
data = _load(root)
|
|
247
|
+
turn = str(start)
|
|
248
|
+
told = data["warned"].get(turn) or []
|
|
249
|
+
if name in told:
|
|
250
|
+
return ""
|
|
251
|
+
data["warned"] = {turn: told + [name]} # only the latest turn is kept
|
|
252
|
+
_save(root, data)
|
|
253
|
+
text = (f"{name} has edited blocks in {len(seen)} areas this turn ({', '.join(seen)}). system-diff runs work "
|
|
254
|
+
"that spans areas as a team: call plan_agents with what is left, launch its agents in parallel, and "
|
|
255
|
+
"keep to one area yourself.")
|
|
256
|
+
publish(root, "all", text, sender=SYSTEM, kind="warning")
|
|
257
|
+
return text
|
|
186
258
|
|
|
187
259
|
|
|
188
260
|
def who_worked(events: Iterable[dict], code) -> Dict[str, Dict[str, Any]]:
|
|
@@ -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
|
|
@@ -195,6 +195,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
195
195
|
pn.add_argument("--width", type=int, default=80)
|
|
196
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:
|
|
@@ -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 []})
|
|
@@ -476,7 +476,19 @@ def view_agents(root: Path, width: int) -> Dict[str, Any]:
|
|
|
476
476
|
return page.json(view="agents", part="")
|
|
477
477
|
|
|
478
478
|
|
|
479
|
-
|
|
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]:
|
|
480
492
|
if view == "agents":
|
|
481
493
|
return view_agents(root, width)
|
|
482
494
|
if view == "map":
|
|
@@ -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"):
|
|
@@ -18,7 +18,7 @@ REFUND = ('from fastapi import APIRouter\nrouter = APIRouter()\n\n\n@router.post
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def page(capsys, repo, *args):
|
|
21
|
-
assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", *args]) == 0
|
|
21
|
+
assert main(["--repo", str(repo), "panel", "--width", "100", "--protocol", "2", "--zoom", "0", *args]) == 0
|
|
22
22
|
out = json.loads(capsys.readouterr().out)
|
|
23
23
|
return out, ["".join(run[0] for run in row) for row in out["rows"]]
|
|
24
24
|
|
|
@@ -268,3 +268,84 @@ def test_the_agents_tab_shows_each_agent_and_warns_when_two_edit_one_block(repo,
|
|
|
268
268
|
assert any(t.startswith("THE TEAM plan: make the API") for t in text)
|
|
269
269
|
assert any("api and store all edit API" in t for t in text)
|
|
270
270
|
assert "● API @api @st" in "\n".join(text) # who works where, on the diagram
|
|
271
|
+
|
|
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
|
+
|
|
298
|
+
def test_one_agent_editing_two_areas_in_a_turn_is_told_once_to_plan_a_team(repo, files):
|
|
299
|
+
from system_diff import agent
|
|
300
|
+
|
|
301
|
+
def prompt():
|
|
302
|
+
agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "UserPromptSubmit", "prompt": "go"}),
|
|
303
|
+
emit=lambda _s: None)
|
|
304
|
+
|
|
305
|
+
def edit(path, text, **who):
|
|
306
|
+
files(repo, {path: text})
|
|
307
|
+
said: list = []
|
|
308
|
+
agent.run_hook(json.dumps({"cwd": str(repo), "hook_event_name": "PostToolUse", "tool_name": "Edit", **who,
|
|
309
|
+
"tool_input": {"file_path": str(repo / path)}}), emit=said.append)
|
|
310
|
+
outs = [json.loads(s) for s in said]
|
|
311
|
+
warns = " ".join(o.get("systemMessage", "") for o in outs)
|
|
312
|
+
context = " ".join(o.get("hookSpecificOutput", {}).get("additionalContext", "") for o in outs)
|
|
313
|
+
return warns, context
|
|
314
|
+
|
|
315
|
+
routes, rules = "app/api/routes.py", "app/core/rules.py"
|
|
316
|
+
prompt()
|
|
317
|
+
warns, context = edit(routes, (repo / routes).read_text() + "\n# one\n")
|
|
318
|
+
assert "areas this turn" not in warns + context # one area so far
|
|
319
|
+
warns, context = edit(rules, (repo / rules).read_text() + "\n# one\n")
|
|
320
|
+
assert "main has edited blocks in 2 areas this turn" in warns and "plan_agents" in warns
|
|
321
|
+
assert "main has edited blocks in 2 areas this turn" in context and "plan_agents" in context
|
|
322
|
+
warns, context = edit("app/db/repo.py", (repo / "app/db/repo.py").read_text() + "\n# one\n")
|
|
323
|
+
assert "areas this turn" not in warns + context # once a turn
|
|
324
|
+
prompt()
|
|
325
|
+
edit(routes, (repo / routes).read_text() + "# two\n")
|
|
326
|
+
warns, context = edit(rules, (repo / rules).read_text() + "# two\n")
|
|
327
|
+
assert "areas this turn" in warns and "areas this turn" in context # a new turn may warn again
|
|
328
|
+
prompt()
|
|
329
|
+
for text in ("# a\n", "# b\n"): # a subagent staying in its one area
|
|
330
|
+
warns, context = edit(routes, (repo / routes).read_text() + text, agent_id="a1")
|
|
331
|
+
assert "areas this turn" not in warns and "a1 has edited" not in context # main's warning may reach it by bus
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_a_narrow_pane_gets_compact_diagrams_unless_asked_otherwise(repo, capsys):
|
|
335
|
+
narrow = json.loads(_run(capsys, repo, "--width", "90"))
|
|
336
|
+
wide = json.loads(_run(capsys, repo, "--width", "140"))
|
|
337
|
+
assert narrow["zoom"] == 1 and wide["zoom"] == 0
|
|
338
|
+
assert json.loads(_run(capsys, repo, "--width", "90", "--zoom", "2"))["zoom"] == 2
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _run(capsys, repo, *args):
|
|
342
|
+
assert main(["--repo", str(repo), "panel", "--protocol", "2", *args]) == 0
|
|
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"] == [ #
|
|
336
|
-
{"
|
|
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
|
|
351
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|