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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- 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
|
+
}
|