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,624 @@
1
+ """The write-evidence ledger, the risk population, and the repository grouping
2
+ (docs/DESIGN.md 4.13.1; milestone N4, part N4b-2).
3
+
4
+ Pure: standard library only, no filesystem access, no subprocess. The reader
5
+ fills each attempt's `ToolCall` list and a `TranscriptState` (what reading the
6
+ transcript saw); this module turns them into a `Ledger` per attempt, decides
7
+ which attempts are in the risk population and whether that population is
8
+ complete, and groups the population's paths by repository.
9
+
10
+ Nothing here renders and nothing here runs git: per-repository observation is
11
+ N5's. A ledger claims less, never more: whatever the recorded text cannot prove
12
+ becomes a `possible` entry or a partial reason.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+
19
+ from . import commands
20
+ from .models import (
21
+ LAUNCH_DIAGNOSTIC_PREFIXES,
22
+ Attempt,
23
+ Generation,
24
+ Ledger,
25
+ LedgerEntry,
26
+ Run,
27
+ ToolCall,
28
+ )
29
+
30
+ # --- the truth table's tool names (4.13.1, L6) ----------------------------------------
31
+
32
+ # Row 1: tool name -> where its target lives on the ToolCall.
33
+ FILE_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
34
+ # Row 7: the read-only tools, which never get an entry.
35
+ READ_ONLY_TOOLS = frozenset({
36
+ "Read", "Grep", "Glob", "NotebookRead", "WebFetch", "WebSearch", "ToolSearch", "TaskOutput",
37
+ "TaskGet", "TaskList", "TaskCreate", "TaskUpdate", "TaskStop", "ListAgents", "SendUserFile",
38
+ "AskUserQuestion", "SendFeedback", "Skill", "StructuredOutput", "SubagentHandback",
39
+ "Artifact", "ScheduleWakeup", "TodoWrite",
40
+ })
41
+ # The shell tools: a call to either carries `shell_context_unverified` (C-15).
42
+ SHELL_TOOLS = ("Bash", "Monitor")
43
+
44
+ # Completion mapping (4.13.11): ToolCall.completion -> LedgerEntry.completion.
45
+ COMPLETION = {
46
+ "completed_ok": "success",
47
+ "completed_error": "error",
48
+ "backgrounded": "no_result",
49
+ "no_result": "no_result",
50
+ "ambiguous": "ambiguous",
51
+ }
52
+
53
+ # The closed partial-reason codes, in the fixed order of 4.13.1.
54
+ REASON_ORDER = (
55
+ "transcript_missing",
56
+ "transcript_unreadable",
57
+ "undecodable_line",
58
+ "unexpected_entry_shape",
59
+ "unknown_entry_type",
60
+ "truncated_field",
61
+ "ambiguous_tool_id",
62
+ "unpaired_write_call",
63
+ "shell_context_unverified",
64
+ "possible_opaque_command",
65
+ "possible_delegation",
66
+ "possible_background_process",
67
+ "possible_unknown_tool",
68
+ "unassignable_attempt",
69
+ "discovery_incomplete",
70
+ )
71
+
72
+ # Entry types a transcript may carry that the ledger ignores (4.13.1); anything
73
+ # else that is not `user` or `assistant` gives `unknown_entry_type`.
74
+ KNOWN_ENTRY_TYPES = frozenset({
75
+ "attachment", "system", "summary", "queue-operation", "last-prompt", "ai-title",
76
+ "atis-latch", "mode", "permission-mode", "pr-link", "agent-name", "bridge-session",
77
+ "custom-title", "file-history-snapshot", "file-history-delta", "cost-state", "frame-link",
78
+ # Claude Code 2.1.300+: the request log (an api-request-blob echoes earlier messages and is
79
+ # never a call the attempt made).
80
+ "api-request", "api-request-shape", "api-request-blob",
81
+ })
82
+ _ARTIFACT_PREFIX = "artifact-"
83
+
84
+
85
+ def entry_type_known(entry_type: str) -> bool:
86
+ """Whether a transcript entry type other than user and assistant is one the
87
+ ledger ignores (4.13.1's known set, plus any type starting `artifact-`)."""
88
+ return entry_type in KNOWN_ENTRY_TYPES or entry_type.startswith(_ARTIFACT_PREFIX)
89
+
90
+
91
+ @dataclass
92
+ class TranscriptState:
93
+ """What reading one transcript observed beyond its tool calls; filled by
94
+ the reader while it streams (reader-private, never exported)."""
95
+
96
+ missing: bool = False # the expected transcript does not exist (or C-5)
97
+ unreadable: bool = False # the file could not be opened
98
+ undecodable: bool = False # some line is not JSON
99
+ unexpected_shape: bool = False # a 4.13.1 shape rule was broken
100
+ unknown_type: bool = False # an entry type outside the known set
101
+ # docs/DESIGN.md 4.13.6 (N6): the top-level string `cwd` values seen, first-seen order
102
+ # (a dict used as an ordered set); cleared when the read is discarded as unreadable.
103
+ cwds: dict = field(default_factory=dict)
104
+
105
+
106
+ def _ordered(reasons) -> list[str]:
107
+ known = [reason for reason in REASON_ORDER if reason in reasons]
108
+ extra = sorted(reason for reason in reasons if reason not in REASON_ORDER)
109
+ return known + extra
110
+
111
+
112
+ # --- one call -------------------------------------------------------------------------
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class CallLedger:
117
+ """The entries one tool call produces, and the partial reasons that call
118
+ alone gives rise to."""
119
+
120
+ entries: list[LedgerEntry]
121
+ reasons: frozenset[str]
122
+
123
+
124
+ def _absolute_cwd(cwd: str | None) -> str | None:
125
+ """A call cwd is a repository candidate only when it is absolute (L9)."""
126
+ if isinstance(cwd, str) and cwd.startswith("/"):
127
+ return commands.normalize_path(cwd)
128
+ return None
129
+
130
+
131
+ def _classification(call: ToolCall):
132
+ if call.classification is not None:
133
+ return call.classification
134
+ if call.tool_name in SHELL_TOOLS or call.tool_name in commands.DELEGATION_TOOLS:
135
+ return commands.classify_call(
136
+ call.tool_name, call.command, run_in_background=call.run_in_background, cwd=call.cwd
137
+ )
138
+ return None
139
+
140
+
141
+ def call_entries(call: ToolCall, transcript_name: str) -> CallLedger:
142
+ """The ledger entries of one call by the per-call truth table (4.13.1, first
143
+ match wins): file tools (row 1), then the classification of N4a for a shell or
144
+ delegation call (rows 2 to 6), then the read-only tools (row 7), then every
145
+ other tool name (row 8)."""
146
+ tool = call.tool_name
147
+ reasons: set[str] = set()
148
+ if call.ambiguous:
149
+ reasons.add("ambiguous_tool_id")
150
+ if tool in SHELL_TOOLS:
151
+ reasons.add("shell_context_unverified")
152
+ completion = COMPLETION[call.completion]
153
+ success = completion == "success"
154
+ result_line = call.result_line if call.result_line is not None else call.line_no
155
+ line = f"{transcript_name}:{result_line}"
156
+ entries: list[LedgerEntry] = []
157
+
158
+ if tool in FILE_TOOLS:
159
+ raw = call.multiedit_path if tool == "MultiEdit" else call.path
160
+ cwd = _absolute_cwd(call.cwd)
161
+ path = None
162
+ if raw is None:
163
+ reasons.add(
164
+ "truncated_field" if call._path_state == "truncated" else "unexpected_entry_shape"
165
+ )
166
+ else:
167
+ # S3: the lexical normalization; empty or relative with no
168
+ # absolute cwd is a shape failure, over 1,024 characters truncated.
169
+ resolved = commands.resolve_target(cwd, raw)
170
+ if resolved is None:
171
+ reasons.add("unexpected_entry_shape")
172
+ else:
173
+ path = resolved[0]
174
+ if resolved[1]:
175
+ reasons.add("truncated_field")
176
+ entries.append(LedgerEntry(
177
+ "observed" if success else "attempted", "tool_result", completion, path, cwd, line,
178
+ ))
179
+ else:
180
+ classified = _classification(call)
181
+ if classified is not None:
182
+ if classified.effect is None: # row 4: read-only, no entry
183
+ pass
184
+ elif classified.effect == "claimed_target": # row 5
185
+ for target in classified.targets:
186
+ if target.truncated:
187
+ reasons.add("truncated_field")
188
+ entries.append(LedgerEntry(
189
+ "claimed_target" if success else "attempted", "command_grammar",
190
+ completion, target.path, target.repo, line,
191
+ ))
192
+ else: # rows 2, 3, 6
193
+ reasons.add(f"possible_{classified.basis}")
194
+ # One entry per call (C3). A call with commit segments takes the
195
+ # single distinct candidate repository of those segments, or
196
+ # null when they name more than one; else the call's candidate.
197
+ repo = classified.repo
198
+ if classified.commit.segments:
199
+ distinct = {seg.repo for seg in classified.commit.segments}
200
+ repo = next(iter(distinct)) if len(distinct) == 1 else None
201
+ entries.append(LedgerEntry(
202
+ "possible", classified.basis, completion, None, repo, line,
203
+ ))
204
+ elif tool in READ_ONLY_TOOLS: # row 7
205
+ pass
206
+ else: # row 8: every other tool name, including every mcp__ tool
207
+ reasons.add("possible_unknown_tool")
208
+ entries.append(LedgerEntry(
209
+ "possible", "unknown_tool", completion, None, _absolute_cwd(call.cwd), line,
210
+ ))
211
+
212
+ if entries and call.result_line is None:
213
+ reasons.add("unpaired_write_call")
214
+ return CallLedger(entries, frozenset(reasons))
215
+
216
+
217
+ # --- one attempt ----------------------------------------------------------------------
218
+
219
+
220
+ def build_ledger(
221
+ tool_calls: list[ToolCall],
222
+ transcript_name: str | None,
223
+ state: TranscriptState | None = None,
224
+ *,
225
+ run_reasons=(),
226
+ ) -> Ledger:
227
+ """The ledger of one attempt: every call's entries in tool-use order, the
228
+ transcript-level reasons `state` records, and the run-level reasons the
229
+ caller passes. 'complete' exactly when no reason applies."""
230
+ state = state if state is not None else TranscriptState()
231
+ reasons: set[str] = set(run_reasons)
232
+ if state.missing:
233
+ reasons.add("transcript_missing")
234
+ if state.unreadable:
235
+ reasons.add("transcript_unreadable")
236
+ if state.undecodable:
237
+ reasons.add("undecodable_line")
238
+ if state.unexpected_shape:
239
+ reasons.add("unexpected_entry_shape")
240
+ if state.unknown_type:
241
+ reasons.add("unknown_entry_type")
242
+ entries: list[LedgerEntry] = []
243
+ if not (state.missing or state.unreadable):
244
+ for call in tool_calls:
245
+ produced = call_entries(call, transcript_name or "")
246
+ entries.extend(produced.entries)
247
+ reasons |= produced.reasons
248
+ ordered = _ordered(reasons)
249
+ return Ledger(entries, "partial" if ordered else "complete", ordered)
250
+
251
+
252
+ # --- generation assignment (workflow runs only) -----------------------------------------
253
+
254
+
255
+ def _generations(run: Run) -> list[Generation]:
256
+ """The run's generations of the lifecycle's own parent-index snapshot, in
257
+ launch-line order (never a second parent read)."""
258
+ lifecycle = run.lifecycle
259
+ found = getattr(lifecycle, "_generations", None) if lifecycle is not None else None
260
+ return sorted(found or [], key=lambda generation: generation.launch_line)
261
+
262
+
263
+ def _timestamps_usable(generations: list[Generation]) -> bool:
264
+ """Every generation's launch call timestamp is present, and they strictly
265
+ increase in launch-line order (4.13.11, FA2)."""
266
+ previous = None
267
+ for generation in generations:
268
+ stamp = generation.launch_ts
269
+ if stamp is None:
270
+ return False
271
+ if previous is not None:
272
+ try:
273
+ if not stamp > previous:
274
+ return False
275
+ except TypeError:
276
+ return False
277
+ previous = stamp
278
+ return True
279
+
280
+
281
+ def _at_or_before(stamp, first) -> bool:
282
+ """Whether a launch timestamp is at or before an attempt's first timestamp;
283
+ two timestamps that cannot be compared (naive against aware) are not."""
284
+ try:
285
+ return stamp <= first
286
+ except TypeError:
287
+ return False
288
+
289
+
290
+ def _timestamp_unplaceable(attempt: Attempt) -> bool:
291
+ """4.13.1 (A5): the attempt has an event-bearing line with no usable `timestamp`
292
+ (absent, not a string, or unparseable) at or before the earliest line carrying
293
+ `first_ts`, so an event of it may precede `first_ts` and the attempt cannot be
294
+ placed in time."""
295
+ unusable = min((event.line_no for event in attempt.events if event.ts is None), default=None)
296
+ if unusable is None:
297
+ return False
298
+ first_line = min(
299
+ (event.line_no for event in attempt.events if event.ts == attempt.first_ts),
300
+ default=None,
301
+ )
302
+ return first_line is None or unusable <= first_line
303
+
304
+
305
+ def generation_of(run: Run, attempt: Attempt) -> Generation | None:
306
+ """The generation a workflow attempt belongs to: the one whose Workflow
307
+ `tool_use` (launch call) timestamp is the latest at or before the attempt's
308
+ `first_ts` (FA2: the call, not its result, bounds the generation). None when
309
+ the attempt cannot be placed: no `first_ts`, before the first launch call, no
310
+ generation at all, or any generation launch timestamp missing or not strictly
311
+ increasing (then nobody can be placed)."""
312
+ generations = _generations(run)
313
+ if not _timestamps_usable(generations):
314
+ return None
315
+ first = attempt.first_ts
316
+ if first is None or _timestamp_unplaceable(attempt):
317
+ return None
318
+ found = None
319
+ for generation in generations:
320
+ if _at_or_before(generation.launch_ts, first):
321
+ found = generation
322
+ return found
323
+
324
+
325
+ def is_unassignable(run: Run, attempt: Attempt) -> bool:
326
+ """A workflow attempt generation assignment could not place; a session
327
+ attempt is never unassignable (assignment applies to workflow attempts)."""
328
+ return run.kind == "workflow" and generation_of(run, attempt) is None
329
+
330
+
331
+ def _launch_found_unused(run: Run) -> bool:
332
+ """R2-4: the run has no generation but its parent snapshot holds launch evidence for it
333
+ that was deliberately not used: an `ambiguous_launch` lifecycle, or a launch diagnostic
334
+ (ambiguous, result-less, rejected, malformed) in the run's parent discovery diagnostics."""
335
+ lifecycle = run.lifecycle
336
+ if lifecycle is not None and lifecycle.source == "ambiguous_launch":
337
+ return True
338
+ parent = (getattr(run, "discovery", None) or {}).get("parent") or []
339
+ return any(
340
+ isinstance(text, str) and text.startswith(LAUNCH_DIAGNOSTIC_PREFIXES) for text in parent
341
+ )
342
+
343
+
344
+ def unassignable_reason(run: Run, attempt: Attempt) -> str | None:
345
+ """Why generation assignment could not place a workflow attempt (docs/DESIGN.md 4.7, field
346
+ milestone F P6, fix round 1 S-m4), checked in the order `generation_of` checks:
347
+ `no_launch_record` (the run has no generation and the parent holds no launch evidence for
348
+ it), `launch_unusable` (no generation, but a launch was found and not used),
349
+ `launch_times_unusable` (a generation's launch timestamp is missing or the
350
+ timestamps are not strictly increasing), then `no_usable_timestamp` (no first timestamp,
351
+ or an early line without one), else `before_launch` (the attempt starts before every
352
+ launch call). None for a placed attempt and for a session attempt."""
353
+ if not is_unassignable(run, attempt):
354
+ return None
355
+ generations = _generations(run)
356
+ if not generations:
357
+ return "launch_unusable" if _launch_found_unused(run) else "no_launch_record"
358
+ if not _timestamps_usable(generations):
359
+ return "launch_times_unusable"
360
+ if attempt.first_ts is None or _timestamp_unplaceable(attempt):
361
+ return "no_usable_timestamp"
362
+ return "before_launch"
363
+
364
+
365
+ # --- the risk population (4.13.1) ---------------------------------------------------------
366
+
367
+ _OUTCOMES_AT_RISK = frozenset({"killed", "failed", "superseded", "unknown"})
368
+ _NEWEST_TERMINALS_AT_RISK = frozenset({"failed", "stopped", "unknown"})
369
+ _NOT_ACTIVITY_KINDS = frozenset({"thinking", "attachment"})
370
+
371
+
372
+ def _workflow_attempt_at_risk(run: Run, attempt: Attempt, newest: Generation | None) -> bool:
373
+ # An attempt with no journal key (journal_state None) can never be closed by
374
+ # the journal: it is treated like an open one (4.13.1, S2).
375
+ if (
376
+ attempt.outcome in _OUTCOMES_AT_RISK
377
+ or attempt.journal_state == "open"
378
+ or attempt.journal_state is None
379
+ ):
380
+ return True
381
+ placed = generation_of(run, attempt)
382
+ if placed is None: # unassignable: never assumed safe
383
+ return True
384
+ return (
385
+ newest is not None
386
+ and placed is newest
387
+ and newest.terminal in _NEWEST_TERMINALS_AT_RISK
388
+ )
389
+
390
+
391
+ def _meaningful(event) -> bool:
392
+ return event.kind not in _NOT_ACTIVITY_KINDS and bool(event.text.strip())
393
+
394
+
395
+ def _main_ends_in_its_interrupt(main: Attempt) -> bool:
396
+ """The interrupt is the final meaningful main-thread event in physical order:
397
+ the main attempt has an interrupt and no meaningful event follows the last
398
+ user event (the kill sentinel)."""
399
+ if main.interrupt is None:
400
+ return False
401
+ last_user = None
402
+ for index, event in enumerate(main.events):
403
+ if event.kind == "user":
404
+ last_user = index
405
+ after = main.events[last_user + 1:] if last_user is not None else main.events
406
+ return not any(_meaningful(event) for event in after)
407
+
408
+
409
+ def risk_population(run: Run) -> list[Attempt]:
410
+ """The attempts whose ledgers must be accounted for, in the total order
411
+ `main` first and then `Run.attempts` index order (4.13.1). Workflow runs:
412
+ outcome killed, failed, superseded, open, or unknown; the newest generation's
413
+ attempts when that generation failed, stopped, or is unknown; and every
414
+ unassignable attempt. Session runs: every subagent with an interrupt, and the
415
+ main attempt when the interrupt is its final meaningful event."""
416
+ members: list[Attempt] = []
417
+ if run.kind == "session":
418
+ if run.main is not None and _main_ends_in_its_interrupt(run.main):
419
+ members.append(run.main)
420
+ members.extend(attempt for attempt in run.attempts if attempt.interrupt is not None)
421
+ return members
422
+ generations = _generations(run)
423
+ newest = generations[-1] if generations else None
424
+ return [
425
+ attempt for attempt in run.attempts if _workflow_attempt_at_risk(run, attempt, newest)
426
+ ]
427
+
428
+
429
+ def _discovery_incomplete(run: Run) -> bool:
430
+ return any(bool(diagnostics) for diagnostics in run.discovery.values())
431
+
432
+
433
+ def risk_population_completeness(run: Run) -> tuple[str, list[str]]:
434
+ """('complete' | 'partial', reasons) for the run's own risk population: the
435
+ reasons are `unassignable_attempt` (some workflow attempt could not be
436
+ placed) and `discovery_incomplete` (any discovery source reported a
437
+ diagnostic), in that order."""
438
+ reasons: list[str] = []
439
+ if run.kind == "workflow" and any(is_unassignable(run, a) for a in run.attempts):
440
+ reasons.append("unassignable_attempt")
441
+ if _discovery_incomplete(run):
442
+ reasons.append("discovery_incomplete")
443
+ return ("partial" if reasons else "complete", reasons)
444
+
445
+
446
+ def _attempt_ledger(run: Run, attempt: Attempt) -> Ledger:
447
+ run_reasons = []
448
+ if is_unassignable(run, attempt):
449
+ run_reasons.append("unassignable_attempt")
450
+ if _discovery_incomplete(run):
451
+ run_reasons.append("discovery_incomplete")
452
+ state = attempt._transcript_state
453
+ if not isinstance(state, TranscriptState):
454
+ state = TranscriptState()
455
+ if attempt.transcript_path is None:
456
+ state = TranscriptState(missing=True)
457
+ name = attempt.transcript_path.name if attempt.transcript_path is not None else None
458
+ return build_ledger(attempt.tool_calls, name, state, run_reasons=run_reasons)
459
+
460
+
461
+ def attach_ledgers(run: Run) -> None:
462
+ """Set every attempt's ledger (the session main thread included) and the
463
+ run's own risk-population completeness. Called by the reader once the run is
464
+ fully assembled (its discovery, lifecycle, and generations are final)."""
465
+ attempts = ([run.main] if run.main is not None else []) + list(run.attempts)
466
+ for attempt in attempts:
467
+ attempt.ledger = _attempt_ledger(run, attempt)
468
+ completeness, reasons = risk_population_completeness(run)
469
+ run.risk_population_completeness = completeness
470
+ run.risk_population_completeness_reasons = reasons
471
+
472
+
473
+ # --- repository grouping (4.13.1, L14) ------------------------------------------------------
474
+
475
+
476
+ @dataclass
477
+ class RepoGroup:
478
+ paths: list[str] = field(default_factory=list)
479
+ attempts: list[str] = field(default_factory=list)
480
+ refused: bool = False
481
+ reasons: list[str] = field(default_factory=list)
482
+
483
+
484
+ @dataclass
485
+ class RepoGroups:
486
+ repos: dict[str, RepoGroup] = field(default_factory=dict)
487
+ unknown: RepoGroup = field(default_factory=RepoGroup)
488
+
489
+
490
+ class _Accumulator:
491
+ def __init__(self) -> None:
492
+ self.paths: set[str] = set()
493
+ self.attempts: list[str] = []
494
+ self.refused = False
495
+ self.reasons: set[str] = set()
496
+
497
+ def add(self, ref: str, *, path: str | None, refused: bool, reasons) -> None:
498
+ if ref not in self.attempts:
499
+ self.attempts.append(ref)
500
+ if path is not None:
501
+ self.paths.add(path)
502
+ if refused:
503
+ self.refused = True
504
+ self.reasons.update(reasons)
505
+
506
+ def freeze(self) -> RepoGroup:
507
+ return RepoGroup(sorted(self.paths), list(self.attempts), self.refused,
508
+ _ordered(self.reasons))
509
+
510
+
511
+ _PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
512
+
513
+
514
+ def attempt_refs(run: Run) -> dict[int, str]:
515
+ """`main` for a session's main thread, otherwise `attempts[<index in
516
+ Run.attempts>]` (it exists for every attempt, canonical id or not)."""
517
+ refs = {id(attempt): f"attempts[{index}]" for index, attempt in enumerate(run.attempts)}
518
+ if run.main is not None:
519
+ refs[id(run.main)] = "main"
520
+ return refs
521
+
522
+
523
+ def _ledger_of(attempt: Attempt) -> Ledger:
524
+ if attempt.ledger is not None:
525
+ return attempt.ledger
526
+ return Ledger([], "partial", ["transcript_missing"])
527
+
528
+
529
+ def _facts_written_targets(attempt: Attempt) -> list[str]:
530
+ """`facts.paths_written` as normalized targets: a raw path a file tool wrote is resolved
531
+ against the call's own absolute cwd (the same placement the rescue plan uses), and the raw
532
+ spelling stands when nothing places a relative one."""
533
+ facts = attempt.facts
534
+ written = facts.paths_written if facts is not None else None
535
+ if not isinstance(written, list):
536
+ return []
537
+ targets: list[str] = []
538
+ for raw in written:
539
+ placed = False
540
+ for call in attempt.tool_calls:
541
+ if call.path != raw:
542
+ continue
543
+ cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
544
+ base = commands.normalize_path(cwd) if cwd is not None else None
545
+ resolved = commands.resolve_target(base, raw)
546
+ if resolved is not None and resolved[0] is not None:
547
+ targets.append(resolved[0])
548
+ placed = True
549
+ if not placed:
550
+ targets.append(commands.normalize_path(raw) if raw.startswith("/") else raw)
551
+ return targets
552
+
553
+
554
+ def attempt_ledger_paths(
555
+ attempt: Attempt, effects: frozenset[str], *, include_facts_written: bool = False,
556
+ ) -> list[str]:
557
+ """The distinct normalized paths one attempt's ledger names with an effect in `effects`, in
558
+ first-seen order (docs/DESIGN.md 4.13.1, 4.13.8). `include_facts_written` adds
559
+ `facts.paths_written`, normalized the way the rescue plan normalizes it. Each surface that
560
+ counts paths (the AGENT table, scan, the relaunch brief) chooses its effect set and its
561
+ population itself; this is the one place that reads an attempt's paths."""
562
+ found: list[str] = []
563
+ seen: set[str] = set()
564
+
565
+ def add(path: str) -> None:
566
+ if path not in seen:
567
+ seen.add(path)
568
+ found.append(path)
569
+
570
+ if include_facts_written:
571
+ for target in _facts_written_targets(attempt):
572
+ add(target)
573
+ if attempt.ledger is not None:
574
+ for entry in attempt.ledger.entries:
575
+ if entry.path is not None and entry.effect in effects:
576
+ add(entry.path)
577
+ return found
578
+
579
+
580
+ def risk_paths(run: Run) -> tuple[list[str], bool]:
581
+ """(distinct paths, complete) over the run's 4.13.1 risk population: every ledger path of
582
+ effect `observed`, `claimed_target`, or `attempted`, plus `facts.paths_written`. `complete`
583
+ is true only when the population itself is complete and every ledger in it is (a
584
+ population attempt with no ledger is partial); the count is a lower bound otherwise."""
585
+ paths: list[str] = []
586
+ seen: set[str] = set()
587
+ complete = risk_population_completeness(run)[0] == "complete"
588
+ for attempt in risk_population(run):
589
+ if _ledger_of(attempt).completeness != "complete":
590
+ complete = False
591
+ for path in attempt_ledger_paths(attempt, _PATH_EFFECTS, include_facts_written=True):
592
+ if path not in seen:
593
+ seen.add(path)
594
+ paths.append(path)
595
+ return paths, complete
596
+
597
+
598
+ def group_by_repo(run: Run) -> RepoGroups:
599
+ """The risk population's ledger paths grouped by candidate repository (L14).
600
+ A partial ledger keeps its repositories and paths and marks them refused; a
601
+ shell call, not a ledger entry, sends its attempt to `unknown`."""
602
+ refs = attempt_refs(run)
603
+ repos: dict[str, _Accumulator] = {}
604
+ unknown = _Accumulator()
605
+ for attempt in risk_population(run):
606
+ ref = refs[id(attempt)]
607
+ built = _ledger_of(attempt)
608
+ partial = built.completeness == "partial"
609
+ for entry in built.entries:
610
+ if entry.repo is not None:
611
+ target = repos.setdefault(entry.repo, _Accumulator())
612
+ else:
613
+ target = unknown
614
+ target.add(
615
+ ref, path=entry.path if entry.effect in _PATH_EFFECTS else None,
616
+ refused=partial, reasons=built.reasons,
617
+ )
618
+ if partial and all(entry.repo is None for entry in built.entries):
619
+ unknown.add(ref, path=None, refused=True, reasons=built.reasons)
620
+ if any(call.tool_name in SHELL_TOOLS for call in attempt.tool_calls):
621
+ unknown.add(ref, path=None, refused=True, reasons=built.reasons)
622
+ return RepoGroups(
623
+ {repo: repos[repo].freeze() for repo in sorted(repos)}, unknown.freeze()
624
+ )