agent-postmortem 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,4611 @@
1
+ """Discover Claude Code run directories/transcripts and parse them into a Run.
2
+
3
+ Tolerant by design: a malformed line becomes an entry in Run.diagnostics, not
4
+ an exception that aborts the scan. Transcripts are read line by line and
5
+ never held in memory as a whole file or a list of raw lines; only the parsed
6
+ Event fields (each text field truncated to _EXCERPT_LIMIT chars) survive. A
7
+ field's full decoded text is still scanned for exit codes and known runtime
8
+ markers before it is truncated (see _scan_markers): the excerpt bounds what
9
+ survives for display and evidence, not what a detector can see happened.
10
+
11
+ Two run kinds (docs/DESIGN.md section 1): 'workflow' (a wf_* directory under
12
+ .../subagents/workflows/) and 'session' (a main-session transcript plus its
13
+ Agent-tool subagent transcripts under .../subagents/agent-*.jsonl). Both
14
+ share the transcript-parsing helpers below (_read_transcript and friends);
15
+ load_run dispatches on layout and load_session owns the session-specific
16
+ assembly (docs/DESIGN.md section 4.2).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import contextvars
23
+ import copy
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ from . import commands, disclosure, ledger, markers, returns
34
+ from .claims import self_reported_problems
35
+ from .detectors.base import attribute_interrupt, meaningful_event_before
36
+ from .markers import (
37
+ API_ERROR,
38
+ BASH_BACKGROUNDED,
39
+ BASH_TIMEOUT,
40
+ CLASSIFIER_UNAVAILABLE,
41
+ GIT_NOT_REPO,
42
+ KILL_SENTINEL_PREFIX,
43
+ MACHINE_SLEEP,
44
+ SANDBOX_WRITE_DENIED,
45
+ SHELL_GUARD_BLOCK_REGEX,
46
+ STRUCTURED_OUTPUT_MISMATCH,
47
+ TOOL_INPUT_INVALID,
48
+ USAGE_LIMIT_SESSION,
49
+ USAGE_LIMIT_WEEKLY,
50
+ )
51
+ from .models import (
52
+ FACTS_UNKNOWN,
53
+ LAUNCH_DIAGNOSTIC_PREFIXES,
54
+ Attempt,
55
+ AttemptFacts,
56
+ Event,
57
+ Generation,
58
+ Interrupt,
59
+ KeyedDiagnostic,
60
+ Lifecycle,
61
+ LogicalAgent,
62
+ MarkerHit,
63
+ QualityNote,
64
+ Run,
65
+ ToolCall,
66
+ discovery_part,
67
+ )
68
+
69
+ _EXCERPT_LIMIT = 400
70
+ _EXIT_CODE_RE = re.compile(r"[Ee]xit code[: ]+(-?\d+)")
71
+
72
+ # ToolCall bounds (docs/DESIGN.md 4.1's v0.2 reader changes): a path or
73
+ # command past these lengths is not truncated (which would silently make it
74
+ # look like a different, shorter one); a path is dropped to None, a command
75
+ # is dropped to None with its sha256 kept instead.
76
+ _TOOL_CALL_PATH_LIMIT = 1024
77
+ _TOOL_CALL_COMMAND_LIMIT = 2048
78
+ # Which tool_use input field ToolCall.path is read from, per tool name
79
+ # (docs/DESIGN.md 4.1's v0.2 reader changes). Every other tool leaves path
80
+ # None.
81
+ _PATH_TOOL_INPUT_FIELD = {
82
+ "Edit": "file_path",
83
+ "Write": "file_path",
84
+ "NotebookEdit": "notebook_path",
85
+ }
86
+
87
+ # docs/DESIGN.md 4.1 (N4b-1): MultiEdit's `file_path` lives in its own private
88
+ # ToolCall field, never in `path`, so schema-2 paths_written, in_flight, and
89
+ # rescue (all of which read `path`) cannot change.
90
+ _MULTIEDIT_TOOL = "MultiEdit"
91
+ # Tools whose tool_use `command` is kept on a ToolCall (docs/DESIGN.md 4.1).
92
+ _COMMAND_TOOLS = ("Bash", "Monitor")
93
+
94
+ # Harness/tooling scaffolding a field's text can carry that is not something
95
+ # the agent said or did (docs/DESIGN.md 4.5): stripped before the excerpt is
96
+ # taken so it never dominates meaningful_event_before's "last real event"
97
+ # search or an evidence line, but markers.py still scans the text before
98
+ # this strip runs, so a marker fragment hidden inside one of these spans is
99
+ # never missed.
100
+ _SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL)
101
+ _TOTAL_TOKENS_RE = re.compile(r"<total_tokens>.*?</total_tokens>", re.DOTALL)
102
+ _HARNESS_NOTE_RE = re.compile(r"\[harness:.*?\]", re.DOTALL)
103
+
104
+ # docs/DESIGN.md 5.3: the opaque id shapes a workflow agent id / session
105
+ # subagent id and a workflow run directory name are documented to have.
106
+ # Ingestion validates every id against these before it is carried into a
107
+ # Run, Attempt, or Finding; see _valid_agent_id and _valid_workflow_run_id.
108
+ _AGENT_ID_RE = re.compile(r"a[0-9a-f]{16}")
109
+ _WORKFLOW_RUN_ID_RE = re.compile(r"wf_[0-9a-f]{8}-[0-9a-f]{3}")
110
+
111
+
112
+ def _valid_agent_id(agent_id: str | None, diagnostics: list[str], context: str) -> str:
113
+ """A conforming agent id (docs/DESIGN.md 5.1: 17 hex chars starting with
114
+ 'a') passes through unchanged -- it carries no user text, so it is not
115
+ redacted anywhere else in the tool. A non-conforming one (a corrupted
116
+ journal entry, a hand-edited or foreign transcript filename) is opaque
117
+ for a different reason: it might be anything, including a real name, so
118
+ it is replaced by 'agent-' + sha256(id)[:8] and never carried into a
119
+ Run/Attempt/Finding verbatim. The diagnostic names only the fixed
120
+ `context` and the replacement, never the original value."""
121
+ if agent_id and _AGENT_ID_RE.fullmatch(agent_id):
122
+ return agent_id
123
+ replacement = "agent-" + hashlib.sha256((agent_id or "").encode("utf-8")).hexdigest()[:8]
124
+ diagnostics.append(
125
+ f"{context}: agent id did not match the expected format, replaced with {replacement}"
126
+ )
127
+ return replacement
128
+
129
+
130
+ def valid_workflow_run_id(name: str, diagnostics: list[str] | None = None) -> str:
131
+ """A conforming workflow run id (docs/DESIGN.md 5.1:
132
+ 'wf_' + 8 hex + '-' + 3 hex) passes through unchanged. A non-conforming
133
+ run directory name is replaced by 'wf_' + sha256(name)[:8] + '-x'; the
134
+ diagnostic never echoes the original directory name."""
135
+ if _WORKFLOW_RUN_ID_RE.fullmatch(name or ""):
136
+ return name
137
+ replacement = "wf_" + hashlib.sha256((name or "").encode("utf-8")).hexdigest()[:8] + "-x"
138
+ if diagnostics is not None:
139
+ diagnostics.append(
140
+ f"workflow run directory: run id did not match the expected format, "
141
+ f"replaced with {replacement}"
142
+ )
143
+ return replacement
144
+
145
+
146
+ # Kept under its old name for the in-module callers; every other module
147
+ # (watch, hooks_cli) uses the public valid_workflow_run_id (N3b C2).
148
+ _valid_workflow_run_id = valid_workflow_run_id
149
+
150
+ # Every literal marker fragment a detector matches with a plain substring
151
+ # test, in one place, so _scan_markers can check all of them against a
152
+ # field's full text in a single pass.
153
+ _MARKER_FRAGMENTS: tuple[str, ...] = (
154
+ *BASH_BACKGROUNDED,
155
+ *BASH_TIMEOUT,
156
+ *TOOL_INPUT_INVALID,
157
+ *STRUCTURED_OUTPUT_MISMATCH,
158
+ *SANDBOX_WRITE_DENIED,
159
+ *CLASSIFIER_UNAVAILABLE,
160
+ *GIT_NOT_REPO,
161
+ *USAGE_LIMIT_SESSION,
162
+ *USAGE_LIMIT_WEEKLY,
163
+ *MACHINE_SLEEP,
164
+ *API_ERROR,
165
+ )
166
+ _SHELL_GUARD_RE = re.compile(SHELL_GUARD_BLOCK_REGEX)
167
+ _SHELL_GUARD_MARKER = "SHELL_GUARD_BLOCK_REGEX"
168
+
169
+ # A tool_result whose resolved tool_name is one of these wraps a child's own
170
+ # output inside the parent's transcript (docs/DESIGN.md 4.2): it is marked
171
+ # envelope=True with markers scrubbed so a child's error text quoted here is
172
+ # never double-counted.
173
+ _ENVELOPE_TOOL_NAMES = frozenset({"Agent", "Task", "Workflow", "TaskOutput"})
174
+
175
+ # docs/DESIGN.md 4.3's self-reference rule: a tool_result whose resolved
176
+ # tool_name is one of these is read_only_output=True unconditionally -- the
177
+ # tool can only ever report what is already on disk, never a runtime
178
+ # failure that happened to this agent.
179
+ _READ_ONLY_TOOL_NAMES = frozenset({"Read", "Grep", "Glob", "NotebookRead"})
180
+
181
+ # docs/DESIGN.md 4.3's self-reference rule (D-B): a Bash command counts as
182
+ # read-only when its text contains one of these read commands as a whole
183
+ # word ANYWHERE in it -- not only as the first word -- so a loop like `for f
184
+ # in ...; do echo "=== $f ==="; cat "$f"; done` is caught too, and none of
185
+ # _NOT_READ_ONLY_BASH_RE's write-indicating tokens (a real write, an
186
+ # interpreter/test runner invocation, or output redirection) appear anywhere
187
+ # in it either.
188
+ _READ_ONLY_BASH_RE = re.compile(
189
+ r"\b(?:cat|sed|head|tail|grep|rg|nl|less|more|bat|wc|diff)\b"
190
+ r"|\bgit\s+(?:show|diff|log|grep|blame)\b"
191
+ )
192
+ _NOT_READ_ONLY_BASH_RE = re.compile(
193
+ r"\b(?:node|codex|python|pytest|npm|npx|curl|make|tee)\b|\buv\s+run\b"
194
+ )
195
+
196
+
197
+ def _is_read_only_bash_command(command: str | None) -> bool:
198
+ if not isinstance(command, str) or not command.strip():
199
+ return False
200
+ if ">" in command:
201
+ return False
202
+ if _NOT_READ_ONLY_BASH_RE.search(command):
203
+ return False
204
+ return bool(_READ_ONLY_BASH_RE.search(command))
205
+
206
+
207
+ def _tool_result_read_only(
208
+ tool_name: str | None, tool_use_id: object, tool_commands: dict | None
209
+ ) -> bool:
210
+ """docs/DESIGN.md 4.3's self-reference rule (D-A): whether a tool_result
211
+ resolved to tool_name is read_only_output -- unconditionally for Read/
212
+ Grep/Glob/NotebookRead, or for Bash when the paired tool_use's command
213
+ (tracked in tool_commands by tool_use_id) is one of the read-only
214
+ forms."""
215
+ if tool_name in _READ_ONLY_TOOL_NAMES:
216
+ return True
217
+ if tool_name != "Bash" or tool_commands is None or not isinstance(tool_use_id, str):
218
+ return False
219
+ return _is_read_only_bash_command(tool_commands.get(tool_use_id))
220
+
221
+ _UUID_RE = re.compile(
222
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
223
+ )
224
+
225
+
226
+ def _exists(path: Path) -> bool:
227
+ """Whether `path` may exist. Only an absent path (or a file under a non-directory) is False:
228
+ an unreadable one (a directory without the execute bit, an overlong name) is not proof of
229
+ absence, so the caller's own open reports it as an unreadable file, a diagnostic and never a
230
+ crash (F fix 8, W8-5). `Path.exists` raises `PermissionError` there."""
231
+ try:
232
+ path.stat()
233
+ except (FileNotFoundError, NotADirectoryError):
234
+ return False
235
+ except OSError:
236
+ return True
237
+ except ValueError:
238
+ return False
239
+ return True
240
+
241
+
242
+ def _is_file(path: Path) -> bool:
243
+ """`Path.is_file` that treats any `OSError` as "not a file" (older Pythons raise for an
244
+ unreadable parent directory)."""
245
+ try:
246
+ return path.is_file()
247
+ except (OSError, ValueError):
248
+ return False
249
+
250
+
251
+ def _is_dir(path: Path) -> bool:
252
+ """`Path.is_dir` that treats any `OSError` as "not a directory"."""
253
+ try:
254
+ return path.is_dir()
255
+ except (OSError, ValueError):
256
+ return False
257
+
258
+
259
+ class NotARun(Exception):
260
+ """path is neither a workflow run directory (has journal.jsonl) nor a
261
+ session transcript (a .jsonl file whose first parsed line carries
262
+ sessionId). docs/DESIGN.md 4.2; the CLI turns this into exit 2."""
263
+
264
+
265
+ class RunUnreadable(Exception):
266
+ """docs/DESIGN.md 4.8 (0.3.4): load_run could not read the run at all, because a
267
+ RecursionError got past every parse site. run_id is the id the run would print (a workflow
268
+ id passed through valid_workflow_run_id, or a session's public s_ id), never a path, so a
269
+ caller can name the run at any disclosure level. The CLI turns this into exit 4."""
270
+
271
+ def __init__(self, run_id: str) -> None:
272
+ super().__init__(run_id)
273
+ self.run_id = run_id
274
+
275
+
276
+ def _scan_markers(text: str | None) -> frozenset[str]:
277
+ """Which markers.py fragments (or, for the one regex marker, its name)
278
+ appear anywhere in text. Called on a field's full decoded text before
279
+ _excerpt bounds it, so a marker past the excerpt limit is still seen."""
280
+ if not text:
281
+ return frozenset()
282
+ hits = {fragment for fragment in _MARKER_FRAGMENTS if fragment in text}
283
+ if _SHELL_GUARD_RE.search(text):
284
+ hits.add(_SHELL_GUARD_MARKER)
285
+ return frozenset(hits)
286
+
287
+
288
+ # --- run discovery and dispatch --------------------------------------------
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class RunRef:
293
+ kind: str # 'workflow' | 'session'
294
+ path: Path
295
+ run_id: str
296
+
297
+
298
+ # Process-level registry backing _session_public_id's collision detection,
299
+ # mirroring ParentIndex's process-level cache below: every (slug, uuid) pair
300
+ # seen so far, keyed by the 10-hex id its payload hashes to. A second,
301
+ # distinct pair landing on the same key is a public-id collision.
302
+ _SESSION_ID_REGISTRY: dict[str, set[tuple[str, str]]] = {}
303
+
304
+
305
+ def _session_id_digest(payload: str) -> str:
306
+ """The full sha256 hex digest behind a session's public id. A thin,
307
+ monkeypatchable seam: a test forces a public-id collision between two
308
+ distinct (slug, uuid) pairs by replacing this function, since sha256
309
+ makes a real 10-hex collision practically unreachable."""
310
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
311
+
312
+
313
+ def _session_public_id(slug: str, uuid: str, diagnostics: list[str] | None = None) -> str:
314
+ """'s_' + the first 10 hex chars of sha256(f"{slug}/{uuid}") (docs/
315
+ DESIGN.md 4.2). A second, distinct (slug, uuid) pair landing on the same
316
+ 10-hex id switches both to a 12-hex id instead and, when diagnostics is
317
+ given, records the collision there."""
318
+ digest = _session_id_digest(f"{slug}/{uuid}")
319
+ short = digest[:10]
320
+ seen = _SESSION_ID_REGISTRY.setdefault(short, set())
321
+ seen.add((slug, uuid))
322
+ if len(seen) > 1:
323
+ if diagnostics is not None:
324
+ others = sorted(f"{s}/{u}" for s, u in seen if (s, u) != (slug, uuid))
325
+ diagnostics.append(
326
+ f"session public id collision on s_{short}: {slug}/{uuid} collides with "
327
+ f"{', '.join(others)}; using a 12-hex id instead"
328
+ )
329
+ return f"s_{digest[:12]}"
330
+ return f"s_{short}"
331
+
332
+
333
+ def discover_runs(
334
+ root: Path, kinds: frozenset[str] = frozenset({"workflow", "session"}),
335
+ skipped: list[Path] | None = None,
336
+ ) -> list[RunRef]:
337
+ """Find every run of the given kinds under root, sorted by path.
338
+
339
+ Workflow refs: <root>/<project>/<session>/subagents/workflows/wf_*
340
+ (docs/DESIGN.md section 1). Session refs: <root>/<project>/<uuid>.jsonl.
341
+ """
342
+ root = Path(root)
343
+ if not _exists(root):
344
+ return []
345
+
346
+ refs: list[RunRef] = []
347
+ if "workflow" in kinds:
348
+ for path in root.glob("*/*/subagents/workflows/wf_*"):
349
+ if _is_dir(path):
350
+ refs.append(RunRef(kind="workflow", path=path, run_id=path.name))
351
+ if "session" in kinds:
352
+ session_paths = []
353
+ for path in root.glob("*/*.jsonl"):
354
+ if not (_is_file(path) and _UUID_RE.fullmatch(path.stem)):
355
+ continue
356
+ is_run, unreadable = _conversation_probe(path)
357
+ if is_run:
358
+ session_paths.append(path)
359
+ elif unreadable and skipped is not None:
360
+ # docs/DESIGN.md 4.8 (0.3.4): a transcript with no readable line is not a run,
361
+ # but scan says it left one out
362
+ skipped.append(path)
363
+ # Register every (slug, uuid) pair in this batch before reading any
364
+ # of them back: a collision between two paths discovered together
365
+ # must give both the same 12-hex id, not just whichever is asked for
366
+ # second (docs/DESIGN.md 4.2).
367
+ for path in session_paths:
368
+ _session_public_id(path.parent.name, path.stem)
369
+ for path in session_paths:
370
+ run_id = _session_public_id(path.parent.name, path.stem)
371
+ refs.append(RunRef(kind="session", path=path, run_id=run_id))
372
+
373
+ refs.sort(key=lambda ref: ref.path)
374
+ return refs
375
+
376
+
377
+ def has_run_candidates(root: Path) -> bool:
378
+ """Whether root holds anything discover_runs could return: a workflow run directory or a
379
+ UUID-named session transcript (docs/DESIGN.md 4.8's empty state). Globs only and never
380
+ opens a file, so an error path that asks cannot fail on a transcript's content; a root
381
+ that cannot be listed answers True, so the caller keeps its original message."""
382
+ root = Path(root)
383
+ try:
384
+ if not _is_dir(root):
385
+ return False
386
+ # the same type checks discover_runs makes, without reading any content
387
+ if any(_is_dir(path) for path in root.glob("*/*/subagents/workflows/wf_*")):
388
+ return True
389
+ return any(
390
+ _is_file(path) and _UUID_RE.fullmatch(path.stem) for path in root.glob("*/*.jsonl")
391
+ )
392
+ except OSError:
393
+ return True
394
+
395
+
396
+ # docs/DESIGN.md 4.1 (G-C, GC-1): a final physical line with no trailing newline whose bytes
397
+ # are not valid UTF-8 is a half-written line (a live write cut inside a character); an
398
+ # undecodable byte anywhere else is the N4 C2 rule. A transcript is read strictly, the cost it
399
+ # always had; only a strict read that fails to decode, on a file whose last line is torn, goes
400
+ # on with errors="surrogateescape", where an undecodable byte shows up as one U+DC80..U+DCFF
401
+ # character in its line (GC-1b: scanning every line cost 4.3 s of CPU on a 248-run scan).
402
+ _UNDECODABLE_BYTE_RE = re.compile("[\udc80-\udcff]")
403
+ _TAIL_STEP = 65536
404
+
405
+
406
+ def _ends_in_torn_line(path: Path) -> bool:
407
+ """Whether the bytes after the file's last line break are not valid UTF-8: a final line
408
+ with no newline, cut inside a character. Reads backwards from the end of the file."""
409
+ chunks: list[bytes] = []
410
+ try:
411
+ with path.open("rb") as handle:
412
+ position = handle.seek(0, os.SEEK_END)
413
+ while position > 0:
414
+ step = min(_TAIL_STEP, position)
415
+ position -= step
416
+ handle.seek(position)
417
+ chunk = handle.read(step)
418
+ cut = max(chunk.rfind(b"\n"), chunk.rfind(b"\r"))
419
+ if cut >= 0:
420
+ chunks.append(chunk[cut + 1 :])
421
+ break
422
+ chunks.append(chunk)
423
+ except OSError:
424
+ return False
425
+ tail = b"".join(reversed(chunks))
426
+ try:
427
+ tail.decode("utf-8")
428
+ except UnicodeDecodeError:
429
+ return True
430
+ return False
431
+
432
+
433
+ def _transcript_lines(path: Path):
434
+ """(line_no, line, torn) for every physical line of a transcript, read strictly. When the
435
+ strict read fails to decode and the file's last line is torn, the read goes on from the
436
+ first line it had not given yet, with errors="surrogateescape": the torn last line comes
437
+ back once, with an empty `line` and torn True; an undecodable byte on any other line raises
438
+ UnicodeDecodeError, as the strict read did. OSError propagates."""
439
+ given = 0
440
+ try:
441
+ with path.open("r", encoding="utf-8") as handle:
442
+ for line_no, line in enumerate(handle, start=1):
443
+ given = line_no
444
+ yield line_no, line, False
445
+ return
446
+ except UnicodeDecodeError:
447
+ if not _ends_in_torn_line(path):
448
+ raise
449
+ with path.open("r", encoding="utf-8", errors="surrogateescape") as handle:
450
+ for line_no, line in enumerate(handle, start=1):
451
+ if line_no <= given:
452
+ continue
453
+ if _UNDECODABLE_BYTE_RE.search(line):
454
+ if line.endswith("\n"):
455
+ raise UnicodeDecodeError(
456
+ "utf-8", b"", 0, 1, "undecodable byte before the end of a transcript line"
457
+ )
458
+ yield line_no, "", True
459
+ else:
460
+ yield line_no, line, False
461
+
462
+
463
+ @contextlib.contextmanager
464
+ def _transcript_reader(path: Path):
465
+ """_transcript_lines(path), closed on exit, so a caller that stops early closes the file."""
466
+ lines = _transcript_lines(path)
467
+ try:
468
+ yield lines
469
+ finally:
470
+ lines.close()
471
+
472
+
473
+ # docs/DESIGN.md 4.1 (0.3.4): a line nested deeper than the parser can follow raises
474
+ # RecursionError, not a JSONDecodeError. Every read of run data treats it as a line that is not
475
+ # JSON, and load_run counts what its read skipped (Run.skipped_lines, Run.skipped_files).
476
+ _UNPARSEABLE = (json.JSONDecodeError, ValueError, RecursionError)
477
+ _TOO_DEEP = "nested too deeply to parse"
478
+ _PARENT_BAD_LINE = "undecodable parent transcript line"
479
+ _PARENT_NOT_OBJECT = "parent transcript line that is not a JSON object"
480
+
481
+
482
+ @dataclass
483
+ class _ReadSkips:
484
+ lines: int = 0
485
+ files: int = 0
486
+
487
+
488
+ _READ_SKIPS: contextvars.ContextVar[_ReadSkips | None] = contextvars.ContextVar(
489
+ "read_skips", default=None,
490
+ )
491
+
492
+
493
+ def _skipped_line(count: int = 1) -> None:
494
+ skips = _READ_SKIPS.get()
495
+ if skips is not None:
496
+ skips.lines += count
497
+
498
+
499
+ def _skipped_file() -> None:
500
+ skips = _READ_SKIPS.get()
501
+ if skips is not None:
502
+ skips.files += 1
503
+
504
+
505
+ def _unparseable_text(exc: BaseException) -> str:
506
+ return _TOO_DEEP if isinstance(exc, RecursionError) else str(exc)
507
+
508
+
509
+ def _has_timestamped_conversation(path: Path) -> bool:
510
+ return _conversation_probe(path)[0]
511
+
512
+
513
+ def _conversation_probe(path: Path) -> tuple[bool, bool]:
514
+ """(is a session run, holds no readable line at all). A session run is a file that carries
515
+ at least one 'user' or 'assistant' entry with a parseable timestamp (docs/DESIGN.md 4.2).
516
+
517
+ Real Claude Code project directories can hold one-line 'bridge-session'
518
+ files (type 'bridge-session', no timestamp, no user/assistant entry at
519
+ all) that are plumbing, not a run anyone had a conversation in; without
520
+ this check they would still match the '<uuid>.jsonl' discovery glob and
521
+ show up in scan as an empty session. Stops at the first qualifying
522
+ entry rather than reading the whole file: a real session's first
523
+ timestamped entry is typically near the top, and a file with none (the
524
+ only case that reads to EOF) is small by construction."""
525
+ if not _is_file(path) or path.suffix != ".jsonl":
526
+ return False, False
527
+ unparseable = False
528
+ parsed = False
529
+ try:
530
+ with _transcript_reader(path) as lines:
531
+ for _line_no, line, _torn in lines:
532
+ line = line.strip()
533
+ if not line:
534
+ continue
535
+ try:
536
+ entry = json.loads(line)
537
+ except _UNPARSEABLE:
538
+ unparseable = True
539
+ continue
540
+ parsed = True
541
+ if not isinstance(entry, dict):
542
+ continue
543
+ if entry.get("type") not in ("user", "assistant"):
544
+ continue
545
+ if _parse_ts(entry.get("timestamp")) is not None:
546
+ return True, False
547
+ except UnicodeError:
548
+ # bytes that are not UTF-8 before any qualifying entry: the file
549
+ # cannot be ruled out as a session, and its own read reports it
550
+ # unreadable (docs/DESIGN.md 4.1, N4 fix C2)
551
+ return True, False
552
+ except OSError:
553
+ return False, False
554
+ # (is a run, has no readable line at all): a file whose every line failed to parse is
555
+ # left out like any non-run, but discover_runs reports it (docs/DESIGN.md 4.8, 0.3.4)
556
+ return False, unparseable and not parsed
557
+
558
+
559
+ def _is_workflow_dir(path: Path) -> bool:
560
+ """A run is a directory with its own journal.jsonl (docs/DESIGN.md
561
+ section 1)."""
562
+ return _is_dir(path) and _exists(path / "journal.jsonl")
563
+
564
+
565
+ def _sniff_session_file(path: Path) -> bool:
566
+ """Whether the first *decodable* line of a .jsonl file carries sessionId
567
+ (docs/DESIGN.md 4.2's load_run dispatch: "a .jsonl file whose first
568
+ parsed line carries sessionId"), without parsing the rest of it. A
569
+ malformed physical line ahead of it is skipped, not decisive: it is
570
+ still recorded as a diagnostic once load_session actually reads the
571
+ file, same as any other malformed line."""
572
+ if not _is_file(path) or path.suffix != ".jsonl":
573
+ return False
574
+ try:
575
+ with _transcript_reader(path) as lines:
576
+ for _line_no, line, _torn in lines:
577
+ line = line.strip()
578
+ if not line:
579
+ continue
580
+ try:
581
+ entry = json.loads(line)
582
+ except _UNPARSEABLE:
583
+ continue
584
+ return isinstance(entry, dict) and "sessionId" in entry
585
+ except UnicodeError:
586
+ return True
587
+ except OSError:
588
+ return False
589
+ return False
590
+
591
+
592
+
593
+ def load_run(ref_or_path: "RunRef | Path | str") -> Run:
594
+ """Parse one run (a workflow directory or a session transcript) into a
595
+ Run. Dispatches on layout, per docs/DESIGN.md 4.2; raises NotARun for
596
+ anything else.
597
+
598
+ A RunRef already carries the kind discover_runs found it under, so it
599
+ is honored directly rather than re-sniffed: discover_runs's workflow
600
+ refs come from */*/subagents/workflows/wf_* directories regardless of
601
+ whether journal.jsonl exists yet (docs/DESIGN.md 4.2), and _load_workflow
602
+ already tolerates a missing journal.jsonl with a diagnostic. Re-checking
603
+ _is_workflow_dir here would raise NotARun for exactly the directories
604
+ discover_runs promised callers a RunRef for, aborting a fleet scan.
605
+
606
+ docs/DESIGN.md 4.1 (0.3.4): the lines and files this read skipped are counted on the Run.
607
+ """
608
+ skips = _ReadSkips()
609
+ token = _READ_SKIPS.set(skips)
610
+ try:
611
+ run = _load_run(ref_or_path)
612
+ except RecursionError:
613
+ # docs/DESIGN.md 4.8 (0.3.4): the last guard, for a recursion no parse site caught
614
+ raise RunUnreadable(_public_run_id(ref_or_path)) from None
615
+ finally:
616
+ _READ_SKIPS.reset(token)
617
+ run.skipped_lines = skips.lines
618
+ run.skipped_files = skips.files
619
+ return run
620
+
621
+
622
+ def _public_run_id(ref_or_path: "RunRef | Path | str") -> str:
623
+ """The id a run at ref_or_path prints, worked out without reading it (RunUnreadable)."""
624
+ if isinstance(ref_or_path, RunRef):
625
+ if ref_or_path.kind == "workflow":
626
+ return valid_workflow_run_id(ref_or_path.path.name)
627
+ return ref_or_path.run_id
628
+ path = Path(ref_or_path)
629
+ if path.suffix == ".jsonl" and not _is_dir(path):
630
+ return _session_public_id(path.parent.name, path.stem)
631
+ return valid_workflow_run_id(path.name)
632
+
633
+
634
+ def _load_run(ref_or_path: "RunRef | Path | str") -> Run:
635
+ if isinstance(ref_or_path, RunRef):
636
+ if ref_or_path.kind == "workflow":
637
+ return _load_workflow(ref_or_path.path)
638
+ if ref_or_path.kind == "session":
639
+ return load_session(ref_or_path.path)
640
+
641
+ path = ref_or_path.path if isinstance(ref_or_path, RunRef) else Path(ref_or_path)
642
+ if _is_workflow_dir(path):
643
+ return _load_workflow(path)
644
+ if _sniff_session_file(path):
645
+ return load_session(path)
646
+ raise NotARun(str(path))
647
+
648
+
649
+ # --- workflow runs -----------------------------------------------------
650
+
651
+
652
+ def _load_workflow(path: Path) -> Run:
653
+ """Parse one workflow run directory (journal.jsonl, meta files,
654
+ transcripts) into a Run."""
655
+ path = Path(path)
656
+ diagnostics: list[str] = []
657
+ run_id = _valid_workflow_run_id(path.name, diagnostics)
658
+ project_slug = _project_slug(path)
659
+
660
+ # docs/DESIGN.md 4.1/4.13.1 (N4b-1): Run.discovery keeps each source's
661
+ # own diagnostics, in the order reported, beside the flat `diagnostics`
662
+ # list (whose content and order are unchanged).
663
+ discovery: dict[str, list[str]] = {
664
+ "journal": [], "parent": [], "transcripts": [], "meta": [],
665
+ }
666
+
667
+ def note(source: str, text: str) -> None:
668
+ diagnostics.append(text)
669
+ if not isinstance(text, QualityNote):
670
+ discovery[source].append(text)
671
+
672
+ journal_start = len(diagnostics)
673
+ journal, started_events, journal_ok, journal_line_numbers = _read_journal(path, diagnostics)
674
+ discovery["journal"].extend(discovery_part(diagnostics[journal_start:]))
675
+ incomplete = not journal_ok
676
+
677
+ # docs/DESIGN.md 4.1 (unit M1a): the per-key state machine decides how
678
+ # each 'started' occurrence closes (or stays open); _workflow_finished_
679
+ # from_occurrences turns that into Run.finished below, and every
680
+ # diagnostic it raises (a mismatched agentId, an orphan result/failed)
681
+ # forces status INCOMPLETE, same as any other reader diagnostic.
682
+ journal_attempts, journal_sm_diagnostics = _run_journal_state_machine(
683
+ journal, journal_line_numbers
684
+ )
685
+ attempt_lookup = {id(occ.started_entry): occ for occ in journal_attempts}
686
+ diagnostics.extend(journal_sm_diagnostics)
687
+ discovery["journal"].extend(journal_sm_diagnostics)
688
+ incomplete = incomplete or bool(journal_sm_diagnostics)
689
+ # docs/DESIGN.md 4.6: line numbers for every journal entry, by identity,
690
+ # so a malformed 'started' entry's diagnostic can still cite
691
+ # 'journal.jsonl:<line>' below -- entries in `started_events` are the
692
+ # same dict objects `_read_journal` appended from `journal`, never copies.
693
+ entry_line_no = {id(entry): line_no for entry, line_no in zip(journal, journal_line_numbers)}
694
+
695
+ claude_version: str | None = None
696
+ session_id: str | None = None
697
+ all_event_ts: list[datetime] = []
698
+ attempts: list[Attempt] = []
699
+ agents_by_key: dict[str, LogicalAgent] = {}
700
+ ordinal_counts: dict[str | None, int] = {}
701
+
702
+ for entry in started_events:
703
+ raw_key = entry.get("key")
704
+ raw_agent_id_field = entry.get("agentId", "")
705
+ raw_label_field = entry.get("label", "")
706
+ raw_phase_field = entry.get("phase", "")
707
+ line_no = entry_line_no.get(id(entry))
708
+
709
+ # docs/DESIGN.md 4.6: a non-string key or agentId names no attempt
710
+ # this reader can safely build -- an int/list/bool key can never
711
+ # index agents_by_key the way a string key does below, and a
712
+ # non-string agentId used to crash _valid_agent_id (M1 review
713
+ # finding 2). Skip the whole entry, with a line-specific diagnostic,
714
+ # and force status INCOMPLETE, rather than build a malformed Attempt.
715
+ # A missing or explicit-null key/agentId is the existing, valid
716
+ # "unkeyed"/"blank id" shape and is untouched here; only a *present
717
+ # but wrongly typed* value is a problem.
718
+ if raw_key is not None and not isinstance(raw_key, str):
719
+ note("journal", f"journal.jsonl:{line_no}: started event has a non-string key")
720
+ incomplete = True
721
+ continue
722
+ if raw_agent_id_field is not None and not isinstance(raw_agent_id_field, str):
723
+ note("journal", f"journal.jsonl:{line_no}: started event has a non-string agentId")
724
+ incomplete = True
725
+ continue
726
+
727
+ key = raw_key
728
+ raw_agent_id = raw_agent_id_field if raw_agent_id_field is not None else ""
729
+
730
+ # docs/DESIGN.md 4.6 (N3b, C-5): a missing, null, or non-canonical
731
+ # string agentId still builds its Attempt below, exactly as before,
732
+ # but the journal entry is not a trustworthy identity, so the run is
733
+ # INCOMPLETE and the diagnostic names the line. (A non-string value
734
+ # was skipped above with its own diagnostic.)
735
+ canonical_agent_id = disclosure.is_canonical_id("agent", raw_agent_id_field)
736
+ if not canonical_agent_id:
737
+ note("journal", f"journal.jsonl:{line_no}: started event has no canonical agentId")
738
+ incomplete = True
739
+
740
+ # A non-string label or phase is cosmetic, never identifying: the
741
+ # entry still becomes an attempt, with the bad value replaced by ""
742
+ # (never None -- report.py's AGENT table formats it directly with
743
+ # f"{label:<22}", which raises on None) and a diagnostic recorded.
744
+ if isinstance(raw_label_field, str):
745
+ label = raw_label_field
746
+ else:
747
+ label = ""
748
+ if raw_label_field is not None:
749
+ note(
750
+ "journal",
751
+ QualityNote(f"journal.jsonl:{line_no}: started event has a non-string label"),
752
+ )
753
+ if isinstance(raw_phase_field, str):
754
+ phase = raw_phase_field
755
+ else:
756
+ phase = ""
757
+ if raw_phase_field is not None:
758
+ note(
759
+ "journal",
760
+ QualityNote(f"journal.jsonl:{line_no}: started event has a non-string phase"),
761
+ )
762
+
763
+ # journal_attempt is None only for an unkeyed entry (key is None --
764
+ # the state machine tracks attempts per key and has nothing to key
765
+ # an unkeyed entry's occurrence by either); a non-string key or
766
+ # agentId already continued past this point above. ordinal falls
767
+ # back to the same per-key counting v0.1 always did.
768
+ journal_attempt = attempt_lookup.get(id(entry))
769
+ if journal_attempt is not None:
770
+ ordinal = journal_attempt.ordinal
771
+ else:
772
+ ordinal_counts[key] = ordinal_counts.get(key, 0) + 1
773
+ ordinal = ordinal_counts[key]
774
+
775
+ # Filesystem lookups use the journal value only when it is a
776
+ # canonical agent id (N3b S14): the id is spliced into a file name,
777
+ # so a value such as `q/../../OUTSIDE` would name a file outside the
778
+ # run directory. A non-canonical id has no transcript and no meta
779
+ # file opened for it; the attempt is already INCOMPLETE (C-5 above).
780
+ meta_start = len(diagnostics)
781
+ meta = _load_meta(path, raw_agent_id, diagnostics) if canonical_agent_id else {}
782
+ discovery["meta"].extend(discovery_part(diagnostics[meta_start:]))
783
+ agent_type = meta.get("agentType")
784
+ id_start = len(diagnostics)
785
+ agent_id = _valid_agent_id(raw_agent_id, diagnostics, "journal.jsonl")
786
+ discovery["journal"].extend(discovery_part(diagnostics[id_start:]))
787
+
788
+ transcript_path = path / f"agent-{raw_agent_id}.jsonl"
789
+ unreadable = False
790
+ transcript_state = None
791
+ if canonical_agent_id and _exists(transcript_path):
792
+ transcript_start = len(diagnostics)
793
+ transcript_state = ledger.TranscriptState()
794
+ transcript_result = _read_transcript_tracked(
795
+ transcript_path, diagnostics, state=transcript_state
796
+ )
797
+ discovery["transcripts"].extend(discovery_part(diagnostics[transcript_start:]))
798
+ (
799
+ events, version, transcript_session_id, unreadable, over_threshold,
800
+ tool_calls, marker_hits,
801
+ ) = transcript_result
802
+ incomplete = incomplete or unreadable or over_threshold
803
+ # docs/DESIGN.md 4.2 (F5 hardening): a duplicate tool_use_id
804
+ # anywhere in this attempt's transcript is a reader diagnostic
805
+ # like any other, so it forces Run.status INCOMPLETE too.
806
+ incomplete = incomplete or any(call.ambiguous for call in tool_calls)
807
+ if claude_version is None and version:
808
+ claude_version = version
809
+ if session_id is None and transcript_session_id:
810
+ session_id = transcript_session_id
811
+ else:
812
+ transcript_path = None
813
+ events = []
814
+ tool_calls = []
815
+ marker_hits = []
816
+
817
+ event_ts = [event.ts for event in events if event.ts is not None]
818
+ first_ts = min(event_ts) if event_ts else None
819
+ last_ts = max(event_ts) if event_ts else None
820
+ all_event_ts.extend(event_ts)
821
+ incomplete = incomplete or _edge_event_has_timestamp_diagnostic(events, diagnostics)
822
+
823
+ # docs/DESIGN.md 4.1: the journal state machine decides the final
824
+ # outcome whenever it closed this attempt ('completed' / 'failed'
825
+ # always win, even over a trailing kill sentinel; 'superseded'
826
+ # becomes 'killed' instead when the sentinel is present -- the v0.1
827
+ # silent retry, unchanged). An attempt the state machine never
828
+ # closed (still open at end of journal) falls back to the
829
+ # transcript alone, exactly as v0.1 always did for every attempt.
830
+ base_outcome = _compute_outcome(transcript_path, events)
831
+ closed_by = journal_attempt.closed_by if journal_attempt is not None else None
832
+ if closed_by == "completed":
833
+ outcome = "completed"
834
+ elif closed_by == "failed":
835
+ outcome = "failed"
836
+ elif closed_by == "superseded":
837
+ if base_outcome == "killed":
838
+ outcome = "killed"
839
+ else:
840
+ outcome = "superseded"
841
+ incomplete = True
842
+ note(
843
+ "journal",
844
+ KeyedDiagnostic(
845
+ f"journal.jsonl: key@{line_no} attempt {ordinal} was replaced by a "
846
+ "later attempt with no recorded outcome",
847
+ f"journal.jsonl: key {key!r} attempt {ordinal} was replaced by a "
848
+ "later attempt with no recorded outcome",
849
+ ),
850
+ )
851
+ else:
852
+ outcome = base_outcome
853
+
854
+ # docs/DESIGN.md section 3/4.1: reported is read only for an
855
+ # attempt the state machine actually closed with a 'result' event
856
+ # (closed_by == 'completed') -- never for 'failed', 'superseded',
857
+ # or an attempt still open at the end of the journal, even when
858
+ # base_outcome alone would otherwise read 'completed' for one of
859
+ # those (an attempt with no journal-recorded result at all has
860
+ # nothing of its own to report).
861
+ reported = None
862
+ reported_line = None
863
+ self_report = None
864
+ if closed_by == "completed" and journal_attempt is not None:
865
+ reported = _build_reported(journal_attempt.closing_entry)
866
+ reported_line = journal_attempt.closing_line
867
+ # docs/DESIGN.md 4.3 (G3): only the shared predicate's answer survives, never a key
868
+ # name or a value
869
+ self_report = self_reported_problems(
870
+ journal_attempt.closing_entry.get("result")
871
+ if isinstance(journal_attempt.closing_entry, dict) else None
872
+ )
873
+
874
+ attempt = Attempt(
875
+ agent_id=agent_id,
876
+ key=key,
877
+ label=label,
878
+ phase=phase,
879
+ agent_type=agent_type,
880
+ meta=meta,
881
+ transcript_path=transcript_path,
882
+ events=events,
883
+ first_ts=first_ts,
884
+ last_ts=last_ts,
885
+ ordinal=ordinal,
886
+ outcome=outcome,
887
+ usage=_sum_usage(events),
888
+ reported=reported,
889
+ reported_line=reported_line,
890
+ _self_report=self_report,
891
+ tool_calls=tool_calls,
892
+ marker_hits=marker_hits,
893
+ # docs/DESIGN.md 4.1 (N4b-1): the state machine's own closure,
894
+ # None for an unkeyed entry (it has no occurrence).
895
+ journal_state=(
896
+ None if journal_attempt is None else (closed_by if closed_by else "open")
897
+ ),
898
+ _transcript_state=transcript_state,
899
+ entry_cwds=tuple(transcript_state.cwds) if transcript_state is not None else (),
900
+ )
901
+ attempt.facts = _build_attempt_facts(
902
+ tool_calls, outcome, last_ts, transcript_path, unreadable
903
+ )
904
+ # Interrupt tracks the transcript's own kill sentinel, independent
905
+ # of the final outcome above (docs/DESIGN.md 4.4): a 'failed'
906
+ # attempt whose transcript also ends in the sentinel still keeps it
907
+ # as evidence rather than losing it to the journal's own verdict.
908
+ if base_outcome == "killed":
909
+ attempt.interrupt = _build_interrupt(attempt)
910
+ attempts.append(attempt)
911
+
912
+ if key is not None:
913
+ agent = agents_by_key.get(key)
914
+ if agent is None:
915
+ agent = LogicalAgent(key=key, label=label, phase=phase, attempts=[])
916
+ agents_by_key[key] = agent
917
+ agent.attempts.append(attempt)
918
+
919
+ # docs/DESIGN.md 4.13.1 (S1, N4 fix): a transcript or meta file of the run
920
+ # directory that no journal started event names.
921
+ discovery["transcripts"].extend(_unnamed_file_diagnostics(path, started_events))
922
+
923
+ run = Run(
924
+ run_id=run_id,
925
+ kind="workflow",
926
+ path=path,
927
+ project_slug=project_slug,
928
+ session_id=session_id,
929
+ claude_version=claude_version,
930
+ journal=journal,
931
+ agents=list(agents_by_key.values()),
932
+ attempts=attempts,
933
+ started_at=min(all_event_ts) if all_event_ts else None,
934
+ ended_at=max(all_event_ts) if all_event_ts else None,
935
+ diagnostics=diagnostics,
936
+ status="INCOMPLETE" if incomplete else "COMPLETE",
937
+ discovery=discovery,
938
+ )
939
+ # One parent-index snapshot serves interrupt attribution, the parent
940
+ # discovery source, and the lifecycle (and so Generation.result_ts):
941
+ # no second parent read (docs/DESIGN.md 4.13.1, N4b-1).
942
+ parent_index = _parent_index_for(run)
943
+ _attribute_interrupts(run, parent_index)
944
+ # docs/DESIGN.md section 3 (N2 root-cause fix): _lifecycle_from_parts is
945
+ # the one table _load_workflow and the public workflow_lifecycle (used
946
+ # by the hook, which has no already-parsed Run to reuse) both build on --
947
+ # hooks_cli's workflow-unit finished check (7.2) ultimately calls the
948
+ # same table (through lifecycle_finished), so this reader and that hook
949
+ # can never again drift into two different answers the way the N2
950
+ # checkpoint did. Unlike workflow_lifecycle, this call passes the
951
+ # journal resolution _load_workflow already computed above (N1N2 review
952
+ # finding 7) instead of letting workflow_lifecycle read and
953
+ # state-machine journal.jsonl a second time.
954
+ journal_resolved = _workflow_finished_from_occurrences(journal_attempts)
955
+ run.lifecycle = _lifecycle_from_parts(run.path, run_id, journal_resolved, parent_index)
956
+ run.finished = lifecycle_finished(run.lifecycle)
957
+ if run.lifecycle.terminal == "unknown":
958
+ # docs/DESIGN.md 4.13.3 (v0.3 N2): a terminal the reader could not
959
+ # pin down (a conflict, an unrecognized status, an ambiguous launch,
960
+ # an unreadable parent, an incomplete generation) is exactly the
961
+ # same "the reader could not fully establish what happened" shape
962
+ # 4.6 already forces INCOMPLETE for everywhere else.
963
+ run.status = "INCOMPLETE"
964
+ # docs/DESIGN.md 4.13.1 (N4b-2): the ledgers and the run's own risk
965
+ # population completeness, from the final discovery and generations.
966
+ ledger.attach_ledgers(run)
967
+ return run
968
+
969
+
970
+ def _unnamed_file_diagnostics(run_dir: Path, started_events: list[dict]) -> list[str]:
971
+ """One diagnostic per `agent-<id>.jsonl` or `agent-<id>.meta.json` regular
972
+ file of a workflow run directory whose id no journal `started` event names
973
+ as its `agentId` string, sorted by file name (docs/DESIGN.md 4.13.1, S1)."""
974
+ named = {
975
+ event["agentId"] for event in started_events if isinstance(event.get("agentId"), str)
976
+ }
977
+ unnamed: list[str] = []
978
+ for pattern, suffix in (("agent-*.jsonl", ".jsonl"), ("agent-*.meta.json", ".meta.json")):
979
+ for file_path in run_dir.glob(pattern):
980
+ agent_id = file_path.name.removeprefix("agent-").removesuffix(suffix)
981
+ if _is_file(file_path) and agent_id not in named:
982
+ unnamed.append(file_path.name)
983
+ return [
984
+ f"{name}: transcript named by no journal started event" for name in sorted(unnamed)
985
+ ]
986
+
987
+
988
+ def _project_slug(path: Path) -> str:
989
+ """The project directory name, two levels above 'subagents' in path.
990
+
991
+ Falls back to the run directory's parent name when path was not reached
992
+ through the standard <root>/<project>/<session>/subagents/workflows/wf_*
993
+ layout (for example a fixture or a bare directory passed on the command
994
+ line), so load_run never raises just because of where it was called from.
995
+ """
996
+ parts = path.parts
997
+ if "subagents" in parts:
998
+ idx = parts.index("subagents")
999
+ if idx >= 2:
1000
+ return parts[idx - 2]
1001
+ return path.parent.name
1002
+
1003
+
1004
+ def _read_journal(
1005
+ path: Path, diagnostics: list[str]
1006
+ ) -> tuple[list[dict], list[dict], bool, list[int]]:
1007
+ """journal, started_events, whether the journal could be parsed cleanly
1008
+ (docs/DESIGN.md 4.6: False when the file is missing or any line in it
1009
+ failed to decode as a JSON object, in which case Run.status must be
1010
+ INCOMPLETE rather than trusting a partial read), and line_numbers -- the
1011
+ 1-based journal.jsonl line number of each entry in `journal`, in the same
1012
+ order, so a caller can later point evidence at the exact line a 'result'
1013
+ or 'failed' event came from (docs/DESIGN.md section 3,
1014
+ Attempt.reported_line)."""
1015
+ journal: list[dict] = []
1016
+ started_events: list[dict] = []
1017
+ line_numbers: list[int] = []
1018
+ journal_path = path / "journal.jsonl"
1019
+ if not _exists(journal_path):
1020
+ diagnostics.append("journal.jsonl: missing")
1021
+ return journal, started_events, False, line_numbers
1022
+
1023
+ ok = True
1024
+ try:
1025
+ with journal_path.open("r", encoding="utf-8") as handle:
1026
+ for line_no, line in enumerate(handle, start=1):
1027
+ # an unfinished last line (no newline yet) is a live write, not a skipped line
1028
+ skipped = 1 if line.endswith("\n") else 0
1029
+ line = line.strip()
1030
+ if not line:
1031
+ continue
1032
+ try:
1033
+ entry = json.loads(line)
1034
+ except _UNPARSEABLE as exc:
1035
+ diagnostics.append(f"journal.jsonl:{line_no}: {_unparseable_text(exc)}")
1036
+ _skipped_line(skipped)
1037
+ ok = False
1038
+ continue
1039
+ if not isinstance(entry, dict):
1040
+ diagnostics.append(f"journal.jsonl:{line_no}: not a JSON object")
1041
+ _skipped_line(skipped)
1042
+ ok = False
1043
+ continue
1044
+ journal.append(entry)
1045
+ line_numbers.append(line_no)
1046
+ if entry.get("type") == "started":
1047
+ started_events.append(entry)
1048
+ except OSError as exc:
1049
+ diagnostics.append(f"journal.jsonl: unreadable: {exc}")
1050
+ return journal, started_events, False, line_numbers
1051
+ except UnicodeError as exc:
1052
+ diagnostics.append(f"journal.jsonl: undecodable: {exc}")
1053
+ return journal, started_events, False, line_numbers
1054
+ return journal, started_events, ok, line_numbers
1055
+
1056
+
1057
+ # --- per-key journal state machine (docs/DESIGN.md 4.1, unit M1a) ------
1058
+ #
1059
+ # Real Claude Code journals carry a `failed` event ("type", "key",
1060
+ # "agentId") that v0.1 never read, and a real `result` event carries
1061
+ # "type", "key", "agentId", "result" -- never the "outcome" field v0.1's
1062
+ # fixtures happened to include but no code ever looked at. Because `failed`
1063
+ # was ignored, a run whose every key actually ended (some by result, some
1064
+ # by failure) was shown as unfinished forever. `_run_journal_state_machine`
1065
+ # is the one place that tracks, per key, at most one open attempt at a
1066
+ # time, consuming events in file order. `workflow_lifecycle` builds on it
1067
+ # (via `_workflow_finished_from_occurrences`) for the journal-resolved row
1068
+ # of the run lifecycle table (docs/DESIGN.md section 3, 4.13.3) that
1069
+ # `_load_workflow`'s Run.finished and hooks_cli's workflow-unit finished
1070
+ # check both ultimately read off Lifecycle -- never this state machine
1071
+ # directly. `workflow_journal_finished` also builds on it, but only for
1072
+ # watch's own, deliberately narrower, journal-only DONE rule (4.12).
1073
+
1074
+
1075
+ @dataclass
1076
+ class _JournalAttempt:
1077
+ """One 'started' occurrence of a key and how the state machine below
1078
+ resolved it, in journal order. `closed_by` is None while the attempt
1079
+ is still open at the end of the journal. reader._load_workflow turns
1080
+ `closed_by` plus whether this attempt's own transcript ends in the kill
1081
+ sentinel into the Attempt's final `outcome`: 'completed' and 'failed'
1082
+ are final as soon as the state machine assigns them (even over a
1083
+ trailing sentinel: the sentinel still becomes Attempt.interrupt, kept
1084
+ as evidence, but the journal's own report of what happened wins); a
1085
+ 'superseded' occurrence becomes 'killed' instead when its transcript
1086
+ carries the sentinel (the v0.1 silent retry, unchanged); an occurrence
1087
+ that is still open falls back to the transcript alone, exactly as
1088
+ v0.1 always did for every attempt."""
1089
+
1090
+ key: str
1091
+ agent_id: str
1092
+ ordinal: int
1093
+ started_entry: dict
1094
+ closed_by: str | None = None # None | 'completed' | 'failed' | 'superseded'
1095
+ closing_entry: dict | None = None
1096
+ # The 1-based journal.jsonl line number closing_entry came from, or None
1097
+ # when line numbers were not supplied to the state machine (callers that
1098
+ # only care about 'finished', such as workflow_journal_finished, never
1099
+ # pass them). _load_workflow uses this to set Attempt.reported_line.
1100
+ closing_line: int | None = None
1101
+
1102
+
1103
+ def _run_journal_state_machine(
1104
+ journal: list[dict],
1105
+ line_numbers: list[int] | None = None,
1106
+ ) -> tuple[list[_JournalAttempt], list[str]]:
1107
+ """The per-key attempt state machine of docs/DESIGN.md 4.1: at most one
1108
+ attempt is open per key at a time, events consumed in file order.
1109
+
1110
+ - 'started' with no attempt open for its key opens attempt n+1.
1111
+ - 'started' while one is open closes it 'superseded' (reader.py may
1112
+ reclassify this as 'killed' once it has the transcript; see
1113
+ _JournalAttempt above), then opens n+1.
1114
+ - 'result' / 'failed' whose agentId equals the open attempt's, or that
1115
+ carries no agentId at all, closes it 'completed' / 'failed'.
1116
+ - 'result' / 'failed' with a different agentId, or with no attempt open
1117
+ for its key, closes nothing and adds a diagnostic.
1118
+
1119
+ An entry that is not a dict, or whose 'key' is not a string, is skipped
1120
+ silently: _read_journal already turned a line that could not become a
1121
+ JSON object into its own diagnostic, and a keyless entry names no
1122
+ attempt this state machine could ever resolve. Returns (occurrences,
1123
+ diagnostics); occurrences are in the same order as their 'started'
1124
+ events.
1125
+
1126
+ line_numbers, when given, is parallel to `journal` (as _read_journal
1127
+ returns it) and fills in each occurrence's `closing_line`. Omitted by
1128
+ every caller that only needs 'finished' (workflow_journal_finished), so
1129
+ it defaults to None for every occurrence rather than requiring every
1130
+ caller to thread a matching list through."""
1131
+ open_by_key: dict[str, _JournalAttempt] = {}
1132
+ ordinal_counts: dict[str, int] = {}
1133
+ occurrences: list[_JournalAttempt] = []
1134
+ diagnostics: list[str] = []
1135
+ if line_numbers is None:
1136
+ line_numbers = [None] * len(journal)
1137
+
1138
+ for entry, line_no in zip(journal, line_numbers):
1139
+ if not isinstance(entry, dict):
1140
+ continue
1141
+ key = entry.get("key")
1142
+ if not isinstance(key, str):
1143
+ continue
1144
+ etype = entry.get("type")
1145
+
1146
+ if etype == "started":
1147
+ raw_agent_id = entry.get("agentId")
1148
+ agent_id = raw_agent_id if isinstance(raw_agent_id, str) else ""
1149
+ current = open_by_key.get(key)
1150
+ if current is not None:
1151
+ current.closed_by = "superseded"
1152
+ current.closing_entry = entry
1153
+ current.closing_line = line_no
1154
+ ordinal_counts[key] = ordinal_counts.get(key, 0) + 1
1155
+ occurrence = _JournalAttempt(
1156
+ key=key, agent_id=agent_id, ordinal=ordinal_counts[key], started_entry=entry,
1157
+ )
1158
+ occurrences.append(occurrence)
1159
+ open_by_key[key] = occurrence
1160
+ elif etype in ("result", "failed"):
1161
+ current = open_by_key.get(key)
1162
+ event_agent_id = entry.get("agentId")
1163
+ if current is None:
1164
+ diagnostics.append(
1165
+ KeyedDiagnostic(
1166
+ f"journal.jsonl: {etype!r} event for key@{line_no} has no open attempt",
1167
+ f"journal.jsonl: {etype!r} event for key {key!r} has no open attempt",
1168
+ )
1169
+ )
1170
+ continue
1171
+ if event_agent_id is not None and event_agent_id != current.agent_id:
1172
+ diagnostics.append(
1173
+ KeyedDiagnostic(
1174
+ f"journal.jsonl: {etype!r} event for key@{line_no} does not match "
1175
+ "the open attempt's agent id",
1176
+ f"journal.jsonl: {etype!r} event for key {key!r} does not match "
1177
+ "the open attempt's agent id",
1178
+ )
1179
+ )
1180
+ continue
1181
+ current.closed_by = "completed" if etype == "result" else "failed"
1182
+ current.closing_entry = entry
1183
+ current.closing_line = line_no
1184
+ del open_by_key[key]
1185
+ # every other type (launched, unknown types) is ignored here, same
1186
+ # as everywhere else in the reader (docs/DESIGN.md 3).
1187
+
1188
+ return occurrences, diagnostics
1189
+
1190
+
1191
+ # --- Attempt.reported: the agent's own self-reported result (docs/ -----
1192
+ # --- DESIGN.md section 3, 4.1; .reviews/proposals/2026-09-v0.2-guided- ---
1193
+ # --- rescue.md section 4.3) ----------------------------------------------
1194
+ #
1195
+ # A real journal 'result' event's own value is a dict in most cases and a
1196
+ # string in the rest (measured: 483/705 dict, 222/705 string on real data).
1197
+ # A dict's values are the agent's self-reported claims about its own work
1198
+ # (tests_passed, ok, report_path, ...); many of those observed keys (note,
1199
+ # summary, bytes, ...) are free text or otherwise unbounded and are never
1200
+ # carried past the key name itself. A string result can run to 25k chars
1201
+ # and is private end to end: only its length ever leaves this function.
1202
+
1203
+ _REPORTED_MAX_KEYS = 40
1204
+ _REPORTED_KEY_NAME_LIMIT = 64
1205
+ _REPORTED_STATUS_LIMIT = 32
1206
+ _REPORTED_REPORT_PATH_LIMIT = 1024
1207
+ _REPORTED_MAX_COMMITS = 20
1208
+ _COMMIT_HEX_RE = re.compile(r"[0-9a-f]{7,40}")
1209
+
1210
+
1211
+ def _reported_claims(value: dict) -> dict:
1212
+ """The allowlisted subset of a dict result's own claims (docs/DESIGN.md
1213
+ section 3): only these exact keys, only when present with the one
1214
+ shape each is allowed to have; every other key or shape is dropped
1215
+ silently (it still shows up in the sibling 'keys' list, never here).
1216
+ `bool` is checked with `type(v) is int` for the integer claims (not
1217
+ `isinstance`), since Python's bool is a subclass of int and 'ok'/
1218
+ 'ready' already have their own, separate bool-only claims."""
1219
+ claims: dict = {}
1220
+
1221
+ for key in ("ok", "ready"):
1222
+ if key in value and isinstance(value[key], bool):
1223
+ claims[key] = value[key]
1224
+
1225
+ if "status" in value:
1226
+ status = value["status"]
1227
+ if isinstance(status, str) and len(status) <= _REPORTED_STATUS_LIMIT:
1228
+ claims["status"] = status
1229
+
1230
+ for key in ("tests_passed", "tests_failed", "suite_passed", "suite_failed"):
1231
+ if key in value and type(value[key]) is int:
1232
+ claims[key] = value[key]
1233
+
1234
+ if "blockers" in value and isinstance(value["blockers"], list):
1235
+ claims["blockers_count"] = len(value["blockers"])
1236
+
1237
+ if "commits" in value and isinstance(value["commits"], list):
1238
+ commits = value["commits"]
1239
+ claims["commits_count"] = len(commits)
1240
+ claims["commits"] = [
1241
+ item for item in commits if isinstance(item, str) and _COMMIT_HEX_RE.fullmatch(item)
1242
+ ][:_REPORTED_MAX_COMMITS]
1243
+
1244
+ if "report_path" in value:
1245
+ report_path = value["report_path"]
1246
+ if isinstance(report_path, str) and len(report_path) <= _REPORTED_REPORT_PATH_LIMIT:
1247
+ claims["report_path"] = report_path
1248
+
1249
+ return claims
1250
+
1251
+
1252
+ def _build_reported(closing_entry: dict) -> dict | None:
1253
+ """Attempt.reported for an attempt the journal state machine closed with
1254
+ a 'result' event (docs/DESIGN.md section 3): None for a 'failed' event,
1255
+ for one that never closed, or for a malformed/absent `result` value.
1256
+ Never reads anything the journal did not itself carry on this one
1257
+ entry."""
1258
+ value = closing_entry.get("result") if isinstance(closing_entry, dict) else None
1259
+ if isinstance(value, str):
1260
+ return {"type": "text", "text_length": len(value)}
1261
+ if isinstance(value, dict):
1262
+ keys = [key[:_REPORTED_KEY_NAME_LIMIT] for key in sorted(value.keys())]
1263
+ return {
1264
+ "type": "dict",
1265
+ "keys": keys[:_REPORTED_MAX_KEYS],
1266
+ "claims": _reported_claims(value),
1267
+ }
1268
+ return None
1269
+
1270
+
1271
+ def _workflow_finished_from_occurrences(occurrences: list[_JournalAttempt]) -> bool:
1272
+ """docs/DESIGN.md 4.1: a key is resolved once it has no open attempt.
1273
+ True when there is at least one (started) key and every key is
1274
+ resolved -- the last occurrence recorded for each key is the one that
1275
+ matters, since an earlier occurrence of the same key is always closed
1276
+ (superseded) the moment a later one starts."""
1277
+ last_by_key: dict[str, _JournalAttempt] = {}
1278
+ for occurrence in occurrences:
1279
+ last_by_key[occurrence.key] = occurrence
1280
+ if not last_by_key:
1281
+ return False
1282
+ return all(occurrence.closed_by is not None for occurrence in last_by_key.values())
1283
+
1284
+
1285
+ def workflow_journal_finished(journal: list[dict]) -> bool:
1286
+ """docs/DESIGN.md 4.1 and 4.12: whether every key the journal started has
1287
+ been resolved by the per-key state machine above -- the journal-only
1288
+ reading of 'finished', never the full definition of Run.finished (that
1289
+ is workflow_lifecycle/lifecycle_finished, docs/DESIGN.md section 3). This
1290
+ narrower rule is kept, and called out by name, for exactly one caller:
1291
+ watch's DONE event (4.12), which deliberately tracks the journal alone as
1292
+ a live progress signal while it is still growing, not the parent's own
1293
+ (slower-arriving) terminal evidence. Not used by _load_workflow or by
1294
+ hooks_cli._workflow_finished since the N2 root-cause fix -- both of
1295
+ those now go through workflow_lifecycle so they can never again drift
1296
+ into two different answers for the same run (the N2 checkpoint had
1297
+ hooks_cli still calling this function directly, which is exactly how
1298
+ that drift happened)."""
1299
+ occurrences, _ = _run_journal_state_machine(journal)
1300
+ return _workflow_finished_from_occurrences(occurrences)
1301
+
1302
+
1303
+ def _load_meta_file(meta_path: Path, diagnostics: list[str]) -> dict:
1304
+ if not _exists(meta_path):
1305
+ return {}
1306
+ try:
1307
+ with meta_path.open("r", encoding="utf-8") as handle:
1308
+ data = json.load(handle)
1309
+ except (*_UNPARSEABLE, OSError) as exc:
1310
+ diagnostics.append(f"{meta_path.name}: {_unparseable_text(exc)}")
1311
+ _skipped_file()
1312
+ return {}
1313
+ if not isinstance(data, dict):
1314
+ diagnostics.append(f"{meta_path.name}: not a JSON object")
1315
+ return {}
1316
+ return data
1317
+
1318
+
1319
+ def _load_meta(run_path: Path, agent_id: str, diagnostics: list[str]) -> dict:
1320
+ return _load_meta_file(run_path / f"agent-{agent_id}.meta.json", diagnostics)
1321
+
1322
+
1323
+ # --- session runs -----------------------------------------------------
1324
+
1325
+
1326
+ def _session_dir(path: Path) -> Path:
1327
+ """The <root>/<slug>/<session>/ directory sitting next to a session's
1328
+ own <session>.jsonl transcript, holding its subagents/ subdirectory."""
1329
+ return path.parent / path.stem
1330
+
1331
+
1332
+ def _discover_session_agent_paths(path: Path) -> list[Path]:
1333
+ subagents_dir = _session_dir(path) / "subagents"
1334
+ if not _is_dir(subagents_dir):
1335
+ return []
1336
+ return sorted(p for p in subagents_dir.glob("agent-*.jsonl") if _is_file(p))
1337
+
1338
+
1339
+ def _orphan_meta_diagnostics(main_path: Path) -> list[str]:
1340
+ """One diagnostic per canonical `subagents/agent-<id>.meta.json` of the
1341
+ session directory whose `agent-<id>.jsonl` is not a file, in sorted id
1342
+ order (docs/DESIGN.md 4.13.1, C1). The meta file's content is not read."""
1343
+ subagents_dir = _session_dir(main_path) / "subagents"
1344
+ if not _is_dir(subagents_dir):
1345
+ return []
1346
+ orphans: list[str] = []
1347
+ for meta_path in subagents_dir.glob("agent-*.meta.json"):
1348
+ agent_id = meta_path.name.removeprefix("agent-").removesuffix(".meta.json")
1349
+ if (
1350
+ _is_file(meta_path)
1351
+ and disclosure.is_canonical_id("agent", agent_id)
1352
+ and not _is_file(subagents_dir / f"agent-{agent_id}.jsonl")
1353
+ ):
1354
+ orphans.append(agent_id)
1355
+ return [
1356
+ f"agent-{agent_id}.meta.json: child named by a meta file has no transcript"
1357
+ for agent_id in sorted(orphans)
1358
+ ]
1359
+
1360
+
1361
+ def _parse_main_attempt(
1362
+ main_path: Path,
1363
+ ) -> tuple[Attempt, str | None, str | None, list[str], bool, list[datetime]]:
1364
+ """(main_attempt, claude_version, session_id, diagnostics, incomplete,
1365
+ event_timestamps) for main_path alone, with no subagent involved. The
1366
+ building block both load_session (one fresh parse) and
1367
+ load_session_view (docs/DESIGN.md 7.2, D6: optionally cached across
1368
+ several calls within one hook invocation) share."""
1369
+ diagnostics: list[str] = []
1370
+ launch_log = _LaunchLog()
1371
+ main_state = ledger.TranscriptState()
1372
+ # docs/DESIGN.md 4.2 (FA4): a fork's inherited prefix is the parent's, not this
1373
+ # session's; the same raw boundary ParentIndex uses.
1374
+ inherited = _fork_boundary(main_path)
1375
+ (
1376
+ main_events, claude_version, session_id, unreadable, over_threshold, main_tool_calls,
1377
+ main_marker_hits,
1378
+ ) = _read_transcript_tracked(
1379
+ main_path, diagnostics, launch_log=launch_log, state=main_state,
1380
+ skip_through_line=inherited,
1381
+ )
1382
+ if inherited and not unreadable:
1383
+ diagnostics.append(QualityNote(
1384
+ f"inherited {inherited} leading lines from another session (a fork); "
1385
+ "they are that session's"
1386
+ ))
1387
+ incomplete = unreadable or over_threshold
1388
+ incomplete = incomplete or any(call.ambiguous for call in main_tool_calls)
1389
+ main_ts = [event.ts for event in main_events if event.ts is not None]
1390
+ incomplete = incomplete or _edge_event_has_timestamp_diagnostic(main_events, diagnostics)
1391
+ main_outcome = _compute_outcome(main_path, main_events)
1392
+ main_last_ts = max(main_ts) if main_ts else None
1393
+ main_attempt = Attempt(
1394
+ agent_id=main_path.stem,
1395
+ key=None,
1396
+ label="main",
1397
+ phase="",
1398
+ agent_type=None,
1399
+ meta={},
1400
+ transcript_path=main_path,
1401
+ events=main_events,
1402
+ first_ts=min(main_ts) if main_ts else None,
1403
+ last_ts=main_last_ts,
1404
+ ordinal=1,
1405
+ outcome=main_outcome,
1406
+ usage=_sum_usage(main_events),
1407
+ tool_calls=main_tool_calls,
1408
+ marker_hits=main_marker_hits,
1409
+ _launch_log=launch_log,
1410
+ _transcript_state=main_state,
1411
+ entry_cwds=tuple(main_state.cwds),
1412
+ )
1413
+ main_attempt.facts = _build_attempt_facts(
1414
+ main_tool_calls, main_outcome, main_last_ts, main_path, unreadable
1415
+ )
1416
+ if main_outcome == "killed":
1417
+ main_attempt.interrupt = _build_interrupt(main_attempt)
1418
+ return main_attempt, claude_version, session_id, diagnostics, incomplete, main_ts
1419
+
1420
+
1421
+ def _load_session_from_paths(
1422
+ main_path: Path, agent_paths: list[Path], *, main_cache: dict | None = None,
1423
+ reconcile: bool = False,
1424
+ ) -> Run:
1425
+ """Parse a session run from an explicit main transcript plus an
1426
+ explicit list of subagent transcript paths (docs/DESIGN.md 4.2, 7.2's
1427
+ D6): the shared body of load_session (every subagent discovered under
1428
+ the session directory) and load_session_view (only the named ones, for
1429
+ an isolated single-subagent analysis).
1430
+
1431
+ `main_cache`, when given, is a plain dict the caller owns and reuses
1432
+ across several calls: the main transcript is then parsed at most once
1433
+ per dict, keyed by path, rather than once per call (docs/DESIGN.md
1434
+ 7.2's "the main transcript is parsed once per invocation"). load_session
1435
+ never passes one, so its own behavior (and cost) is unchanged.
1436
+
1437
+ Raises NotARun (docs/DESIGN.md 4.2) for a main_path with no timestamped
1438
+ user/assistant entry -- a bridge-session marker, for example -- the same
1439
+ rule discover_runs applies, so a direct call here is never fooled by one
1440
+ that slipped past discovery some other way."""
1441
+ main_path = Path(main_path)
1442
+ if not _has_timestamped_conversation(main_path):
1443
+ raise NotARun(str(main_path))
1444
+ diagnostics: list[str] = []
1445
+ slug = main_path.parent.name
1446
+ uuid = main_path.stem
1447
+ run_id = _session_public_id(slug, uuid, diagnostics)
1448
+
1449
+ cached = main_cache.get(main_path) if main_cache is not None else None
1450
+ if cached is None:
1451
+ cached = _parse_main_attempt(main_path)
1452
+ if main_cache is not None:
1453
+ main_cache[main_path] = cached
1454
+ main_attempt, claude_version, session_id, main_diagnostics, incomplete, main_ts = cached
1455
+ if main_cache is not None:
1456
+ # The cached parse is shared by every view of this invocation, but a
1457
+ # ledger depends on the run it belongs to (its discovery), so each run
1458
+ # gets its own copy of the main attempt.
1459
+ main_attempt = copy.copy(main_attempt)
1460
+ diagnostics.extend(main_diagnostics)
1461
+ # docs/DESIGN.md 4.1/4.13.1 (N4b-1): per-source diagnostics beside the
1462
+ # flat list. A session has no journal.
1463
+ discovery: dict[str, list[str]] = {
1464
+ "journal": [], "parent": [], "transcripts": discovery_part(main_diagnostics),
1465
+ "meta": [],
1466
+ }
1467
+
1468
+ all_ts = list(main_ts)
1469
+ agents: list[LogicalAgent] = []
1470
+ attempts: list[Attempt] = []
1471
+
1472
+ # docs/DESIGN.md 4.13.1 (G-A): the facts every analyzed child's transcript recorded, for the
1473
+ # return decision (resume requests may come from any transcript of the session).
1474
+ child_facts: dict[Path, returns.ReturnFacts] = {}
1475
+ child_meta: list[tuple[Attempt, str, dict, bool]] = []
1476
+
1477
+ for agent_path in agent_paths:
1478
+ agent_path = Path(agent_path)
1479
+ id_start = len(diagnostics)
1480
+ agent_id = _valid_agent_id(
1481
+ agent_path.stem.removeprefix("agent-"), diagnostics, "session subagent"
1482
+ )
1483
+ discovery["transcripts"].extend(discovery_part(diagnostics[id_start:]))
1484
+ meta_path = agent_path.with_name(f"{agent_path.stem}.meta.json")
1485
+ meta_start = len(diagnostics)
1486
+ meta = _load_meta_file(meta_path, diagnostics)
1487
+ meta_bad = len(diagnostics) > meta_start
1488
+ discovery["meta"].extend(discovery_part(diagnostics[meta_start:]))
1489
+ label = meta.get("description", "")
1490
+ if not isinstance(label, str):
1491
+ label = ""
1492
+ agent_type = meta.get("agentType")
1493
+ phase = meta.get("workflowPhase", "")
1494
+ if not isinstance(phase, str):
1495
+ phase = ""
1496
+
1497
+ transcript_start = len(diagnostics)
1498
+ sub_state = ledger.TranscriptState()
1499
+ sub_log = _LaunchLog()
1500
+ (
1501
+ events, sub_version, _, sub_unreadable, sub_over_threshold, sub_tool_calls,
1502
+ sub_marker_hits,
1503
+ ) = _read_transcript_tracked(agent_path, diagnostics, state=sub_state, launch_log=sub_log)
1504
+ sub_log.facts.path = agent_path
1505
+ sub_log.facts.unreadable = sub_unreadable
1506
+ child_facts[agent_path] = sub_log.facts
1507
+ discovery["transcripts"].extend(discovery_part(diagnostics[transcript_start:]))
1508
+ incomplete = incomplete or sub_unreadable or sub_over_threshold
1509
+ incomplete = incomplete or any(call.ambiguous for call in sub_tool_calls)
1510
+ if claude_version is None and sub_version:
1511
+ claude_version = sub_version
1512
+ event_ts = [event.ts for event in events if event.ts is not None]
1513
+ all_ts.extend(event_ts)
1514
+ incomplete = incomplete or _edge_event_has_timestamp_diagnostic(events, diagnostics)
1515
+ outcome = _compute_outcome(agent_path, events)
1516
+ sub_last_ts = max(event_ts) if event_ts else None
1517
+
1518
+ attempt = Attempt(
1519
+ agent_id=agent_id,
1520
+ key=agent_id,
1521
+ label=label,
1522
+ phase=phase,
1523
+ agent_type=agent_type,
1524
+ meta=meta,
1525
+ transcript_path=agent_path,
1526
+ events=events,
1527
+ first_ts=min(event_ts) if event_ts else None,
1528
+ last_ts=sub_last_ts,
1529
+ ordinal=1,
1530
+ outcome=outcome,
1531
+ usage=_sum_usage(events),
1532
+ tool_calls=sub_tool_calls,
1533
+ marker_hits=sub_marker_hits,
1534
+ _transcript_state=sub_state,
1535
+ entry_cwds=tuple(sub_state.cwds),
1536
+ )
1537
+ attempt.facts = _build_attempt_facts(
1538
+ sub_tool_calls, outcome, sub_last_ts, agent_path, sub_unreadable
1539
+ )
1540
+ child_meta.append((attempt, agent_path.stem.removeprefix("agent-"), meta, meta_bad))
1541
+ if outcome == "killed":
1542
+ attempt.interrupt = _build_interrupt(attempt)
1543
+ attempts.append(attempt)
1544
+ agents.append(LogicalAgent(key=agent_id, label=label, phase=phase, attempts=[attempt]))
1545
+
1546
+ run = Run(
1547
+ run_id=run_id,
1548
+ kind="session",
1549
+ path=main_path,
1550
+ project_slug=slug,
1551
+ session_id=session_id or uuid,
1552
+ claude_version=claude_version,
1553
+ journal=[],
1554
+ main=main_attempt,
1555
+ agents=agents,
1556
+ attempts=attempts,
1557
+ started_at=min(all_ts) if all_ts else None,
1558
+ ended_at=max(all_ts) if all_ts else None,
1559
+ diagnostics=diagnostics,
1560
+ status="INCOMPLETE" if incomplete else "COMPLETE",
1561
+ # docs/DESIGN.md 4.13.3 (v0.3 N2, D11): a session carries no Workflow
1562
+ # launch and no generation to pair, so it has no lifecycle model to
1563
+ # report at all -- finished is null, not true, distinct from the
1564
+ # dataclass's own "never set" default of True.
1565
+ finished=None,
1566
+ discovery=discovery,
1567
+ )
1568
+ if child_meta:
1569
+ _assign_return_states(main_path, child_facts, child_meta)
1570
+ parent_index = _parent_index_for(run)
1571
+ _attribute_interrupts(run, parent_index)
1572
+ if reconcile:
1573
+ # docs/DESIGN.md 4.13.1 (C1, N4 fix): a canonical meta file whose
1574
+ # child has no transcript, in sorted id order, before the launches.
1575
+ discovery["transcripts"].extend(_orphan_meta_diagnostics(main_path))
1576
+ if reconcile and main_attempt._launch_log is not None:
1577
+ # docs/DESIGN.md 4.13.1 (C-14, N4b-1): every Agent, Task, and
1578
+ # Workflow launch of the main transcript reconciles with a discovered
1579
+ # child, or adds one `transcripts` diagnostic. Workflow launches use
1580
+ # the generations of the same parent-index snapshot.
1581
+ discovery["transcripts"].extend(
1582
+ _reconcile_session_launches(
1583
+ main_path, main_attempt._launch_log, agent_paths, list(parent_index.generations)
1584
+ )
1585
+ )
1586
+ ledger.attach_ledgers(run)
1587
+ return run
1588
+
1589
+
1590
+ def _read_evidence_facts(path: Path) -> "returns.ReturnFacts":
1591
+ """The return facts of a transcript read as evidence only (never an analyzed attempt): a
1592
+ nested child's launcher, or any other session transcript that may hold a resume request. A
1593
+ transcript that is not a file, or cannot be read, is `unreadable`."""
1594
+ log = _LaunchLog()
1595
+ if not _is_file(path):
1596
+ facts = returns.ReturnFacts(path, unreadable=True)
1597
+ return facts
1598
+ result = _read_transcript_tracked(path, [], launch_log=log)
1599
+ facts = log.facts
1600
+ facts.path = path
1601
+ facts.unreadable = result[3]
1602
+ return facts
1603
+
1604
+
1605
+ class _SessionReturns:
1606
+ """The return decision of the session subagents of one main transcript (docs/DESIGN.md
1607
+ 4.13.1, T0 to T5): the main transcript's facts and accepted notifications come from its
1608
+ ParentIndex (incremental, shared with the hooks); a nested child's launcher and every other
1609
+ transcript read for a resume request are evidence only."""
1610
+
1611
+ def __init__(self, main_path: Path, loaded: "dict[Path, returns.ReturnFacts]"):
1612
+ self.main_path = Path(main_path)
1613
+ self.index = ParentIndex.for_path(self.main_path)
1614
+ self.main = self.index.facts
1615
+ self.main_unreadable = self.index._unreadable_diagnostic is not None
1616
+ self.loaded = loaded
1617
+ self._evidence: dict[Path, returns.ReturnFacts] = {}
1618
+ self.subagents = _session_dir(self.main_path) / "subagents"
1619
+ ids: set[str] = set()
1620
+ if _is_dir(self.subagents):
1621
+ patterns = (("agent-*.jsonl", ".jsonl"), ("agent-*.meta.json", ".meta.json"))
1622
+ for pattern, suffix in patterns:
1623
+ for found in self.subagents.glob(pattern):
1624
+ ids.add(found.name.removeprefix("agent-").removesuffix(suffix))
1625
+ self.siblings = frozenset(ids)
1626
+ self._notes = [
1627
+ returns.Note(n.line_no, n.task_id, n.tool_use_id, n.status, n.ts)
1628
+ for n in self.index._accepted_notifications()
1629
+ ]
1630
+
1631
+ def path_of(self, agent_id: str) -> Path:
1632
+ return self.subagents / f"agent-{agent_id}.jsonl"
1633
+
1634
+ def facts_of(self, agent_id: str) -> "returns.ReturnFacts":
1635
+ path = self.path_of(agent_id)
1636
+ if path in self.loaded:
1637
+ return self.loaded[path]
1638
+ if path not in self._evidence:
1639
+ self._evidence[path] = _read_evidence_facts(path)
1640
+ return self._evidence[path]
1641
+
1642
+ def notes_of(self, agent_id: str) -> "list[returns.Note]":
1643
+ return [n for n in self._notes if n.task_id == agent_id]
1644
+
1645
+ def launcher_facts(self, key: str) -> "returns.ReturnFacts":
1646
+ return self.main if key == "main" else self.facts_of(key)
1647
+
1648
+ def digest_for(self, agent_id: str, meta: dict) -> str:
1649
+ return returns.main_side_digest(
1650
+ agent_id, self.main, self.notes_of(agent_id), meta.get("toolUseId", returns.ABSENT)
1651
+ )
1652
+
1653
+ def decide(self, agent_id: str, meta: dict, meta_bad: bool):
1654
+ """(Decision, the launcher's facts or None)."""
1655
+ key, reason = returns.launcher_of(meta, self.siblings, agent_id)
1656
+ issuers = [self.main, *self.loaded.values()]
1657
+ launcher = None
1658
+ if key is not None and not meta_bad:
1659
+ launcher = self.launcher_facts(key)
1660
+ if not any(launcher is f for f in issuers):
1661
+ issuers.append(launcher)
1662
+
1663
+ def early(reason_word: str):
1664
+ return returns.Decision(
1665
+ returns.UNKNOWN, reason_word,
1666
+ returns.resume_count(agent_id, issuers, self.siblings),
1667
+ ), launcher
1668
+
1669
+ if meta_bad:
1670
+ return early("malformed")
1671
+ if key is None:
1672
+ return early(reason)
1673
+ # GA-1: a transcript that may hold a resume request and cannot be read at all hides
1674
+ # which child it resumed, so every child of the session is unknown (a malformed or
1675
+ # half-written line inside a readable transcript is only a recorded limit, 4.13.1)
1676
+ if self.main_unreadable or any(f.unreadable for f in issuers):
1677
+ return early("parent_unreadable")
1678
+ return returns.decide(
1679
+ agent_id, launcher, self.main, self.notes_of(agent_id), issuers, self.siblings,
1680
+ meta.get("toolUseId", returns.ABSENT),
1681
+ ), launcher
1682
+
1683
+
1684
+ def _assign_return_states(
1685
+ main_path: Path, child_facts: "dict[Path, returns.ReturnFacts]", children: list,
1686
+ ) -> None:
1687
+ """Set return_state, return_reason, resumes, returned_to_parent and return_evidence on
1688
+ every session subagent attempt (docs/DESIGN.md 4.1, 4.13.1)."""
1689
+ context = _SessionReturns(main_path, child_facts)
1690
+ for attempt, agent_id, meta, meta_bad in children:
1691
+ decision, launcher = context.decide(agent_id, meta, meta_bad)
1692
+ attempt.return_state = decision.state
1693
+ attempt.return_reason = decision.reason
1694
+ attempt.resumes = decision.resumes
1695
+ attempt.returned_to_parent = decision.state == returns.RETURNED
1696
+ if decision.evidence is not None:
1697
+ source, line, in_launcher = decision.evidence
1698
+ where = launcher.path if in_launcher and launcher is not None else Path(main_path)
1699
+ attempt.return_evidence = (source, where, line)
1700
+
1701
+
1702
+ def session_child_return(main_path: Path, agent_path: Path, cache: dict | None = None):
1703
+ """(Decision, main-side digest, launcher transcript path or None, unreadable tokens) of one
1704
+ session subagent, from the main transcript and, when it is nested, its launcher read as
1705
+ evidence only (docs/DESIGN.md 7.2's finished rule and fingerprint). The child's own
1706
+ transcript is not read, only its meta file. `cache`, a dict the caller owns for one hook
1707
+ invocation, keeps the main transcript's context so its notifications are listed once."""
1708
+ agent_path = Path(agent_path)
1709
+ agent_id = agent_path.stem.removeprefix("agent-")
1710
+ meta_path = agent_path.with_name(f"{agent_path.stem}.meta.json")
1711
+ diagnostics: list[str] = []
1712
+ meta = _load_meta_file(meta_path, diagnostics)
1713
+ key = ("returns", Path(main_path))
1714
+ context = cache.get(key) if cache is not None else None
1715
+ if context is None:
1716
+ context = _SessionReturns(Path(main_path), {})
1717
+ if cache is not None:
1718
+ cache[key] = context
1719
+ decision, launcher = context.decide(agent_id, meta, bool(diagnostics))
1720
+ path = launcher.path if launcher is not None and launcher is not context.main else None
1721
+ # GA-3: which inputs could not be read, as stable tokens for the hook's fingerprint
1722
+ unreadable = tuple(
1723
+ token for token, bad in (
1724
+ ("main", context.main_unreadable),
1725
+ ("meta", bool(diagnostics)),
1726
+ ("launcher", path is not None and launcher.unreadable),
1727
+ ) if bad
1728
+ )
1729
+ return decision, context.digest_for(agent_id, meta), path, unreadable
1730
+
1731
+
1732
+ def load_session(path: Path) -> Run:
1733
+ """Parse a session run: the main transcript plus its Agent-tool
1734
+ subagent transcripts (docs/DESIGN.md 4.2). Run.main is the main
1735
+ transcript as an Attempt (label='main', key=None, ordinal=1); each
1736
+ subagents/agent-*.jsonl becomes one Attempt and one LogicalAgent.
1737
+ Retries of an Agent-tool subagent are not observable in v0.1: each
1738
+ transcript file is exactly one attempt, ordinal 1."""
1739
+ path = Path(path)
1740
+ return _load_session_from_paths(path, _discover_session_agent_paths(path), reconcile=True)
1741
+
1742
+
1743
+ def load_session_view(
1744
+ main_path: Path, subagent_paths: list[Path], *, main_cache: dict | None = None
1745
+ ) -> Run:
1746
+ """Parse main_path plus only the named subagent_paths -- never the rest
1747
+ of the session's subagents/ directory (docs/DESIGN.md 7.2's D6):
1748
+ analyzing one finished subagent unit in isolation must not be
1749
+ contaminated by an unrelated sibling's parse failure, and must not cost
1750
+ O(every subagent of the session) to do it. An empty subagent_paths list
1751
+ is a main-thread-only view (docs/DESIGN.md 7.2's D5: session-end).
1752
+
1753
+ Attempts are matched back to a unit by `attempt.transcript_path`, never
1754
+ by re-deriving an agent id from the filename: _valid_agent_id may
1755
+ replace a non-conforming id, so a caller must never assume the id it
1756
+ passed in survives unchanged onto the Attempt it gets back."""
1757
+ return _load_session_from_paths(
1758
+ Path(main_path), [Path(p) for p in subagent_paths], main_cache=main_cache
1759
+ )
1760
+
1761
+
1762
+ # --- outcome and interrupt attribution --------------------------------
1763
+
1764
+
1765
+ def _last_user_event_index(events: list[Event]) -> int | None:
1766
+ last = None
1767
+ for index, event in enumerate(events):
1768
+ if event.kind == "user":
1769
+ last = index
1770
+ return last
1771
+
1772
+
1773
+ def _compute_outcome(transcript_path: Path | None, events: list[Event]) -> str:
1774
+ if transcript_path is None:
1775
+ return "unknown"
1776
+ index = _last_user_event_index(events)
1777
+ if index is not None and events[index].text.startswith(KILL_SENTINEL_PREFIX):
1778
+ return "killed"
1779
+ return "completed"
1780
+
1781
+
1782
+ def _build_interrupt(attempt: Attempt) -> Interrupt | None:
1783
+ """Interrupt for an attempt whose last user event is the kill sentinel
1784
+ (docs/DESIGN.md 4.4). preceding comes from
1785
+ detectors.base.meaningful_event_before (4.5); silence_seconds is None
1786
+ when a timestamp is missing, the delta is negative, or preceding is None
1787
+ (including because a compaction event stopped the walk -- a context
1788
+ reset makes "silence" meaningless). cause stays 'unattributed' and
1789
+ cause_evidence None here; _attribute_interrupts fills them in once every
1790
+ attempt of the run has been loaded (docs/DESIGN.md 4.4 needs to know
1791
+ whether a key has a later attempt)."""
1792
+ events = attempt.events
1793
+ kill_index = _last_user_event_index(events)
1794
+ if kill_index is None:
1795
+ return None
1796
+ kill_event = events[kill_index]
1797
+ if not kill_event.text.startswith(KILL_SENTINEL_PREFIX):
1798
+ return None
1799
+
1800
+ preceding = meaningful_event_before(attempt, kill_index)
1801
+
1802
+ silence_seconds: float | None = None
1803
+ if preceding is not None and preceding.ts is not None and kill_event.ts is not None:
1804
+ delta = (kill_event.ts - preceding.ts).total_seconds()
1805
+ if delta >= 0:
1806
+ silence_seconds = delta
1807
+
1808
+ return Interrupt(
1809
+ kill_ts=kill_event.ts,
1810
+ preceding=preceding,
1811
+ silence_seconds=silence_seconds,
1812
+ cause="unattributed",
1813
+ cause_evidence=None,
1814
+ )
1815
+
1816
+
1817
+ # docs/DESIGN.md 4.1's v0.2 reader changes: the tool names ToolCall.path is
1818
+ # ever set for; used by _build_attempt_facts as a defensive filter (path is
1819
+ # already None for every other tool -- see _make_tool_call -- so this only
1820
+ # guards against a future tool being added to _PATH_TOOL_INPUT_FIELD without
1821
+ # also being a "wrote a file" tool).
1822
+ _PATH_WRITING_TOOL_NAMES = frozenset(_PATH_TOOL_INPUT_FIELD)
1823
+
1824
+ # outcomes for which a not-yet-resolved tool call is genuinely "in flight"
1825
+ # (docs/DESIGN.md 4.1's v0.2 reader changes): a 'completed' attempt reports
1826
+ # [] unconditionally, since the journal's own verdict wins and a stray
1827
+ # no_result call in its transcript proves nothing was actually abandoned.
1828
+ _IN_FLIGHT_OUTCOMES = frozenset({"killed", "failed", "superseded"})
1829
+
1830
+
1831
+ def _build_attempt_facts(
1832
+ tool_calls: list[ToolCall],
1833
+ outcome: str,
1834
+ last_ts: datetime | None,
1835
+ transcript_path: Path | None,
1836
+ unreadable: bool,
1837
+ ) -> AttemptFacts | None:
1838
+ """AttemptFacts derived from one attempt's tool_calls, computed once the
1839
+ transcript is fully read (docs/DESIGN.md 4.1/4.2's v0.2 reader changes).
1840
+
1841
+ None only when transcript_path is None -- no transcript exists at all.
1842
+ A transcript that exists but could not even be opened (`unreadable`,
1843
+ docs/DESIGN.md 4.6) still produces an AttemptFacts, with every field set
1844
+ to FACTS_UNKNOWN rather than a misleadingly empty list or zero count."""
1845
+ if transcript_path is None:
1846
+ return None
1847
+ if unreadable:
1848
+ return AttemptFacts(
1849
+ paths_written=FACTS_UNKNOWN,
1850
+ in_flight=FACTS_UNKNOWN,
1851
+ commits=FACTS_UNKNOWN,
1852
+ cwds=FACTS_UNKNOWN,
1853
+ background_unfinished=FACTS_UNKNOWN,
1854
+ last_tool=FACTS_UNKNOWN,
1855
+ seconds_since_last_tool_at_end=FACTS_UNKNOWN,
1856
+ commit_commands_without_id=FACTS_UNKNOWN,
1857
+ )
1858
+
1859
+ paths_written: list[str] = []
1860
+ seen_paths: set[str] = set()
1861
+ in_flight: list[dict] = []
1862
+ commits: list[str] = []
1863
+ seen_commits: set[str] = set()
1864
+ cwds: list[str] = []
1865
+ seen_cwds: set[str] = set()
1866
+ background_unfinished = 0
1867
+ commit_commands_without_id = 0
1868
+
1869
+ for call in tool_calls:
1870
+ completion = call.completion
1871
+
1872
+ if (
1873
+ call.tool_name in _PATH_WRITING_TOOL_NAMES
1874
+ and call.path is not None
1875
+ and completion == "completed_ok"
1876
+ and call.path not in seen_paths
1877
+ ):
1878
+ seen_paths.add(call.path)
1879
+ paths_written.append(call.path)
1880
+
1881
+ if completion == "no_result" and outcome in _IN_FLIGHT_OUTCOMES:
1882
+ in_flight.append(
1883
+ {"tool": call.tool_name, "path": call.path, "line_no": call.line_no}
1884
+ )
1885
+
1886
+ for commit_id in call.commit_ids:
1887
+ if commit_id not in seen_commits:
1888
+ seen_commits.add(commit_id)
1889
+ commits.append(commit_id)
1890
+
1891
+ # FA6 (C1): a CALL (never a segment) that completed_ok, contains a commit command,
1892
+ # and yielded no id (a quiet commit prints no summary line). Whether the shell
1893
+ # reached the command is not provable from one call's exit status.
1894
+ if (
1895
+ call.tool_name == "Bash"
1896
+ and completion == "completed_ok"
1897
+ and call.classification is not None
1898
+ and call.classification.commit_command_present
1899
+ and not call.commit_ids
1900
+ ):
1901
+ commit_commands_without_id += 1
1902
+
1903
+ if call.cwd is not None and call.cwd not in seen_cwds:
1904
+ seen_cwds.add(call.cwd)
1905
+ cwds.append(call.cwd)
1906
+
1907
+ if completion == "backgrounded":
1908
+ background_unfinished += 1
1909
+
1910
+ last_tool = tool_calls[-1].tool_name if tool_calls else None
1911
+
1912
+ seconds_since_last_tool_at_end: float | None = None
1913
+ if tool_calls and last_ts is not None:
1914
+ last_call_ts = tool_calls[-1].ts
1915
+ if last_call_ts is not None:
1916
+ delta = (last_ts - last_call_ts).total_seconds()
1917
+ if delta >= 0:
1918
+ seconds_since_last_tool_at_end = delta
1919
+
1920
+ return AttemptFacts(
1921
+ paths_written=paths_written,
1922
+ in_flight=in_flight,
1923
+ commits=commits,
1924
+ cwds=cwds,
1925
+ background_unfinished=background_unfinished,
1926
+ last_tool=last_tool,
1927
+ seconds_since_last_tool_at_end=seconds_since_last_tool_at_end,
1928
+ commit_commands_without_id=commit_commands_without_id,
1929
+ )
1930
+
1931
+
1932
+ def _sum_usage(events: list[Event]) -> dict:
1933
+ totals: dict[str, int | float] = {}
1934
+ for event in events:
1935
+ for key, value in event.usage.items():
1936
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
1937
+ totals[key] = totals.get(key, 0) + value
1938
+ return totals
1939
+
1940
+
1941
+ def _note(text: str, quality: bool) -> str:
1942
+ """text tagged as a data-quality note (docs/DESIGN.md 4.13.1, A2) when
1943
+ `quality`, else unchanged."""
1944
+ return QualityNote(text) if quality else text
1945
+
1946
+
1947
+ def _parse_ts(
1948
+ value: object,
1949
+ path: Path | None = None,
1950
+ line_no: int | None = None,
1951
+ diagnostics: list[str] | None = None,
1952
+ *,
1953
+ quality: bool = False,
1954
+ ) -> datetime | None:
1955
+ """Parse an ISO 8601 timestamp, or None for an absent one.
1956
+
1957
+ A present but unparseable value is a malformed timestamp, not a merely
1958
+ absent one: it is recorded as a diagnostic (path:line) so a reader never
1959
+ silently loses temporal evidence without a trace, even though the caller
1960
+ still gets None back and must treat duration as unknown.
1961
+ """
1962
+ if value is None:
1963
+ return None
1964
+ if not isinstance(value, str) or not value:
1965
+ if diagnostics is not None and path is not None and line_no is not None:
1966
+ diagnostics.append(
1967
+ _note(f"{path.name}:{line_no}: timestamp is not a string", quality)
1968
+ )
1969
+ return None
1970
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
1971
+ try:
1972
+ parsed = datetime.fromisoformat(text)
1973
+ except ValueError:
1974
+ if diagnostics is not None and path is not None and line_no is not None:
1975
+ diagnostics.append(
1976
+ _note(f"{path.name}:{line_no}: timestamp could not be parsed", quality)
1977
+ )
1978
+ return None
1979
+ if parsed.tzinfo is None:
1980
+ parsed = parsed.replace(tzinfo=timezone.utc)
1981
+ return parsed
1982
+
1983
+
1984
+ def _strip_harness_noise(text: str) -> str:
1985
+ """Remove <system-reminder>, <total_tokens>, and [harness: ...] spans
1986
+ (docs/DESIGN.md 4.5) from a field's full text before it becomes an
1987
+ Event's excerpt."""
1988
+ text = _SYSTEM_REMINDER_RE.sub("", text)
1989
+ text = _TOTAL_TOKENS_RE.sub("", text)
1990
+ text = _HARNESS_NOTE_RE.sub("", text)
1991
+ return text
1992
+
1993
+
1994
+ def _excerpt(text: str | None) -> str:
1995
+ if not text:
1996
+ return ""
1997
+ return _strip_harness_noise(text)[:_EXCERPT_LIMIT]
1998
+
1999
+
2000
+ def _parse_exit_code(text: str) -> int | None:
2001
+ match = _EXIT_CODE_RE.search(text)
2002
+ if not match:
2003
+ return None
2004
+ try:
2005
+ return int(match.group(1))
2006
+ except ValueError:
2007
+ return None
2008
+
2009
+
2010
+ def _flatten_block_text(content: object) -> str:
2011
+ """Flatten a tool_result 'content' field (string or list of blocks) to
2012
+ text. docs/DESIGN.md section 3: a content list item can itself be a
2013
+ plain string, not only a {"text": ...} dict block, so both forms are
2014
+ collected here."""
2015
+ if isinstance(content, str):
2016
+ return content
2017
+ if isinstance(content, list):
2018
+ parts = []
2019
+ for block in content:
2020
+ if isinstance(block, str):
2021
+ parts.append(block)
2022
+ elif isinstance(block, dict) and isinstance(block.get("text"), str):
2023
+ parts.append(block["text"])
2024
+ return "\n".join(parts)
2025
+ if content is None:
2026
+ return ""
2027
+ return str(content)
2028
+
2029
+
2030
+ # --- marker provenance (docs/DESIGN.md 4.13.2, v0.3 N1) --------------------
2031
+
2032
+
2033
+ def _flatten_for_markers(content: object) -> str:
2034
+ """docs/DESIGN.md 4.13.2's text flattening, used only for MarkerHit
2035
+ evaluation -- never Event.text, which keeps using _flatten_block_text's
2036
+ older "join every text/string element" rule so existing display/export
2037
+ behavior is untouched. A string content as-is; a list's first element
2038
+ whose type is 'text' (a bare string list item does not count here,
2039
+ unlike _flatten_block_text); anything else, ''. Leading whitespace
2040
+ (spaces, tabs, carriage returns, newlines) is then stripped; no other
2041
+ normalization."""
2042
+ if isinstance(content, str):
2043
+ flat = content
2044
+ elif isinstance(content, list):
2045
+ flat = ""
2046
+ for block in content:
2047
+ if (
2048
+ isinstance(block, dict)
2049
+ and block.get("type") == "text"
2050
+ and isinstance(block.get("text"), str)
2051
+ ):
2052
+ flat = block["text"]
2053
+ break
2054
+ else:
2055
+ flat = ""
2056
+ return flat.lstrip(" \t\r\n")
2057
+
2058
+
2059
+ def _tool_result_shapes(
2060
+ tool_name: str | None, is_codex_bash: bool, is_error: bool = False,
2061
+ ) -> set[str]:
2062
+ """Which marker-registry shape tags a tool_result block paired to
2063
+ tool_name (and, for a Bash call, whether its own command runs codex as a
2064
+ program; whether the block has is_error true) is a candidate block for
2065
+ (docs/DESIGN.md 4.13.2)."""
2066
+ shapes: set[str] = set()
2067
+ if is_error:
2068
+ shapes.add(markers.SHAPE_TOOL_RESULT_ERROR)
2069
+ if tool_name == "Bash":
2070
+ shapes.add(markers.SHAPE_TOOL_RESULT_BASH)
2071
+ if is_codex_bash:
2072
+ shapes.add(markers.SHAPE_TOOL_RESULT_BASH_CODEX)
2073
+ if tool_name == "StructuredOutput":
2074
+ shapes.add(markers.SHAPE_TOOL_RESULT_STRUCTURED_OUTPUT)
2075
+ else:
2076
+ shapes.add(markers.SHAPE_TOOL_RESULT_ANY_EXCEPT_STRUCTURED_OUTPUT)
2077
+ return shapes
2078
+
2079
+
2080
+ def _marker_hits_for_text(
2081
+ text: str,
2082
+ applicable_shapes: set[str],
2083
+ *,
2084
+ event_line: int,
2085
+ tool_use_id: str | None,
2086
+ result_line: int | None,
2087
+ ) -> list[MarkerHit]:
2088
+ """Every MarkerHit the registry yields for one flattened block of text
2089
+ (docs/DESIGN.md 4.13.2's candidate rule): for each canonical id whose
2090
+ permitted shape intersects applicable_shapes, a candidate (one of its
2091
+ fragment strings occurs anywhere in text) yields exactly one MarkerHit,
2092
+ anchored when its predicate holds. An id whose shape never matches this
2093
+ block yields nothing at all, not even a negative hit."""
2094
+ hits: list[MarkerHit] = []
2095
+ for spec in markers.REGISTRY.values():
2096
+ matched_shape = next((shape for shape in spec.shapes if shape in applicable_shapes), None)
2097
+ if matched_shape is None:
2098
+ continue
2099
+ if not spec.candidate(text):
2100
+ continue
2101
+ anchored = spec.predicate(text, matched_shape)
2102
+ hits.append(
2103
+ MarkerHit(
2104
+ marker=spec.id,
2105
+ producer=spec.producer,
2106
+ event_line=event_line,
2107
+ tool_use_id=tool_use_id,
2108
+ result_line=result_line,
2109
+ anchored=anchored,
2110
+ in_payload=not anchored,
2111
+ )
2112
+ )
2113
+ return hits
2114
+
2115
+
2116
+ def _user_entry_marker_hits(
2117
+ entry: dict, line_no: int, tool_names: dict, tool_commands: dict,
2118
+ ) -> list[MarkerHit]:
2119
+ """Every MarkerHit from one user entry (docs/DESIGN.md 4.13.2): the
2120
+ whole entry's own flattened text (kill_sentinel's permitted shape --
2121
+ "a user entry, not a tool_result block"), plus each tool_result block it
2122
+ carries, evaluated separately by its own tool_use_id."""
2123
+ hits: list[MarkerHit] = []
2124
+ message = entry.get("message")
2125
+ message = message if isinstance(message, dict) else {}
2126
+ content = message.get("content")
2127
+
2128
+ whole_text = _flatten_for_markers(content)
2129
+ hits.extend(
2130
+ _marker_hits_for_text(
2131
+ whole_text, {markers.SHAPE_USER_ENTRY},
2132
+ event_line=line_no, tool_use_id=None, result_line=None,
2133
+ )
2134
+ )
2135
+
2136
+ if isinstance(content, list):
2137
+ for block in content:
2138
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
2139
+ continue
2140
+ raw_tool_use_id = block.get("tool_use_id")
2141
+ tool_use_id = raw_tool_use_id if isinstance(raw_tool_use_id, str) else None
2142
+ tool_name = tool_names.get(tool_use_id) if tool_use_id is not None else None
2143
+ command = tool_commands.get(tool_use_id) if tool_use_id is not None else None
2144
+ is_codex_bash = tool_name == "Bash" and markers.runs_codex_program(command)
2145
+ block_text = _flatten_for_markers(block.get("content"))
2146
+ shapes = _tool_result_shapes(tool_name, is_codex_bash, block.get("is_error") is True)
2147
+ hits.extend(
2148
+ _marker_hits_for_text(
2149
+ block_text, shapes,
2150
+ event_line=line_no, tool_use_id=tool_use_id, result_line=line_no,
2151
+ )
2152
+ )
2153
+ return hits
2154
+
2155
+
2156
+ def _assistant_entry_flatten(entry: dict) -> str:
2157
+ message = entry.get("message")
2158
+ message = message if isinstance(message, dict) else {}
2159
+ return _flatten_for_markers(message.get("content"))
2160
+
2161
+
2162
+ def _finalize_assistant_marker_hits(
2163
+ assistant_entries: list[tuple[int, str]],
2164
+ final_entry_type: str | None,
2165
+ final_entry_line_no: int | None,
2166
+ attempt_has_codex_bash: bool,
2167
+ ) -> list[MarkerHit]:
2168
+ """The two whole-assistant-entry marker shapes that need attempt-wide
2169
+ context the streaming pass cannot have yet (docs/DESIGN.md 4.13.2):
2170
+ sandbox_write_denied's "assistant text block of an attempt that has
2171
+ such a call" shape (every assistant entry, once attempt_has_codex_bash
2172
+ is known), and usage_limit_session/weekly, machine_sleep, api_error's
2173
+ "the attempt's own final transcript entry, an assistant entry" shape
2174
+ (only the one entry that really is last, once the whole transcript has
2175
+ been read)."""
2176
+ hits: list[MarkerHit] = []
2177
+ for line_no, text in assistant_entries:
2178
+ shapes: set[str] = set()
2179
+ if attempt_has_codex_bash:
2180
+ shapes.add(markers.SHAPE_ASSISTANT_CODEX_ATTEMPT)
2181
+ if final_entry_type == "assistant" and line_no == final_entry_line_no:
2182
+ shapes.add(markers.SHAPE_FINAL_ASSISTANT_ENTRY)
2183
+ if not shapes:
2184
+ continue
2185
+ hits.extend(
2186
+ _marker_hits_for_text(
2187
+ text, shapes, event_line=line_no, tool_use_id=None, result_line=None,
2188
+ )
2189
+ )
2190
+ return hits
2191
+
2192
+
2193
+ def _make_tool_call(
2194
+ tool_id: str,
2195
+ tool_name: str | None,
2196
+ input_obj: dict,
2197
+ line_no: int,
2198
+ ts: datetime | None,
2199
+ cwd_raw: object,
2200
+ ) -> ToolCall:
2201
+ """One ToolCall, filled from the tool_use block's own FULL parsed input
2202
+ (never Event's 400-char excerpt) -- docs/DESIGN.md 4.1/4.2's v0.2 reader
2203
+ changes, plus the N4b-1 private fields (run_in_background, a Monitor
2204
+ command, MultiEdit's path, and the 4.13.1 classification computed from
2205
+ the FULL command)."""
2206
+ path = None
2207
+ path_state = None # why a file tool's target is None (docs/DESIGN.md 4.13.1)
2208
+ field_name = _PATH_TOOL_INPUT_FIELD.get(tool_name or "")
2209
+ if field_name is not None:
2210
+ raw_path = input_obj.get(field_name)
2211
+ if isinstance(raw_path, str) and len(raw_path) <= _TOOL_CALL_PATH_LIMIT:
2212
+ path = raw_path
2213
+ else:
2214
+ path_state = "truncated" if isinstance(raw_path, str) else "missing"
2215
+
2216
+ multiedit_path = None
2217
+ if tool_name == _MULTIEDIT_TOOL:
2218
+ raw_path = input_obj.get("file_path")
2219
+ if isinstance(raw_path, str) and len(raw_path) <= _TOOL_CALL_PATH_LIMIT:
2220
+ multiedit_path = raw_path
2221
+ else:
2222
+ path_state = "truncated" if isinstance(raw_path, str) else "missing"
2223
+
2224
+ command = None
2225
+ command_sha256 = None
2226
+ run_in_background = None
2227
+ raw_command = None
2228
+ if tool_name in _COMMAND_TOOLS:
2229
+ raw_command = input_obj.get("command")
2230
+ run_in_background = input_obj.get("run_in_background")
2231
+ if isinstance(raw_command, str):
2232
+ if len(raw_command) <= _TOOL_CALL_COMMAND_LIMIT:
2233
+ command = raw_command
2234
+ else:
2235
+ command_sha256 = hashlib.sha256(raw_command.encode("utf-8")).hexdigest()
2236
+
2237
+ cwd = os.path.normpath(cwd_raw) if isinstance(cwd_raw, str) and cwd_raw else None
2238
+
2239
+ return ToolCall(
2240
+ tool_use_id=tool_id,
2241
+ tool_name=tool_name,
2242
+ line_no=line_no,
2243
+ ts=ts,
2244
+ path=path,
2245
+ command=command,
2246
+ command_sha256=command_sha256,
2247
+ runs_codex_program=tool_name == "Bash" and markers.runs_codex_program(
2248
+ raw_command if isinstance(raw_command, str) else None
2249
+ ),
2250
+ cwd=cwd,
2251
+ run_in_background=run_in_background,
2252
+ multiedit_path=multiedit_path,
2253
+ classification=commands.classify_call(
2254
+ tool_name, raw_command, run_in_background=run_in_background, cwd=cwd
2255
+ ),
2256
+ _path_state=path_state,
2257
+ )
2258
+
2259
+
2260
+ def _apply_background_hits(
2261
+ hits: list[MarkerHit], tool_call_index: dict[str, ToolCall], tool_commands: dict
2262
+ ) -> None:
2263
+ """An anchored bash_backgrounded hit on a Bash result makes that call
2264
+ row 2 of the truth table (docs/DESIGN.md 4.13.1): the classification is
2265
+ recomputed from the FULL command with the hit. A payload-only hit never
2266
+ does."""
2267
+ for hit in hits:
2268
+ if hit.marker != "bash_backgrounded" or not hit.anchored or hit.tool_use_id is None:
2269
+ continue
2270
+ call = tool_call_index.get(hit.tool_use_id)
2271
+ if call is None or call.tool_name != "Bash":
2272
+ continue
2273
+ call.classification = commands.classify_call(
2274
+ "Bash", tool_commands.get(hit.tool_use_id),
2275
+ run_in_background=call.run_in_background, backgrounded_hit=True, cwd=call.cwd,
2276
+ )
2277
+
2278
+
2279
+ # --- the session launch log (docs/DESIGN.md 4.13.1, C-14, N4b-1) ----------------
2280
+
2281
+ # The child-launching session tools; SendMessage continues an existing child
2282
+ # and launches none.
2283
+ _LAUNCH_TOOLS = ("Agent", "Task", "Workflow")
2284
+ _ABSENT = object()
2285
+
2286
+
2287
+ def _result_digest(block: dict, tool_use_result: object) -> str | None:
2288
+ """docs/DESIGN.md 4.13.1 and 4.13.3 (N4 fix, A4): identifies one launch
2289
+ result for the duplicate rule. Two results with the same digest (the
2290
+ `tool_result` block and the entry-level `toolUseResult` identical as
2291
+ parsed JSON) are one result. None when the pair cannot be serialized, so
2292
+ such a result never equals another."""
2293
+ try:
2294
+ text = json.dumps([block, tool_use_result], sort_keys=True, ensure_ascii=True)
2295
+ except (TypeError, ValueError):
2296
+ return None
2297
+ return hashlib.sha256(text.encode("ascii")).hexdigest()
2298
+
2299
+
2300
+ def _is_rejected_launch(block: dict, tool_use_result: object) -> bool:
2301
+ """docs/DESIGN.md 4.13.1 (N4 fix, A4 and A5): a launch result whose
2302
+ `tool_result` block has `is_error` true and whose entry-level
2303
+ `toolUseResult` is a string. This is the rejected shape only; it is not
2304
+ proof that no run was created (Codex round 2, R2-3): a rejected resume
2305
+ launch stays involved with the run it names, and an initial rejected
2306
+ launch stays `undiscovered` in the session launch table."""
2307
+ return block.get("is_error") is True and isinstance(tool_use_result, str)
2308
+
2309
+
2310
+ def _distinct_results(results: list, digest_of) -> list:
2311
+ """The results of one launch with byte-identical repeats (same digest, a
2312
+ digest of None never repeats) collapsed into the first of each."""
2313
+ seen: set[str] = set()
2314
+ out = []
2315
+ for result in results:
2316
+ digest = digest_of(result)
2317
+ if digest is not None:
2318
+ if digest in seen:
2319
+ continue
2320
+ seen.add(digest)
2321
+ out.append(result)
2322
+ return out
2323
+
2324
+
2325
+ def _launch_status_kind(tool_use_result: object) -> str:
2326
+ """The status class of an Agent launch result (docs/DESIGN.md 4.1, 4.13.1; N5 fix 1, S2):
2327
+ 'async' for `status` `async_launched` or `isAsync` true, 'plain' for a result with no
2328
+ `status` key, 'completed' for the string `completed`, else 'other' (any other string, or a
2329
+ value that is not a string)."""
2330
+ if not isinstance(tool_use_result, dict):
2331
+ return "plain"
2332
+ status = tool_use_result.get("status", _ABSENT)
2333
+ if tool_use_result.get("isAsync") is True or status == "async_launched":
2334
+ return "async"
2335
+ if status is _ABSENT:
2336
+ return "plain"
2337
+ return "completed" if status == "completed" else "other"
2338
+
2339
+
2340
+ def _carried_ids(result: tuple) -> list[str]:
2341
+ """The string ids one recorded launch result carries, in either location."""
2342
+ return [value for value in (result[0], result[1]) if isinstance(value, str)]
2343
+
2344
+
2345
+ class _LaunchLog:
2346
+ """What a session main transcript recorded while it streamed, for the
2347
+ launch reconciliation below: every launch (an assistant `tool_use` block
2348
+ named Agent, Task, or Workflow, with its raw id and physical line), how
2349
+ many assistant `tool_use` blocks carry each string id (4.2's duplicate
2350
+ rule), and, per tool_use_id, each `tool_result` block's own `agentId` and
2351
+ its entry's `toolUseResult.agentId` (_ABSENT when the key is missing)."""
2352
+
2353
+ def __init__(self) -> None:
2354
+ # (tool, raw id, physical line, the string resumeFromRunId of a Workflow's input)
2355
+ self.launches: list[tuple[str, object, int, str | None]] = []
2356
+ self.use_counts: dict[str, int] = {}
2357
+ # per tool_use_id: (block agentId, entry agentId, rejected, digest, is_error, kind)
2358
+ # where kind is the launch result's status class: 'plain' (no status),
2359
+ # 'completed', 'async' (async_launched or isAsync true), or 'other'
2360
+ self.results: dict[str, list[tuple[object, object, bool, str | None, bool, str]]] = {}
2361
+ # docs/DESIGN.md 4.13.1 (G-A): the facts the return decision reads (returns.ReturnFacts)
2362
+ self.facts = returns.ReturnFacts()
2363
+
2364
+ def clear(self) -> None:
2365
+ self.launches.clear()
2366
+ self.use_counts.clear()
2367
+ self.results.clear()
2368
+ self.facts = returns.ReturnFacts()
2369
+
2370
+ def record_use(
2371
+ self, tool_name: str | None, tool_id: object, line_no: int,
2372
+ resume_from: str | None = None, ts: datetime | None = None, tool_input: object = None,
2373
+ ) -> None:
2374
+ self.facts.note_use(tool_name, tool_id, line_no, ts, tool_input)
2375
+ if isinstance(tool_id, str):
2376
+ self.use_counts[tool_id] = self.use_counts.get(tool_id, 0) + 1
2377
+ if tool_name in _LAUNCH_TOOLS:
2378
+ self.launches.append((tool_name, tool_id, line_no, resume_from))
2379
+
2380
+ def record_result(
2381
+ self, tool_use_id: str, block: dict, tool_use_result: object,
2382
+ tur_present: bool = True, line_no: int = 0,
2383
+ ) -> None:
2384
+ self.facts.note_result(tool_use_id, block, tool_use_result, tur_present, line_no)
2385
+ block_agent = block["agentId"] if "agentId" in block else _ABSENT
2386
+ entry_agent = (
2387
+ tool_use_result["agentId"]
2388
+ if isinstance(tool_use_result, dict) and "agentId" in tool_use_result
2389
+ else _ABSENT
2390
+ )
2391
+ # docs/DESIGN.md 4.13.3 (A5): matching is by id only, so the digest of
2392
+ # every result is computed, whether or not its launch came first.
2393
+ digest = _result_digest(block, tool_use_result)
2394
+ self.results.setdefault(tool_use_id, []).append((
2395
+ block_agent, entry_agent, _is_rejected_launch(block, tool_use_result), digest,
2396
+ block.get("is_error") is True, _launch_status_kind(tool_use_result),
2397
+ ))
2398
+
2399
+ def child_returned(self, child_id: str, notifications=None) -> bool:
2400
+ """docs/DESIGN.md 4.1, 4.13.1 (S11, N5c-1): whether the child with this
2401
+ id returned a result to its parent. True only when exactly one Agent or
2402
+ Task launch block names the child through a result, no other `tool_use`
2403
+ block shares that launch's id, the launch has exactly one result (results
2404
+ identical as parsed JSON count once), and that result is not an error,
2405
+ not the rejected shape, carries only string ids, and its ids equal the
2406
+ child when both locations are present. Anything else is False: a missing,
2407
+ error, rejected, malformed, duplicate, or conflicting result, a
2408
+ duplicated launch id, and a child with no matching launch. A result whose
2409
+ status is `async_launched` (or whose `isAsync` is true) is not a return by
2410
+ itself (N5 fix 1, S2): `notifications`, a callable giving the transcript's
2411
+ accepted task notifications (4.13.3), must hold one for this child and this
2412
+ launch after the launch's own `tool_use` line (FA3: not after its result line,
2413
+ which the lifecycle uses), and every one of that pair must say `completed`. Any other status
2414
+ string is not a return."""
2415
+ naming = []
2416
+ launch_lines: dict[str, int] = {}
2417
+ for tool, raw_id, line, _resume in self.launches:
2418
+ if tool not in ("Agent", "Task") or not isinstance(raw_id, str):
2419
+ continue
2420
+ if any(child_id in _carried_ids(result) for result in self.results.get(raw_id, [])):
2421
+ naming.append(raw_id)
2422
+ launch_lines[raw_id] = line
2423
+ if len(naming) != 1:
2424
+ return False
2425
+ launch_id = naming[0]
2426
+ if self.use_counts.get(launch_id, 0) != 1:
2427
+ return False
2428
+ results = _distinct_results(self.results[launch_id], lambda result: result[3])
2429
+ if len(results) != 1:
2430
+ return False
2431
+ block_agent, entry_agent, rejected, _digest, is_error, kind = results[0]
2432
+ if rejected or is_error:
2433
+ return False
2434
+ present = [value for value in (block_agent, entry_agent) if value is not _ABSENT]
2435
+ if not (present and all(value == child_id for value in present)):
2436
+ return False
2437
+ if kind in ("plain", "completed"):
2438
+ return True
2439
+ if kind != "async" or notifications is None:
2440
+ return False
2441
+ # FA3: the return-specific boundary is the launch's own tool_use line (a
2442
+ # fast child can notify between the call and its result line); a
2443
+ # notification before it cannot be about this launch.
2444
+ launch_line = launch_lines[launch_id]
2445
+ pair = [
2446
+ notification for notification in notifications()
2447
+ if notification.task_id == child_id and notification.tool_use_id == launch_id
2448
+ and notification.line_no > launch_line
2449
+ ]
2450
+ return bool(pair) and all(
2451
+ _TERMINAL_STATUS_VOCAB.get(notification.status) == "completed"
2452
+ for notification in pair
2453
+ )
2454
+
2455
+
2456
+ def _discovered_run_dir(workflows_dir: Path, run_id: str) -> bool:
2457
+ """A canonical workflow run id that names a directory of the session's
2458
+ `workflows` directory."""
2459
+ return bool(_WORKFLOW_RUN_ID_RE.fullmatch(run_id)) and _is_dir(workflows_dir / run_id)
2460
+
2461
+
2462
+ def _launch_code(
2463
+ tool: str,
2464
+ raw_id: object,
2465
+ line_no: int,
2466
+ resume_from: str | None,
2467
+ log: _LaunchLog,
2468
+ child_ids: frozenset[str],
2469
+ generations: list[Generation],
2470
+ workflows_dir: Path,
2471
+ ) -> str | None:
2472
+ """The first applicable code of the decision table for one launch (4.13.1),
2473
+ or None when it reconciles."""
2474
+ if not isinstance(raw_id, str):
2475
+ return "malformed_launch"
2476
+ if log.use_counts.get(raw_id, 0) > 1:
2477
+ return "ambiguous_tool_id"
2478
+ results = log.results.get(raw_id, [])
2479
+ if not results:
2480
+ return "no_result"
2481
+ results = _distinct_results(results, lambda result: result[3])
2482
+ if len(results) > 1:
2483
+ return "ambiguous_result"
2484
+ if tool == "Workflow":
2485
+ if results[0][2]:
2486
+ # A5: an error result is not proof that no run exists.
2487
+ if resume_from is not None:
2488
+ return None if _discovered_run_dir(workflows_dir, resume_from) else "undiscovered"
2489
+ return "undiscovered"
2490
+ for generation in generations:
2491
+ if (
2492
+ generation.tool_use_id == raw_id
2493
+ and generation.launch_line == line_no
2494
+ and _WORKFLOW_RUN_ID_RE.fullmatch(generation.run_id or "")
2495
+ and _is_dir(workflows_dir / generation.run_id)
2496
+ ):
2497
+ return None
2498
+ return "undiscovered"
2499
+ block_agent, entry_agent, _rejected, _digest, _is_error, _kind = results[0]
2500
+ present = [value for value in (block_agent, entry_agent) if value is not _ABSENT]
2501
+ if any(not isinstance(value, str) for value in present):
2502
+ return "non_string_id"
2503
+ if len(present) == 2 and present[0] != present[1]:
2504
+ return "conflicting_ids"
2505
+ if not present:
2506
+ return "no_id"
2507
+ return None if present[0] in child_ids else "undiscovered"
2508
+
2509
+
2510
+ def _reconcile_session_launches(
2511
+ main_path: Path,
2512
+ log: _LaunchLog,
2513
+ agent_paths: list[Path],
2514
+ generations: list[Generation],
2515
+ ) -> list[str]:
2516
+ """One diagnostic per Agent, Task, or Workflow launch of the main
2517
+ transcript that does not reconcile with a discovered child, in tool-use
2518
+ order (docs/DESIGN.md 4.13.1, C-14)."""
2519
+ child_ids = frozenset(Path(p).stem.removeprefix("agent-") for p in agent_paths)
2520
+ workflows_dir = _session_dir(main_path) / "subagents" / "workflows"
2521
+ out: list[str] = []
2522
+ for tool, raw_id, line_no, resume_from in log.launches:
2523
+ code = _launch_code(
2524
+ tool, raw_id, line_no, resume_from, log, child_ids, generations, workflows_dir
2525
+ )
2526
+ if code is not None:
2527
+ out.append(
2528
+ f"{main_path.name}:{line_no}: {tool} launch has no discovered child ({code})"
2529
+ )
2530
+ return out
2531
+
2532
+
2533
+ def _note_entry_shape(entry: dict, raw_type: object, state: "ledger.TranscriptState") -> None:
2534
+ """The entry-level shape rules of docs/DESIGN.md 4.13.1 (unexpected shape and
2535
+ unknown entry type) for the ledger's reasons."""
2536
+ if not isinstance(raw_type, str):
2537
+ state.unexpected_shape = True
2538
+ return
2539
+ if raw_type == "user" or raw_type == "assistant":
2540
+ message = entry.get("message")
2541
+ if not isinstance(message, dict) or not isinstance(message.get("content"), (str, list)):
2542
+ state.unexpected_shape = True
2543
+ elif not ledger.entry_type_known(raw_type):
2544
+ state.unknown_type = True
2545
+
2546
+
2547
+ def _fork_scan(path: Path) -> tuple[int, bool]:
2548
+ """(boundary, settled) for a session main transcript (docs/DESIGN.md 4.2, FA4).
2549
+
2550
+ boundary is the physical line number of the last line of the inherited prefix: the
2551
+ last entry whose `sessionId` is a string differing from the file's own stem, before
2552
+ the first entry carrying the stem itself (0 when there is no such entry, or no own
2553
+ entry at all). settled is True once an own entry was seen, so the boundary can never
2554
+ change as the file grows; False means the file has no own entry yet (nothing is
2555
+ inherited for now). An unreadable file inherits nothing. Undecodable, blank, and
2556
+ sessionId-less lines never decide the boundary themselves."""
2557
+ own = path.stem
2558
+ last_foreign = 0
2559
+ try:
2560
+ with _transcript_reader(path) as lines:
2561
+ for line_no, line, _torn in lines:
2562
+ # cheap prefilter: an entry with a top-level sessionId spells the key
2563
+ # literally, so a line without it is never decoded here
2564
+ if '"sessionId"' not in line:
2565
+ continue
2566
+ try:
2567
+ entry = json.loads(line)
2568
+ except _UNPARSEABLE:
2569
+ continue
2570
+ if not isinstance(entry, dict):
2571
+ continue
2572
+ session = entry.get("sessionId")
2573
+ if not isinstance(session, str):
2574
+ continue
2575
+ if session == own:
2576
+ return last_foreign, True
2577
+ last_foreign = line_no
2578
+ except (OSError, UnicodeError):
2579
+ return 0, False
2580
+ return 0, False
2581
+
2582
+
2583
+ def _fork_boundary(path: Path) -> int:
2584
+ """The number of the last inherited physical line of a session main transcript, 0
2585
+ when nothing is inherited (see _fork_scan)."""
2586
+ return _fork_scan(path)[0]
2587
+
2588
+
2589
+ def _read_transcript(
2590
+ transcript_path: Path, diagnostics: list[str], *, launch_log: "_LaunchLog | None" = None,
2591
+ state: "ledger.TranscriptState | None" = None, skip_through_line: int = 0,
2592
+ ) -> tuple[list[Event], str | None, str | None, int, int, list[ToolCall], list[MarkerHit]]:
2593
+ """events, version, session_id, (total_lines, bad_lines), tool_calls, and
2594
+ marker_hits: total_lines/bad_lines are the non-blank physical line count
2595
+ and how many of them failed to decode as JSON at all (docs/DESIGN.md
2596
+ 4.6's "5 percent of a transcript's lines were undecodable"; a line that
2597
+ decodes but is not a JSON object is a different, already-diagnosed
2598
+ problem, not an undecodable one). tool_calls is every ToolCall in
2599
+ transcript order (docs/DESIGN.md 4.1/4.2's v0.2 reader changes), filled
2600
+ from each tool_use block's own full parsed input and updated in place
2601
+ when its matching tool_result is seen. marker_hits is every MarkerHit
2602
+ built from the marker provenance registry (docs/DESIGN.md 4.13.2, v0.3
2603
+ N1), in the order its block was encountered, except the two whole-
2604
+ assistant-entry shapes (sandbox_write_denied's attempt-has-a-codex-call
2605
+ shape, and the four final-transcript-entry markers), which are resolved
2606
+ once the whole transcript -- and therefore tool_calls and the true last
2607
+ entry -- are known, and so are appended last.
2608
+
2609
+ skip_through_line (FA4): the physical lines 1..skip_through_line are a fork's
2610
+ inherited prefix (a session main transcript only, _fork_boundary) and are skipped
2611
+ entirely, before any accumulator, count, or diagnostic is touched; line numbers
2612
+ keep counting physically."""
2613
+ events: list[Event] = []
2614
+ version: str | None = None
2615
+ session_id: str | None = None
2616
+ tool_names: dict[str, str | None] = {}
2617
+ # docs/DESIGN.md 4.3 (D-A): the Bash tool_use command text paired to
2618
+ # each tool_use_id, so a later tool_result can be judged read-only
2619
+ # without re-reading the tool_use event.
2620
+ tool_commands: dict[str, str | None] = {}
2621
+ # docs/DESIGN.md 4.1/4.2's v0.2 reader changes: every ToolCall built so
2622
+ # far, in transcript order, plus an index by tool_use_id so the matching
2623
+ # tool_result (which can arrive in any order relative to a sibling call
2624
+ # started in the same assistant message) updates the right one.
2625
+ tool_calls: list[ToolCall] = []
2626
+ tool_call_index: dict[str, ToolCall] = {}
2627
+ # docs/DESIGN.md 4.2's v0.2 reader changes (F5 hardening): every
2628
+ # ToolCall built so far for a given tool_use_id, in the order seen --
2629
+ # unlike tool_call_index (which only ever points at the latest one),
2630
+ # this is how a second tool_use block with the same id is recognized as
2631
+ # a duplicate at all.
2632
+ tool_calls_by_id: dict[str, list[ToolCall]] = {}
2633
+ total_lines = 0
2634
+ bad_lines = 0
2635
+ # Usage dedup is scoped to one transcript (docs/DESIGN.md 4.2): the
2636
+ # reader keeps a set of (message.id, requestId) pairs seen while reading
2637
+ # this file only, resetting for the next attempt's transcript.
2638
+ dedup: dict[tuple[str, str], dict] = {}
2639
+ # docs/DESIGN.md 4.13.2 (v0.3 N1): marker hits decided immediately
2640
+ # (every tool_result block, plus kill_sentinel on each user entry's own
2641
+ # text), and the raw material for the two whole-assistant-entry shapes
2642
+ # that need attempt-wide context, resolved after the loop.
2643
+ marker_hits: list[MarkerHit] = []
2644
+ assistant_marker_candidates: list[tuple[int, str]] = []
2645
+ final_entry_type: str | None = None
2646
+ final_entry_line_no: int | None = None
2647
+
2648
+ with _transcript_reader(transcript_path) as lines:
2649
+ for line_no, line, torn in lines:
2650
+ if line_no <= skip_through_line:
2651
+ continue
2652
+ if torn:
2653
+ # docs/DESIGN.md 4.1 (G-C, GC-1): a final line cut inside a character is a
2654
+ # half-written line, like an ASCII one; the diagnostic quotes none of it
2655
+ total_lines += 1
2656
+ bad_lines += 1
2657
+ # not counted as skipped: a live write in progress, not a bad line (0.3.4)
2658
+ diagnostics.append(
2659
+ f"{transcript_path.name}:{line_no}: half-written last line (cut inside a "
2660
+ "character)"
2661
+ )
2662
+ if state is not None:
2663
+ state.undecodable = True
2664
+ continue
2665
+ # an unfinished last line (no newline yet) is a live write, not a skipped line
2666
+ skipped = 1 if line.endswith("\n") else 0
2667
+ line = line.strip()
2668
+ if not line:
2669
+ continue
2670
+ total_lines += 1
2671
+ try:
2672
+ entry = json.loads(line)
2673
+ except _UNPARSEABLE as exc:
2674
+ diagnostics.append(f"{transcript_path.name}:{line_no}: {_unparseable_text(exc)}")
2675
+ bad_lines += 1
2676
+ _skipped_line(skipped)
2677
+ if state is not None:
2678
+ state.undecodable = True
2679
+ continue
2680
+ if not isinstance(entry, dict):
2681
+ _skipped_line(skipped)
2682
+ diagnostics.append(f"{transcript_path.name}:{line_no}: not a JSON object")
2683
+ if state is not None:
2684
+ state.unexpected_shape = True
2685
+ continue
2686
+
2687
+ if state is not None:
2688
+ # docs/DESIGN.md 4.13.6 (N6): the run cwd set, from the one read this
2689
+ # transcript already gets; a top-level non-empty string only.
2690
+ cwd_value = entry.get("cwd")
2691
+ if isinstance(cwd_value, str) and cwd_value:
2692
+ state.cwds.setdefault(cwd_value, None)
2693
+
2694
+ if version is None and isinstance(entry.get("version"), str):
2695
+ version = entry["version"]
2696
+ if session_id is None and isinstance(entry.get("sessionId"), str):
2697
+ session_id = entry["sessionId"]
2698
+
2699
+ raw_type = entry.get("type", "")
2700
+ ts = _parse_ts(
2701
+ entry.get("timestamp"), transcript_path, line_no, diagnostics, quality=True
2702
+ )
2703
+ # docs/DESIGN.md 4.13.2: trailing "attachment" entries (Claude Code 2.1.305+
2704
+ # writes a hook_success one after the last assistant entry) do not count.
2705
+ if raw_type != "attachment":
2706
+ final_entry_type = raw_type
2707
+ final_entry_line_no = line_no
2708
+ if state is not None:
2709
+ _note_entry_shape(entry, entry.get("type"), state)
2710
+
2711
+ if raw_type == "user":
2712
+ events.extend(
2713
+ _user_events(
2714
+ entry, transcript_path, line_no, ts, raw_type,
2715
+ tool_names, tool_commands, diagnostics,
2716
+ tool_call_index=tool_call_index, launch_log=launch_log, state=state,
2717
+ )
2718
+ )
2719
+ entry_hits = _user_entry_marker_hits(entry, line_no, tool_names, tool_commands)
2720
+ marker_hits.extend(entry_hits)
2721
+ _apply_background_hits(entry_hits, tool_call_index, tool_commands)
2722
+ elif raw_type == "assistant":
2723
+ events.extend(
2724
+ _assistant_events(
2725
+ entry, transcript_path, line_no, ts, raw_type,
2726
+ tool_names, tool_commands, dedup, diagnostics,
2727
+ tool_calls=tool_calls, tool_call_index=tool_call_index,
2728
+ tool_calls_by_id=tool_calls_by_id, launch_log=launch_log, state=state,
2729
+ )
2730
+ )
2731
+ assistant_marker_candidates.append((line_no, _assistant_entry_flatten(entry)))
2732
+ elif raw_type == "attachment":
2733
+ events.append(
2734
+ _attachment_event(entry, transcript_path, line_no, ts, raw_type, diagnostics)
2735
+ )
2736
+ elif raw_type == "system" and entry.get("subtype") == "compact_boundary":
2737
+ events.append(_compaction_event(transcript_path, line_no, ts, raw_type))
2738
+ else:
2739
+ events.append(_unknown_event(entry, transcript_path, line_no, ts, raw_type))
2740
+
2741
+ attempt_has_codex_bash = any(
2742
+ call.tool_name == "Bash" and call.runs_codex_program
2743
+ for call in tool_calls
2744
+ )
2745
+ marker_hits.extend(
2746
+ _finalize_assistant_marker_hits(
2747
+ assistant_marker_candidates, final_entry_type, final_entry_line_no,
2748
+ attempt_has_codex_bash,
2749
+ )
2750
+ )
2751
+
2752
+ return events, version, session_id, total_lines, bad_lines, tool_calls, marker_hits
2753
+
2754
+
2755
+ # docs/DESIGN.md 4.6: a transcript whose undecodable-line rate exceeds this
2756
+ # is not merely lossy in a few spots, it is unreliable enough that the run's
2757
+ # analysis status must say so rather than silently reporting COMPLETE over
2758
+ # whatever survived.
2759
+ _UNDECODABLE_LINE_THRESHOLD = 0.05
2760
+
2761
+
2762
+ def _read_transcript_tracked(
2763
+ transcript_path: Path, diagnostics: list[str], *, launch_log: "_LaunchLog | None" = None,
2764
+ state: "ledger.TranscriptState | None" = None, skip_through_line: int = 0,
2765
+ ) -> tuple[list[Event], str | None, str | None, bool, bool, list[ToolCall], list[MarkerHit]]:
2766
+ """_read_transcript, plus the two docs/DESIGN.md 4.6 conditions it alone
2767
+ can observe: `unreadable` when the file could not even be opened (a
2768
+ permission or IO error, never mere absence -- a caller only reaches this
2769
+ for a transcript path it already confirmed exists), and
2770
+ `over_threshold` when more than _UNDECODABLE_LINE_THRESHOLD of its lines
2771
+ failed to decode. tool_calls and marker_hits are [] when unreadable
2772
+ (docs/DESIGN.md 4.1's v0.2 reader changes: a caller that gets
2773
+ unreadable=True builds an AttemptFacts with every field set to
2774
+ FACTS_UNKNOWN instead of trusting an empty tool_calls list; marker_hits
2775
+ has no such sibling and is simply [])."""
2776
+ try:
2777
+ handle = transcript_path.open("r", encoding="utf-8")
2778
+ handle.close()
2779
+ except OSError as exc:
2780
+ diagnostics.append(f"{transcript_path.name}: unreadable: {exc}")
2781
+ if state is not None:
2782
+ state.unreadable = True
2783
+ return [], None, None, True, False, [], []
2784
+
2785
+ diagnostics_before = len(diagnostics)
2786
+ try:
2787
+ events, version, session_id, total_lines, bad_lines, tool_calls, marker_hits = (
2788
+ _read_transcript(
2789
+ transcript_path, diagnostics, launch_log=launch_log, state=state,
2790
+ skip_through_line=skip_through_line,
2791
+ )
2792
+ )
2793
+ except (OSError, UnicodeError) as exc:
2794
+ # docs/DESIGN.md 4.1 (N4 fix, C2): a failure anywhere in the read (bytes
2795
+ # that are not UTF-8, or an I/O error, after any number of good lines)
2796
+ # is the same unreadable shape as an open failure: whatever the read
2797
+ # accumulated before it failed is discarded, so the ledger is exactly
2798
+ # Ledger([], 'partial', ['transcript_unreadable', ...]).
2799
+ del diagnostics[diagnostics_before:]
2800
+ diagnostics.append(f"{transcript_path.name}: unreadable: {exc}")
2801
+ if state is not None:
2802
+ state.undecodable = False
2803
+ state.unexpected_shape = False
2804
+ state.unknown_type = False
2805
+ state.unreadable = True
2806
+ state.cwds.clear()
2807
+ if launch_log is not None:
2808
+ launch_log.clear()
2809
+ return [], None, None, True, False, [], []
2810
+ over_threshold = total_lines > 0 and (bad_lines / total_lines) > _UNDECODABLE_LINE_THRESHOLD
2811
+ return events, version, session_id, False, over_threshold, tool_calls, marker_hits
2812
+
2813
+
2814
+ def _edge_event_has_timestamp_diagnostic(events: list[Event], diagnostics: list[str]) -> bool:
2815
+ """Whether the first or last event in events came from a line _parse_ts
2816
+ already flagged with a timestamp diagnostic (docs/DESIGN.md 4.6): such
2817
+ an event's ts is None not because the field was absent (harmless) but
2818
+ because it was present and malformed, which means first_ts/last_ts for
2819
+ this attempt may be silently wrong, not just imprecise."""
2820
+ if not events:
2821
+ return False
2822
+ for event in (events[0], events[-1]):
2823
+ prefix = f"{event.path.name}:{event.line_no}: timestamp"
2824
+ if any(diag.startswith(prefix) for diag in diagnostics):
2825
+ return True
2826
+ return False
2827
+
2828
+
2829
+ def _as_object(
2830
+ value: object, path: Path, line_no: int, diagnostics: list[str] | None, field: str,
2831
+ *, quality: bool = False,
2832
+ ) -> dict:
2833
+ """value if it is already a dict, else {} with a path:line diagnostic
2834
+ (docs/DESIGN.md section 3: the reader tolerates unknown shapes). A
2835
+ missing field (None) is the common, harmless case and never diagnosed;
2836
+ only a present-but-wrong-shaped field is."""
2837
+ if isinstance(value, dict):
2838
+ return value
2839
+ if value is not None and diagnostics is not None:
2840
+ diagnostics.append(_note(
2841
+ f"{path.name}:{line_no}: {field} is not an object (got {type(value).__name__})",
2842
+ quality,
2843
+ ))
2844
+ return {}
2845
+
2846
+
2847
+ def _as_text(
2848
+ value: object, path: Path, line_no: int, diagnostics: list[str] | None, field: str
2849
+ ) -> str:
2850
+ """value if it is already a string, else '' with a path:line
2851
+ diagnostic. A missing field ('' from .get(field, "")) is never
2852
+ diagnosed; only a present-but-wrong-shaped field is."""
2853
+ if isinstance(value, str):
2854
+ return value
2855
+ if value and diagnostics is not None:
2856
+ diagnostics.append(_note(
2857
+ f"{path.name}:{line_no}: {field} is not a string (got {type(value).__name__})",
2858
+ True,
2859
+ ))
2860
+ return ""
2861
+
2862
+
2863
+ def _user_events(
2864
+ entry, path, line_no, ts, raw_type, tool_names, tool_commands=None, diagnostics=None,
2865
+ tool_call_index=None, launch_log=None, state=None,
2866
+ ) -> list[Event]:
2867
+ message = _as_object(entry.get("message"), path, line_no, diagnostics, "message")
2868
+ content = message.get("content")
2869
+ events: list[Event] = []
2870
+
2871
+ if isinstance(content, str):
2872
+ events.append(Event(
2873
+ path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
2874
+ tool_name=None, text=_excerpt(content), exit_code=None,
2875
+ duration_ms=None, is_error=False, usage={}, markers=_scan_markers(content),
2876
+ ))
2877
+ return events
2878
+
2879
+ if not isinstance(content, list):
2880
+ return events
2881
+
2882
+ for block in content:
2883
+ if isinstance(block, str):
2884
+ # docs/DESIGN.md section 3: "User blocks: tool_result (...) or a
2885
+ # plain string." A bare string list item is user text, the same
2886
+ # as a {"type":"text","text":...} block, and it is where the
2887
+ # kill sentinel itself sometimes arrives (docs/DESIGN.md 4.4).
2888
+ events.append(Event(
2889
+ path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
2890
+ tool_name=None, text=_excerpt(block), exit_code=None,
2891
+ duration_ms=None, is_error=False, usage={}, markers=_scan_markers(block),
2892
+ ))
2893
+ continue
2894
+ if not isinstance(block, dict):
2895
+ continue
2896
+ block_type = block.get("type")
2897
+ if block_type == "tool_result":
2898
+ tool_use_id = block.get("tool_use_id")
2899
+ if state is not None and not isinstance(tool_use_id, str):
2900
+ state.unexpected_shape = True
2901
+ tool_name = tool_names.get(tool_use_id) if isinstance(tool_use_id, str) else None
2902
+ full_text = _flatten_block_text(block.get("content"))
2903
+ is_envelope = tool_name in _ENVELOPE_TOOL_NAMES
2904
+ is_error = bool(block.get("is_error", False))
2905
+ events.append(Event(
2906
+ path=path, line_no=line_no, ts=ts, kind="tool_result", raw_type=raw_type,
2907
+ tool_name=tool_name, text=_excerpt(full_text),
2908
+ exit_code=_parse_exit_code(full_text),
2909
+ duration_ms=None, is_error=is_error,
2910
+ usage={},
2911
+ markers=frozenset() if is_envelope else _scan_markers(full_text),
2912
+ envelope=is_envelope,
2913
+ read_only_output=_tool_result_read_only(tool_name, tool_use_id, tool_commands),
2914
+ tool_use_id=tool_use_id if isinstance(tool_use_id, str) else None,
2915
+ ))
2916
+ # docs/DESIGN.md 4.1/4.2's v0.2 reader changes: fill in the
2917
+ # matching ToolCall (by tool_use_id, never by position -- two
2918
+ # tool_use blocks in one assistant message can have their
2919
+ # results arrive in either order) from this tool_result's own
2920
+ # FULL text, before _excerpt bounds it.
2921
+ if launch_log is not None and isinstance(tool_use_id, str):
2922
+ launch_log.record_result(
2923
+ tool_use_id, block, entry.get("toolUseResult"),
2924
+ "toolUseResult" in entry, line_no,
2925
+ )
2926
+ elif launch_log is not None:
2927
+ launch_log.facts.note_bad_result(tool_use_id, block, entry.get("toolUseResult"))
2928
+ if tool_call_index is not None and isinstance(tool_use_id, str):
2929
+ call = tool_call_index.get(tool_use_id)
2930
+ if call is not None:
2931
+ call.result_line = line_no
2932
+ call.is_error = is_error
2933
+ call.backgrounded = all(
2934
+ fragment in full_text for fragment in BASH_BACKGROUNDED
2935
+ )
2936
+ call.exit_code = _parse_exit_code(full_text)
2937
+ # docs/DESIGN.md 4.13.1's commit grammar (C-9), from the
2938
+ # commit segments of the call's classification (computed
2939
+ # from the FULL command text, C-11), for a completed_ok or
2940
+ # completed_error call only (A1).
2941
+ if (
2942
+ call.tool_name == "Bash"
2943
+ and call.classification is not None
2944
+ and call.completion in commands.COMMIT_COMPLETIONS
2945
+ ):
2946
+ call.commit_ids = commands.commit_ids(
2947
+ call.classification.commit, full_text
2948
+ )
2949
+ elif block_type == "text":
2950
+ block_text = _as_text(block.get("text", ""), path, line_no, diagnostics, "block text")
2951
+ events.append(Event(
2952
+ path=path, line_no=line_no, ts=ts, kind="user", raw_type=raw_type,
2953
+ tool_name=None, text=_excerpt(block_text), exit_code=None,
2954
+ duration_ms=None, is_error=False, usage={}, markers=_scan_markers(block_text),
2955
+ ))
2956
+ else:
2957
+ events.append(_unknown_event(block, path, line_no, ts, raw_type))
2958
+ return events
2959
+
2960
+
2961
+ def _assistant_events(
2962
+ entry, path, line_no, ts, raw_type, tool_names, tool_commands=None, dedup=None,
2963
+ diagnostics=None, tool_calls=None, tool_call_index=None, tool_calls_by_id=None,
2964
+ launch_log=None, state=None,
2965
+ ) -> list[Event]:
2966
+ """Every block in one assistant transcript entry, with usage attached to
2967
+ exactly one of them (the first), regardless of how many blocks the
2968
+ message carries or whether any of them is a text block. message.usage
2969
+ describes the whole entry, not any single block, so summing per-Event
2970
+ usage across an attempt must not double it (two text blocks) or lose it
2971
+ (a tool-use-only message).
2972
+
2973
+ dedup and diagnostics are optional so existing direct callers (tests
2974
+ exercising a single entry in isolation) keep working unchanged; a real
2975
+ _read_transcript call always supplies both. When given, a (message.id,
2976
+ requestId) pair already in dedup zeroes this entry's usage (docs/
2977
+ DESIGN.md 4.2's dedup rule); an entry missing either field is always
2978
+ counted (the ccusage convention).
2979
+
2980
+ Usage never silently disappears: it is attached to the first event this
2981
+ entry emits, whatever kind that event is (tool_use, text, thinking, or
2982
+ even an 'unknown' block this milestone's markers.py does not recognize
2983
+ yet). An entry that emits no event at all (empty content, or content
2984
+ made only of unrecognized blocks) still gets a synthetic 'unknown' event
2985
+ so its usage is not lost."""
2986
+ message = _as_object(entry.get("message"), path, line_no, diagnostics, "message")
2987
+ content = message.get("content")
2988
+ remaining_usage = _as_object(
2989
+ message.get("usage"), path, line_no, diagnostics, "usage", quality=True
2990
+ )
2991
+
2992
+ message_id = message.get("id")
2993
+ request_id = entry.get("requestId")
2994
+ pair = (message_id, request_id) if message_id and request_id else None
2995
+ if pair is not None and dedup is not None:
2996
+ if pair in dedup:
2997
+ if diagnostics is not None and dedup[pair] != remaining_usage:
2998
+ diagnostics.append(QualityNote(
2999
+ f"{path.name}:{line_no}: duplicate usage for a (message id, requestId) pair "
3000
+ "differs from its first occurrence"
3001
+ ))
3002
+ remaining_usage = {}
3003
+ else:
3004
+ dedup[pair] = remaining_usage
3005
+
3006
+ events: list[Event] = []
3007
+
3008
+ def _take_usage() -> dict:
3009
+ nonlocal remaining_usage
3010
+ usage, remaining_usage = remaining_usage, {}
3011
+ return usage
3012
+
3013
+ def _finish() -> list[Event]:
3014
+ # message.usage describes the whole entry; if nothing above claimed
3015
+ # it (empty content, or content made only of blocks no branch below
3016
+ # recognizes), it must not simply vanish (docs/DESIGN.md 4.2).
3017
+ if not events and remaining_usage:
3018
+ unknown_event = _unknown_event(entry, path, line_no, ts, raw_type)
3019
+ unknown_event.usage = _take_usage()
3020
+ events.append(unknown_event)
3021
+ return events
3022
+
3023
+ if isinstance(content, str):
3024
+ events.append(Event(
3025
+ path=path, line_no=line_no, ts=ts, kind="assistant", raw_type=raw_type,
3026
+ tool_name=None, text=_excerpt(content), exit_code=None,
3027
+ duration_ms=None, is_error=False, usage=_take_usage(), markers=_scan_markers(content),
3028
+ ))
3029
+ return _finish()
3030
+
3031
+ if not isinstance(content, list):
3032
+ return _finish()
3033
+
3034
+ for block in content:
3035
+ if not isinstance(block, dict):
3036
+ continue
3037
+ block_type = block.get("type")
3038
+ if block_type == "tool_use":
3039
+ tool_name = block.get("name")
3040
+ if not isinstance(tool_name, str):
3041
+ tool_name = None
3042
+ tool_id = block.get("id")
3043
+ if isinstance(tool_id, str):
3044
+ tool_names[tool_id] = tool_name
3045
+ if state is not None and (tool_name is None or not isinstance(tool_id, str)):
3046
+ state.unexpected_shape = True
3047
+ if launch_log is not None:
3048
+ launch_input = block.get("input")
3049
+ resume = (
3050
+ launch_input.get("resumeFromRunId") if isinstance(launch_input, dict) else None
3051
+ )
3052
+ launch_log.record_use(
3053
+ tool_name, tool_id, line_no, resume if isinstance(resume, str) else None,
3054
+ ts=ts, tool_input=launch_input,
3055
+ )
3056
+ raw_input = block.get("input", {})
3057
+ input_obj = _as_object(raw_input, path, line_no, diagnostics, "tool_use input")
3058
+ input_text = json.dumps(input_obj, ensure_ascii=False)
3059
+ if isinstance(tool_id, str) and tool_commands is not None and tool_name == "Bash":
3060
+ command = input_obj.get("command")
3061
+ tool_commands[tool_id] = command if isinstance(command, str) else None
3062
+ if isinstance(tool_id, str) and tool_calls is not None and tool_call_index is not None:
3063
+ call = _make_tool_call(tool_id, tool_name, input_obj, line_no, ts, entry.get("cwd"))
3064
+ tool_calls.append(call)
3065
+ tool_call_index[tool_id] = call
3066
+ # docs/DESIGN.md 4.2's v0.2 reader changes (F5 hardening): a
3067
+ # tool_use_id seen before in this same transcript means the
3068
+ # pairing below can no longer tell which tool_result belongs
3069
+ # to which call. Diagnose it (so Run.status becomes
3070
+ # INCOMPLETE) and mark EVERY ToolCall sharing this id --
3071
+ # including ones already built -- ambiguous, never only the
3072
+ # newest one.
3073
+ if tool_calls_by_id is not None:
3074
+ siblings = tool_calls_by_id.setdefault(tool_id, [])
3075
+ if siblings:
3076
+ first_line = siblings[0].line_no
3077
+ if diagnostics is not None:
3078
+ diagnostics.append(
3079
+ f"{path.name}:{line_no}: duplicate tool_use_id {tool_id} "
3080
+ f"at lines {first_line} and {line_no}"
3081
+ )
3082
+ for sibling in siblings:
3083
+ sibling.ambiguous = True
3084
+ sibling.commit_ids = ()
3085
+ call.ambiguous = True
3086
+ siblings.append(call)
3087
+ events.append(Event(
3088
+ path=path, line_no=line_no, ts=ts, kind="tool_use", raw_type=raw_type,
3089
+ tool_name=tool_name, text=_excerpt(input_text),
3090
+ exit_code=None, duration_ms=None, is_error=False, usage=_take_usage(),
3091
+ markers=_scan_markers(input_text),
3092
+ ))
3093
+ elif block_type == "text":
3094
+ block_text = _as_text(block.get("text", ""), path, line_no, diagnostics, "block text")
3095
+ events.append(Event(
3096
+ path=path, line_no=line_no, ts=ts, kind="assistant", raw_type=raw_type,
3097
+ tool_name=None, text=_excerpt(block_text), exit_code=None,
3098
+ duration_ms=None, is_error=False, usage=_take_usage(),
3099
+ markers=_scan_markers(block_text),
3100
+ ))
3101
+ elif block_type == "thinking":
3102
+ events.append(Event(
3103
+ path=path, line_no=line_no, ts=ts, kind="thinking", raw_type=raw_type,
3104
+ tool_name=None, text="", exit_code=None,
3105
+ duration_ms=None, is_error=False, usage=_take_usage(), markers=frozenset(),
3106
+ ))
3107
+ else:
3108
+ unknown_event = _unknown_event(block, path, line_no, ts, raw_type)
3109
+ unknown_event.usage = _take_usage()
3110
+ events.append(unknown_event)
3111
+ return _finish()
3112
+
3113
+
3114
+ def _attachment_event(entry, path, line_no, ts, raw_type, diagnostics=None) -> Event:
3115
+ attachment = _as_object(
3116
+ entry.get("attachment"), path, line_no, diagnostics, "attachment", quality=True
3117
+ )
3118
+ text = attachment.get("text")
3119
+ if not isinstance(text, str):
3120
+ pieces = [str(attachment[key]) for key in ("stdout", "stderr") if attachment.get(key)]
3121
+ if pieces:
3122
+ text = "\n".join(pieces)
3123
+ else:
3124
+ content = attachment.get("content")
3125
+ text = (
3126
+ content if isinstance(content, str) else json.dumps(attachment, ensure_ascii=False)
3127
+ )
3128
+
3129
+ exit_code = attachment.get("exitCode")
3130
+ if not isinstance(exit_code, int):
3131
+ exit_code = None
3132
+ duration_ms = attachment.get("durationMs")
3133
+ if not isinstance(duration_ms, int):
3134
+ duration_ms = None
3135
+
3136
+ return Event(
3137
+ path=path, line_no=line_no, ts=ts, kind="attachment", raw_type=raw_type,
3138
+ tool_name=None, text=_excerpt(text), exit_code=exit_code,
3139
+ duration_ms=duration_ms, is_error=False, usage={}, markers=_scan_markers(text),
3140
+ )
3141
+
3142
+
3143
+ def _compaction_event(path, line_no, ts, raw_type) -> Event:
3144
+ """A system entry with subtype == 'compact_boundary' (docs/DESIGN.md
3145
+ section 3): a context compaction, carrying no excerpt text or markers of
3146
+ its own."""
3147
+ return Event(
3148
+ path=path, line_no=line_no, ts=ts, kind="compaction", raw_type=raw_type,
3149
+ tool_name=None, text="", exit_code=None, duration_ms=None, is_error=False,
3150
+ usage={}, markers=frozenset(),
3151
+ )
3152
+
3153
+
3154
+ def _unknown_event(entry, path, line_no, ts, raw_type) -> Event:
3155
+ entry_text = json.dumps(entry, ensure_ascii=False)
3156
+ return Event(
3157
+ path=path, line_no=line_no, ts=ts, kind="unknown", raw_type=raw_type,
3158
+ tool_name=None, text=_excerpt(entry_text),
3159
+ exit_code=None, duration_ms=None, is_error=False, usage={},
3160
+ markers=_scan_markers(entry_text),
3161
+ )
3162
+
3163
+
3164
+ # --- parent transcript index --------------------------------------------
3165
+
3166
+
3167
+ @dataclass(frozen=True)
3168
+ class _TaskStop:
3169
+ ts: datetime | None
3170
+ ids: frozenset[str] # task_id, agent_id, or any other string value in input
3171
+ line_no: int
3172
+ text: str # excerpt of the tool_use input, for Interrupt.cause_evidence
3173
+ # docs/DESIGN.md 4.13.3 (v0.3 N1): the input's own 'task_id'/'shell_id'
3174
+ # fields specifically, None when absent or not a string -- used to match
3175
+ # a generation's own task_id exactly, rather than the looser `ids`
3176
+ # membership test 4.4's attribute_interrupt still uses.
3177
+ task_id: str | None = None
3178
+ shell_id: str | None = None
3179
+
3180
+
3181
+ @dataclass(frozen=True)
3182
+ class _KillSentinel:
3183
+ ts: datetime
3184
+ line_no: int
3185
+ text: str
3186
+
3187
+
3188
+ def _string_values(data: object) -> frozenset[str]:
3189
+ if not isinstance(data, dict):
3190
+ return frozenset()
3191
+ return frozenset(value for value in data.values() if isinstance(value, str))
3192
+
3193
+
3194
+ @dataclass(frozen=True)
3195
+ class _Launch:
3196
+ """docs/DESIGN.md 4.13.3 (v0.3 N1): one Workflow tool_use block."""
3197
+
3198
+ line_no: int
3199
+ tool_use_id: str
3200
+ resume_from: str | None
3201
+ # FA2: the parsed timestamp of the assistant entry holding this tool_use
3202
+ # block (None when absent or unparseable): the generation's launch_ts.
3203
+ ts: datetime | None = None
3204
+
3205
+
3206
+ # docs/DESIGN.md 4.13.3's status vocabulary: for notifications and
3207
+ # synchronous launch results alike, 'completed' -> 'completed', 'failed' ->
3208
+ # 'failed', 'killed'/'stopped'/'cancelled' -> 'stopped'. Any string not in
3209
+ # this table is unrecognized.
3210
+ _TERMINAL_STATUS_VOCAB = {
3211
+ "completed": "completed",
3212
+ "failed": "failed",
3213
+ "killed": "stopped",
3214
+ "stopped": "stopped",
3215
+ "cancelled": "stopped",
3216
+ }
3217
+
3218
+ _ENVELOPE_TAG_RE = {
3219
+ "task-id": re.compile(r"<task-id>(.*?)</task-id>", re.DOTALL),
3220
+ "tool-use-id": re.compile(r"<tool-use-id>(.*?)</tool-use-id>", re.DOTALL),
3221
+ "status": re.compile(r"<status>(.*?)</status>", re.DOTALL),
3222
+ }
3223
+
3224
+
3225
+ def _parse_envelope(text: object) -> tuple[str, str, str] | None:
3226
+ """docs/DESIGN.md 4.13.3's envelope: after stripping surrounding
3227
+ whitespace, the content starts with <task-notification>, ends with
3228
+ </task-notification>, and contains exactly one each of <task-id>,
3229
+ <tool-use-id>, and <status>. Returns (task_id, tool_use_id, status), or
3230
+ None when the shape does not hold -- a quoted copy inside prose or a
3231
+ compaction summary never has the WHOLE text be just the envelope, so it
3232
+ is never mistaken for a real one."""
3233
+ if not isinstance(text, str):
3234
+ return None
3235
+ stripped = text.strip()
3236
+ if not (
3237
+ stripped.startswith("<task-notification>") and stripped.endswith("</task-notification>")
3238
+ ):
3239
+ return None
3240
+ values: dict[str, str] = {}
3241
+ for tag, pattern in _ENVELOPE_TAG_RE.items():
3242
+ matches = pattern.findall(stripped)
3243
+ if len(matches) != 1:
3244
+ return None
3245
+ values[tag] = matches[0].strip()
3246
+ return values["task-id"], values["tool-use-id"], values["status"]
3247
+
3248
+
3249
+ @dataclass(frozen=True)
3250
+ class _NotificationCandidate:
3251
+ line_no: int
3252
+ form: str # 'queue' | 'user_with_origin' | 'user_no_origin'
3253
+ content: object # the raw (unparsed) envelope candidate text
3254
+ ts: datetime | None = field(default=None, compare=False)
3255
+
3256
+
3257
+ @dataclass(frozen=True)
3258
+ class _Notification:
3259
+ line_no: int
3260
+ task_id: str
3261
+ tool_use_id: str
3262
+ status: str
3263
+ ts: datetime | None = field(default=None, compare=False)
3264
+
3265
+
3266
+ @dataclass(frozen=True)
3267
+ class _AmbiguousLaunch:
3268
+ """docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): one group of
3269
+ duplicate tool_use/tool_result blocks sharing a Workflow tool_use id.
3270
+ `candidate_run_ids` is every run id this ambiguous launch could be
3271
+ evidence for -- every launch's own `resumeFromRunId` plus every
3272
+ well-formed result's own `runId` -- so a caller can tell whether it
3273
+ "involves" a particular inspected run (rule 1: unknown/ambiguous_launch
3274
+ for exactly those runs, ignored for every other one)."""
3275
+
3276
+ tool_use_id: str
3277
+ diagnostic: str # the exact "ambiguous launch '<id>': lines ..." string
3278
+ candidate_run_ids: frozenset[str]
3279
+
3280
+
3281
+ _HASH_CHUNK_SIZE = 65536
3282
+
3283
+ # docs/DESIGN.md 4.13.3's run lifecycle activity rule: 10 minutes, the same
3284
+ # threshold hooks_cli._UNKNOWN_QUIET_SECONDS already uses for the "possibly
3285
+ # live" hook heuristic (7.2/12). Restated here (not imported) since
3286
+ # hooks_cli.py already imports reader.py -- reader.py importing hooks_cli
3287
+ # back would be a cycle.
3288
+ ACTIVITY_RECENT_SECONDS = 600.0
3289
+
3290
+
3291
+ def activity_word(newest_mtime: float | None, now: float | None = None) -> str:
3292
+ """docs/DESIGN.md 4.13.3: 'recent' when the newest mtime among a run's
3293
+ own files is under 10 minutes old, 'stale' when older, 'unknown' when
3294
+ every file's stat failed (newest_mtime is None). `now` defaults to the
3295
+ real wall clock -- this is a file-freshness check, never part of
3296
+ report._now()'s own injectable-clock convention, which only ever governs
3297
+ human-facing relative ages computed from parsed transcript timestamps."""
3298
+ if newest_mtime is None:
3299
+ return "unknown"
3300
+ if now is None:
3301
+ now = time.time()
3302
+ return "recent" if (now - newest_mtime) < ACTIVITY_RECENT_SECONDS else "stale"
3303
+
3304
+
3305
+ def _workflow_run_files(path: Path) -> list[Path]:
3306
+ """Every file directly inside a workflow run's own wf_* directory (docs/
3307
+ DESIGN.md 4.13.3's activity rule population; the same population
3308
+ cli._run_files_for_liveness uses for kind == 'workflow', duplicated here
3309
+ since reader.py cannot import cli.py)."""
3310
+ try:
3311
+ return [p for p in path.iterdir() if _is_file(p)]
3312
+ except OSError:
3313
+ return []
3314
+
3315
+
3316
+ def _workflow_activity(path: Path) -> str:
3317
+ newest: float | None = None
3318
+ for file_path in _workflow_run_files(path):
3319
+ try:
3320
+ mtime = file_path.stat().st_mtime
3321
+ except OSError:
3322
+ continue
3323
+ if newest is None or mtime > newest:
3324
+ newest = mtime
3325
+ return activity_word(newest)
3326
+
3327
+
3328
+ # --- parent inspection completeness (docs/DESIGN.md 4.13.3, v0.3 N2) -------
3329
+
3330
+
3331
+ _BENIGN_ENQUEUE_KEYS = frozenset({"type", "operation", "sessionId", "timestamp"})
3332
+
3333
+
3334
+ def _entry_shape_ok(entry: dict) -> bool:
3335
+ """docs/DESIGN.md 4.13.3's Run lifecycle paragraph: the shape every
3336
+ 'user'/'assistant'/'queue-operation' entry, and every tool_use/
3337
+ tool_result block inside one, must have for parent inspection to stay
3338
+ complete. Any entry type not named here (system, thinking, ...) is
3339
+ unconstrained."""
3340
+ etype = entry.get("type")
3341
+ if etype == "user":
3342
+ message = entry.get("message")
3343
+ if not isinstance(message, dict):
3344
+ return False
3345
+ content = message.get("content")
3346
+ if not isinstance(content, (str, list)):
3347
+ return False
3348
+ if isinstance(content, list):
3349
+ for block in content:
3350
+ if isinstance(block, dict) and block.get("type") == "tool_result":
3351
+ if not isinstance(block.get("tool_use_id"), str):
3352
+ return False
3353
+ return True
3354
+ if etype == "assistant":
3355
+ message = entry.get("message")
3356
+ if not isinstance(message, dict):
3357
+ return False
3358
+ content = message.get("content")
3359
+ if not isinstance(content, list):
3360
+ return False
3361
+ for block in content:
3362
+ if isinstance(block, dict) and block.get("type") == "tool_use":
3363
+ if not isinstance(block.get("id"), str) or not isinstance(block.get("name"), str):
3364
+ return False
3365
+ return True
3366
+ if etype == "queue-operation":
3367
+ operation = entry.get("operation")
3368
+ if not isinstance(operation, str):
3369
+ return False
3370
+ if operation == "enqueue" and not isinstance(entry.get("content"), str):
3371
+ # docs/DESIGN.md 4.13.3 (N4 fix, M-N4-1 b): a bare enqueue marker
3372
+ # whose key set is exactly these four carries nothing at all
3373
+ return set(entry) == _BENIGN_ENQUEUE_KEYS
3374
+ return True
3375
+ return True
3376
+
3377
+
3378
+ class ParentIndex:
3379
+ """Incremental, fingerprinted cache of a main session transcript (docs/
3380
+ DESIGN.md 4.2, 4.13.3): TaskStop calls, kill sentinels, compaction
3381
+ timestamps, and -- v0.3 N1 -- Workflow launch/result pairs turned into
3382
+ Generation records. Consulted by detectors.base.attribute_interrupt
3383
+ (4.4) via reader._attribute_interrupts, and by ParentIndex.generations
3384
+ for a run's own lifecycle work (N2).
3385
+
3386
+ `for_path` re-reads only the bytes appended since the last access,
3387
+ unless the file's inode changed, shrank, or its already-parsed prefix no
3388
+ longer hashes the same (a same-size rewrite) -- any of which rebuilds
3389
+ the whole index from zero. `lines_parsed` and `rebuilds` are exposed for
3390
+ tests; both start at 0 and the initial build is never counted as a
3391
+ rebuild.
3392
+ """
3393
+
3394
+ _cache: dict[Path, "ParentIndex"] = {}
3395
+
3396
+ def __init__(
3397
+ self,
3398
+ path: Path | None,
3399
+ task_stops: list[_TaskStop],
3400
+ kill_sentinels: list[_KillSentinel],
3401
+ compaction_timestamps: list[datetime],
3402
+ diagnostics: list[str] | None = None,
3403
+ tool_result_agent_ids: frozenset[str] = frozenset(),
3404
+ generations: list[Generation] | None = None,
3405
+ ):
3406
+ self.path = path
3407
+ self.task_stops = task_stops
3408
+ self.kill_sentinels = kill_sentinels
3409
+ # Backward-compatible view: every earlier caller of this index only
3410
+ # ever wanted the timestamps.
3411
+ self.kill_timestamps = [sentinel.ts for sentinel in kill_sentinels]
3412
+ self.compaction_timestamps = compaction_timestamps
3413
+ # docs/DESIGN.md 7.2 (D2): agentId values seen on a tool_result
3414
+ # block anywhere in this transcript -- a subagent named here has
3415
+ # already returned control to its parent. A plain, growing set (not
3416
+ # frozen): == frozenset() still holds for an empty one, and nothing
3417
+ # else needs it immutable.
3418
+ self.tool_result_agent_ids: set[str] = set(tool_result_agent_ids)
3419
+ # docs/DESIGN.md 4.13.1 (G-A): launches, resume requests and their results, for the
3420
+ # return decision of a session subagent; incremental like everything else here
3421
+ self.facts = returns.ReturnFacts(path)
3422
+ # (docs/DESIGN.md 4.13.3, v0.3 N1) every Generation this index could
3423
+ # build from the transcript seen so far, across every run any launch
3424
+ # here is associated with (association rules 2-5 collapse into a
3425
+ # single run_id per launch -- the result's own runId when well
3426
+ # formed, otherwise the launch's own resumeFromRunId -- so no
3427
+ # external "inspected run X" is needed to compute the full set; a
3428
+ # caller that cares about one run filters by Generation.run_id).
3429
+ # Ordered by launch_line.
3430
+ self.generations: list[Generation] = list(generations) if generations is not None else []
3431
+ # (docs/DESIGN.md 4.13.3's association rule 1, v0.3 N2) every
3432
+ # ambiguous launch group seen so far, for Lifecycle's own
3433
+ # unknown/ambiguous_launch row -- an ambiguous launch produces no
3434
+ # Generation (N1), so this is the only place that information
3435
+ # survives past _rebuild_generations.
3436
+ self.ambiguous_launches: list[_AmbiguousLaunch] = []
3437
+ # path:line diagnostics from a malformed parent-transcript entry, a
3438
+ # single "unreadable"/"undecodable" entry when the file itself could
3439
+ # not be read, or a generation-building diagnostic (an ambiguous
3440
+ # launch, a result-less or malformed launch with no resume, an
3441
+ # unrecognized launch status) -- docs/DESIGN.md section 2's tolerant
3442
+ # parsing, a bad line becomes a diagnostic, never an exception.
3443
+ # Callers fold these into the consuming Run's own diagnostics.
3444
+ self.diagnostics: list[str] = diagnostics if diagnostics is not None else []
3445
+
3446
+ # --- incremental cache bookkeeping (docs/DESIGN.md 4.13.3, v0.3 N1)
3447
+ # irrelevant to an index built directly with pre-collected lists
3448
+ # (as tests/test_attribution.py does): populated only by _refresh.
3449
+ self._parsed_offset = 0
3450
+ self._next_line_no = 1
3451
+ self._prefix_digest: str | None = None
3452
+ self._inode: int | None = None
3453
+ self._seen_size = 0
3454
+ self.lines_parsed = 0
3455
+ self.rebuilds = 0
3456
+ # docs/DESIGN.md 4.13.3's Run lifecycle paragraph (N1N2 review
3457
+ # findings 1/7): true when the parent transcript (none at all counts
3458
+ # as complete, vacuously) is readable, has no undecodable line, and
3459
+ # every entry seen so far passes _entry_shape_ok -- computed inside
3460
+ # this same incremental pass, never by a second, independent full
3461
+ # re-read of the file. Reset to True by _reset_state before a
3462
+ # rebuild-from-zero reparses everything; otherwise only ever flips
3463
+ # False and stays there for the life of this cached index, the same
3464
+ # append-only accumulation lines_parsed/diagnostics already use --
3465
+ # except that a transient I/O failure (below) can force one more
3466
+ # full reinspection, which may then legitimately set it True again.
3467
+ self.inspection_complete = True
3468
+ # (N1N2 review round 2, new finding 1) True exactly when the most
3469
+ # recent _refresh() ended in an OSError from stat/rehash/reparse:
3470
+ # content validity (inspection_complete, above, and every per-line
3471
+ # diagnostic) is persistent -- it only ever reflects bytes this index
3472
+ # has actually read and checked -- while this flag is the current
3473
+ # *I/O availability* of the refresh itself, which is transient. A
3474
+ # true value forces the next _refresh() to do a full reinspection
3475
+ # from byte 0 regardless of what the ordinary inode/size/prefix-hash
3476
+ # comparison would otherwise conclude (that comparison can see no
3477
+ # change at all when the file itself never changed, which is exactly
3478
+ # the case a one-time, non-recurring I/O hiccup leaves behind), so
3479
+ # that every retained line is checked again before
3480
+ # inspection_complete is allowed to become True once more. Cleared
3481
+ # the moment a reinspection's own read succeeds, whether or not its
3482
+ # own needs_reset was already true for an ordinary reason.
3483
+ self._needs_full_recheck = False
3484
+ self._raw_diagnostics: list[str] = list(self.diagnostics)
3485
+ # (N1N2 review round 2, original finding 1) gen_diagnostics as last
3486
+ # computed by _rebuild_generations, kept separately from
3487
+ # _raw_diagnostics so self.diagnostics can be recomputed by
3488
+ # _recompute_diagnostics from both of these plus
3489
+ # _unreadable_diagnostic below, on demand and without a second,
3490
+ # independent full read -- including from inside _refresh's own
3491
+ # failure branches, which never reach _rebuild_generations at all.
3492
+ self._gen_diagnostics: list[str] = []
3493
+ # The same generation-building diagnostics, each with the run ids it
3494
+ # involves (N4 fix, A4), for discovery_diagnostics_for.
3495
+ self._gen_records: list[tuple[str, frozenset[str]]] = []
3496
+ # A single, stable, file-level diagnostic ("<name>: parent
3497
+ # transcript unreadable") while the most recent _refresh() ended in
3498
+ # an OSError from stat/rehash/reparse; None once a read succeeds
3499
+ # again. Never a growing list -- the same read failure recurring
3500
+ # across repeated refreshes is one fact, not one diagnostic per
3501
+ # refresh.
3502
+ self._unreadable_diagnostic: str | None = None
3503
+ self._launches_by_id: dict[str, list[_Launch]] = {}
3504
+ # (result line, toolUseResult, the line's parsed timestamp or None)
3505
+ # (result line, toolUseResult, the line's parsed timestamp or None,
3506
+ # a rejected launch, the result digest or None)
3507
+ self._results_by_id: dict[
3508
+ str, list[tuple[int, object, datetime | None, bool, str | None]]
3509
+ ] = {}
3510
+ self._notification_candidates: list[_NotificationCandidate] = []
3511
+ self._first_origin_line: int | None = None
3512
+ # docs/DESIGN.md 4.2 (FA4): the physical lines 1.._skip_through are a fork's
3513
+ # inherited prefix and are never ingested; _boundary_settled is True once the
3514
+ # transcript's own first entry fixed that boundary for good.
3515
+ self._skip_through = 0
3516
+ self._boundary_settled = False
3517
+ # F fix 1 (S-m5): the fork scan's answer for one file fingerprint (inode, size,
3518
+ # mtime), so a poll of an unchanged file, and the two steps of one refresh, share a
3519
+ # single scan. Dropped on any read failure and bypassed while a recheck is pending.
3520
+ self._fork_fingerprint: tuple | None = None
3521
+ self._fork_result: tuple[int, bool] = (0, False)
3522
+
3523
+ @property
3524
+ def pending_full_recheck(self) -> bool:
3525
+ """True while the last refresh ended in an I/O failure, so the next access reinspects
3526
+ from byte 0 whatever the file's stat says (docs/DESIGN.md 4.13.3). A caller that caches
3527
+ the file's stat signature must not cache it while this is true (watch, 4.12)."""
3528
+ return self._needs_full_recheck
3529
+
3530
+ @classmethod
3531
+ def for_path(cls, path: Path) -> "ParentIndex":
3532
+ path = Path(path)
3533
+ cached = cls._cache.get(path)
3534
+ if cached is None:
3535
+ cached = cls(path, [], [], [])
3536
+ cls._cache[path] = cached
3537
+ cached._refresh()
3538
+ return cached
3539
+
3540
+ @classmethod
3541
+ def _scan(cls, path: Path) -> "ParentIndex":
3542
+ """A fresh, uncached, one-shot full scan of path -- used directly by
3543
+ tests that want a single ParentIndex snapshot without touching the
3544
+ process-level `for_path` cache."""
3545
+ index = cls(path, [], [], [])
3546
+ index._refresh()
3547
+ return index
3548
+
3549
+ def generations_for(self, run_id: str) -> list[Generation]:
3550
+ """Generations of run_id specifically (docs/DESIGN.md 4.13.3),
3551
+ ordered by launch_line, a convenience filter over the flat
3552
+ `generations` attribute for a caller (N2's lifecycle computation)
3553
+ that only ever cares about one run at a time."""
3554
+ return [generation for generation in self.generations if generation.run_id == run_id]
3555
+
3556
+ def ambiguous_diagnostics_for(self, run_id: str) -> list[str]:
3557
+ """docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): the
3558
+ diagnostic(s) of every ambiguous launch that involves run_id -- one
3559
+ of its duplicate results names run_id as its own runId, or one of
3560
+ its duplicate launches resumes from run_id. [] when none do (the
3561
+ launch is simply ignored for this run, per rule 1)."""
3562
+ return [
3563
+ amb.diagnostic for amb in self.ambiguous_launches
3564
+ if run_id in amb.candidate_run_ids
3565
+ ]
3566
+
3567
+ def discovery_diagnostics_for(self, run_id: str | None) -> list[str]:
3568
+ """The parent diagnostics that reach run_id's `Run.discovery['parent']`
3569
+ (docs/DESIGN.md 4.13.1, N4 fix A4): every file-level diagnostic (an
3570
+ undecodable line, a malformed entry, an unreadable parent), then each
3571
+ generation-building diagnostic that involves run_id. run_id None (a
3572
+ session) gets the file-level ones only."""
3573
+ diagnostics = list(self._raw_diagnostics)
3574
+ diagnostics.extend(
3575
+ text for text, involved in self._gen_records
3576
+ if run_id is not None and run_id in involved
3577
+ )
3578
+ if self._unreadable_diagnostic is not None:
3579
+ diagnostics.append(self._unreadable_diagnostic)
3580
+ return diagnostics
3581
+
3582
+ def skipped_line_count(self) -> int:
3583
+ """docs/DESIGN.md 4.1 (0.3.4): the complete lines of the parent transcript this index
3584
+ could not parse."""
3585
+ return sum(
3586
+ 1 for text in self._raw_diagnostics
3587
+ if _PARENT_BAD_LINE in text or _PARENT_NOT_OBJECT in text
3588
+ )
3589
+
3590
+ # --- incremental scan ------------------------------------------------
3591
+
3592
+ def _reset_state(self) -> None:
3593
+ """Discard every piece of state derived from the file's own bytes
3594
+ (docs/DESIGN.md 4.13.3's rebuild-from-zero), keeping path and the
3595
+ two counters (lines_parsed, rebuilds), which are cumulative for the
3596
+ life of this object."""
3597
+ self.task_stops = []
3598
+ self.kill_sentinels = []
3599
+ self.kill_timestamps = []
3600
+ self.compaction_timestamps = []
3601
+ self.tool_result_agent_ids = set()
3602
+ self.facts = returns.ReturnFacts(self.path)
3603
+ self.generations = []
3604
+ self.ambiguous_launches = []
3605
+ self._raw_diagnostics = []
3606
+ self._launches_by_id = {}
3607
+ self._results_by_id = {}
3608
+ self._notification_candidates = []
3609
+ self._first_origin_line = None
3610
+ self._skip_through = 0
3611
+ self._boundary_settled = False
3612
+ self._parsed_offset = 0
3613
+ self._next_line_no = 1
3614
+ self._prefix_digest = None
3615
+ # A rebuild-from-zero reparses the whole file from byte 0, so
3616
+ # completeness is recomputed from scratch too (N1N2 review finding
3617
+ # 1/7): this reset, like the others above, is not itself one of the
3618
+ # two counters _refresh's own docstring calls "cumulative for the
3619
+ # life of this object".
3620
+ self.inspection_complete = True
3621
+
3622
+ def _hash_prefix(self, offset: int) -> str:
3623
+ hasher = hashlib.sha256()
3624
+ if offset <= 0 or self.path is None:
3625
+ return hasher.hexdigest()
3626
+ with self.path.open("rb") as handle:
3627
+ remaining = offset
3628
+ while remaining > 0:
3629
+ chunk = handle.read(min(_HASH_CHUNK_SIZE, remaining))
3630
+ if not chunk:
3631
+ break
3632
+ hasher.update(chunk)
3633
+ remaining -= len(chunk)
3634
+ return hasher.hexdigest()
3635
+
3636
+ def _state_snapshot(self) -> dict:
3637
+ """A shallow copy of every attribute a rebuild-from-zero touches
3638
+ (everything _reset_state() wipes, plus lines_parsed, which
3639
+ _parse_increment can advance before it raises) -- taken right before
3640
+ _reset_state() runs, restored verbatim by _restore_state if the
3641
+ rebuild's own read then fails (N1N2 fix review finding 1). Keeps a
3642
+ failed rebuild from committing _reset_state()'s wipe."""
3643
+ return {
3644
+ "task_stops": list(self.task_stops),
3645
+ "kill_sentinels": list(self.kill_sentinels),
3646
+ "kill_timestamps": list(self.kill_timestamps),
3647
+ "compaction_timestamps": list(self.compaction_timestamps),
3648
+ "tool_result_agent_ids": set(self.tool_result_agent_ids),
3649
+ "facts": self.facts,
3650
+ "generations": list(self.generations),
3651
+ "ambiguous_launches": list(self.ambiguous_launches),
3652
+ "_raw_diagnostics": list(self._raw_diagnostics),
3653
+ "_launches_by_id": dict(self._launches_by_id),
3654
+ "_results_by_id": dict(self._results_by_id),
3655
+ "_notification_candidates": list(self._notification_candidates),
3656
+ "_first_origin_line": self._first_origin_line,
3657
+ "_skip_through": self._skip_through,
3658
+ "_boundary_settled": self._boundary_settled,
3659
+ "_parsed_offset": self._parsed_offset,
3660
+ "_next_line_no": self._next_line_no,
3661
+ "_prefix_digest": self._prefix_digest,
3662
+ "lines_parsed": self.lines_parsed,
3663
+ }
3664
+
3665
+ def _restore_state(self, snapshot: dict) -> None:
3666
+ for name, value in snapshot.items():
3667
+ setattr(self, name, value)
3668
+
3669
+ def _recompute_diagnostics(self) -> None:
3670
+ """The public, combined view (docs/DESIGN.md section 2: "a bad line
3671
+ becomes a diagnostic string on the Run, never an exception"):
3672
+ per-line decode/shape diagnostics, generation-building diagnostics,
3673
+ and -- last -- the single current file-level diagnostic, if any.
3674
+ Called from _rebuild_generations after a successful pass, and
3675
+ directly from _mark_unreadable so a failure branch that never
3676
+ reaches _rebuild_generations at all still leaves self.diagnostics
3677
+ current before _refresh returns (N1N2 review round 2, original
3678
+ finding 1)."""
3679
+ diagnostics = list(self._raw_diagnostics) + list(self._gen_diagnostics)
3680
+ if self._unreadable_diagnostic is not None:
3681
+ diagnostics.append(self._unreadable_diagnostic)
3682
+ self.diagnostics = diagnostics
3683
+
3684
+ def _fork_for(self, stat) -> tuple[int, bool]:
3685
+ """`_fork_scan(self.path)` once per file fingerprint (S-m5)."""
3686
+ fingerprint = (stat.st_ino, stat.st_size, stat.st_mtime_ns)
3687
+ if fingerprint != self._fork_fingerprint:
3688
+ self._fork_result = _fork_scan(self.path)
3689
+ self._fork_fingerprint = fingerprint
3690
+ return self._fork_result
3691
+
3692
+ def _mark_unreadable(self) -> None:
3693
+ """Record a transient I/O failure from anywhere in _refresh's own
3694
+ stat/rehash/reparse steps (N1N2 review round 2, new finding 1):
3695
+ inspection is no longer complete, and the *next* access must do a
3696
+ full reinspection from byte 0 regardless of what the ordinary
3697
+ inode/size/prefix-hash comparison would otherwise conclude -- that
3698
+ comparison sees no change at all when the file itself never changed,
3699
+ which is exactly what a one-time, non-recurring I/O hiccup leaves
3700
+ behind, so nothing else would ever re-trigger the recheck this needs.
3701
+ Also records the one stable, file-level diagnostic a read failure
3702
+ gets (original finding 1, PARTLY): never a growing list, since the
3703
+ same ongoing failure recurring across refreshes is one fact, and
3704
+ visible on self.diagnostics immediately -- this failure path never
3705
+ reaches _rebuild_generations, which is the only other place that
3706
+ would otherwise bring self.diagnostics up to date."""
3707
+ self.inspection_complete = False
3708
+ self._needs_full_recheck = True
3709
+ self._unreadable_diagnostic = f"{self.path.name}: parent transcript unreadable"
3710
+ self._recompute_diagnostics()
3711
+
3712
+ def _refresh(self) -> None:
3713
+ if self.path is None:
3714
+ return
3715
+ try:
3716
+ stat = self.path.stat()
3717
+ except FileNotFoundError:
3718
+ # Simply not there yet (docs/DESIGN.md section 2/4.6's
3719
+ # "unreadable, not merely absent" convention): tolerated,
3720
+ # complete vacuously, current state kept as-is.
3721
+ return
3722
+ except OSError:
3723
+ # Exists but could not even be stat'd -- unreadable, not merely
3724
+ # absent (N1N2 review finding 1): current state is kept as-is,
3725
+ # the same tolerant "do not raise" rule as every other branch of
3726
+ # this method, but inspection is no longer complete.
3727
+ self._mark_unreadable()
3728
+ return
3729
+ if not _is_file(self.path):
3730
+ return
3731
+
3732
+ inode = stat.st_ino
3733
+ size = stat.st_size
3734
+ # A prior transient failure always forces a full reinspection here,
3735
+ # skipping the ordinary comparison below entirely: that comparison
3736
+ # can only ever detect a *structural* change (a different inode, a
3737
+ # shrink, a rewritten prefix), so a one-time I/O hiccup against a
3738
+ # file that never actually changed would otherwise never be noticed
3739
+ # again, and inspection_complete would stay False forever.
3740
+ needs_reset = self._needs_full_recheck
3741
+ if needs_reset:
3742
+ self._fork_fingerprint = None # a pending recheck rescans (S-m5)
3743
+ if not needs_reset:
3744
+ try:
3745
+ if self._inode is not None and (inode != self._inode or size < self._seen_size):
3746
+ needs_reset = True
3747
+ elif self._parsed_offset > 0:
3748
+ digest = self._hash_prefix(self._parsed_offset)
3749
+ needs_reset = digest != self._prefix_digest
3750
+ if needs_reset:
3751
+ # R2-3: a structural change (a new inode, a shrink, a rewritten prefix)
3752
+ # can leave the (inode, size, mtime) fingerprint equal, so the cached
3753
+ # fork boundary is stale too: the settle scan below must be fresh.
3754
+ self._fork_fingerprint = None
3755
+ except OSError:
3756
+ # A read failure while re-hashing the already-parsed prefix,
3757
+ # with nothing reset yet: current state is kept as-is,
3758
+ # inspection is no longer complete.
3759
+ self._mark_unreadable()
3760
+ return
3761
+
3762
+ # N1N2 fix review finding 1: _reset_state() wipes generations/
3763
+ # task_stops/kill_sentinels/etc. before the rebuild's own read has
3764
+ # even been attempted. Snapshot first, so a read failure right after
3765
+ # the reset (a permission flap, a transient I/O error, a concurrent
3766
+ # edit mid-read) can be undone rather than committed -- the whole
3767
+ # reset-and-reparse only ever takes effect together.
3768
+ # FA4: a fork whose first own entry was not written yet inherited nothing; once it
3769
+ # appears the boundary moves and what was ingested so far must be rebuilt.
3770
+ if (
3771
+ not needs_reset
3772
+ and not self._boundary_settled
3773
+ and self._parsed_offset > 0
3774
+ and self._fork_for(stat)[0] != self._skip_through
3775
+ ):
3776
+ needs_reset = True
3777
+ snapshot = self._state_snapshot() if needs_reset else None
3778
+ if needs_reset:
3779
+ self._reset_state()
3780
+ if not self._boundary_settled:
3781
+ self._skip_through, self._boundary_settled = self._fork_for(stat)
3782
+ try:
3783
+ self._parse_increment(size)
3784
+ except OSError:
3785
+ # A read failure partway through reading the new tail (N1N2
3786
+ # review finding 1: the concrete scenario is Path.open raising
3787
+ # for an existing file whose stat() still succeeded) never
3788
+ # raises past this point; the index simply stays incomplete and
3789
+ # keeps whatever state it already had -- restoring the
3790
+ # pre-reset snapshot first when this call had just reset it, so
3791
+ # "whatever state it already had" means what it had before this
3792
+ # _refresh() call, not the freshly wiped, still-empty state.
3793
+ if snapshot is not None:
3794
+ self._restore_state(snapshot)
3795
+ self._mark_unreadable()
3796
+ return
3797
+ # The read just succeeded, whether or not needs_reset was already
3798
+ # true for an ordinary reason: I/O availability is restored, and
3799
+ # inspection_complete now reflects this pass's own, freshly-checked
3800
+ # content-validity findings rather than a stale failure. The
3801
+ # file-level diagnostic clears with it -- _rebuild_generations below
3802
+ # recomputes self.diagnostics from the now-current raw/gen pieces.
3803
+ self._needs_full_recheck = False
3804
+ self._unreadable_diagnostic = None
3805
+ if needs_reset:
3806
+ self.rebuilds += 1
3807
+
3808
+ self._inode = inode
3809
+ self._seen_size = size
3810
+ self._rebuild_generations()
3811
+
3812
+ def _parse_increment(self, size: int) -> None:
3813
+ """Parse only the complete lines in [parsed_offset, last newline
3814
+ before size) (docs/DESIGN.md 4.13.3): a partial trailing line is
3815
+ left for the next access. Updates parsed_offset, the prefix digest,
3816
+ and lines_parsed; every other raw accumulator is appended to, never
3817
+ replaced, so a later _rebuild_generations sees the full history.
3818
+ Physical line numbers continue from `_next_line_no` (reset to 1 by
3819
+ _reset_state) rather than being recounted from byte 0 each time, so
3820
+ parsing cost -- including line numbering -- stays proportional to
3821
+ new lines, never to parsed_offset itself."""
3822
+ if size <= self._parsed_offset:
3823
+ return
3824
+ with self.path.open("rb") as handle:
3825
+ handle.seek(self._parsed_offset)
3826
+ chunk = handle.read(size - self._parsed_offset)
3827
+ last_newline = chunk.rfind(b"\n")
3828
+ if last_newline == -1:
3829
+ return
3830
+ complete = chunk[: last_newline + 1]
3831
+ start_line_no = self._next_line_no
3832
+ lines = complete.split(b"\n")[:-1]
3833
+ for offset_in_chunk, raw_line in enumerate(lines):
3834
+ line_no = start_line_no + offset_in_chunk
3835
+ # docs/DESIGN.md 4.13.3: "lines_parsed adds one per complete line
3836
+ # parsed" -- every complete physical line counts, blank ones
3837
+ # included (N1N2 review finding 8), so the increment happens
3838
+ # before the blank-line skip below, not after it.
3839
+ self.lines_parsed += 1
3840
+ if line_no <= self._skip_through:
3841
+ continue
3842
+ text = raw_line.strip()
3843
+ if not text:
3844
+ continue
3845
+ try:
3846
+ entry = json.loads(text)
3847
+ except (UnicodeDecodeError, *_UNPARSEABLE) as exc:
3848
+ # docs/DESIGN.md 4.13.3's Run lifecycle paragraph: parent
3849
+ # inspection is complete only when the file "has no
3850
+ # undecodable line" (N1N2 review finding 1/7 -- computed
3851
+ # here, in the same incremental pass, rather than by a
3852
+ # second full re-read). docs/DESIGN.md section 2: a bad line
3853
+ # becomes a diagnostic too, never only a dropped flag (N1N2
3854
+ # review round 2, original finding 1) -- a fixed reason and
3855
+ # the line number only, never the undecodable text itself.
3856
+ self.inspection_complete = False
3857
+ reason = f": {_TOO_DEEP}" if isinstance(exc, RecursionError) else ""
3858
+ self._raw_diagnostics.append(
3859
+ f"{self.path.name}:{line_no}: {_PARENT_BAD_LINE}{reason}"
3860
+ )
3861
+ continue
3862
+ if not isinstance(entry, dict):
3863
+ # docs/DESIGN.md 4.1 (0.3.4): skipped and counted like the transcript reader's;
3864
+ # nothing in it was evidence, so inspection stays complete
3865
+ self._raw_diagnostics.append(f"{self.path.name}:{line_no}: {_PARENT_NOT_OBJECT}")
3866
+ continue
3867
+ if not _entry_shape_ok(entry):
3868
+ # Same rule, the shape-violation half (N1N2 review round 2,
3869
+ # original finding 1): a fixed reason and the line number
3870
+ # only, regardless of which of 4.13.3's sub-rules failed.
3871
+ self.inspection_complete = False
3872
+ self._raw_diagnostics.append(
3873
+ f"{self.path.name}:{line_no}: malformed parent transcript entry"
3874
+ )
3875
+ self._ingest_entry(entry, line_no)
3876
+ self._next_line_no = start_line_no + len(lines)
3877
+ self._parsed_offset += len(complete)
3878
+ self._prefix_digest = self._hash_prefix(self._parsed_offset)
3879
+
3880
+ def _ingest_entry(self, entry: dict, line_no: int) -> None:
3881
+ raw_type = entry.get("type")
3882
+ ts = _parse_ts(entry.get("timestamp"))
3883
+
3884
+ if raw_type == "system" and entry.get("subtype") == "compact_boundary":
3885
+ if ts is not None:
3886
+ self.compaction_timestamps.append(ts)
3887
+ return
3888
+
3889
+ if raw_type == "queue-operation":
3890
+ # docs/DESIGN.md 4.13.3: a queue-operation 'enqueue' entry whose
3891
+ # content is the envelope, one of the two notification delivery
3892
+ # forms -- never subject to the origin-line ordering rule.
3893
+ if entry.get("operation") == "enqueue" and isinstance(entry.get("content"), str):
3894
+ self._notification_candidates.append(
3895
+ _NotificationCandidate(line_no, "queue", entry["content"], ts)
3896
+ )
3897
+ return
3898
+
3899
+ message = _as_object(
3900
+ entry.get("message"), self.path, line_no, self._raw_diagnostics, "message"
3901
+ )
3902
+ content = message.get("content")
3903
+
3904
+ if raw_type == "assistant" and isinstance(content, list):
3905
+ for block in content:
3906
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
3907
+ continue
3908
+ name = block.get("name")
3909
+ self.facts.note_use(name, block.get("id"), line_no, ts, block.get("input"))
3910
+ if name == "TaskStop":
3911
+ input_obj = block.get("input")
3912
+ input_dict = input_obj if isinstance(input_obj, dict) else {}
3913
+ input_text = json.dumps(input_dict, ensure_ascii=False)
3914
+ raw_task_id = input_dict.get("task_id")
3915
+ raw_shell_id = input_dict.get("shell_id")
3916
+ self.task_stops.append(_TaskStop(
3917
+ ts=ts, ids=_string_values(input_obj), line_no=line_no,
3918
+ text=_excerpt(input_text),
3919
+ task_id=raw_task_id if isinstance(raw_task_id, str) else None,
3920
+ shell_id=raw_shell_id if isinstance(raw_shell_id, str) else None,
3921
+ ))
3922
+ elif name == "Workflow":
3923
+ tool_id = block.get("id")
3924
+ if not isinstance(tool_id, str):
3925
+ continue
3926
+ input_obj = block.get("input")
3927
+ input_dict = input_obj if isinstance(input_obj, dict) else {}
3928
+ raw_resume = input_dict.get("resumeFromRunId")
3929
+ launch = _Launch(
3930
+ line_no=line_no, tool_use_id=tool_id,
3931
+ resume_from=raw_resume if isinstance(raw_resume, str) else None,
3932
+ ts=ts,
3933
+ )
3934
+ self._launches_by_id.setdefault(tool_id, []).append(launch)
3935
+ return
3936
+
3937
+ if raw_type != "user":
3938
+ return
3939
+
3940
+ text = _flatten_block_text(content)
3941
+ if text.startswith(KILL_SENTINEL_PREFIX) and ts is not None:
3942
+ self.kill_sentinels.append(_KillSentinel(ts=ts, line_no=line_no, text=_excerpt(text)))
3943
+ self.kill_timestamps.append(ts)
3944
+
3945
+ origin = entry.get("origin")
3946
+ has_origin = isinstance(origin, dict)
3947
+ if has_origin and self._first_origin_line is None:
3948
+ self._first_origin_line = line_no
3949
+ if has_origin and origin.get("kind") == "task-notification":
3950
+ self._notification_candidates.append(
3951
+ _NotificationCandidate(line_no, "user_with_origin", message.get("content"), ts)
3952
+ )
3953
+ elif not has_origin:
3954
+ # docs/DESIGN.md 4.13.3: counted only once the first line in the
3955
+ # transcript carrying 'origin' is known, at generation-build
3956
+ # time (_accepted_notifications); recorded provisionally here.
3957
+ self._notification_candidates.append(
3958
+ _NotificationCandidate(line_no, "user_no_origin", message.get("content"), ts)
3959
+ )
3960
+
3961
+ if isinstance(content, list):
3962
+ for block in content:
3963
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
3964
+ continue
3965
+ # docs/DESIGN.md 7.2 (D2): a tool_result block carrying the
3966
+ # agentId of the child that produced it -- the parent's own
3967
+ # record that a subagent already returned control to it.
3968
+ block_agent_id = block.get("agentId")
3969
+ if isinstance(block_agent_id, str) and block_agent_id:
3970
+ self.tool_result_agent_ids.add(block_agent_id)
3971
+ # docs/DESIGN.md 4.13.3 (v0.3 N1): a Workflow launch's own
3972
+ # result is the tool_result block, in a user entry, whose
3973
+ # tool_use_id equals the launch's id; the handshake payload
3974
+ # itself sits at the ENTRY level (entry["toolUseResult"]),
3975
+ # the same place an Agent-tool result does (docs/DESIGN.md
3976
+ # 7.2's D-A).
3977
+ raw_tool_use_id = block.get("tool_use_id")
3978
+ if not isinstance(raw_tool_use_id, str):
3979
+ self.facts.note_bad_result(raw_tool_use_id, block, entry.get("toolUseResult"))
3980
+ if isinstance(raw_tool_use_id, str):
3981
+ self.facts.note_result(
3982
+ raw_tool_use_id, block, entry.get("toolUseResult"),
3983
+ "toolUseResult" in entry, line_no,
3984
+ )
3985
+ raw_result = entry.get("toolUseResult")
3986
+ self._results_by_id.setdefault(raw_tool_use_id, []).append((
3987
+ line_no, raw_result, ts, _is_rejected_launch(block, raw_result),
3988
+ _result_digest(block, raw_result),
3989
+ ))
3990
+ # docs/DESIGN.md 7.2 (D-A): the entry-level toolUseResult also
3991
+ # carries agentId directly for an Agent-tool result.
3992
+ tool_use_result = entry.get("toolUseResult")
3993
+ if isinstance(tool_use_result, dict):
3994
+ entry_agent_id = tool_use_result.get("agentId")
3995
+ if isinstance(entry_agent_id, str) and entry_agent_id:
3996
+ self.tool_result_agent_ids.add(entry_agent_id)
3997
+
3998
+ # --- generation building (docs/DESIGN.md 4.13.3, v0.3 N1) ------------
3999
+
4000
+ def _accepted_notifications(self) -> list[_Notification]:
4001
+ """Every valid envelope from an accepted notification record (docs/
4002
+ DESIGN.md 4.13.3), the two delivery forms of one notification (same
4003
+ task id, tool-use id, status) collapsed into the earliest line."""
4004
+ parsed: list[_Notification] = []
4005
+ for candidate in self._notification_candidates:
4006
+ if candidate.form == "user_no_origin":
4007
+ if (
4008
+ self._first_origin_line is not None
4009
+ and candidate.line_no >= self._first_origin_line
4010
+ ):
4011
+ continue
4012
+ envelope = _parse_envelope(candidate.content)
4013
+ if envelope is None:
4014
+ continue
4015
+ task_id, tool_use_id, status = envelope
4016
+ parsed.append(
4017
+ _Notification(candidate.line_no, task_id, tool_use_id, status, candidate.ts)
4018
+ )
4019
+
4020
+ best: dict[tuple[str, str, str], _Notification] = {}
4021
+ for notification in parsed:
4022
+ key = (notification.task_id, notification.tool_use_id, notification.status)
4023
+ current = best.get(key)
4024
+ if current is None or notification.line_no < current.line_no:
4025
+ best[key] = notification
4026
+ return list(best.values())
4027
+
4028
+ @staticmethod
4029
+ def _combine_terminal_evidence(
4030
+ records: list[tuple[str, str, int]],
4031
+ ) -> tuple[str | None, str | None, int | None, list[str]]:
4032
+ """docs/DESIGN.md 4.13.3's "Terminal evidence per generation" rules,
4033
+ given records of (value, source, line) already restricted to this
4034
+ generation and sorted by nothing in particular (sorted here)."""
4035
+ if not records:
4036
+ return None, None, None, []
4037
+ ordered = sorted(records, key=lambda record: record[2])
4038
+ values = {record[0] for record in ordered}
4039
+ if len(values) == 1:
4040
+ value, source, line = ordered[0]
4041
+ return value, source, line, []
4042
+ if (
4043
+ len(ordered) == 2
4044
+ and ordered[0][1] == "task_stop"
4045
+ and ordered[1][1] == "notification"
4046
+ and ordered[1][0] in ("completed", "failed")
4047
+ ):
4048
+ value, source, line = ordered[1]
4049
+ return value, source, line, []
4050
+ baseline_value = ordered[0][0]
4051
+ conflict_line = next(
4052
+ record[2] for record in ordered[1:] if record[0] != baseline_value
4053
+ )
4054
+ return (
4055
+ "unknown", "conflict", conflict_line,
4056
+ [f"conflicting terminal evidence at line {conflict_line}"],
4057
+ )
4058
+
4059
+ def _generation_terminal(
4060
+ self,
4061
+ *,
4062
+ task_id: str,
4063
+ tool_use_id: str,
4064
+ result_line: int,
4065
+ launch_status: str,
4066
+ accepted_notifications: list[_Notification],
4067
+ ) -> tuple[str | None, str | None, int | None, list[str]]:
4068
+ if launch_status not in _TERMINAL_STATUS_VOCAB and launch_status != "async_launched":
4069
+ # docs/DESIGN.md 4.13.3's Launch status paragraph gives this
4070
+ # exact literal string, with no line number (unlike the "...
4071
+ # at line N" diagnostics the same section gives for a
4072
+ # malformed or missing result); the line is still available on
4073
+ # terminal_line, so it is not lost by omitting it here.
4074
+ return (
4075
+ "unknown", "unrecognized_status", result_line,
4076
+ ["unrecognized launch status"],
4077
+ )
4078
+
4079
+ records: list[tuple[str, str, int]] = []
4080
+ for notification in accepted_notifications:
4081
+ if (
4082
+ notification.task_id != task_id
4083
+ or notification.tool_use_id != tool_use_id
4084
+ or notification.line_no <= result_line
4085
+ ):
4086
+ continue
4087
+ value = _TERMINAL_STATUS_VOCAB.get(notification.status, "unknown")
4088
+ records.append((value, "notification", notification.line_no))
4089
+ for task_stop in self.task_stops:
4090
+ if task_stop.line_no <= result_line:
4091
+ continue
4092
+ if task_stop.task_id == task_id or task_stop.shell_id == task_id:
4093
+ records.append(("stopped", "task_stop", task_stop.line_no))
4094
+ if launch_status != "async_launched":
4095
+ records.append((_TERMINAL_STATUS_VOCAB[launch_status], "launch_result", result_line))
4096
+
4097
+ return self._combine_terminal_evidence(records)
4098
+
4099
+ def _incomplete_generation(
4100
+ self, launch: _Launch, result_line: int | None, diagnostic: str,
4101
+ result_ts: datetime | None = None,
4102
+ ) -> Generation:
4103
+ """docs/DESIGN.md 4.13.3: an incomplete generation (association
4104
+ rules 4 and 5), only ever built when the launch's own
4105
+ resumeFromRunId names a run -- globally, that run is the only one
4106
+ this launch could ever be incomplete evidence for."""
4107
+ return Generation(
4108
+ launch_line=launch.line_no, tool_use_id=launch.tool_use_id,
4109
+ resume_from=launch.resume_from, result_line=result_line,
4110
+ run_id=launch.resume_from, task_id=None, launch_status=None,
4111
+ terminal="unknown", terminal_source="incomplete_generation",
4112
+ terminal_line=None, diagnostics=[diagnostic], result_ts=result_ts,
4113
+ launch_ts=launch.ts,
4114
+ )
4115
+
4116
+ @staticmethod
4117
+ def _involved(launch: "_Launch", results: list = ()) -> frozenset[str]:
4118
+ """The runs a result-less, malformed, or rejected launch involves: the run
4119
+ its resumeFromRunId names, plus every string `toolUseResult.runId` on any
4120
+ of its results (A4, A5); none for an initial launch whose results name no
4121
+ run."""
4122
+ involved = set() if launch.resume_from is None else {launch.resume_from}
4123
+ for _line, raw_result, _ts, _rejected, _digest in results:
4124
+ if isinstance(raw_result, dict) and isinstance(raw_result.get("runId"), str):
4125
+ involved.add(raw_result["runId"])
4126
+ return frozenset(involved)
4127
+
4128
+ def _rebuild_generations(self) -> None:
4129
+ """Recomputed fresh from the raw accumulators on every access (docs/
4130
+ DESIGN.md 4.13.3): cheap, bounded by the number of launches/
4131
+ notifications seen so far, never by file size -- only the line
4132
+ parsing above is incremental."""
4133
+ generations: list[Generation] = []
4134
+ gen_diagnostics: list[str] = []
4135
+ gen_records: list[tuple[str, frozenset[str]]] = []
4136
+ ambiguous_launches: list[_AmbiguousLaunch] = []
4137
+ accepted_notifications = self._accepted_notifications()
4138
+
4139
+ for tool_use_id, launches in self._launches_by_id.items():
4140
+ all_results = self._results_by_id.get(tool_use_id, [])
4141
+ # N4 fix, A4: byte-identical repeats of one result are one result.
4142
+ results = _distinct_results(all_results, lambda result: result[4])
4143
+ if len(launches) > 1 or len(results) > 1:
4144
+ lines = sorted(
4145
+ {launch.line_no for launch in launches} | {r[0] for r in all_results}
4146
+ )
4147
+ diagnostic = (
4148
+ f"{LAUNCH_DIAGNOSTIC_PREFIXES[0]} {tool_use_id!r}: lines "
4149
+ + ", ".join(str(line) for line in lines)
4150
+ )
4151
+ # docs/DESIGN.md 4.13.3's association rule 1 (v0.3 N2): every
4152
+ # run id this ambiguous launch is evidence for -- each
4153
+ # launch's own resumeFromRunId, plus each well-formed
4154
+ # result's own runId -- so Lifecycle can tell whether it
4155
+ # "involves" a particular inspected run.
4156
+ candidate_run_ids: set[str] = set()
4157
+ for dup_launch in launches:
4158
+ if dup_launch.resume_from is not None:
4159
+ candidate_run_ids.add(dup_launch.resume_from)
4160
+ for _line, raw_result, _result_ts, _rejected, _digest in all_results:
4161
+ if isinstance(raw_result, dict) and isinstance(raw_result.get("runId"), str):
4162
+ candidate_run_ids.add(raw_result["runId"])
4163
+ gen_diagnostics.append(diagnostic)
4164
+ gen_records.append((diagnostic, frozenset(candidate_run_ids)))
4165
+ ambiguous_launches.append(_AmbiguousLaunch(
4166
+ tool_use_id=tool_use_id, diagnostic=diagnostic,
4167
+ candidate_run_ids=frozenset(candidate_run_ids),
4168
+ ))
4169
+ continue
4170
+
4171
+ launch = launches[0]
4172
+ if not results:
4173
+ diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[1]} at line {launch.line_no}"
4174
+ gen_diagnostics.append(diagnostic)
4175
+ # N4 fix, A4: it involves a run only through its resume.
4176
+ gen_records.append((diagnostic, self._involved(launch)))
4177
+ if launch.resume_from is not None:
4178
+ generations.append(self._incomplete_generation(launch, None, diagnostic))
4179
+ continue
4180
+
4181
+ result_line, raw_result, result_ts, rejected, _digest = results[0]
4182
+ if rejected:
4183
+ # N4 fix, A4 and A5: a rejected launch builds no generation. An
4184
+ # error result is not proof that no run exists, so one with a
4185
+ # resume stays involved with the run it names.
4186
+ if launch.resume_from is not None:
4187
+ diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[2]} at line {result_line}"
4188
+ gen_diagnostics.append(diagnostic)
4189
+ gen_records.append((diagnostic, self._involved(launch)))
4190
+ continue
4191
+ well_formed = (
4192
+ isinstance(raw_result, dict)
4193
+ and isinstance(raw_result.get("runId"), str)
4194
+ and isinstance(raw_result.get("taskId"), str)
4195
+ and isinstance(raw_result.get("status"), str)
4196
+ )
4197
+ if not well_formed:
4198
+ diagnostic = f"{LAUNCH_DIAGNOSTIC_PREFIXES[3]} at line {result_line}"
4199
+ gen_diagnostics.append(diagnostic)
4200
+ gen_records.append((diagnostic, self._involved(launch, all_results)))
4201
+ if launch.resume_from is not None:
4202
+ generations.append(
4203
+ self._incomplete_generation(launch, result_line, diagnostic, result_ts)
4204
+ )
4205
+ continue
4206
+
4207
+ run_id = raw_result["runId"]
4208
+ task_id = raw_result["taskId"]
4209
+ launch_status = raw_result["status"]
4210
+ terminal, source, line, extra_diag = self._generation_terminal(
4211
+ task_id=task_id, tool_use_id=tool_use_id, result_line=result_line,
4212
+ launch_status=launch_status, accepted_notifications=accepted_notifications,
4213
+ )
4214
+ generations.append(Generation(
4215
+ launch_line=launch.line_no, tool_use_id=tool_use_id,
4216
+ resume_from=launch.resume_from, result_line=result_line,
4217
+ run_id=run_id, task_id=task_id, launch_status=launch_status,
4218
+ terminal=terminal, terminal_source=source, terminal_line=line,
4219
+ diagnostics=extra_diag, result_ts=result_ts, launch_ts=launch.ts,
4220
+ ))
4221
+
4222
+ generations.sort(key=lambda generation: generation.launch_line)
4223
+ self.generations = generations
4224
+ self.ambiguous_launches = ambiguous_launches
4225
+ self._gen_diagnostics = gen_diagnostics
4226
+ self._gen_records = gen_records
4227
+ self._recompute_diagnostics()
4228
+
4229
+
4230
+ def _parent_transcript_path_for_workflow_dir(path: Path) -> Path | None:
4231
+ """The <root>/<slug>/<session>.jsonl that sits next to the
4232
+ <session>/subagents/ directory a workflow run directory `path` was
4233
+ launched from (docs/DESIGN.md 4.2, 4.4, 4.13.3). None when `path` was
4234
+ not reached through that standard layout (a flat fixture, or a bare
4235
+ directory passed directly on the command line) -- there is simply no
4236
+ parent to attribute against, the same as one that does not exist yet.
4237
+ Takes a bare path (never a Run) so it works equally from an already-
4238
+ loaded Run's own run.path (_parent_transcript_path, below) and from
4239
+ workflow_lifecycle's computation straight off the run directory, with
4240
+ no Run object required."""
4241
+ parts = path.parts
4242
+ if "subagents" not in parts:
4243
+ return None
4244
+ idx = parts.index("subagents")
4245
+ if idx < 1:
4246
+ return None
4247
+ session_dir = Path(*parts[:idx])
4248
+ return session_dir.parent / f"{session_dir.name}.jsonl"
4249
+
4250
+
4251
+ def _parent_transcript_path(run: Run) -> Path | None:
4252
+ """The main session transcript a killed attempt's cause is attributed
4253
+ against (docs/DESIGN.md 4.2, 4.4): run.path itself for a session (its
4254
+ subagents' parent is the session's own main transcript), or
4255
+ _parent_transcript_path_for_workflow_dir(run.path) for a workflow."""
4256
+ if run.kind == "session":
4257
+ return run.path
4258
+ return _parent_transcript_path_for_workflow_dir(run.path)
4259
+
4260
+
4261
+ def parent_transcript_path(run: Run) -> Path | None:
4262
+ """Public wrapper around _parent_transcript_path, for a detector
4263
+ (docs/DESIGN.md 4.13.3's workflow_failed, N2) that needs the parent
4264
+ transcript's own path to build a 'path:line' evidence string without
4265
+ re-deriving the standard-layout rule itself."""
4266
+ return _parent_transcript_path(run)
4267
+
4268
+
4269
+ def lifecycle_terminal_line(run: Run) -> int | None:
4270
+ """The physical parent-transcript line that decided run.lifecycle's own
4271
+ terminal (docs/DESIGN.md 4.13.3), read straight off the private
4272
+ `_terminal_line` already captured in the exact snapshot that produced
4273
+ `run.lifecycle` (N1N2 review finding 2) -- never re-derived by a fresh
4274
+ ParentIndex query, which could by now reflect a newer generation
4275
+ appended to the parent transcript after load_run() computed
4276
+ `run.lifecycle`. Lifecycle's own public shape still has exactly the five
4277
+ fields docs/DESIGN.md 4.1/5.3b document; this reads the one internal
4278
+ field alongside them. None when run.lifecycle is None (a session) or
4279
+ when no generation ever decided the stored terminal."""
4280
+ if run.lifecycle is None:
4281
+ return None
4282
+ return run.lifecycle._terminal_line
4283
+
4284
+
4285
+ def _parent_index_for(run: Run) -> "ParentIndex":
4286
+ """The one parent-index snapshot a run load uses: the run's parent
4287
+ transcript's index, or an empty path-less one when there is none."""
4288
+ parent_path = _parent_transcript_path(run)
4289
+ return (
4290
+ ParentIndex.for_path(parent_path)
4291
+ if parent_path is not None
4292
+ else ParentIndex(None, [], [], [])
4293
+ )
4294
+
4295
+
4296
+ def _attribute_interrupts(run: Run, parent_index: "ParentIndex | None" = None) -> None:
4297
+ """Fill in cause and cause_evidence on every attempt.interrupt of run
4298
+ (docs/DESIGN.md 4.4), via detectors.base.attribute_interrupt. Called
4299
+ once a Run's attempts are fully assembled: the runtime cause needs to
4300
+ know whether a key has a later attempt, and the parent transcript (if
4301
+ any) is scanned once and shared across every attempt here. run.main is
4302
+ never touched: whether a main-session interrupt was human is not
4303
+ knowable from the transcript (docs/DESIGN.md 4.3's documented limit)."""
4304
+ if parent_index is None:
4305
+ parent_index = _parent_index_for(run)
4306
+ # A malformed or unreadable parent transcript never aborts attribution
4307
+ # (it already falls back to 'unattributed' below); its diagnostics still
4308
+ # surface on the Run (docs/DESIGN.md section 2's tolerant-parsing rule).
4309
+ # This alone does not flip Run.status to INCOMPLETE: docs/DESIGN.md 4.6
4310
+ # scopes that to the journal and the transcripts the journal itself
4311
+ # names, and the parent transcript consulted here is neither -- it is
4312
+ # supplementary evidence for a cause that safely defaults to
4313
+ # 'unattributed' when it cannot be read.
4314
+ run.diagnostics.extend(parent_index.diagnostics)
4315
+ if run.kind == "workflow":
4316
+ # docs/DESIGN.md 4.1 (0.3.4): a session's parent is its own transcript, counted already
4317
+ _skipped_line(parent_index.skipped_line_count())
4318
+ # docs/DESIGN.md 4.13.1 (N4 fix, A4): the discovery list holds only the
4319
+ # parent diagnostics that involve this run (a session involves none of the
4320
+ # launch ones: its own launch table reports them).
4321
+ run.discovery["parent"] = parent_index.discovery_diagnostics_for(
4322
+ run.run_id if run.kind == "workflow" else None
4323
+ )
4324
+ for attempt in run.attempts:
4325
+ if attempt.interrupt is None:
4326
+ continue
4327
+ cause, evidence = attribute_interrupt(run, attempt, parent_index)
4328
+ attempt.interrupt.cause = cause
4329
+ attempt.interrupt.cause_evidence = evidence
4330
+
4331
+
4332
+ # --- run lifecycle (docs/DESIGN.md 4.13.3, v0.3 N2) -------------------------
4333
+
4334
+ # docs/DESIGN.md 4.13.3: Run.finished is true for exactly these lifecycle
4335
+ # terminals; false for 'unknown'/'none'; None (handled separately by every
4336
+ # caller) for a session, which never has a Lifecycle at all.
4337
+ _LIFECYCLE_FINISHED_TERMINALS = frozenset({"completed", "failed", "stopped", "journal_resolved"})
4338
+
4339
+
4340
+ def _lifecycle_from_parts(
4341
+ path: Path, run_id: str, journal_resolved: bool,
4342
+ parent_index: "ParentIndex | None" = None, after_launch_line: int = 0,
4343
+ ) -> Lifecycle:
4344
+ """docs/DESIGN.md 4.13.3's run lifecycle table, evaluated from the newest
4345
+ generation (by launch_line) associated with run_id. Needs only the run
4346
+ directory, its run id, and whether the journal is resolved -- never an
4347
+ attempt's own transcript -- which is what lets workflow_lifecycle (below)
4348
+ compute a full Lifecycle straight from a run directory, and lets
4349
+ _load_workflow reuse the exact same table logic for an already-loaded
4350
+ Run without a second, independently-written copy of it.
4351
+
4352
+ `after_launch_line` (docs/DESIGN.md 4.12, N8b K5.4, watch's journal epochs) leaves out every
4353
+ generation whose launch line is not greater than it: the generations of an ended journal
4354
+ epoch. 0, the default, leaves out none."""
4355
+ if parent_index is None:
4356
+ parent_path = _parent_transcript_path_for_workflow_dir(path)
4357
+ parent_index = (
4358
+ ParentIndex.for_path(parent_path)
4359
+ if parent_path is not None
4360
+ else ParentIndex(None, [], [], [])
4361
+ )
4362
+ generations = [
4363
+ generation for generation in parent_index.generations_for(run_id)
4364
+ if generation.launch_line > after_launch_line
4365
+ ]
4366
+ activity = _workflow_activity(path)
4367
+
4368
+ # docs/DESIGN.md 4.13.3's Run lifecycle paragraph (N1N2 review findings
4369
+ # 1/7): completeness is the cached snapshot's own flag, computed inside
4370
+ # ParentIndex's incremental pass as it parsed the file -- never a second,
4371
+ # independent full re-read here. A missing parent_path builds a bare,
4372
+ # path-less ParentIndex above, whose inspection_complete defaults to
4373
+ # True (vacuous, 4.6's "unreadable, not merely absent" convention).
4374
+ if not parent_index.inspection_complete:
4375
+ return Lifecycle(
4376
+ terminal="unknown", source="parent_unavailable", activity=activity,
4377
+ generations=len(generations), diagnostics=[],
4378
+ _generations=list(generations),
4379
+ )
4380
+
4381
+ ambiguous_diagnostics = parent_index.ambiguous_diagnostics_for(run_id)
4382
+ if ambiguous_diagnostics:
4383
+ return Lifecycle(
4384
+ terminal="unknown", source="ambiguous_launch", activity=activity,
4385
+ generations=len(generations), diagnostics=list(ambiguous_diagnostics),
4386
+ _generations=list(generations),
4387
+ )
4388
+
4389
+ newest = max(generations, key=lambda generation: generation.launch_line, default=None)
4390
+
4391
+ if newest is not None and newest.terminal is not None:
4392
+ return Lifecycle(
4393
+ terminal=newest.terminal, source=newest.terminal_source, activity=activity,
4394
+ generations=len(generations), diagnostics=list(newest.diagnostics),
4395
+ # N1N2 review finding 2: captured in this same snapshot so
4396
+ # workflow_failed's evidence never drifts to a newer generation
4397
+ # a later ParentIndex refresh might see.
4398
+ _terminal_line=newest.terminal_line,
4399
+ _generations=list(generations),
4400
+ )
4401
+
4402
+ if journal_resolved:
4403
+ return Lifecycle(
4404
+ terminal="journal_resolved", source="journal", activity=activity,
4405
+ generations=len(generations), diagnostics=[],
4406
+ _generations=list(generations),
4407
+ )
4408
+
4409
+ return Lifecycle(
4410
+ terminal="none", source=None, activity=activity,
4411
+ generations=len(generations), diagnostics=[],
4412
+ _generations=list(generations),
4413
+ )
4414
+
4415
+
4416
+ def workflow_lifecycle(path: Path) -> Lifecycle:
4417
+ """docs/DESIGN.md section 3 and 4.13.3 (N2 root-cause fix): the one
4418
+ public function that computes a workflow run directory's Lifecycle from
4419
+ the run directory alone -- journal.jsonl, the parent index, and the run
4420
+ directory's own file mtimes -- parsing no agent transcript. _load_workflow
4421
+ calls this for Run.lifecycle, and hooks_cli._workflow_finished (7.2)
4422
+ calls this too (through lifecycle_finished), so a hook's notion of
4423
+ 'finished' is never a second, independently maintained rule -- the same
4424
+ "one function is the only definition of this rule" docs/DESIGN.md
4425
+ section 3 states for Run.finished itself.
4426
+
4427
+ `path` need not exist yet, and its journal.jsonl need not either (a
4428
+ workflow directory can exist before its journal does): both are read the
4429
+ same tolerant way _load_workflow's own journal parsing is."""
4430
+ path = Path(path)
4431
+ run_id = _valid_workflow_run_id(path.name, [])
4432
+ journal, _started_events, _journal_ok, journal_line_numbers = _read_journal(path, [])
4433
+ journal_attempts, _diagnostics = _run_journal_state_machine(journal, journal_line_numbers)
4434
+ journal_resolved = _workflow_finished_from_occurrences(journal_attempts)
4435
+ return _lifecycle_from_parts(path, run_id, journal_resolved)
4436
+
4437
+
4438
+ def lifecycle_from_journal_state(
4439
+ path: Path, run_id: str, journal_resolved: bool, parent_index: "ParentIndex",
4440
+ *, after_launch_line: int = 0,
4441
+ ) -> Lifecycle:
4442
+ """docs/DESIGN.md 4.12, 4.13.3 (N8b K5.4): the run lifecycle table evaluated from a fact the
4443
+ caller already has. `watch` follows a growing journal incrementally, so it hands in
4444
+ `journal_resolved` computed from its own in-memory occurrences and the parent index it
4445
+ refreshes itself, instead of paying for `workflow_lifecycle`'s full journal read and
4446
+ state-machine run on every poll. `after_launch_line` leaves out the generations of an ended
4447
+ journal epoch (see `_lifecycle_from_parts`)."""
4448
+ return _lifecycle_from_parts(
4449
+ Path(path), run_id, journal_resolved, parent_index, after_launch_line,
4450
+ )
4451
+
4452
+
4453
+ def journal_resolved_from_occurrences(occurrences: list) -> bool:
4454
+ """The journal-only `finished` fact (docs/DESIGN.md 4.1, 4.12) for occurrences a caller
4455
+ already holds from `_run_journal_state_machine`: true when every key the journal started has
4456
+ no open attempt."""
4457
+ return _workflow_finished_from_occurrences(occurrences)
4458
+
4459
+
4460
+ def lifecycle_finished(lifecycle: Lifecycle) -> bool:
4461
+ """docs/DESIGN.md section 3 (N2 root-cause fix): the one rule for whether
4462
+ a workflow run's own Lifecycle counts as finished -- true for
4463
+ 'completed', 'failed', 'stopped', and 'journal_resolved'; false for
4464
+ 'unknown' and 'none'. Lives next to _LIFECYCLE_FINISHED_TERMINALS so
4465
+ _load_workflow's Run.finished and hooks_cli's workflow-unit finished
4466
+ check both call this one function instead of each re-deriving the
4467
+ terminal set on its own."""
4468
+ return lifecycle.terminal in _LIFECYCLE_FINISHED_TERMINALS
4469
+
4470
+
4471
+ def attempt_unresolved_in_journal(journal: list[dict], key: str | None, ordinal: int) -> bool:
4472
+ """Public counterpart of the per-key journal state machine's own
4473
+ closed_by (docs/DESIGN.md 4.13.3): true when the ordinal-th occurrence of
4474
+ key was never closed by a 'result' or 'failed' event -- still open at the
4475
+ end of the journal the reader actually read. report.py's AGENT table END
4476
+ column (4.7) uses this to show 'no result' instead of the 'completed'
4477
+ _compute_outcome falls back to for an open attempt whose own transcript
4478
+ simply has not (yet) ended in the kill sentinel."""
4479
+ if key is None:
4480
+ return False
4481
+ occurrences, _diagnostics = _run_journal_state_machine(journal)
4482
+ for occurrence in occurrences:
4483
+ if occurrence.key == key and occurrence.ordinal == ordinal:
4484
+ return occurrence.closed_by is None
4485
+ return False
4486
+
4487
+
4488
+ # --- identity: the run cwd set and agent ids (docs/DESIGN.md 4.13.6, N6) -----------
4489
+
4490
+
4491
+ def run_cwd_set(run: Run) -> frozenset[str]:
4492
+ """Every top-level non-empty string `cwd` the run's own transcripts carried: a
4493
+ session's main transcript and each child attempt's, or a workflow run's attempt
4494
+ transcripts. Never the journal, a meta file, or a workflow's parent session
4495
+ transcript (the reader collects `Attempt.entry_cwds` only from an attempt's own
4496
+ transcript)."""
4497
+ cwds: set[str] = set()
4498
+ if run.main is not None:
4499
+ cwds.update(run.main.entry_cwds)
4500
+ for attempt in run.attempts:
4501
+ cwds.update(attempt.entry_cwds)
4502
+ return frozenset(cwds)
4503
+
4504
+
4505
+ def run_matches_cwd(run: Run, caller: str) -> bool:
4506
+ """`--here` containment (docs/DESIGN.md 4.13.11, N6): both the caller's directory and
4507
+ each candidate cwd are made absolute and normalized (no `~` expansion, no symlink
4508
+ resolution); the run matches when the caller equals or lies below some candidate.
4509
+ A candidate that cannot be compared (ValueError, OSError) is no match for that
4510
+ candidate, and a run with an empty cwd set never matches."""
4511
+ candidates = run_cwd_set(run)
4512
+ if not candidates:
4513
+ return False
4514
+ try:
4515
+ caller_path = os.path.normpath(os.path.abspath(caller))
4516
+ except (ValueError, OSError):
4517
+ return False
4518
+ for candidate in candidates:
4519
+ try:
4520
+ candidate_path = os.path.normpath(os.path.abspath(candidate))
4521
+ if os.path.commonpath([candidate_path, caller_path]) == candidate_path:
4522
+ return True
4523
+ except (ValueError, OSError):
4524
+ continue
4525
+ return False
4526
+
4527
+
4528
+ def caller_workflow_paths(
4529
+ root: Path, session_id: str | None, caller: str | None,
4530
+ ) -> frozenset[Path]:
4531
+ """The workflow run directories `--here` adds for the caller's own session (docs/DESIGN.md
4532
+ 4.13.6, G5): the `wf_*` directories under `<project>/<session>/subagents/workflows` of the one
4533
+ session run under `root` whose transcript stem equals `session_id` (a canonical UUID,
4534
+ lowercase), when that session run itself passes `run_matches_cwd` for `caller`. Empty for no
4535
+ id, no caller directory, no or several such session runs, a session that does not work here,
4536
+ or a session that cannot be loaded."""
4537
+ if session_id is None or caller is None or not _UUID_RE.fullmatch(session_id):
4538
+ return frozenset()
4539
+ owners = [
4540
+ ref for ref in discover_runs(Path(root), kinds=frozenset({"session"}))
4541
+ if ref.path.stem.lower() == session_id.lower()
4542
+ ]
4543
+ if len(owners) != 1:
4544
+ return frozenset()
4545
+ owner = owners[0]
4546
+ try:
4547
+ run = load_run(owner)
4548
+ except (NotARun, RunUnreadable):
4549
+ return frozenset()
4550
+ if not run_matches_cwd(run, caller):
4551
+ return frozenset()
4552
+ workflows = owner.path.parent / owner.path.stem / "subagents" / "workflows"
4553
+ try:
4554
+ return frozenset(path for path in workflows.glob("wf_*") if _is_dir(path))
4555
+ except OSError:
4556
+ return frozenset()
4557
+
4558
+
4559
+ def agent_id_candidates(ref: RunRef) -> frozenset[str]:
4560
+ """A superset, found without parsing any transcript, of every agent id the loader
4561
+ can turn into an attempt of this run (docs/DESIGN.md 4.8, N6). A session: the stem id
4562
+ of every `subagents/agent-*.jsonl` the session loader would discover. A workflow:
4563
+ the `agentId` string of every journal `started` event `_read_journal` returns, plus
4564
+ the stem id of every `agent-*.jsonl` in the run directory. An id that is not
4565
+ canonical is dropped; a directory that cannot be listed contributes nothing."""
4566
+ found: set[str] = set()
4567
+ if ref.kind == "session":
4568
+ try:
4569
+ paths = _discover_session_agent_paths(ref.path)
4570
+ except OSError:
4571
+ paths = []
4572
+ found.update(path.stem.removeprefix("agent-") for path in paths)
4573
+ else:
4574
+ _journal, started_events, _ok, _lines = _read_journal(ref.path, [])
4575
+ for event in started_events:
4576
+ agent_id = event.get("agentId")
4577
+ if isinstance(agent_id, str):
4578
+ found.add(agent_id)
4579
+ try:
4580
+ for path in ref.path.glob("agent-*.jsonl"):
4581
+ if _is_file(path):
4582
+ found.add(path.stem.removeprefix("agent-"))
4583
+ except OSError:
4584
+ pass
4585
+ return frozenset(agent_id for agent_id in found if _AGENT_ID_RE.fullmatch(agent_id))
4586
+
4587
+
4588
+ def run_holds_agent(ref: RunRef, agent_ids) -> bool:
4589
+ """The confirmation step of agent resolution: the run, loaded, holds an attempt
4590
+ whose agent id is one of `agent_ids`. A ref that is not a run is no match. A run that
4591
+ cannot be read cannot refute the candidate check, so it stays a match: the command that
4592
+ loads it then says it could not be read (docs/DESIGN.md 4.8, 0.3.4)."""
4593
+ wanted = set(agent_ids)
4594
+ try:
4595
+ run = load_run(ref)
4596
+ except NotARun:
4597
+ return False
4598
+ except RunUnreadable:
4599
+ return True
4600
+ return any(attempt.agent_id in wanted for attempt in run.attempts)
4601
+
4602
+
4603
+ def runs_holding_agent(refs, agent_id: str) -> list[RunRef]:
4604
+ """The refs, in the order given, whose loaded Run holds an attempt with this
4605
+ canonical agent id: a cheap candidate check, then the exact confirmation."""
4606
+ if not _AGENT_ID_RE.fullmatch(agent_id):
4607
+ return []
4608
+ return [
4609
+ ref for ref in refs
4610
+ if agent_id in agent_id_candidates(ref) and run_holds_agent(ref, (agent_id,))
4611
+ ]