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,815 @@
1
+ """Data model for agent-postmortem.
2
+
3
+ reader.py fills these; detectors read them; report.py renders them. Every
4
+ dataclass carries a to_dict() so report.py and the --json output do not
5
+ re-implement Path and datetime conversion in more than one place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING: # the classifier is pure; models only names its result type
16
+ from .commands import CallClass
17
+
18
+
19
+ class KeyedDiagnostic(str):
20
+ """A reader diagnostic that would quote a journal key (N3b P10b, D-h).
21
+
22
+ Its plain string is the safe form, which names the journal line instead
23
+ of the key (`key@<line>`); a sink that does nothing special therefore
24
+ can never print a key. `.raw` carries the exact text v0.2 printed, for
25
+ the raw sinks only (raw text and raw JSON ask for it explicitly through
26
+ raw_diagnostic, before any Redactor call). A journal key is arbitrary
27
+ text a run's author chose, and a valid-shaped one (`v2:` plus 64 hex)
28
+ can embed a real agent id, so no other sink may read `.raw`."""
29
+
30
+ raw: str
31
+
32
+ def __new__(cls, safe: str, raw: str) -> "KeyedDiagnostic":
33
+ obj = super().__new__(cls, safe)
34
+ obj.raw = raw
35
+ return obj
36
+
37
+
38
+ class QualityNote(str):
39
+ """A reader diagnostic that is only a data-quality note (docs/DESIGN.md
40
+ 4.13.1, A2): it cannot hide an attempt, a tool call, or a launch, so it
41
+ stays in `Run.diagnostics` and never reaches a `Run.discovery` list. The
42
+ reader tags the note where it raises it; nothing matches message text."""
43
+
44
+
45
+ def discovery_part(diagnostics) -> list[str]:
46
+ """The diagnostics of `diagnostics` that belong in a discovery list: every
47
+ one that is not a QualityNote, in order."""
48
+ return [item for item in diagnostics if not isinstance(item, QualityNote)]
49
+
50
+
51
+ def raw_diagnostic(diagnostic: str) -> str:
52
+ """The raw form of a diagnostic for a raw sink: a KeyedDiagnostic's
53
+ `.raw`, any other diagnostic unchanged. Call it only where the level is
54
+ raw."""
55
+ return diagnostic.raw if isinstance(diagnostic, KeyedDiagnostic) else str(diagnostic)
56
+
57
+
58
+ def _path_str(value: Path | None) -> str | None:
59
+ return str(value) if value is not None else None
60
+
61
+
62
+ def _iso(value: datetime | None) -> str | None:
63
+ return value.isoformat() if value is not None else None
64
+
65
+
66
+ @dataclass
67
+ class Event:
68
+ path: Path # transcript file
69
+ line_no: int # 1-based
70
+ ts: datetime | None
71
+ kind: str # 'user' | 'assistant' | 'thinking' | 'tool_use' | 'tool_result'
72
+ # | 'attachment' | 'compaction' | 'unknown'
73
+ raw_type: str # the transcript's own type field
74
+ tool_name: str | None # for tool_use / tool_result (resolved via tool_use_id when possible)
75
+ text: str # excerpt, at most 400 chars, already truncated by the reader; '' for thinking
76
+ exit_code: int | None
77
+ duration_ms: int | None
78
+ is_error: bool
79
+ usage: dict # empty unless assistant message carried usage
80
+ # Which markers.py fragments (or, for a regex marker, its name) were
81
+ # found anywhere in the field's full decoded text before it was
82
+ # truncated to `text`. A marker landing after the excerpt limit must
83
+ # still be detectable; this is how a detector sees it without the
84
+ # reader holding the full, untruncated text.
85
+ markers: frozenset[str] = field(default_factory=frozenset)
86
+ # A tool_result whose resolved tool_name is Agent, Task, Workflow, or
87
+ # TaskOutput (docs/DESIGN.md 4.2): the parent's copy of a child's own
88
+ # output. markers is always empty on such an event so a child's error
89
+ # text quoted in the parent's result is never counted twice.
90
+ envelope: bool = False
91
+ # A tool_result whose resolved tool_name is Read, Grep, Glob, or
92
+ # NotebookRead, or a Bash tool_result whose command is one of the
93
+ # read-only forms docs/DESIGN.md 4.3 lists (docs/DESIGN.md 4.3's
94
+ # self-reference rule): the marker-based tooling detectors skip such an
95
+ # event, since its content is merely quoting something on disk (a
96
+ # marker string in this repository's own source, for example) rather
97
+ # than reporting a tool failure that actually happened to this agent.
98
+ # bash_backgrounded and bash_timeout never consult this field: a
99
+ # timed-out `cat` is still a timed-out command.
100
+ read_only_output: bool = False
101
+ # The tool_result block's own tool_use_id (docs/DESIGN.md 4.13.2, v0.3
102
+ # N1): set only for a tool_result-kind Event, None otherwise. Reader-
103
+ # internal plumbing, not a documented v0.1/v0.2 field: it exists so a
104
+ # MarkerHit (which carries tool_use_id but no text of its own) can be
105
+ # matched back to the exact Event it was built from even when a single
106
+ # user entry carries more than one tool_result block on the same
107
+ # line_no (two parallel tool calls whose results arrive together).
108
+ tool_use_id: str | None = None
109
+
110
+ def to_dict(self) -> dict:
111
+ return {
112
+ "path": _path_str(self.path),
113
+ "line_no": self.line_no,
114
+ "ts": _iso(self.ts),
115
+ "kind": self.kind,
116
+ "raw_type": self.raw_type,
117
+ "tool_name": self.tool_name,
118
+ # text is deliberately absent: the JSON export carries structure
119
+ # and findings, never transcript excerpts (privacy and size).
120
+ "exit_code": self.exit_code,
121
+ "duration_ms": self.duration_ms,
122
+ "is_error": self.is_error,
123
+ "usage": self.usage,
124
+ "markers": sorted(self.markers),
125
+ "envelope": self.envelope,
126
+ "read_only_output": self.read_only_output,
127
+ "tool_use_id": self.tool_use_id,
128
+ }
129
+
130
+
131
+ @dataclass
132
+ class MarkerHit:
133
+ """One typed marker hit (docs/DESIGN.md 4.1, 4.13.2, v0.3 N1), built by
134
+ the reader from the marker provenance registry (markers.REGISTRY) while
135
+ parsing an attempt's own transcript. Every field here is part of the
136
+ binding model contract; detectors consume a hit only when `anchored and
137
+ not in_payload`."""
138
+
139
+ marker: str # canonical id from markers.REGISTRY
140
+ producer: str # 'harness_user' | 'harness_tool_notice' | 'command_output'
141
+ # | 'runtime_assistant' | 'codex_companion'
142
+ event_line: int # physical line of the entry carrying the marker
143
+ tool_use_id: str | None # set for every tool_result shape; None otherwise
144
+ result_line: int | None # equals event_line for a tool_result shape; None otherwise
145
+ anchored: bool # the registry predicate held at its anchor
146
+ in_payload: bool # the text matched only outside the anchor (never evidence)
147
+
148
+ def to_dict(self) -> dict:
149
+ return {
150
+ "marker": self.marker,
151
+ "producer": self.producer,
152
+ "event_line": self.event_line,
153
+ "tool_use_id": self.tool_use_id,
154
+ "result_line": self.result_line,
155
+ "anchored": self.anchored,
156
+ "in_payload": self.in_payload,
157
+ }
158
+
159
+
160
+ @dataclass
161
+ class Interrupt:
162
+ """Set by the reader on an attempt whose last user event is the kill
163
+ sentinel (docs/DESIGN.md section 4.4). cause and cause_evidence start as
164
+ 'unattributed' and None while an attempt's own transcript is read, then
165
+ reader._attribute_interrupts fills them in against the parent transcript
166
+ (ParentIndex) once every attempt of the run has been loaded."""
167
+
168
+ kill_ts: datetime | None
169
+ preceding: Event | None # last meaningful event before the sentinel (4.5)
170
+ silence_seconds: float | None # None when a timestamp is missing, the
171
+ # delta is negative, or a compaction lies between preceding and the kill
172
+ cause: str # 'parent_stop' | 'human' | 'runtime' | 'unattributed'
173
+ cause_evidence: Event | None # the parent TaskStop or parent sentinel, if any
174
+
175
+ def to_dict(self) -> dict:
176
+ return {
177
+ "kill_ts": _iso(self.kill_ts),
178
+ "preceding": self.preceding.to_dict() if self.preceding is not None else None,
179
+ "silence_seconds": self.silence_seconds,
180
+ "cause": self.cause,
181
+ "cause_evidence": (
182
+ self.cause_evidence.to_dict() if self.cause_evidence is not None else None
183
+ ),
184
+ }
185
+
186
+
187
+ @dataclass
188
+ class ToolCall:
189
+ """One tool_use/tool_result pair, filled by the reader while streaming
190
+ from the FULL decoded transcript entry -- never from Event.text, which is
191
+ already bounded to 400 chars (docs/DESIGN.md 4.2/5.1's v0.2 reader
192
+ changes). Reader-internal and private: report.py's export allowlist
193
+ (5.3) never includes a ToolCall directly (a command is private), only
194
+ the facts AttemptFacts derives from a list of them.
195
+
196
+ tool_use_id/tool_name/line_no/ts describe the tool_use block itself
197
+ (line_no and ts are the tool_use entry's own, not the result's). The
198
+ remaining fields start at their 'no result yet' default and are filled
199
+ in when the matching tool_result is seen, looked up by tool_use_id (not
200
+ by position -- docs/DESIGN.md 5.1's v0.2 reader changes: two tool_use
201
+ blocks in one assistant message can have their results arrive in either
202
+ order)."""
203
+
204
+ tool_use_id: str
205
+ tool_name: str | None
206
+ line_no: int
207
+ ts: datetime | None
208
+ # file_path/notebook_path of an Edit, Write, or NotebookEdit call, taken
209
+ # from the parsed tool_use input (never the excerpt): at most 1024
210
+ # chars, else None. None for every other tool.
211
+ path: str | None = None
212
+ # Bash's or Monitor's own command, exact. Longer than 2048 chars: None here, with
213
+ # command_sha256 set instead so a long command is never silently
214
+ # truncated into something that reads like a different, shorter one.
215
+ command: str | None = None
216
+ command_sha256: str | None = None
217
+ # Whether the FULL raw command runs codex as a program
218
+ # (markers.runs_codex_program), computed while streaming so a command
219
+ # over 2048 chars (command is None) still counts. Reader-internal: never
220
+ # exported (docs/DESIGN.md 4.1, 5.3b).
221
+ runs_codex_program: bool = False
222
+ # The tool_use entry's own top-level 'cwd' field, normalized with
223
+ # os.path.normpath (a pure string operation: no symlink resolution, no
224
+ # filesystem access).
225
+ cwd: str | None = None
226
+ # The line number of the tool_result carrying this call's tool_use_id,
227
+ # or None when no such result was ever seen in this transcript.
228
+ result_line: int | None = None
229
+ is_error: bool | None = None
230
+ # The tool_result carries the BASH_BACKGROUNDED marker (both fragments).
231
+ backgrounded: bool = False
232
+ # Only when the result states one in the runtime's own form -- the same
233
+ # parsing the reader already does for Event.exit_code.
234
+ exit_code: int | None = None
235
+ # For a Bash call that is a commit call under docs/DESIGN.md 4.13.1's
236
+ # commit grammar (C-9, amended by A1) and completed_ok or
237
+ # completed_error: the last k line-start summary matches of the FULL
238
+ # result text (commands.commit_ids), at most 20.
239
+ commit_ids: tuple[str, ...] = ()
240
+ # docs/DESIGN.md 4.2's v0.2 reader changes (F5 hardening): set on every
241
+ # ToolCall sharing a tool_use_id with at least one other ToolCall in the
242
+ # same transcript (a duplicate tool_use id, which makes it impossible to
243
+ # know which tool_result truly belongs to which call). Checked first by
244
+ # `completion`, ahead of every other state: an ambiguous call's own
245
+ # is_error/exit_code/backgrounded fields can never be trusted, whichever
246
+ # one of the duplicates happened to receive them.
247
+ ambiguous: bool = False
248
+ # docs/DESIGN.md 4.1 (v0.3, N4b-1), reader-private and never in schema 2:
249
+ # the tool_use input's own `run_in_background` value for a Bash or Monitor
250
+ # call, exactly as written (None when absent and for every other tool).
251
+ run_in_background: object = None
252
+ # A MultiEdit call's input `file_path` (at most 1024 chars, else None).
253
+ # Kept apart from `path` so schema-2 paths_written, in_flight, and rescue
254
+ # (which read `path`) never change.
255
+ multiedit_path: str | None = None
256
+ # The 4.13.1 classification of this call (commands.classify_call),
257
+ # computed from the FULL command while streaming (C-11) and with the
258
+ # anchored bash_backgrounded hit of its result; None for every tool
259
+ # outside rows 2 to 6 of the truth table.
260
+ classification: "CallClass | None" = None
261
+ # Reader-private (never in to_dict, out of equality and repr): why `path`
262
+ # (or `multiedit_path`) is None for a file tool -- 'missing' (absent or not
263
+ # a string) or 'truncated' (over 1,024 characters); None otherwise. The
264
+ # ledger (4.13.1) turns it into `unexpected_entry_shape` or `truncated_field`.
265
+ _path_state: str | None = field(default=None, compare=False, repr=False)
266
+
267
+ @property
268
+ def completion(self) -> str:
269
+ """'ambiguous' (this tool_use_id was duplicated in the transcript --
270
+ checked first, since nothing else about the call can be trusted
271
+ then), 'completed_ok' (result seen, is_error false, not backgrounded,
272
+ and the parsed exit code -- if any -- is 0), 'completed_error'
273
+ (is_error true, or a parsed nonzero exit code even when is_error is
274
+ false: a contradiction the runtime can produce, and exit code wins),
275
+ 'backgrounded', or 'no_result'. Backgrounded is checked before
276
+ is_error: a backgrounded Bash call is a distinct, still-pending
277
+ state, not an ordinary tool error."""
278
+ if self.ambiguous:
279
+ return "ambiguous"
280
+ if self.result_line is None:
281
+ return "no_result"
282
+ if self.backgrounded:
283
+ return "backgrounded"
284
+ if self.is_error:
285
+ return "completed_error"
286
+ if self.exit_code is not None and self.exit_code != 0:
287
+ return "completed_error"
288
+ return "completed_ok"
289
+
290
+ def to_dict(self) -> dict:
291
+ return {
292
+ "tool_use_id": self.tool_use_id,
293
+ "tool_name": self.tool_name,
294
+ "line_no": self.line_no,
295
+ "ts": _iso(self.ts),
296
+ "path": self.path,
297
+ "command": self.command,
298
+ "command_sha256": self.command_sha256,
299
+ "cwd": self.cwd,
300
+ "result_line": self.result_line,
301
+ "is_error": self.is_error,
302
+ "backgrounded": self.backgrounded,
303
+ "exit_code": self.exit_code,
304
+ "commit_ids": list(self.commit_ids),
305
+ "ambiguous": self.ambiguous,
306
+ "completion": self.completion,
307
+ "run_in_background": self.run_in_background,
308
+ "multiedit_path": self.multiedit_path,
309
+ "classification": (
310
+ self.classification.to_dict() if self.classification is not None else None
311
+ ),
312
+ }
313
+
314
+
315
+ # docs/DESIGN.md 4.1's v0.2 reader changes: the literal value used in
316
+ # AttemptFacts.to_dict() for any field the reader could not establish
317
+ # because the attempt's transcript exists but could not be read (never for
318
+ # a merely missing transcript -- Attempt.facts is None for that instead, see
319
+ # Attempt.to_dict()). A plain string, not None, so a caller that only reads
320
+ # to_dict()'s JSON-shaped output can tell the two apart from an ordinary
321
+ # empty result (an empty list, a zero count) without a side channel.
322
+ FACTS_UNKNOWN = "UNKNOWN"
323
+
324
+
325
+ @dataclass
326
+ class AttemptFacts:
327
+ """Facts about one attempt derived from its ToolCall list (docs/
328
+ DESIGN.md 4.1/4.2's v0.2 reader changes). Built once, after the
329
+ attempt's transcript is fully read, by reader._build_attempt_facts.
330
+
331
+ Every field below is either its documented type or the literal string
332
+ FACTS_UNKNOWN ('UNKNOWN') when the transcript existed but could not be
333
+ read; the type union is left informal (not `| Literal['UNKNOWN']`) since
334
+ nothing in this codebase runs a static type checker over it."""
335
+
336
+ paths_written: list[str]
337
+ in_flight: list[dict]
338
+ commits: list[str]
339
+ cwds: list[str]
340
+ background_unfinished: int
341
+ last_tool: str | None
342
+ seconds_since_last_tool_at_end: float | None
343
+ # (field milestone F, FA6) shell CALLS with completion completed_ok that ran a
344
+ # commit command (commands.commit_command_ran, quiet forms included) and yielded
345
+ # no commit id; FACTS_UNKNOWN when the facts are unknown, never a false 0. Default
346
+ # 0 for a hand-built fact.
347
+ commit_commands_without_id: "int | str" = 0
348
+
349
+ def to_dict(self) -> dict:
350
+ return {
351
+ "paths_written": self.paths_written,
352
+ "in_flight": self.in_flight,
353
+ "commits": self.commits,
354
+ "commit_commands_without_id": self.commit_commands_without_id,
355
+ "cwds": self.cwds,
356
+ "background_unfinished": self.background_unfinished,
357
+ "last_tool": self.last_tool,
358
+ "seconds_since_last_tool_at_end": self.seconds_since_last_tool_at_end,
359
+ }
360
+
361
+
362
+ @dataclass
363
+ class LedgerEntry:
364
+ """One (tool call, target) entry of an attempt's write ledger (docs/DESIGN.md
365
+ 4.1, 4.13.1). The five-valued fields are closed enums; `path` and `repo` are
366
+ None when the call names none (`repo` is a lexical candidate, never a
367
+ trusted location); `line` is `<transcript file name>:<line>`."""
368
+
369
+ effect: str # 'observed' | 'claimed_target' | 'attempted' | 'possible'
370
+ basis: str # 'tool_result' | 'command_grammar' | 'delegation' | 'opaque_command'
371
+ # | 'background_process' | 'unknown_tool'
372
+ completion: str # 'success' | 'error' | 'no_result' | 'ambiguous'
373
+ path: str | None
374
+ repo: str | None
375
+ line: str
376
+
377
+ def to_dict(self) -> dict:
378
+ return {
379
+ "effect": self.effect,
380
+ "basis": self.basis,
381
+ "completion": self.completion,
382
+ "path": self.path,
383
+ "repo": self.repo,
384
+ "line": self.line,
385
+ }
386
+
387
+
388
+ @dataclass
389
+ class Ledger:
390
+ """Attempt.ledger's own shape (docs/DESIGN.md 4.1, 4.13.1): the entries in
391
+ tool-use order, 'complete' only when there is no partial reason, and the
392
+ closed partial-reason codes in the fixed order of 4.13.1."""
393
+
394
+ entries: list[LedgerEntry]
395
+ completeness: str # 'complete' | 'partial'
396
+ reasons: list[str] # [] when complete
397
+
398
+ def to_dict(self) -> dict:
399
+ return {
400
+ "entries": [entry.to_dict() for entry in self.entries],
401
+ "completeness": self.completeness,
402
+ "reasons": list(self.reasons),
403
+ }
404
+
405
+
406
+ @dataclass
407
+ class Attempt:
408
+ agent_id: str
409
+ key: str | None
410
+ label: str
411
+ phase: str
412
+ agent_type: str | None
413
+ meta: dict
414
+ transcript_path: Path | None
415
+ events: list[Event]
416
+ first_ts: datetime | None
417
+ last_ts: datetime | None
418
+ ordinal: int # 1-based order among attempts sharing a key
419
+ # 'completed' | 'failed' | 'killed' | 'superseded' | 'unknown'. For a
420
+ # workflow attempt this is decided by the per-key journal state machine
421
+ # (docs/DESIGN.md 4.1): a 'result' or 'failed' journal event closes it
422
+ # 'completed' / 'failed' (even over a trailing kill sentinel -- the
423
+ # journal's own report wins, though the sentinel is still kept as
424
+ # Interrupt evidence, see below); being superseded by a later 'started'
425
+ # for the same key closes it 'killed' when its own transcript carries
426
+ # the sentinel (the v0.1 silent retry) or 'superseded' otherwise (a
427
+ # reader diagnostic then forces Run.status INCOMPLETE). An attempt the
428
+ # state machine never closes (still open when the journal ends) falls
429
+ # back to the transcript alone, same as every session attempt always
430
+ # does (session runs have no journal at all).
431
+ outcome: str
432
+ usage: dict # summed input/output/cache tokens
433
+ # Set by the reader whenever this attempt's own transcript ends in the
434
+ # kill sentinel (docs/DESIGN.md 4.4), independent of outcome above: a
435
+ # 'failed' attempt can still carry an Interrupt as evidence.
436
+ interrupt: Interrupt | None = None
437
+ # docs/DESIGN.md section 3: the agent's own self-reported return value,
438
+ # read only for an attempt the journal state machine closed with a
439
+ # 'result' event (never 'failed', never one still open). None in every
440
+ # other case. For a dict result: {'type': 'dict', 'keys': [sorted key
441
+ # names, at most 40, each truncated to 64 chars], 'claims': {...an
442
+ # allowlisted subset of self-reported claims...}}. For a string result:
443
+ # {'type': 'text', 'text_length': <int>} -- the string itself is private
444
+ # (up to 25k chars observed) and is never stored here or printed.
445
+ reported: dict | None = None
446
+ # The 1-based journal.jsonl line number of the 'result' event `reported`
447
+ # was built from (None when reported is None). Reader-internal: it is
448
+ # how detectors/claim_contradiction.py builds its evidence line without
449
+ # re-scanning the journal; report.py's export allowlist never includes
450
+ # it directly (docs/DESIGN.md 5.3).
451
+ reported_line: int | None = None
452
+ # docs/DESIGN.md 4.1/4.2's v0.2 reader changes: every tool call of this
453
+ # attempt, in transcript order (tool_use order, not result-arrival
454
+ # order). Reader-internal/private (a command is private): the export
455
+ # allowlist (5.3) never includes this, only Attempt.facts.
456
+ tool_calls: list["ToolCall"] = field(default_factory=list)
457
+ # Derived from tool_calls once the transcript is fully read. None only
458
+ # when this attempt has no transcript at all (a missing file); a
459
+ # transcript that exists but could not be read still produces an
460
+ # AttemptFacts, with the fields that could not be established set to
461
+ # FACTS_UNKNOWN (models.py).
462
+ facts: "AttemptFacts | None" = None
463
+ # (docs/DESIGN.md 4.13.2, v0.3 N1) every typed marker hit built while
464
+ # parsing this attempt's own transcript, in the order encountered. []
465
+ # when the attempt carries none; never None -- a transcript that exists
466
+ # but could not be read still produces [], the same way `facts` still
467
+ # produces a FACTS_UNKNOWN record rather than being silently absent,
468
+ # except a MarkerHit is built per event, not derived from a whole-
469
+ # transcript read, so there is nothing to mark UNKNOWN here.
470
+ marker_hits: list["MarkerHit"] = field(default_factory=list)
471
+ # docs/DESIGN.md 4.1 (v0.3, N4b-1): the journal state machine's own
472
+ # closure of this attempt -- 'open' | 'completed' | 'failed' |
473
+ # 'superseded' for a workflow attempt with a journal key; None for a
474
+ # session main thread, a session subagent, and a workflow attempt with no
475
+ # journal key. Separate from `outcome` (a superseded attempt whose
476
+ # transcript ends in the kill sentinel has outcome 'killed').
477
+ journal_state: str | None = None
478
+ # docs/DESIGN.md 4.1 (v0.3, N4b-2): this attempt's write ledger. The reader
479
+ # always sets it once a run is assembled (never None from N4 on; a
480
+ # transcript that is missing or unreadable gives a partial ledger with no
481
+ # entries); a hand-built Attempt may leave it None.
482
+ ledger: "Ledger | None" = None
483
+ # docs/DESIGN.md 4.1 (v0.3, N5c-1, S11): a typed private field, None for a
484
+ # workflow attempt and the session main thread; for a session subagent,
485
+ # whether it returned a result to its parent (reader._LaunchLog.child_returned).
486
+ # Never in to_dict, never exported.
487
+ returned_to_parent: bool | None = None
488
+ # docs/DESIGN.md 4.1, 4.13.1 (v0.3.1, G-A): the return state of a session subagent (T0 to
489
+ # T5), its reason, and the number of delivered resumes; None / None / 0 for every other
490
+ # attempt. `returned_to_parent` is exactly `return_state == "returned"`.
491
+ return_state: str | None = None
492
+ return_reason: str | None = None
493
+ resumes: int = 0
494
+ # Reader-internal (never in to_dict): what decided a `reported_failed` state,
495
+ # (source, transcript path, line) with source `launch_result` or `notification`.
496
+ return_evidence: tuple | None = field(default=None, compare=False, repr=False)
497
+ # docs/DESIGN.md 4.3 (G3, v0.3.1): claims.SelfReport of the journal `result` this attempt
498
+ # was closed with (None for a hand-built attempt or one with no dict result). Reader-internal:
499
+ # in no to_dict, no export; it keeps no key name and no value.
500
+ _self_report: object = field(default=None, compare=False, repr=False)
501
+ # Reader-internal (never in to_dict): what reading this attempt's transcript
502
+ # saw that the ledger's reasons need (ledger.TranscriptState); None for a
503
+ # hand-built attempt.
504
+ _transcript_state: object = field(default=None, compare=False, repr=False)
505
+ # Reader-internal (never in to_dict): the launches and results a session
506
+ # main transcript recorded while it streamed, for Run.discovery's launch
507
+ # reconciliation (reader._LaunchLog); None for every other attempt.
508
+ _launch_log: object = field(default=None, compare=False, repr=False)
509
+ # docs/DESIGN.md 4.13.6 (v0.3, N6): every top-level non-empty string `cwd` on a JSON
510
+ # object line of this attempt's own transcript, first-seen order, deduplicated. Feeds
511
+ # `--here` (reader.run_cwd_set); internal only, in no to_dict, JSON, or rendering.
512
+ entry_cwds: tuple[str, ...] = field(default=(), compare=False, repr=False)
513
+
514
+ def to_dict(self) -> dict:
515
+ return {
516
+ "agent_id": self.agent_id,
517
+ "key": self.key,
518
+ "label": self.label,
519
+ "phase": self.phase,
520
+ "agent_type": self.agent_type,
521
+ "meta": self.meta,
522
+ "transcript_path": _path_str(self.transcript_path),
523
+ "events": [event.to_dict() for event in self.events],
524
+ "first_ts": _iso(self.first_ts),
525
+ "last_ts": _iso(self.last_ts),
526
+ "ordinal": self.ordinal,
527
+ "outcome": self.outcome,
528
+ "usage": self.usage,
529
+ "interrupt": self.interrupt.to_dict() if self.interrupt is not None else None,
530
+ "reported": self.reported,
531
+ "reported_line": self.reported_line,
532
+ "tool_calls": [call.to_dict() for call in self.tool_calls],
533
+ "facts": self.facts.to_dict() if self.facts is not None else None,
534
+ "marker_hits": [hit.to_dict() for hit in self.marker_hits],
535
+ "journal_state": self.journal_state,
536
+ "ledger": self.ledger.to_dict() if self.ledger is not None else None,
537
+ "return_state": self.return_state,
538
+ "return_reason": self.return_reason,
539
+ "resumes": self.resumes,
540
+ }
541
+
542
+
543
+ def effective_end(attempt: "Attempt") -> str:
544
+ """docs/DESIGN.md 5.3b (E1): what the evidence establishes about how an attempt ended. A
545
+ workflow attempt (and the session main thread): its `outcome`. A session subagent: the kill
546
+ sentinel (`outcome` killed) first, then its return state (`returned` completed,
547
+ `reported_failed` failed, `reported_killed` killed, `pending` open, `unknown` unknown).
548
+ The internal `outcome` stays private and keeps driving risk, ledgers, WASTED and rescue."""
549
+ state = attempt.return_state
550
+ if state is None:
551
+ return attempt.outcome
552
+ if attempt.outcome == "killed":
553
+ return "killed"
554
+ return {
555
+ "returned": "completed", "reported_failed": "failed", "reported_killed": "killed",
556
+ "pending": "open",
557
+ }.get(state, "unknown")
558
+
559
+
560
+ @dataclass
561
+ class LogicalAgent:
562
+ key: str
563
+ label: str
564
+ phase: str
565
+ attempts: list[Attempt]
566
+
567
+ def to_dict(self) -> dict:
568
+ return {
569
+ "key": self.key,
570
+ "label": self.label,
571
+ "phase": self.phase,
572
+ "attempts": [attempt.to_dict() for attempt in self.attempts],
573
+ }
574
+
575
+
576
+ # R2-4: the launch-evidence diagnostics a parent index builds (reader.ParentIndex._rebuild_
577
+ # generations); a run whose Run.discovery['parent'] holds one has a launch that was found and
578
+ # not used, which is not the same as no launch record.
579
+ LAUNCH_DIAGNOSTIC_PREFIXES = (
580
+ "ambiguous launch", "launch without result", "rejected launch result",
581
+ "malformed launch result",
582
+ )
583
+
584
+
585
+ @dataclass
586
+ class Lifecycle:
587
+ """Run.lifecycle for a workflow run (docs/DESIGN.md 4.1, 4.13.3, v0.3
588
+ N2); always None for a session (sessions carry no Workflow launch and no
589
+ generation to pair, D11)."""
590
+
591
+ terminal: str # 'completed' | 'failed' | 'stopped' | 'journal_resolved'
592
+ # | 'unknown' | 'none'
593
+ source: str | None # 'notification' | 'task_stop' | 'launch_result' | 'journal'
594
+ # | 'conflict' | 'unrecognized_status' | 'incomplete_generation'
595
+ # | 'parent_unavailable' | 'ambiguous_launch'; None exactly when terminal
596
+ # is 'none'
597
+ activity: str # 'recent' | 'stale' | 'unknown' (stat failed)
598
+ generations: int # >= 0; 0 for a run with no associated launch
599
+ diagnostics: list[str] # [] when none fired
600
+ # Internal only (N1N2 review finding 2, fixed after cross-model review):
601
+ # not one of the five public fields docs/DESIGN.md 4.1/5.3b document for
602
+ # Lifecycle, so it is excluded from equality/repr (never breaks a test
603
+ # or caller that compares/prints a Lifecycle built without naming it)
604
+ # and from to_dict() below. The parent-transcript line that decided
605
+ # `terminal`, captured in the exact same snapshot that produced this
606
+ # Lifecycle -- reader.lifecycle_terminal_line(run) reads it back so
607
+ # workflow_failed cites the evidence that actually decided the stored
608
+ # terminal, never a fresh re-query that could now see a newer generation
609
+ # appended to the parent transcript after this Lifecycle was computed.
610
+ _terminal_line: int | None = field(default=None, compare=False, repr=False)
611
+ # (v0.3, N4b-1) every Generation of this run in the same parent-index
612
+ # snapshot that produced this Lifecycle, so a later reader of generation
613
+ # facts (result_ts) never needs a second parent read. Internal like
614
+ # _terminal_line: out of equality, repr, and to_dict().
615
+ _generations: list = field(default_factory=list, compare=False, repr=False)
616
+
617
+ def to_dict(self) -> dict:
618
+ return {
619
+ "terminal": self.terminal,
620
+ "source": self.source,
621
+ "activity": self.activity,
622
+ "generations": self.generations,
623
+ "diagnostics": self.diagnostics,
624
+ }
625
+
626
+
627
+ @dataclass
628
+ class Run:
629
+ run_id: str
630
+ kind: str = field(default="workflow", kw_only=True) # 'workflow' | 'session'
631
+ path: Path
632
+ project_slug: str # the -Users-x-Documents-y directory name, redacted on output
633
+ session_id: str | None
634
+ claude_version: str | None
635
+ journal: list[dict]
636
+ main: Attempt | None = field(default=None, kw_only=True) # session kind only: the main thread
637
+ agents: list[LogicalAgent]
638
+ attempts: list[Attempt] # child attempts only; never includes main
639
+ started_at: datetime | None
640
+ ended_at: datetime | None
641
+ diagnostics: list[str]
642
+ status: str = field(default="COMPLETE", kw_only=True) # 'COMPLETE' | 'INCOMPLETE' (4.6)
643
+ # docs/DESIGN.md 4.1 (0.3.4): physical lines this run's read skipped because they were not
644
+ # JSON, nested too deeply to parse, or not a JSON object, and its files (an agent's
645
+ # meta.json) that could not be parsed at all. Read by scan's SKIPPED line; never exported.
646
+ skipped_lines: int = field(default=0, kw_only=True)
647
+ skipped_files: int = field(default=0, kw_only=True)
648
+ # docs/DESIGN.md 7.4's D8 (redefined by v0.3 N2, 4.13.3): whether this run
649
+ # has actually finished. For a workflow, derived from Lifecycle.terminal:
650
+ # true for 'completed'/'failed'/'stopped'/'journal_resolved', false for
651
+ # 'unknown'/'none'. None for a session (there is no lifecycle model to
652
+ # report, D11). Defaults to True so a Run built directly in a test,
653
+ # without setting this, is never mistaken for "still running".
654
+ finished: bool | None = field(default=True, kw_only=True)
655
+ # (v0.3, N2, 4.13.3) None for a session; never None for a workflow run
656
+ # once N2 lands.
657
+ lifecycle: Lifecycle | None = field(default=None, kw_only=True)
658
+ # (v0.3, N4b-1, 4.13.1) exactly the keys journal, parent, transcripts,
659
+ # meta; each holds the diagnostics that source reported, in order; []
660
+ # means that source was clean. Independent of `diagnostics` and `status`.
661
+ discovery: dict = field(
662
+ default_factory=lambda: {"journal": [], "parent": [], "transcripts": [], "meta": []},
663
+ kw_only=True,
664
+ )
665
+ # (v0.3, N4b-2, 4.13.1) 'complete' | 'partial' once the reader has assembled
666
+ # the run (None only on a hand-built Run), and the closed reason codes
667
+ # ('unassignable_attempt', 'discovery_incomplete', in that order; [] when
668
+ # complete). Private: in to_dict(), never in export_run().
669
+ risk_population_completeness: str | None = field(default=None, kw_only=True)
670
+ risk_population_completeness_reasons: list = field(default_factory=list, kw_only=True)
671
+
672
+ def to_dict(self) -> dict:
673
+ return {
674
+ "run_id": self.run_id,
675
+ "kind": self.kind,
676
+ "path": _path_str(self.path),
677
+ "project_slug": self.project_slug,
678
+ "session_id": self.session_id,
679
+ "claude_version": self.claude_version,
680
+ "journal": self.journal,
681
+ "main": self.main.to_dict() if self.main is not None else None,
682
+ "agents": [agent.to_dict() for agent in self.agents],
683
+ "attempts": [attempt.to_dict() for attempt in self.attempts],
684
+ "started_at": _iso(self.started_at),
685
+ "ended_at": _iso(self.ended_at),
686
+ "diagnostics": self.diagnostics,
687
+ "status": self.status,
688
+ "finished": self.finished,
689
+ "lifecycle": self.lifecycle.to_dict() if self.lifecycle is not None else None,
690
+ "discovery": {key: list(value) for key, value in self.discovery.items()},
691
+ "risk_population_completeness": self.risk_population_completeness,
692
+ "risk_population_completeness_reasons": list(
693
+ self.risk_population_completeness_reasons
694
+ ),
695
+ }
696
+
697
+
698
+ @dataclass(frozen=True)
699
+ class TitleParts:
700
+ """A finding title that names a path, as parts (docs/DESIGN.md 4.13.4, 5.1; F fix 7, W7-2):
701
+ a fixed `template` holding exactly one `{path}` placeholder and the `path` value. A renderer
702
+ formats the template once, with the path rendered by the level's path function, and does not
703
+ scan the result, so the path is never in-band for the free-text scanner to rediscover (a
704
+ backquote, a space or an operator inside it cannot change where it ends)."""
705
+
706
+ template: str
707
+ path: str
708
+
709
+ def raw(self) -> str:
710
+ """The sentence with the real path (what `raw` prints). One substitution of the first
711
+ placeholder: a path that holds the text `{path}` is data, never a second placeholder."""
712
+ head, _sep, tail = self.template.partition("{path}")
713
+ return head + self.path + tail
714
+
715
+
716
+ @dataclass
717
+ class Finding:
718
+ detector: str # detector id, e.g. 'silent_retry'
719
+ severity: str # 'critical' | 'major' | 'minor' | 'info'
720
+ title: str # one line, verdict style, includes the number
721
+ run_id: str
722
+ agent_id: str | None
723
+ evidence: list[str] # 'relative/path:line: excerpt' (<=160 chars each), redacted at render time
724
+ metrics: dict # detector-specific numbers (counts, seconds, tokens)
725
+ rule_id: str | None # the rule that would have prevented it, if any
726
+ # Every rule this finding backs, in priority order (unit M1d). Most
727
+ # detectors back at most one rule, so rule_ids is just (rule_id,); a
728
+ # detector whose one finding backs more than one rule (silent_retry's
729
+ # R-RETRY-SAFE plus R-CAP-CONCURRENCY on a burst) lists every rule here
730
+ # instead of emitting a duplicate Finding per rule. __post_init__ keeps
731
+ # this and rule_id in sync: passing only rule_id fills rule_ids in as
732
+ # (rule_id,); passing only rule_ids fills rule_id in as rule_ids[0].
733
+ # This keeps every caller that only ever set rule_id (every detector but
734
+ # silent_retry, every test fixture) working unchanged.
735
+ rule_ids: tuple[str, ...] = ()
736
+ # sha256(run_id, detector, agent_id, first evidence path:line)[:12]
737
+ # (docs/DESIGN.md 4.1), computed by detectors/base.py's make_finding_id.
738
+ # Defaults to '' for a Finding built directly in a test rather than by a
739
+ # detector; every detector module sets a real one.
740
+ finding_id: str = ""
741
+ # F fix 7 (W7-2, docs/DESIGN.md 4.13.4): set by a detector whose title names a path. `title`
742
+ # then holds the same sentence with the real path (`title_parts.raw()`); a renderer uses the
743
+ # parts, never `title`, at share and local.
744
+ title_parts: TitleParts | None = None
745
+
746
+ def __post_init__(self) -> None:
747
+ if not self.rule_ids:
748
+ if self.rule_id is not None:
749
+ self.rule_ids = (self.rule_id,)
750
+ elif self.rule_id is None:
751
+ self.rule_id = self.rule_ids[0]
752
+
753
+ def to_dict(self) -> dict:
754
+ return {
755
+ "detector": self.detector,
756
+ "severity": self.severity,
757
+ "title": self.title,
758
+ "run_id": self.run_id,
759
+ "agent_id": self.agent_id,
760
+ "evidence": self.evidence,
761
+ "metrics": self.metrics,
762
+ "rule_id": self.rule_id,
763
+ "rule_ids": list(self.rule_ids),
764
+ "finding_id": self.finding_id,
765
+ }
766
+
767
+
768
+ @dataclass
769
+ class Generation:
770
+ """One Workflow launch associated with an inspected run (docs/DESIGN.md
771
+ 4.1, 4.13.3, v0.3 N1), built by reader.ParentIndex from the parent
772
+ transcript's own Workflow tool_use/tool_result pairs."""
773
+
774
+ launch_line: int # the Workflow tool_use's own physical line
775
+ tool_use_id: str # that tool_use's own 'id'
776
+ resume_from: str | None # its input.resumeFromRunId, when a string
777
+ result_line: int | None # the unique matching tool_result line; None for
778
+ # a result-less resume (incomplete generation)
779
+ run_id: str # the inspected run id this generation belongs to
780
+ task_id: str | None # toolUseResult.taskId; None when incomplete
781
+ launch_status: str | None # toolUseResult.status as written
782
+ # ('async_launched' on every launch observed so far); None when incomplete
783
+ terminal: str | None # 'completed' | 'failed' | 'stopped' | 'unknown' | None
784
+ terminal_source: str | None # 'notification' | 'task_stop' | 'launch_result'
785
+ # | 'conflict' | 'unrecognized_status' | 'incomplete_generation';
786
+ # None when terminal is None
787
+ terminal_line: int | None # the accepted notification, TaskStop, or launch
788
+ # result line that set terminal; None otherwise
789
+ diagnostics: list[str] # each names the physical lines involved
790
+ # (v0.3, N4b-1) the parsed timestamp of the launch result line, taken from
791
+ # the same parent-index snapshot that produced the lifecycle; None when
792
+ # there is no result line, it has no timestamp, or it does not parse.
793
+ result_ts: datetime | None = None
794
+ # (field milestone F, FA2) the parsed timestamp of the assistant entry that
795
+ # holds the Workflow tool_use block (launch_line), from the same snapshot;
796
+ # None when the entry has no timestamp or it does not parse. Generation
797
+ # assignment (ledger.generation_of) keys on it, not on result_ts.
798
+ launch_ts: datetime | None = None
799
+
800
+ def to_dict(self) -> dict:
801
+ return {
802
+ "launch_line": self.launch_line,
803
+ "tool_use_id": self.tool_use_id,
804
+ "resume_from": self.resume_from,
805
+ "result_line": self.result_line,
806
+ "run_id": self.run_id,
807
+ "task_id": self.task_id,
808
+ "launch_status": self.launch_status,
809
+ "terminal": self.terminal,
810
+ "terminal_source": self.terminal_source,
811
+ "terminal_line": self.terminal_line,
812
+ "diagnostics": self.diagnostics,
813
+ "result_ts": _iso(self.result_ts),
814
+ "launch_ts": _iso(self.launch_ts),
815
+ }