expluro 0.3.0__tar.gz → 0.4.0__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 (32) hide show
  1. {expluro-0.3.0 → expluro-0.4.0}/PKG-INFO +1 -1
  2. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/PKG-INFO +1 -1
  3. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/SOURCES.txt +2 -0
  4. {expluro-0.3.0 → expluro-0.4.0}/pyproject.toml +1 -1
  5. expluro-0.4.0/topo/attribution.py +329 -0
  6. {expluro-0.3.0 → expluro-0.4.0}/topo/backend.py +104 -2
  7. {expluro-0.3.0 → expluro-0.4.0}/topo/capture.py +54 -2
  8. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/_branch_detection.py +47 -0
  9. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/acp.py +181 -55
  10. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/claude.py +68 -29
  11. expluro-0.4.0/topo/drivers/codex.py +1622 -0
  12. expluro-0.4.0/topo/drivers/kiro_session_files.py +275 -0
  13. {expluro-0.3.0 → expluro-0.4.0}/topo/main.py +10 -3
  14. {expluro-0.3.0 → expluro-0.4.0}/topo/renderer.py +105 -12
  15. {expluro-0.3.0 → expluro-0.4.0}/topo/session.py +701 -59
  16. {expluro-0.3.0 → expluro-0.4.0}/topo/session_files.py +39 -32
  17. {expluro-0.3.0 → expluro-0.4.0}/topo/snapshot.py +446 -6
  18. expluro-0.3.0/topo/drivers/kiro_session_files.py +0 -167
  19. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/dependency_links.txt +0 -0
  20. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/entry_points.txt +0 -0
  21. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/requires.txt +0 -0
  22. {expluro-0.3.0 → expluro-0.4.0}/expluro.egg-info/top_level.txt +0 -0
  23. {expluro-0.3.0 → expluro-0.4.0}/setup.cfg +0 -0
  24. {expluro-0.3.0 → expluro-0.4.0}/topo/__init__.py +0 -0
  25. {expluro-0.3.0 → expluro-0.4.0}/topo/auth.py +0 -0
  26. {expluro-0.3.0 → expluro-0.4.0}/topo/constants.py +0 -0
  27. {expluro-0.3.0 → expluro-0.4.0}/topo/display.py +0 -0
  28. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/__init__.py +0 -0
  29. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/_permissions.py +0 -0
  30. {expluro-0.3.0 → expluro-0.4.0}/topo/drivers/base.py +0 -0
  31. {expluro-0.3.0 → expluro-0.4.0}/topo/picker.py +0 -0
  32. {expluro-0.3.0 → expluro-0.4.0}/topo/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -6,6 +6,7 @@ expluro.egg-info/entry_points.txt
6
6
  expluro.egg-info/requires.txt
7
7
  expluro.egg-info/top_level.txt
8
8
  topo/__init__.py
9
+ topo/attribution.py
9
10
  topo/auth.py
10
11
  topo/backend.py
11
12
  topo/capture.py
@@ -24,4 +25,5 @@ topo/drivers/_permissions.py
24
25
  topo/drivers/acp.py
25
26
  topo/drivers/base.py
26
27
  topo/drivers/claude.py
28
+ topo/drivers/codex.py
27
29
  topo/drivers/kiro_session_files.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Expluro CLI — graph layer on top of your AI agent"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -0,0 +1,329 @@
1
+ """Hunk-level reason attribution for the Files-changed tab.
2
+
3
+ Maps git commit hashes (from `git blame` in the shadow repo) to expluro
4
+ event ids, so each hunk in a node's net diff can be traced back to the
5
+ tool call that produced it. Output feeds the reason-linked diff UI:
6
+ clicking a hunk's attribution chip opens a side rail with the responsible
7
+ prompt + tool call + assistant message.
8
+
9
+ Kept separate from snapshot.py (which stays git-mechanical) — this
10
+ module knows about expluro's event semantics.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any, Iterable
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # Flat tool_input keys that carry a file path. Different agents use
21
+ # different conventions:
22
+ # - Claude Code: `file_path` (Write/Edit/MultiEdit), `notebook_path` (NotebookEdit)
23
+ # - ACP (Kiro): `path`
24
+ # - Codex: path is NOT at the top level for fileChange items;
25
+ # it lives at `changes[*].path`. _extract_tool_paths
26
+ # below handles that nested shape. `savedPath` is
27
+ # the flat key on Codex `imageGeneration` items.
28
+ _PATH_KEYS = ('file_path', 'path', 'notebook_path', 'savedPath')
29
+
30
+
31
+ # Best-effort recognition of "this event is a file-mutating tool call
32
+ # by named path." Three drivers' naming conventions need to match:
33
+ # - Claude Code: 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'
34
+ # - ACP (Kiro): kind='edit', titles like 'Edit File: foo.py', 'Create File: bar.py'
35
+ # - Codex: item type='fileChange' (the main one), 'imageGeneration'
36
+ # (writes a generated image to disk).
37
+ #
38
+ # Case-insensitive substring match keeps us robust to capitalization
39
+ # AND to verbose titles ("Edit File") AND to single-word codex item
40
+ # types ("fileChange" → matches via 'filechange'). The explicit
41
+ # read-tool exclusion below prevents `Read`/`Grep` from spuriously
42
+ # naming files.
43
+ #
44
+ # Bash / commandExecution are intentionally NOT mutation tokens —
45
+ # they execute shell commands that may or may not mutate files, and
46
+ # topo has no structured path to attribute. Future: a per-turn
47
+ # snapshot boundary would let us attribute Bash effects too.
48
+ #
49
+ # mcpToolCall / dynamicToolCall are also excluded — arbitrary tools
50
+ # whose file effects are unpredictable; conservatively classify any
51
+ # files they touch as Unattributed rather than misclaiming attribution.
52
+ _MUTATION_TOOL_TOKENS = (
53
+ # Claude Code names
54
+ 'write', 'edit', 'multiedit', 'notebookedit',
55
+ # ACP / Kiro verbose titles
56
+ 'create file', 'write file', 'edit file',
57
+ # Codex item types (lowercase form because we case-fold)
58
+ 'filechange', 'imagegeneration',
59
+ # Patch operations across agents
60
+ 'patch', 'apply patch',
61
+ )
62
+ _READ_TOOL_TOKENS = ('read', 'grep', 'glob', 'search', 'ls', 'list')
63
+
64
+
65
+ def _extract_tool_paths(tool_input: dict) -> list[str]:
66
+ """Pull every file path the agent named out of a tool's input dict.
67
+
68
+ Three shapes to handle:
69
+ - Claude / ACP: flat string at `tool_input.file_path` /
70
+ `tool_input.path` / `tool_input.notebook_path`.
71
+ - Codex `imageGeneration`: flat string at `tool_input.savedPath`.
72
+ - Codex `fileChange`: nested under `tool_input.changes[*].path`,
73
+ where each entry also has `diff` and `kind`. A single
74
+ fileChange item can touch multiple files in one call.
75
+
76
+ Returns the deduplicated path strings, preserving input order so
77
+ the most-specific path appears first when callers iterate.
78
+ """
79
+ paths: list[str] = []
80
+ seen: set[str] = set()
81
+
82
+ def _add(value: object) -> None:
83
+ if isinstance(value, str) and value and value not in seen:
84
+ seen.add(value)
85
+ paths.append(value)
86
+
87
+ if not isinstance(tool_input, dict):
88
+ return paths
89
+ # Flat string keys first — covers Claude, ACP, and Codex's
90
+ # imageGeneration savedPath.
91
+ for key in _PATH_KEYS:
92
+ _add(tool_input.get(key))
93
+ # Codex fileChange items nest paths under `changes[*].path`.
94
+ changes = tool_input.get('changes')
95
+ if isinstance(changes, list):
96
+ for entry in changes:
97
+ if isinstance(entry, dict):
98
+ _add(entry.get('path'))
99
+ return paths
100
+
101
+
102
+ def _is_file_mutation_tool(tool_name: str | None) -> bool:
103
+ """Does this tool_name look like an agent file-mutation operation?
104
+
105
+ Case-insensitive. Mutation tokens take precedence over read tokens
106
+ so an unlikely "edit_read_metadata"-style name still classifies as
107
+ mutation if any mutation token matches. Returns False for empty
108
+ names and for tools whose name is purely a read/list operation.
109
+ """
110
+ if not tool_name:
111
+ return False
112
+ n = tool_name.strip().lower()
113
+ for token in _MUTATION_TOOL_TOKENS:
114
+ if token in n:
115
+ return True
116
+ # No mutation match — check if it's a clear read-only tool.
117
+ for token in _READ_TOOL_TOKENS:
118
+ if n == token or n.startswith(token + ' ') or n.startswith(token + '_'):
119
+ return False
120
+ return False
121
+
122
+
123
+ def build_hunk_attributions(
124
+ file_changes: list[dict],
125
+ events: Iterable[dict],
126
+ ) -> None:
127
+ """Splice `hunk_attributions` and `attribution_class` into each
128
+ file_change row in place.
129
+
130
+ Args:
131
+ file_changes: rows from `SnapshotManager.net_diff_summary`. Each
132
+ row may carry a `hunk_blame` field (added by snapshot.py)
133
+ holding per-hunk commit-hash counts. After this call, each
134
+ row also carries `hunk_attributions` (and `hunk_blame` is
135
+ removed — it's an intermediate the backend doesn't need)
136
+ and a per-file `attribution_class` of either
137
+ `'attributed'` (some tool event in this node explicitly
138
+ named this file's path) or `'unattributed'` (file changed
139
+ but no captured tool action referenced it — e.g., a
140
+ background process, an external editor, /tmp cleanup, etc.).
141
+ events: chronologically-ordered list of the node's events. Each
142
+ event is expected to be a dict with at minimum `id` and
143
+ `snapshot_hash`; events touching files have
144
+ `payload.diff_summary.files_changed`.
145
+
146
+ Side effect: each file_change row gains `hunk_attributions` and
147
+ `attribution_class`. The class is used by the UI to render
148
+ unattributed files in a separate section without a misleading
149
+ reason block.
150
+ """
151
+ events_list = list(events)
152
+ # Map shadow-git commit hash → first event_id with that snapshot_hash.
153
+ # FIRST because subsequent events sharing a hash mean the tool ran
154
+ # but committed nothing — they didn't contribute changes. Earliest
155
+ # event is the one that introduced the snapshot.
156
+ commit_to_event: dict[str, str] = {}
157
+ # Per-path: list of event_ids in chronological order that touched
158
+ # the path. Used for the deletion fallback (last tool call to touch
159
+ # a path) and for diagnostics.
160
+ path_to_events: dict[str, list[str]] = {}
161
+ # Set of file paths that any mutation-capable tool event explicitly
162
+ # named in its tool_input — the agent's stated target, NOT the
163
+ # post-call diff. A file is "Attributed" iff its path matches one
164
+ # of these. Stored as raw (typically absolute) paths; matched
165
+ # against the relative file_change paths via suffix comparison
166
+ # because git diff yields paths relative to the work-tree root
167
+ # while tool inputs use absolute paths.
168
+ tool_named_paths: set[str] = set()
169
+ for ev in events_list:
170
+ ev_id = str(ev.get('id') or '')
171
+ if not ev_id:
172
+ continue
173
+ snap = (ev.get('snapshot_hash') or '').strip()
174
+ if snap and snap not in commit_to_event:
175
+ commit_to_event[snap] = ev_id
176
+ payload = ev.get('payload') or {}
177
+ diff_summary = payload.get('diff_summary') or {}
178
+ files = diff_summary.get('files_changed') or []
179
+ for fc in files:
180
+ if isinstance(fc, dict):
181
+ path = fc.get('path')
182
+ if path:
183
+ path_to_events.setdefault(path, []).append(ev_id)
184
+ # Collect paths the agent explicitly named, regardless of
185
+ # event_type. pre_tool_use carries the intended input;
186
+ # post_tool_use repeats it. Either is fine for set membership.
187
+ # _is_file_mutation_tool() recognizes Claude Code's tool names,
188
+ # ACP kind='edit', and common verbose forms ("Edit File", etc.)
189
+ # so the classifier works across drivers.
190
+ if _is_file_mutation_tool(ev.get('tool_name')):
191
+ tool_input = payload.get('tool_input') or {}
192
+ for named in _extract_tool_paths(tool_input):
193
+ tool_named_paths.add(named)
194
+
195
+ for row in file_changes:
196
+ hunk_blame = row.pop('hunk_blame', None) or []
197
+ path = row.get('path', '')
198
+ attributions: list[dict] = []
199
+ for h_idx, hb in enumerate(hunk_blame):
200
+ attributions.append(_attribute_hunk(
201
+ hunk_index=h_idx,
202
+ hunk_blame=hb,
203
+ path=path,
204
+ commit_to_event=commit_to_event,
205
+ path_to_events=path_to_events,
206
+ ))
207
+ row['hunk_attributions'] = attributions
208
+ row['attribution_class'] = (
209
+ 'attributed' if _path_matches_tool_named(path, tool_named_paths)
210
+ else 'unattributed'
211
+ )
212
+
213
+
214
+ def _path_matches_tool_named(rel_path: str, tool_named_paths: set[str]) -> bool:
215
+ """Does `rel_path` (from git diff, relative to project root) refer
216
+ to the same file as any path in `tool_named_paths` (from tool_input,
217
+ typically absolute)?
218
+
219
+ Suffix match with a path-separator boundary so `dir/a.py` doesn't
220
+ spuriously match `otherdir/a.py`. Exact match also accepted for the
221
+ rare case where both sides happen to be relative.
222
+ """
223
+ if not rel_path or not tool_named_paths:
224
+ return False
225
+ if rel_path in tool_named_paths:
226
+ return True
227
+ suffix = '/' + rel_path
228
+ for named in tool_named_paths:
229
+ if named.endswith(suffix):
230
+ return True
231
+ return False
232
+
233
+
234
+ def _attribute_hunk(
235
+ hunk_index: int,
236
+ hunk_blame: dict,
237
+ path: str,
238
+ commit_to_event: dict[str, str],
239
+ path_to_events: dict[str, list[str]],
240
+ ) -> dict:
241
+ """Build the hunk_attributions entry for a single hunk."""
242
+ contributors_map: dict[str, int] = {}
243
+ unresolved = 0
244
+ for sha, count in (hunk_blame.get('commit_counts') or {}).items():
245
+ ev_id = commit_to_event.get(sha)
246
+ if ev_id:
247
+ contributors_map[ev_id] = contributors_map.get(ev_id, 0) + count
248
+ else:
249
+ # Lines blamed to a commit that doesn't map to any event in
250
+ # this node — typically lines that came from the parent
251
+ # branch's history (i.e. lines this node inherited and
252
+ # didn't touch). Skipped from contributors; counted for
253
+ # confidence calculation.
254
+ unresolved += count
255
+
256
+ contributors = [
257
+ {'event_id': ev_id, 'line_count': count}
258
+ for ev_id, count in sorted(
259
+ contributors_map.items(), key=lambda kv: kv[1], reverse=True,
260
+ )
261
+ ]
262
+
263
+ added_count = hunk_blame.get('added_count', 0)
264
+ deleted_count = hunk_blame.get('deleted_count', 0)
265
+
266
+ # Method + confidence decision tree:
267
+ # - If we have any contributors AND most blamed lines resolved →
268
+ # blame, high (single contributor) or medium (multiple).
269
+ # - If we have contributors but lots of unresolved → blame, medium.
270
+ # - No added lines at all (pure deletion) → deletion_fallback,
271
+ # primary = last event to touch the path.
272
+ # - Added lines but no contributors mapped → file_fallback,
273
+ # primary = last event to touch the path. Low confidence; the
274
+ # blame went somewhere we couldn't resolve.
275
+ # - No fallback candidate either → unknown, null primary.
276
+ if contributors:
277
+ if unresolved == 0 and len(contributors) == 1:
278
+ method = 'blame'
279
+ confidence = 'high'
280
+ elif unresolved > added_count // 2 and added_count > 0:
281
+ method = 'blame'
282
+ confidence = 'medium'
283
+ else:
284
+ method = 'blame'
285
+ confidence = 'medium' if len(contributors) > 1 else 'high'
286
+ primary = contributors[0]['event_id']
287
+ elif added_count == 0 and deleted_count > 0:
288
+ last_ev = _last_event_for_path(path, path_to_events)
289
+ method = 'deletion_fallback'
290
+ confidence = 'low'
291
+ primary = last_ev
292
+ if last_ev:
293
+ contributors = [{'event_id': last_ev, 'line_count': deleted_count}]
294
+ else:
295
+ last_ev = _last_event_for_path(path, path_to_events)
296
+ if last_ev:
297
+ method = 'file_fallback'
298
+ confidence = 'low'
299
+ primary = last_ev
300
+ contributors = [{'event_id': last_ev, 'line_count': added_count + deleted_count}]
301
+ else:
302
+ method = 'unknown'
303
+ confidence = 'low'
304
+ primary = None
305
+
306
+ return {
307
+ 'hunk_index': hunk_index,
308
+ 'new_start': hunk_blame.get('new_start', 0),
309
+ 'new_lines': hunk_blame.get('new_lines', 0),
310
+ 'old_start': hunk_blame.get('old_start', 0),
311
+ 'old_lines': hunk_blame.get('old_lines', 0),
312
+ 'primary_event_id': primary,
313
+ 'method': method,
314
+ 'confidence': confidence,
315
+ 'contributors': contributors,
316
+ }
317
+
318
+
319
+ def _last_event_for_path(path: str, path_to_events: dict[str, list[str]]) -> str | None:
320
+ """Return the most recent event_id that touched `path`, or None.
321
+
322
+ Used by the deletion and file fallbacks when blame can't attribute
323
+ the hunk to a specific commit (deletion-only hunks, blame pointing
324
+ at an unmappable commit).
325
+ """
326
+ ids = path_to_events.get(path)
327
+ if not ids:
328
+ return None
329
+ return ids[-1]
@@ -64,6 +64,32 @@ class BackendClient:
64
64
  async def patch_graph(self, graph_id: str, data: dict[str, Any]) -> dict[str, Any]:
65
65
  return await self._patch(f"/api/graphs/{graph_id}/", data)
66
66
 
67
+ async def delete_graph(self, graph_id: str) -> None:
68
+ """Delete a graph and all of its nodes/edges/events/marks via Django cascade.
69
+
70
+ Used by scripts/cleanup_playground.py, not by the running CLI.
71
+ DELETE responds 204 No Content, so we can't reuse the JSON-parsing
72
+ _request helper — call _client directly with the same auth+refresh
73
+ pattern in miniature.
74
+ """
75
+ token = get_access_token()
76
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
77
+ url = f"{self.base_url}/api/graphs/{graph_id}/"
78
+ try:
79
+ resp = await self._client.request("DELETE", url, headers=headers)
80
+ except httpx.ConnectError as exc:
81
+ raise BackendError(0, f"Cannot reach backend at {self.base_url}") from exc
82
+ except httpx.TimeoutException as exc:
83
+ raise BackendError(0, "Request timed out.") from exc
84
+ if resp.status_code == 401:
85
+ try:
86
+ new_token = await self._refresh_access_token()
87
+ headers["Authorization"] = f"Bearer {new_token}"
88
+ resp = await self._client.request("DELETE", url, headers=headers)
89
+ except BackendError:
90
+ raise BackendError(401, f"Session expired. Please run `{APP_NAME} login` again.")
91
+ _raise_for_status(resp)
92
+
67
93
  async def list_graphs_for_path(self, project_path: str) -> list[dict[str, Any]]:
68
94
  result = await self._get(f"/api/graphs/?project_path={project_path}")
69
95
  return result if isinstance(result, list) else result.get("results", [])
@@ -77,12 +103,21 @@ class BackendClient:
77
103
  graph_id: str,
78
104
  parent_id: str | None = None,
79
105
  tool: str | None = None,
106
+ base_git_hash: str | None = None,
80
107
  ) -> dict[str, Any]:
108
+ """Create a node. For root nodes, pass `base_git_hash` (the shadow
109
+ repo's HEAD after init) so the Files-changed tab can compute net
110
+ diff against the project's starting state instead of the empty
111
+ tree. Branch children get their base_git_hash from the backend's
112
+ branch endpoint and don't pass it here.
113
+ """
81
114
  payload: dict[str, Any] = {"graph": graph_id}
82
115
  if parent_id:
83
116
  payload["parent"] = parent_id
84
117
  if tool:
85
118
  payload["tool"] = tool
119
+ if base_git_hash:
120
+ payload["base_git_hash"] = base_git_hash
86
121
  return await self._post("/api/nodes/", payload)
87
122
 
88
123
  async def patch_node(self, node_id: str, data: dict[str, Any]) -> dict[str, Any]:
@@ -91,8 +126,34 @@ class BackendClient:
91
126
  async def checkout_node(self, node_id: str) -> dict[str, Any]:
92
127
  return await self._post(f"/api/nodes/{node_id}/checkout/", {})
93
128
 
94
- async def branch_node(self, node_id: str, branch_from_index: int) -> dict[str, Any]:
95
- return await self._post(f"/api/nodes/{node_id}/branch/", {"branch_from_index": branch_from_index})
129
+ async def get_node(self, node_id: str) -> dict[str, Any]:
130
+ """Fetch the full NodeSerializer payload — kind, generation_count,
131
+ materialized_context, inherited_context_length, branch_reason,
132
+ outcome, etc. Used by the CLI's LLM pipeline because checkout_node
133
+ only returns the checkout response (a slim shape)."""
134
+ return await self._get(f"/api/nodes/{node_id}/")
135
+
136
+ async def branch_node(
137
+ self,
138
+ node_id: str,
139
+ branch_from_index: int,
140
+ edge_kind: str = "manual_branch",
141
+ branch_reason: str = "",
142
+ ) -> dict[str, Any]:
143
+ """Create a child node from `node_id` at the chosen branch point.
144
+
145
+ edge_kind and branch_reason are set atomically during creation so
146
+ the panel banner renders the right label even if a follow-up PATCH
147
+ would have failed. edge_kind is immutable after creation; this is
148
+ the only place a caller gets to set it.
149
+ """
150
+ payload: dict[str, Any] = {
151
+ "branch_from_index": branch_from_index,
152
+ "edge_kind": edge_kind,
153
+ }
154
+ if branch_reason:
155
+ payload["branch_reason"] = branch_reason[:500]
156
+ return await self._post(f"/api/nodes/{node_id}/branch/", payload)
96
157
 
97
158
  async def list_nodes(self, graph_id: str, limit: int = 10, offset: int = 0) -> dict[str, Any]:
98
159
  result = await self._get(f"/api/nodes/?graph={graph_id}&limit={limit}&offset={offset}")
@@ -109,6 +170,47 @@ class BackendClient:
109
170
  async def move_event(self, event_id: str, target_node_id: str) -> dict[str, Any]:
110
171
  return await self._post(f"/api/nodes/events/{event_id}/move/", {"target_node_id": target_node_id})
111
172
 
173
+ async def list_events(self, node_id: str) -> Any:
174
+ """Fetch the durable event list for a node. Used by the LLM
175
+ review-artifact pipeline to read the transcript that produced
176
+ the current node state."""
177
+ return await self._get(f"/api/nodes/{node_id}/events/")
178
+
179
+ async def generate_now(self, node_id: str) -> tuple[int, dict[str, Any] | None]:
180
+ """Ask the backend to generate the review artifact for `node_id`.
181
+
182
+ Returns (status_code, body). Body is None for 202/204. Callers
183
+ in the CLI fire this as a fire-and-forget background task on
184
+ lifecycle transitions (/branch, /checkpoint, /exit). Backend
185
+ owns the lock, the LLM call, validation, persistence, and the
186
+ node_updated broadcast — the CLI is just a requester.
187
+
188
+ Status semantics (matches endpoint):
189
+ 202 — accepted; generation running in a daemon thread on the
190
+ backend. Listen for `node_updated` over Channels to
191
+ know when the artifact lands. This is the happy path.
192
+ 204 — skipped (empty node, no new events since last gen, or
193
+ kind != work).
194
+ 409 — locked elsewhere, or cap reached. Body has `detail`.
195
+ """
196
+ token = get_access_token()
197
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
198
+ url = f"{self.base_url}/api/nodes/{node_id}/generate_now/"
199
+ # Backend returns 202 sub-second (it spawns the LLM call in a
200
+ # daemon thread), so the class-default 15s timeout is plenty.
201
+ resp = await self._client.post(url, headers=headers, json={})
202
+ if resp.status_code == 401:
203
+ new_token = await self._refresh_access_token()
204
+ headers["Authorization"] = f"Bearer {new_token}"
205
+ resp = await self._client.post(url, headers=headers, json={})
206
+ body: dict[str, Any] | None = None
207
+ if resp.status_code not in (202, 204):
208
+ try:
209
+ body = resp.json()
210
+ except Exception:
211
+ body = None
212
+ return resp.status_code, body
213
+
112
214
  # ------------------------------------------------------------------
113
215
  # Internal helpers
114
216
  # ------------------------------------------------------------------
@@ -20,8 +20,9 @@ import logging
20
20
  import uuid
21
21
  from datetime import datetime, timezone
22
22
  from pathlib import Path
23
- from typing import TYPE_CHECKING, Any
23
+ from typing import TYPE_CHECKING, Any, Callable, Optional
24
24
 
25
+ from .backend import BackendError
25
26
  from .constants import APP_DIR
26
27
 
27
28
  if TYPE_CHECKING:
@@ -58,10 +59,22 @@ class EventCapture:
58
59
  graph_id: str,
59
60
  node_id: str,
60
61
  snapshot_manager: "SnapshotManager | None" = None,
62
+ on_node_locked: Optional[Callable[[str], None]] = None,
61
63
  ) -> None:
62
64
  self._backend = backend
63
65
  self._graph_id = graph_id
64
66
  self._node_id = node_id
67
+ # Invoked when the backend rejects an event with 409 because
68
+ # the target node is branched. Receives the node_id that was
69
+ # rejected. The session loop uses this to mark the node as
70
+ # branched in CLI state so the next user input triggers the
71
+ # auto-checkpoint guard. Set on construction so the loop and
72
+ # capture don't need a shared mutable state object.
73
+ self._on_node_locked = on_node_locked
74
+ # Guard so the callback fires at most once per node_id (an
75
+ # in-flight stream may attempt several event posts after the
76
+ # node flips to branched; we only need to recover once).
77
+ self._locked_node_notified: set[str] = set()
65
78
  # Optional — when present, every side-effecting tool call gets a
66
79
  # filesystem snapshot committed to a shadow git repo and the resulting
67
80
  # commit hash + diff summary attached to the post_tool_use event.
@@ -92,6 +105,15 @@ class EventCapture:
92
105
  def node_id(self) -> str:
93
106
  return self._node_id
94
107
 
108
+ def set_on_node_locked(self, callback: Optional[Callable[[str], None]]) -> None:
109
+ """Set/replace the branched-node-rejection callback.
110
+
111
+ Lets the session loop wire the callback after EventCapture is
112
+ constructed (the session's state dict lives lower in the
113
+ function and can't be closed over at construction time).
114
+ """
115
+ self._on_node_locked = callback
116
+
95
117
  def register(self, driver: "BaseDriver") -> None:
96
118
  """Wire pre/post tool hooks onto the driver."""
97
119
  driver.register_pre_tool_hook(self._on_pre_tool)
@@ -384,9 +406,39 @@ class EventCapture:
384
406
  self._log_file.flush()
385
407
 
386
408
  # 2. Send to backend — best effort, never crash the session on failure
409
+ target_node_id = self._node_id
387
410
  try:
388
- resp = await self._backend.append_delta_event(self._node_id, event)
411
+ resp = await self._backend.append_delta_event(target_node_id, event)
389
412
  return resp.get("id") if isinstance(resp, dict) else None
413
+ except BackendError as exc:
414
+ # 409 from append_event means the backend now considers this
415
+ # node branched/frozen. The session-loop guard at
416
+ # session.py:876 is supposed to prevent us ever reaching
417
+ # here — if we do, it's a CLI bug, race, or a third-party
418
+ # actor branched the node while a stream was mid-flight.
419
+ #
420
+ # Surface it loudly and trigger session-level recovery via
421
+ # the on_node_locked callback so the next user input runs
422
+ # the auto-checkpoint guard. The 409'd event itself is
423
+ # lost (still in the local JSONL log), which is acceptable
424
+ # for a defense-in-depth path.
425
+ if exc.status_code == 409:
426
+ if target_node_id not in self._locked_node_notified:
427
+ self._locked_node_notified.add(target_node_id)
428
+ logger.error(
429
+ "Backend refused event on branched node %s — durable event "
430
+ "log will be out of sync until the next user input triggers "
431
+ "the auto-checkpoint guard. Failed event: %s",
432
+ target_node_id, event.get("event_type"),
433
+ )
434
+ if self._on_node_locked is not None:
435
+ try:
436
+ self._on_node_locked(target_node_id)
437
+ except Exception:
438
+ logger.exception("on_node_locked callback raised")
439
+ return None
440
+ logger.warning("Failed to send event to backend: %s", exc)
441
+ return None
390
442
  except Exception as exc:
391
443
  logger.warning("Failed to send event to backend: %s", exc)
392
444
  return None
@@ -97,3 +97,50 @@ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
97
97
  except ValueError:
98
98
  return None, buffered_head[m.end():]
99
99
  return confidence, buffered_head[m.end():]
100
+
101
+
102
+ def strip_all_branch_tags(text: str) -> tuple[float | None, str]:
103
+ """Strip every leading BRANCH tag from `text` and return (confidence, rest).
104
+
105
+ Defensive against three model misbehaviours, in one place so every
106
+ driver inherits the same protection:
107
+
108
+ - **Multiple stacked tags** — e.g. `<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`.
109
+ We loop `parse_branch_flag` and strip them all; the *first*
110
+ confidence wins (subsequent tags are usually a model emitting
111
+ the marker more than once, not actually multiple decisions).
112
+
113
+ - **Partial tag at end** — e.g. `<!--BRANCH:0.5` with no closing
114
+ `-->`. Happens when a tool call interrupts a tag stream. We drop
115
+ the partial-tag bytes so they don't leak into rendered text.
116
+
117
+ - **Malformed digits** — handled inside `parse_branch_flag`
118
+ (the tag is consumed silently, no confidence reported).
119
+
120
+ Returns the first valid confidence found (or None if no parseable
121
+ tag was at the head), and the remaining text after all leading tags
122
+ are stripped (or empty string if the remaining content was a
123
+ partial-tag prefix).
124
+ """
125
+ first_confidence: float | None = None
126
+ buf = text
127
+ while True:
128
+ confidence, rest = parse_branch_flag(buf)
129
+ if confidence is None and rest == buf:
130
+ # No tag matched at all — buf is plain content.
131
+ break
132
+ # A tag was consumed (confidence may still be None if the tag's
133
+ # digits were malformed). Capture the first valid confidence.
134
+ if first_confidence is None and confidence is not None:
135
+ first_confidence = confidence
136
+ buf = rest
137
+ if not buf:
138
+ break
139
+
140
+ # If the buffer is now a partial tag prefix (no closing `-->`),
141
+ # the model started a tag we never saw the end of. Drop it
142
+ # rather than leak `<!--BRANCH:0.5` to the renderer.
143
+ if buf.startswith("<!--BRANCH:") and "-->" not in buf:
144
+ buf = ""
145
+
146
+ return first_confidence, buf