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,1269 @@
1
+ """Claude Code plugin hook entry points (docs/DESIGN.md 7.2).
2
+
3
+ `agent-postmortem hook <check|session-start|session-end|workflow-launched|
4
+ capabilities>` is what plugin/hooks/run.sh invokes for each Claude Code hook
5
+ event. Every subcommand except `capabilities` reads one JSON hook payload
6
+ from stdin and exits 0 no matter what happens: a hook must never fail a
7
+ turn. `dispatch()` is the single try/except boundary -- nothing below it
8
+ ever raises out to the caller, and nothing is printed until every state
9
+ write for this invocation has already succeeded, so a mid-flight exception
10
+ never leaves a half-written notification on stdout.
11
+
12
+ State (docs/DESIGN.md 7.5) is read and written entirely through
13
+ `state.transaction()`/`state.update()`; `StateLocked` (another
14
+ agent-postmortem process holds the lock) is just another exception
15
+ `dispatch()` swallows into a silent exit 0.
16
+
17
+ Units and their fingerprints, the four unit states (running, finished,
18
+ unknown, analyzed), the finished rules for a workflow unit and a subagent
19
+ unit, the 3-stable-check unknown rule, and the notification policy are all
20
+ implemented here as docs/DESIGN.md 7.2 describes them. A few elaborations
21
+ the prose leaves implicit are pinned down concretely here:
22
+
23
+ - State is namespaced by root identity plus session id, not session id
24
+ alone: `_session_state_key(root, session_id)` is
25
+ `sha256(str(root.resolve()))[:8] + ":" + session_id`. Two different roots
26
+ (two machines' state.json merged, two checkouts) that happen to reuse the
27
+ same session id can never collide or share a unit's analyzed state.
28
+ - A `digest.pending` entry (docs/DESIGN.md 7.5) is a session state key
29
+ qualified with its unit id as `"<session-key>::<unit-id>"` -- a bare unit
30
+ id is only unique within one session, and the whole point of the digest
31
+ is to look across every session under one root.
32
+ - Once a unit is analyzed, its state entry additionally carries `verdict`,
33
+ `wasted_seconds`, and `run_id` (beyond the {fingerprint, state,
34
+ stable_checks, reported} docs/DESIGN.md 7.2 names) so `session-start`'s
35
+ digest can summarize it without re-parsing the run from disk. A workflow
36
+ unit registered by `workflow-launched` before its directory exists may
37
+ also carry `transcript_dir`, the locator it was given.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ import os
45
+ import re
46
+ import sys
47
+ import time
48
+ import traceback
49
+ from dataclasses import dataclass, replace
50
+ from importlib import metadata
51
+ from pathlib import Path
52
+
53
+ from . import config, detectors, disclosure, reader, report, state
54
+ from .markers import KILL_SENTINEL_PREFIX
55
+ from .redact import Redactor
56
+
57
+ # docs/DESIGN.md 7.2: AGENT_POSTMORTEM_NOTIFY in {critical, major, minor,
58
+ # info}, default major.
59
+ _SEVERITY_RANK = {"info": 0, "minor": 1, "major": 2, "critical": 3}
60
+ _DEFAULT_NOTIFY = "major"
61
+
62
+ # docs/DESIGN.md 7.2's workflow-launched fallback: a "Run ID: wf_..." match
63
+ # inside tool_response text when it carries no structured runId.
64
+ _RUN_ID_RE = re.compile(r"Run ID:\s*(wf_[A-Za-z0-9-]+)")
65
+
66
+ # docs/DESIGN.md 4.6: a unit whose fingerprint has not moved for this many
67
+ # consecutive checks, with no terminal evidence yet, becomes 'unknown'.
68
+ _UNKNOWN_AFTER_STABLE_CHECKS = 3
69
+
70
+ # docs/DESIGN.md 12's D-A: the stable-check rule alone is not enough --
71
+ # real data showed the plain 3-stable-check rule finalizing a workflow that
72
+ # was still actively running, because three Stop checks happened to land
73
+ # within the same quiet minute. A unit may only cross into 'unknown' once
74
+ # it has ALSO gone quiet for a while: the newest mtime among its own files
75
+ # at least this many seconds old. 600 = 10 minutes.
76
+ _UNKNOWN_QUIET_SECONDS = 600
77
+
78
+ # session-start analyzes at most this many newly finished units across the
79
+ # whole root in one digest (docs/DESIGN.md 7.2).
80
+ _DIGEST_ANALYSIS_CAP = 20
81
+
82
+ # Separator between a session's state key and a unit id inside a
83
+ # digest.pending entry.
84
+ _DIGEST_SEPARATOR = "::"
85
+ # Separator between a root's hash and a session id inside a session state
86
+ # key (docs/DESIGN.md 7.5).
87
+ _ROOT_KEY_SEPARATOR = ":"
88
+
89
+ # docs/DESIGN.md 7.2's background_tasks status field: values that mean a
90
+ # named task has actually finished, not merely that it was mentioned.
91
+ _FINISHED_TASK_STATUSES = frozenset({"completed", "finished", "done"})
92
+
93
+
94
+ def _notify_threshold() -> str:
95
+ value = os.environ.get("AGENT_POSTMORTEM_NOTIFY", "").strip().lower()
96
+ return value if value in _SEVERITY_RANK else _DEFAULT_NOTIFY
97
+
98
+
99
+ # --- stdin / payload -------------------------------------------------------
100
+
101
+
102
+ def _read_payload() -> dict | None:
103
+ """The hook JSON on stdin, or None for empty or malformed stdin (docs/
104
+ DESIGN.md 7.2: exit 0, no output, in either case)."""
105
+ try:
106
+ raw = sys.stdin.read()
107
+ except Exception:
108
+ return None
109
+ if not raw or not raw.strip():
110
+ return None
111
+ try:
112
+ payload = json.loads(raw)
113
+ except (json.JSONDecodeError, ValueError, RecursionError):
114
+ return None
115
+ return payload if isinstance(payload, dict) else None
116
+
117
+
118
+ def _is_subagent_context(payload: dict) -> bool:
119
+ """docs/DESIGN.md 7.2's D9: presence alone, regardless of value or
120
+ type. An empty string, null, or a non-string agent_id all still mean
121
+ this payload names a subagent -- the only payloads with no agent_id key
122
+ at all are the ones a top-level session's own hooks ever receive."""
123
+ return "agent_id" in payload
124
+
125
+
126
+ def _resolve_session_id(payload: dict) -> str | None:
127
+ """The session id always comes from transcript_path's own filename stem
128
+ (docs/DESIGN.md 7.5's D3); a payload session_id that disagrees is
129
+ ignored; there is no fallback."""
130
+ transcript = payload.get("transcript_path")
131
+ if isinstance(transcript, str) and transcript:
132
+ return Path(transcript).stem
133
+ return None
134
+
135
+
136
+ def _session_dir_from_payload(payload: dict) -> Path | None:
137
+ """docs/DESIGN.md 7.2: parent of transcript_path is the slug directory,
138
+ its stem is the session id, units live under <slug>/<session>/subagents/.
139
+ None (exit 0 silently) when transcript_path is missing/not a string, or
140
+ that subagents/ directory does not exist yet. Used only by `check`
141
+ (docs/DESIGN.md 7.2's D4): session-end, session-start, and
142
+ workflow-launched use `_session_dir_plain` instead, since each of them
143
+ must still act on a session that has never spawned a subagent."""
144
+ transcript = payload.get("transcript_path")
145
+ if not isinstance(transcript, str) or not transcript:
146
+ return None
147
+ transcript_path = Path(transcript)
148
+ session_dir = transcript_path.parent / transcript_path.stem
149
+ if not (session_dir / "subagents").is_dir():
150
+ return None
151
+ return session_dir
152
+
153
+
154
+ def _session_dir_plain(payload: dict) -> Path | None:
155
+ """docs/DESIGN.md 7.2's D4: session-end, session-start, and
156
+ workflow-launched need only transcript_path naming an existing file --
157
+ plain Path arithmetic, no subagents/ directory check. Unlike `check`
158
+ (`_session_dir_from_payload`), these three must still act on a session
159
+ that has never spawned a subagent (the common case): session-end must
160
+ still analyze the main thread, session-start must still be able to
161
+ compute `root` for its cross-session sweep, and workflow-launched must
162
+ still be able to register a unit before its own subagents/ directory
163
+ even exists (docs/DESIGN.md 7.2's D8)."""
164
+ transcript = payload.get("transcript_path")
165
+ if not isinstance(transcript, str) or not transcript:
166
+ return None
167
+ transcript_path = Path(transcript)
168
+ if not transcript_path.is_file():
169
+ return None
170
+ return transcript_path.parent / transcript_path.stem
171
+
172
+
173
+ def _root_key(root: Path) -> str:
174
+ """A short, stable fingerprint of a root directory's identity (docs/
175
+ DESIGN.md 7.5's D3), so state.json can hold units from more than one
176
+ root without a session id collision between them ever being possible."""
177
+ return hashlib.sha256(str(Path(root).resolve()).encode("utf-8")).hexdigest()[:8]
178
+
179
+
180
+ def _session_state_key(root: Path, session_id: str) -> str:
181
+ return f"{_root_key(root)}{_ROOT_KEY_SEPARATOR}{session_id}"
182
+
183
+
184
+ # --- units: discovery, fingerprint, finished-content rules ------------------
185
+
186
+
187
+ def _unit_files(kind: str, path: Path) -> list[Path]:
188
+ """The files whose (name, size, mtime_ns) make up a unit's fingerprint
189
+ (docs/DESIGN.md 7.2): every file directly inside a workflow directory,
190
+ or a subagent transcript plus its sibling .meta.json when one exists."""
191
+ if kind == "workflow":
192
+ try:
193
+ return sorted((p for p in path.iterdir() if p.is_file()), key=lambda p: p.name)
194
+ except OSError:
195
+ return []
196
+ if kind == "subagent":
197
+ files = [path]
198
+ meta = path.with_name(f"{path.stem}.meta.json")
199
+ if meta.exists():
200
+ files.append(meta)
201
+ return files
202
+ return [path] # a lone transcript (session-end's main-session unit)
203
+
204
+
205
+ def _fingerprint(files: list[Path]) -> list[list]:
206
+ entries: list[list] = []
207
+ for f in files:
208
+ try:
209
+ st = f.stat()
210
+ except OSError:
211
+ continue
212
+ entries.append([f.name, st.st_size, st.st_mtime_ns])
213
+ entries.sort(key=lambda entry: entry[0])
214
+ return entries
215
+
216
+
217
+ def _quiet_long_enough(files: list[Path]) -> bool:
218
+ """docs/DESIGN.md 12's D-A: whether every one of a unit's own files
219
+ was last written at least _UNKNOWN_QUIET_SECONDS ago. A unit with no
220
+ statable files at all (already gone by the time this runs) has nothing
221
+ left to wait on, so it counts as quiet."""
222
+ newest: float | None = None
223
+ for f in files:
224
+ try:
225
+ mtime = f.stat().st_mtime
226
+ except OSError:
227
+ continue
228
+ if newest is None or mtime > newest:
229
+ newest = mtime
230
+ if newest is None:
231
+ return True
232
+ return (time.time() - newest) >= _UNKNOWN_QUIET_SECONDS
233
+
234
+
235
+ def _kind_from_unit_id(unit_id: str) -> str:
236
+ return "workflow" if unit_id.startswith("subagents/workflows/") else "subagent"
237
+
238
+
239
+ def _discover_units(session_dir: Path) -> list[tuple[str, str, Path]]:
240
+ """(unit_id, kind, path) for every subagents/workflows/wf_* directory and
241
+ every subagents/agent-*.jsonl file, unit_id relative to session_dir."""
242
+ units: list[tuple[str, str, Path]] = []
243
+ workflows_dir = session_dir / "subagents" / "workflows"
244
+ if workflows_dir.is_dir():
245
+ try:
246
+ wf_dirs = sorted(
247
+ p for p in workflows_dir.iterdir() if p.is_dir() and p.name.startswith("wf_")
248
+ )
249
+ except OSError:
250
+ wf_dirs = []
251
+ for wf_dir in wf_dirs:
252
+ units.append((f"subagents/workflows/{wf_dir.name}", "workflow", wf_dir))
253
+ subagents_dir = session_dir / "subagents"
254
+ if subagents_dir.is_dir():
255
+ try:
256
+ agent_files = sorted(subagents_dir.glob("agent-*.jsonl"))
257
+ except OSError:
258
+ agent_files = []
259
+ for agent_file in agent_files:
260
+ if agent_file.is_file():
261
+ units.append((f"subagents/{agent_file.name}", "subagent", agent_file))
262
+ return units
263
+
264
+
265
+ def _iter_json_lines(path: Path):
266
+ with path.open("r", encoding="utf-8") as handle:
267
+ for line in handle:
268
+ line = line.strip()
269
+ if not line:
270
+ continue
271
+ try:
272
+ entry = json.loads(line)
273
+ except (json.JSONDecodeError, ValueError, RecursionError):
274
+ continue
275
+ if isinstance(entry, dict):
276
+ yield entry
277
+
278
+
279
+ def _workflow_finished(journal_path: Path) -> bool:
280
+ """docs/DESIGN.md 7.2 and section 3 (N2 root-cause fix): a workflow unit
281
+ is finished iff Run.finished would be true for it -- reader.
282
+ workflow_lifecycle computes the exact same Lifecycle _load_workflow
283
+ derives Run.lifecycle from, from the run directory alone (journal,
284
+ parent index, directory mtimes), parsing no agent transcript; reader.
285
+ lifecycle_finished then applies the one 'finished' rule to it. This
286
+ hook and reader.py's own Run.finished can therefore never again
287
+ disagree about what 'finished' means the way they used to: the parent
288
+ can report a terminal (failed/stopped) before the journal's own open
289
+ key ever closes, or the journal can be fully resolved while an
290
+ ambiguous launch leaves the lifecycle unknown, and a journal-only rule
291
+ (v0.1's own, and the N2 checkpoint's unintentional revival of it here)
292
+ gets either case backwards. `journal_path` is still a *journal* path
293
+ (every call site and the existing unit tests pass one) -- its parent
294
+ directory is the workflow run directory workflow_lifecycle needs, and
295
+ is derived here rather than changing every caller's own signature.
296
+ workflow_lifecycle already tolerates a run directory whose journal.jsonl
297
+ is missing or empty (a workflow directory can exist before its journal
298
+ does) the same tolerant way _load_workflow's own journal parsing does,
299
+ so this never raises for that case either."""
300
+ return reader.lifecycle_finished(reader.workflow_lifecycle(journal_path.parent))
301
+
302
+
303
+ def _entry_user_text(entry: dict) -> str:
304
+ message = entry.get("message")
305
+ content = message.get("content") if isinstance(message, dict) else None
306
+ if isinstance(content, str):
307
+ return content
308
+ if isinstance(content, list):
309
+ parts = []
310
+ for block in content:
311
+ if isinstance(block, str):
312
+ parts.append(block)
313
+ elif isinstance(block, dict) and isinstance(block.get("text"), str):
314
+ parts.append(block["text"])
315
+ return "\n".join(parts)
316
+ return ""
317
+
318
+
319
+ def _subagent_ends_with_kill_sentinel(path: Path) -> bool:
320
+ """docs/DESIGN.md 7.2's D2: whether the transcript's own last physical
321
+ line is a kill sentinel. A trailing line that fails to parse as JSON at
322
+ all (a partial write still in flight) means not finished -- it is never
323
+ skipped in favor of an earlier, valid line."""
324
+ last_line: str | None = None
325
+ try:
326
+ with reader._transcript_reader(path) as lines:
327
+ for _line_no, raw_line, torn in lines:
328
+ if torn:
329
+ # docs/DESIGN.md 4.1 (G-C, GC-1): a last line cut inside a character is a
330
+ # partial write still in flight: not finished
331
+ return False
332
+ stripped = raw_line.strip()
333
+ if stripped:
334
+ last_line = stripped
335
+ except (OSError, UnicodeError):
336
+ return False
337
+ if last_line is None:
338
+ return False
339
+ try:
340
+ last = json.loads(last_line)
341
+ except (json.JSONDecodeError, ValueError, RecursionError):
342
+ return False
343
+ if not isinstance(last, dict) or last.get("type") != "user":
344
+ return False
345
+ return _entry_user_text(last).startswith(KILL_SENTINEL_PREFIX)
346
+
347
+
348
+ def _background_task_status(tasks: object, agent_id: str) -> str | None:
349
+ """docs/DESIGN.md 7.2's D2: 'running' when a background_tasks entry
350
+ names agent_id without indicating it is done (the existing, purely
351
+ negative signal: presence alone means still active), 'finished' when an
352
+ entry names it and carries a status/finished flag saying so, or None
353
+ when the payload says nothing about this agent id at all."""
354
+ if not isinstance(tasks, list):
355
+ return None
356
+ for task in tasks:
357
+ if isinstance(task, str):
358
+ if task == agent_id:
359
+ return "running"
360
+ continue
361
+ if not isinstance(task, dict):
362
+ continue
363
+ names_agent = any(value == agent_id for value in task.values() if isinstance(value, str))
364
+ if not names_agent:
365
+ continue
366
+ status = task.get("status")
367
+ if isinstance(status, str) and status.strip().lower() in _FINISHED_TASK_STATUSES:
368
+ return "finished"
369
+ if task.get("finished") is True:
370
+ return "finished"
371
+ return "running"
372
+ return None
373
+
374
+
375
+ def _unit_fingerprint(
376
+ kind: str, path: Path, main_transcript: Path, main_cache: dict,
377
+ ) -> list[list]:
378
+ """The unit's fingerprint (docs/DESIGN.md 7.2): its files' (name, size, mtime_ns) and, for a
379
+ subagent (H1, v0.3.1), a digest of the main-side evidence for the child (its launch and
380
+ resume ids, lines, timestamps and result classes, and its accepted notifications) and,
381
+ when the child is nested, the launcher transcript's size, mtime_ns and inode. GA-3: also the
382
+ child's current (return_state, return_reason) and a stable token for each of the main
383
+ transcript, the meta file and a nested launcher that cannot be read (and for a decision that
384
+ raised), so a read failure that leaves every file's size and mtime alone still changes it."""
385
+ entries = _fingerprint(_unit_files(kind, path))
386
+ if kind != "subagent":
387
+ return entries
388
+ try:
389
+ decision, digest, launcher, unreadable = reader.session_child_return(
390
+ main_transcript, path, main_cache
391
+ )
392
+ except Exception:
393
+ entries.append(["#unreadable:decision", 1, 0])
394
+ entries.sort(key=lambda entry: entry[0])
395
+ return entries
396
+ entries.append(["#main", len(digest), int(digest, 16)])
397
+ word = f"{decision.state}/{decision.reason}".encode("ascii", "replace")
398
+ entries.append(["#return", len(word), int(hashlib.sha256(word).hexdigest()[:12], 16)])
399
+ for token in unreadable:
400
+ entries.append([f"#unreadable:{token}", 1, 0])
401
+ if launcher is not None:
402
+ try:
403
+ st = launcher.stat()
404
+ entries.append([f"#launcher:{st.st_ino}", st.st_size, st.st_mtime_ns])
405
+ except OSError:
406
+ entries.append(["#launcher:missing", 0, 0])
407
+ entries.sort(key=lambda entry: entry[0])
408
+ return entries
409
+
410
+
411
+ def _subagent_finished_evidence(
412
+ path: Path, agent_id: str, payload: dict, main_transcript: Path, main_cache: dict,
413
+ ) -> bool:
414
+ """docs/DESIGN.md 7.2's D2 and H1: a subagent unit is finished only with
415
+ authoritative evidence -- its last line is a kill sentinel, or its return
416
+ state (4.13.1, T0 to T5, read from the main transcript and, for a nested
417
+ child, its launcher read as evidence only) is `returned`,
418
+ `reported_failed` or `reported_killed`, or the payload itself lists it as
419
+ finished. An assistant text entry with no tool_use is never evidence: it
420
+ is also the ordinary shape of an agent still waiting for its next turn. A
421
+ payload entry that names this agent id as still running is checked first
422
+ and overrides every other signal -- the runtime's own live view wins."""
423
+ task_status = _background_task_status(payload.get("background_tasks"), agent_id)
424
+ if task_status == "running":
425
+ return False
426
+ if _subagent_ends_with_kill_sentinel(path):
427
+ return True
428
+ # docs/DESIGN.md 7.2 (H1, v0.3.1): finished only when the child's return state (4.13.1,
429
+ # T0 to T5) is terminal; an asynchronous launch with no notification, a resume in
430
+ # progress and an unestablished return are not
431
+ try:
432
+ decision, _digest, _launcher, _unreadable = reader.session_child_return(
433
+ main_transcript, path, main_cache
434
+ )
435
+ except Exception:
436
+ # fail closed: evidence that cannot be read is not evidence of a return
437
+ return task_status == "finished"
438
+ if decision.state in ("returned", "reported_failed", "reported_killed"):
439
+ return True
440
+ return task_status == "finished"
441
+
442
+
443
+ # --- analysis: turning a finished unit into (run_id, findings, status, headline,
444
+ # failed), where `failed` is the run's own lifecycle terminal being `failed`
445
+ # (docs/DESIGN.md 4.6, 4.13.4: the verdict word's FAILED condition) ------------
446
+
447
+
448
+ def _analyze_finished_workflow(path: Path) -> tuple[str, list, str, str, bool]:
449
+ run = reader.load_run(path)
450
+ findings = detectors.run_detectors(run)
451
+ headline = report.summary_line(run).split(";", 1)[0].strip()
452
+ return run.run_id, findings, run.status, headline, report.lifecycle_failed(run)
453
+
454
+
455
+ def _analyze_finished_subagent(
456
+ path: Path, main_transcript: Path, main_cache: dict
457
+ ) -> tuple[str, list, str, str, bool]:
458
+ """docs/DESIGN.md 7.2's D6: analyzed in isolation via
459
+ reader.load_session_view(main_transcript, [path], ...) -- main plus only
460
+ this one subagent, never the whole session's subagents/ directory, so an
461
+ unrelated sibling's parse trouble can never leak into this unit's
462
+ status, and analyzing N subagents never costs O(N session-wide parses).
463
+ main_cache lets several subagents analyzed in the same hook invocation
464
+ share one parse of the main transcript."""
465
+ view = reader.load_session_view(main_transcript, [path], main_cache=main_cache)
466
+ attempt = next((a for a in view.attempts if a.transcript_path == path), None)
467
+ if attempt is None:
468
+ raise LookupError(f"no attempt parsed for subagent transcript {path}")
469
+ logical_agent = next((a for a in view.agents if attempt in a.attempts), None)
470
+ # Isolation comes from what load_session_view parsed (main plus this one
471
+ # subagent), not from filtering afterward; the agent_id filter here only
472
+ # keeps a main-thread tooling finding (agent_id is the main uuid, or
473
+ # None) from being attributed to this subagent's own unit.
474
+ findings = [f for f in detectors.run_detectors(view) if f.agent_id == attempt.agent_id]
475
+ # A synthetic single-subagent view of the session so the headline (docs/
476
+ # DESIGN.md 7.2: "the run summary line's first clause") describes this
477
+ # one subagent, not the whole session's totals.
478
+ synthetic = replace(
479
+ view,
480
+ attempts=[attempt],
481
+ agents=[logical_agent] if logical_agent is not None else [],
482
+ started_at=attempt.first_ts,
483
+ ended_at=attempt.last_ts,
484
+ )
485
+ headline = report.summary_line(synthetic).split(";", 1)[0].strip()
486
+ # A session has no lifecycle, so a subagent unit is never failed by one.
487
+ return view.run_id, findings, view.status, headline, report.lifecycle_failed(view)
488
+
489
+
490
+ # --- per-unit state transition ---------------------------------------------
491
+
492
+
493
+ @dataclass
494
+ class _NotifyInfo:
495
+ """docs/DESIGN.md 7.2's D-C: the pieces of a would-be notify line, kept
496
+ structured (not pre-formatted into text) so _cmd_check can group every
497
+ unit that shares one run_id into a single line instead of one line per
498
+ unit.
499
+
500
+ is_incomplete and quiet_unfinished are deliberately two separate flags,
501
+ not one: is_incomplete means the reader itself found real diagnostics
502
+ (a bad journal, an unreadable transcript, ...) or the forced analysis
503
+ attempt outright raised -- a genuine "could not fully inspect this"
504
+ (docs/DESIGN.md 12's D-C: suffix ' (inspection incomplete)').
505
+ quiet_unfinished means the unit was finalized purely because it went
506
+ quiet (D-A's stable-check + mtime fallback) with no such diagnostics of
507
+ its own -- the content genuinely may still be running, just quiet
508
+ (suffix ' (unfinished)'). The two are mutually exclusive in practice
509
+ (see _check_one_unit) but kept as separate booleans rather than an
510
+ enum so a default of False on the plain, fully-analyzed path needs no
511
+ third case to spell out."""
512
+
513
+ run_id: str
514
+ verdict_word: str
515
+ headline: str
516
+ is_incomplete: bool
517
+ finding_count: int
518
+ quiet_unfinished: bool = False
519
+ # N3b P17: the canonical agent ids behind the findings that triggered
520
+ # this notification, in finding order; rendered as stable aliases.
521
+ agent_ids: tuple[str, ...] = ()
522
+
523
+
524
+ @dataclass
525
+ class _UnitOutcome:
526
+ entry: dict
527
+ notify: _NotifyInfo | None
528
+ newly_analyzed: bool = False
529
+
530
+
531
+ def _fallback_run_id(kind: str, path: Path) -> str:
532
+ """An identifier to notify with when a unit can never actually be
533
+ analyzed (docs/DESIGN.md 7.2's D6: the forced-INCOMPLETE analysis
534
+ attempt itself raised). A workflow directory's name is used only when it
535
+ passes the reader's validator (N3b C2), else the replacement `run` uses
536
+ for it; a subagent has no such name of its own, so one is
537
+ derived the same way a session's public id is (a short hash), prefixed
538
+ the same way (docs/DESIGN.md 4.2's 's_')."""
539
+ if kind == "workflow":
540
+ return reader.valid_workflow_run_id(path.name)
541
+ return "s_" + hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:10]
542
+
543
+
544
+ def _entry_skeleton(
545
+ fingerprint: list[list], state_word: str, stable_checks: int, *,
546
+ reported: list[str], priority: bool, transcript_dir: str | None,
547
+ ) -> dict:
548
+ entry: dict = {
549
+ "fingerprint": fingerprint,
550
+ "state": state_word,
551
+ "stable_checks": stable_checks,
552
+ "reported": reported,
553
+ }
554
+ if priority:
555
+ entry["priority"] = True
556
+ if transcript_dir:
557
+ entry["transcript_dir"] = transcript_dir
558
+ return entry
559
+
560
+
561
+ def _notified_agent_ids(
562
+ shown: list, reported_prev: list[str], threshold_rank: int,
563
+ ) -> tuple[str, ...]:
564
+ """The canonical agent ids of the findings a notification is about, in
565
+ finding order, each once (a non-canonical or missing id is never named)."""
566
+ ids: list[str] = []
567
+ for finding in shown:
568
+ if _SEVERITY_RANK.get(finding.severity, 0) < threshold_rank:
569
+ continue
570
+ if finding.finding_id in reported_prev:
571
+ continue
572
+ agent_id = finding.agent_id
573
+ if disclosure.is_canonical_id("agent", agent_id) and agent_id not in ids:
574
+ ids.append(agent_id)
575
+ return tuple(ids)
576
+
577
+
578
+ def _check_one_unit(
579
+ kind: str, path: Path, prev: dict | None, payload: dict,
580
+ main_transcript: Path, main_cache: dict, cfg: config.Config,
581
+ ) -> _UnitOutcome:
582
+ prev_state = prev.get("state") if prev else None
583
+ prev_fp = prev.get("fingerprint") if prev else None
584
+ reported_prev = list(prev.get("reported", [])) if prev else []
585
+ priority = bool(prev.get("priority")) if prev else False
586
+ transcript_dir = prev.get("transcript_dir") if prev else None
587
+
588
+ def _skeleton(fingerprint, state_word, stable_checks) -> dict:
589
+ return _entry_skeleton(
590
+ fingerprint, state_word, stable_checks,
591
+ reported=reported_prev, priority=priority, transcript_dir=transcript_dir,
592
+ )
593
+
594
+ files = _unit_files(kind, path)
595
+ current_fp = _unit_fingerprint(kind, path, main_transcript, main_cache)
596
+ fp_unchanged = prev is not None and prev_fp == current_fp
597
+
598
+ # docs/DESIGN.md 7.2's D1: an analyzed unit is re-statted every check.
599
+ # Unchanged, it is left exactly as it was; changed (appended to,
600
+ # deleted and recreated under the same id, ...), it is re-opened with
601
+ # stable_checks reset, keeping its reported finding ids so a finding
602
+ # already surfaced is never re-notified just because the surrounding
603
+ # content moved.
604
+ if prev_state == "analyzed" and fp_unchanged:
605
+ return _UnitOutcome(entry=dict(prev), notify=None)
606
+ # GA-8: a changed analyzed unit is re-opened below with stable_checks reset, and its finished
607
+ # evidence is evaluated in this same check (it is not parked as `running` first).
608
+
609
+ # docs/DESIGN.md 7.2's D7: fingerprint first. An unchanged unit advances
610
+ # stable_checks without reparsing anything, unless this is the one
611
+ # check that crosses the unknown threshold -- that check still needs a
612
+ # real parse so the forced-INCOMPLETE analysis has real findings. D-A:
613
+ # crossing the stable-check count alone is not enough to finalize --
614
+ # the unit must also have gone quiet (_quiet_long_enough); short of
615
+ # that it stays pending, however high stable_checks climbs.
616
+ if fp_unchanged:
617
+ stable_checks = prev.get("stable_checks", 0) + 1
618
+ if stable_checks < _UNKNOWN_AFTER_STABLE_CHECKS or not _quiet_long_enough(files):
619
+ return _UnitOutcome(entry=_skeleton(current_fp, "running", stable_checks), notify=None)
620
+ state_word = "unknown"
621
+ else:
622
+ stable_checks = 0
623
+ state_word = None
624
+
625
+ if state_word != "unknown":
626
+ if kind == "workflow":
627
+ content_finished = _workflow_finished(path / "journal.jsonl")
628
+ else:
629
+ agent_id = path.stem.removeprefix("agent-")
630
+ content_finished = _subagent_finished_evidence(
631
+ path, agent_id, payload, main_transcript, main_cache
632
+ )
633
+ state_word = "finished" if content_finished else "running"
634
+
635
+ if state_word not in ("finished", "unknown"):
636
+ return _UnitOutcome(entry=_skeleton(current_fp, state_word, stable_checks), notify=None)
637
+
638
+ try:
639
+ if kind == "workflow":
640
+ run_id, findings, status, headline, failed = _analyze_finished_workflow(path)
641
+ else:
642
+ run_id, findings, status, headline, failed = _analyze_finished_subagent(
643
+ path, main_transcript, main_cache
644
+ )
645
+ except Exception:
646
+ if os.environ.get("AGENT_POSTMORTEM_DEBUG"):
647
+ # docs/DESIGN.md 7.2's D10: a redacted traceback, never the raw
648
+ # one -- it can quote a path or a session id straight out of
649
+ # the transcript that failed to parse.
650
+ redactor = Redactor()
651
+ print(redactor.scrub_leftover_paths(traceback.format_exc()), file=sys.stderr)
652
+ if state_word == "unknown":
653
+ # docs/DESIGN.md 7.2's D6: this was already the forced,
654
+ # must-produce-INCOMPLETE analysis attempt (stable_checks had
655
+ # already crossed the threshold); if even that raises, there is
656
+ # nothing left to wait for. Finalize as analyzed/INCOMPLETE
657
+ # with no findings rather than retrying the same failure on
658
+ # every future check forever.
659
+ run_id = _fallback_run_id(kind, path)
660
+ verdict = report.verdict_word([], "INCOMPLETE")
661
+ entry = _skeleton(current_fp, "analyzed", 0)
662
+ entry["verdict"] = verdict
663
+ entry["wasted_seconds"] = 0.0
664
+ entry["run_id"] = run_id
665
+ verdict_word = verdict.split(" ", 1)[0]
666
+ # docs/DESIGN.md 7.2's D-C: unlike the pure "still running, no
667
+ # diagnostics, no findings" unknown-fallback case below, this is
668
+ # a genuine failure (the forced analysis attempt itself raised)
669
+ # -- always notify-worthy.
670
+ notify = _NotifyInfo(
671
+ run_id=run_id, verdict_word=verdict_word, headline="unit could not be analyzed",
672
+ is_incomplete=True, finding_count=0,
673
+ )
674
+ return _UnitOutcome(entry=entry, notify=notify)
675
+ # docs/DESIGN.md 7.2's D6: an analysis exception counts as a stable
676
+ # check, not a reset, so a unit whose content genuinely cannot be
677
+ # parsed still reaches unknown/INCOMPLETE within a bounded number of
678
+ # checks instead of being silently stuck forever -- subject to the
679
+ # same D-A quiet gate as the fingerprint-unchanged path above.
680
+ bumped = stable_checks + 1
681
+ next_state = (
682
+ "unknown"
683
+ if bumped >= _UNKNOWN_AFTER_STABLE_CHECKS and _quiet_long_enough(files)
684
+ else "running"
685
+ )
686
+ return _UnitOutcome(entry=_skeleton(current_fp, next_state, bumped), notify=None)
687
+
688
+ # docs/DESIGN.md 7.2: re-stat after parsing; a fingerprint that moved
689
+ # while parsing means the unit is still being written, so it stays
690
+ # pending and nothing is emitted this check.
691
+ post_fp = _unit_fingerprint(kind, path, main_transcript, main_cache)
692
+ if post_fp != current_fp:
693
+ return _UnitOutcome(entry=_skeleton(post_fp, "running", 0), notify=None)
694
+
695
+ # docs/DESIGN.md 4.6: reader_incomplete is true only when the reader
696
+ # itself found real diagnostics (a bad journal, an unreadable/mostly-
697
+ # undecodable transcript, a timestamp diagnostic); is_incomplete is the
698
+ # broader "the stored verdict should carry (INCOMPLETE)" flag, true
699
+ # whenever this was also forced through the unknown fallback with no
700
+ # such diagnostics of its own.
701
+ reader_incomplete = status == "INCOMPLETE"
702
+ is_incomplete = state_word == "unknown" or reader_incomplete
703
+ effective_status = "INCOMPLETE" if is_incomplete else status
704
+ # docs/DESIGN.md 4.10/7.2: a detector silenced by the user's config still
705
+ # ran and its findings are kept nowhere the hook ever exports them
706
+ # (there is no `hook check --json`), but the hook verdict word and the
707
+ # notify-worthy finding set must never be swayed by one -- the same rule
708
+ # `run`'s own verdict/stories/next follow. wasted_seconds is left over
709
+ # the full finding set, an informational number cached for
710
+ # session-start's digest, not a notify decision.
711
+ shown, _silenced = config.partition_silenced(findings, cfg)
712
+ verdict = report.verdict_word(shown, effective_status, terminal_failed=failed)
713
+ wasted_seconds = sum(
714
+ f.metrics.get("wasted_seconds", 0.0) for f in findings if f.detector == "wasted_time"
715
+ )
716
+
717
+ threshold_rank = _SEVERITY_RANK[_notify_threshold()]
718
+ new_finding_ids = [
719
+ f.finding_id
720
+ for f in shown
721
+ if _SEVERITY_RANK.get(f.severity, 0) >= threshold_rank and f.finding_id not in reported_prev
722
+ ]
723
+
724
+ entry = _skeleton(current_fp, "analyzed", 0)
725
+ entry["reported"] = sorted(set(reported_prev) | set(new_finding_ids))
726
+ entry["verdict"] = verdict
727
+ entry["wasted_seconds"] = wasted_seconds
728
+ entry["run_id"] = run_id
729
+
730
+ notify = None
731
+ # docs/DESIGN.md 7.2's D-C: an INCOMPLETE that comes only from the
732
+ # unknown-finalization fallback -- no reader diagnostics, no findings
733
+ # at or above the notify threshold -- is noise with nothing a reader
734
+ # can act on, so it is not notified. INCOMPLETE is notified only when
735
+ # the reader itself reported diagnostics (reader_incomplete) or when
736
+ # findings actually reach the threshold; either one also still carries
737
+ # a suffix below -- "(inspection incomplete)" when the reader itself
738
+ # found real diagnostics, or "(unfinished)" (docs/DESIGN.md 12's D-C)
739
+ # when the only reason this is incomplete at all is the quiet-unknown
740
+ # fallback finalizing content that may simply still be running.
741
+ if new_finding_ids or reader_incomplete:
742
+ verdict_word = verdict.split(" ", 1)[0]
743
+ notify = _NotifyInfo(
744
+ run_id=run_id, verdict_word=verdict_word, headline=headline,
745
+ is_incomplete=reader_incomplete,
746
+ quiet_unfinished=state_word == "unknown" and not reader_incomplete,
747
+ finding_count=len(new_finding_ids),
748
+ agent_ids=_notified_agent_ids(shown, reported_prev, threshold_rank),
749
+ )
750
+ return _UnitOutcome(entry=entry, notify=notify)
751
+
752
+
753
+ def _process_units_for_session(
754
+ data: dict, session_key: str, session_dir: Path, payload: dict, cap: int | None = None,
755
+ cfg: config.Config | None = None,
756
+ ) -> list[_UnitOutcome]:
757
+ """Classify (and, when newly finished/unknown, analyze) every unit of
758
+ one session, mutating `data` in place: unit states, and digest.pending
759
+ for every unit that newly became analyzed this call. Returns the
760
+ outcomes for units processed this call; `cap`, when given, stops after
761
+ that many units are newly analyzed (docs/DESIGN.md 7.2's session-start
762
+ digest budget)."""
763
+ sessions = data.setdefault("sessions", {})
764
+ session_entry = sessions.setdefault(session_key, {"units": {}, "last_check": None})
765
+ unit_states = session_entry.setdefault("units", {})
766
+ digest = data.setdefault("digest", {"pending": []})
767
+ pending = digest.setdefault("pending", [])
768
+
769
+ main_transcript = session_dir.parent / f"{session_dir.name}.jsonl"
770
+ units = _discover_units(session_dir)
771
+ discovered_ids = {unit_id for unit_id, _, _ in units}
772
+ # docs/DESIGN.md 7.2's D8: workflow-launched may register a unit before
773
+ # its conventional directory exists, storing the transcriptDir it was
774
+ # given; fall back to that stored locator whenever the conventional
775
+ # path was not discovered on disk and the unit is not already settled.
776
+ for unit_id, entry in unit_states.items():
777
+ if unit_id in discovered_ids or entry.get("state") == "analyzed":
778
+ continue
779
+ stored_dir = entry.get("transcript_dir")
780
+ if stored_dir and _kind_from_unit_id(unit_id) == "workflow":
781
+ units.append((unit_id, "workflow", Path(stored_dir)))
782
+
783
+ # docs/DESIGN.md 7.2: workflow-launched marks a unit priority so check
784
+ # inspects it first.
785
+ ordered = sorted(
786
+ units, key=lambda u: (0 if unit_states.get(u[0], {}).get("priority") else 1, u[0])
787
+ )
788
+
789
+ # docs/DESIGN.md 7.2's D6: one main-transcript parse shared by every
790
+ # subagent unit analyzed within this one call.
791
+ main_cache: dict = {}
792
+ # docs/DESIGN.md 4.10: loaded once per call, never per unit -- every unit
793
+ # of this session checked in this one call sees the same config.
794
+ active_cfg = cfg if cfg is not None else config.load_config()
795
+
796
+ outcomes: list[_UnitOutcome] = []
797
+ analyzed_count = 0
798
+ for unit_id, kind, path in ordered:
799
+ if cap is not None and analyzed_count >= cap:
800
+ break
801
+ prev = unit_states.get(unit_id)
802
+ was_analyzed = prev is not None and prev.get("state") == "analyzed"
803
+ outcome = _check_one_unit(
804
+ kind, path, prev, payload, main_transcript, main_cache, active_cfg,
805
+ )
806
+ unit_states[unit_id] = outcome.entry
807
+ outcome.newly_analyzed = outcome.entry.get("state") == "analyzed" and not was_analyzed
808
+ if outcome.newly_analyzed:
809
+ analyzed_count += 1
810
+ qualified = f"{session_key}{_DIGEST_SEPARATOR}{unit_id}"
811
+ if qualified not in pending:
812
+ pending.append(qualified)
813
+ outcomes.append(outcome)
814
+
815
+ session_entry["last_check"] = state.now_iso()
816
+ return outcomes
817
+
818
+
819
+ # --- hook check --------------------------------------------------------
820
+
821
+ _VERDICT_WORD_RANK = {"CLEAN": 0, "DEGRADED": 1, "FAILED": 2}
822
+
823
+
824
+ _MAX_NAMED_AGENTS = 3
825
+
826
+
827
+ def _agents_clause(group: list[_NotifyInfo], redactor: Redactor) -> str:
828
+ """N3b P17: ` agents a:<alias>, ...` after the command, at most three,
829
+ then `+N more`; empty when no finding names a canonical agent id."""
830
+ ids: list[str] = []
831
+ for notify in group:
832
+ for agent_id in notify.agent_ids:
833
+ if agent_id not in ids:
834
+ ids.append(agent_id)
835
+ if not ids:
836
+ return ""
837
+ named = ", ".join(redactor.identity_text("agent", a) for a in ids[:_MAX_NAMED_AGENTS])
838
+ more = f", +{len(ids) - _MAX_NAMED_AGENTS} more" if len(ids) > _MAX_NAMED_AGENTS else ""
839
+ return f" agents {named}{more}"
840
+
841
+
842
+ _POSTMORTEM_HINT = "/postmortem"
843
+
844
+
845
+ def _format_notify_lines(notifications: list[_NotifyInfo], redactor: Redactor) -> list[str]:
846
+ """docs/DESIGN.md 7.2's D-C: group by run id. A workflow unit's run id
847
+ is unique to it, so it always forms a group of one and keeps the plain
848
+ per-unit line; several subagent units of one session share that
849
+ session's own run id, so they collapse into a single
850
+ '<n> subagents finished, <k> findings' line instead of one line each.
851
+
852
+ The lines are returned already redacted, piece by piece: the fixed slash command
853
+ `/postmortem` the plugin tells the user to type is not a path and must not meet the
854
+ free-text path scanner (docs/DESIGN.md 5.1, F fix 4); every other piece does."""
855
+ groups: dict[str, list[_NotifyInfo]] = {}
856
+ order: list[str] = []
857
+ for notify in notifications:
858
+ if notify.run_id not in groups:
859
+ groups[notify.run_id] = []
860
+ order.append(notify.run_id)
861
+ groups[notify.run_id].append(notify)
862
+
863
+ lines: list[str] = []
864
+ for run_id in order:
865
+ group = groups[run_id]
866
+ worst = max(group, key=lambda n: _VERDICT_WORD_RANK.get(n.verdict_word, 0))
867
+ # docs/DESIGN.md 12's D-C: a genuine reader diagnostic anywhere in
868
+ # the group outranks a merely-quiet unit (a workflow's own
869
+ # diagnostic finding is never noise even if a sibling subagent in
870
+ # the same group only went quiet).
871
+ if any(n.is_incomplete for n in group):
872
+ suffix = " (inspection incomplete)"
873
+ elif any(n.quiet_unfinished for n in group):
874
+ suffix = " (unfinished)"
875
+ else:
876
+ suffix = ""
877
+ if len(group) == 1:
878
+ only = group[0]
879
+ # each piece is scanned on its own: a path in the headline hides the rest of the
880
+ # text it is scanned with (docs/DESIGN.md 5.1), so the run id must not follow it
881
+ lines.append(
882
+ f"{only.verdict_word}{suffix} {redactor(only.headline)} {redactor(run_id)} "
883
+ + f"{_POSTMORTEM_HINT} {redactor(run_id)}"
884
+ + redactor(_agents_clause(group, redactor))
885
+ )
886
+ continue
887
+ total_findings = sum(n.finding_count for n in group)
888
+ finding_plural = "" if total_findings == 1 else "s"
889
+ lines.append(
890
+ redactor(
891
+ f"{worst.verdict_word}{suffix} {len(group)} subagents finished, "
892
+ f"{total_findings} finding{finding_plural} {run_id} "
893
+ )
894
+ + f"{_POSTMORTEM_HINT} {redactor(run_id)}"
895
+ + redactor(_agents_clause(group, redactor))
896
+ )
897
+ return lines
898
+
899
+
900
+ def _cmd_check(payload: dict) -> None:
901
+ session_dir = _session_dir_from_payload(payload)
902
+ if session_dir is None:
903
+ return
904
+ session_id = _resolve_session_id(payload)
905
+ if session_id is None:
906
+ return
907
+ root = session_dir.parent.parent
908
+ session_key = _session_state_key(root, session_id)
909
+ stop_hook_active = bool(payload.get("stop_hook_active", False))
910
+
911
+ notifications: list[_NotifyInfo] = []
912
+ with state.transaction() as data:
913
+ outcomes = _process_units_for_session(data, session_key, session_dir, payload)
914
+ for outcome in outcomes:
915
+ if outcome.notify is not None:
916
+ notifications.append(outcome.notify)
917
+
918
+ if not notifications:
919
+ return
920
+
921
+ # N3b P17, P8: one registration for every agent the message names (none
922
+ # when it names no agent); always share. If the book is unavailable the
923
+ # names are positional and the diagnostic is part of the message, so it
924
+ # reaches systemMessage and, when emitted, additionalContext, inside the
925
+ # one JSON object.
926
+ identities: list[tuple[str, str]] = []
927
+ for notify in notifications:
928
+ for agent_id in notify.agent_ids:
929
+ if ("agent", agent_id) not in identities:
930
+ identities.append(("agent", agent_id))
931
+ book = disclosure.register(identities) if identities else None
932
+ redactor = Redactor(context=disclosure.DisclosureContext(
933
+ level="share", book=book, identities=tuple(identities),
934
+ ))
935
+ lines = _format_notify_lines(notifications, redactor)
936
+ message = "\n".join(lines)
937
+ if book is not None and not book.available:
938
+ message += f" ({disclosure.UNAVAILABLE_DIAGNOSTIC})"
939
+ output: dict = {"systemMessage": message}
940
+ if not stop_hook_active:
941
+ output["hookSpecificOutput"] = {
942
+ "hookEventName": payload.get("hook_event_name"),
943
+ "additionalContext": message,
944
+ }
945
+ print(json.dumps(output))
946
+
947
+
948
+ # --- hook session-start --------------------------------------------------
949
+
950
+
951
+ def _cmd_session_start(payload: dict) -> None:
952
+ source = payload.get("source")
953
+ if source not in ("startup", "resume"):
954
+ return
955
+ session_dir = _session_dir_plain(payload)
956
+ if session_dir is None:
957
+ return
958
+ root = session_dir.parent.parent
959
+ # docs/DESIGN.md 7.5's D3: this sweep, and the digest it prints, only
960
+ # ever concerns entries qualified with this root's own key.
961
+ root_prefix = _root_key(root) + _ROOT_KEY_SEPARATOR
962
+
963
+ message: str | None = None
964
+ with state.transaction() as data:
965
+ remaining = _DIGEST_ANALYSIS_CAP
966
+ for ref in reader.discover_runs(root, kinds=frozenset({"session"})):
967
+ if remaining <= 0:
968
+ break
969
+ other_session_dir = ref.path.parent / ref.path.stem
970
+ if not (other_session_dir / "subagents").is_dir():
971
+ continue
972
+ other_session_key = _session_state_key(root, ref.path.stem)
973
+ # docs/DESIGN.md 7.2's D2: the cross-session sweep never has a
974
+ # real Stop-hook payload for another session, so it must never
975
+ # use an empty payload as evidence that a subagent is NOT still
976
+ # running; {} simply names no agent id at all, which
977
+ # _background_task_status already treats as "no signal either
978
+ # way", never as "finished".
979
+ outcomes = _process_units_for_session(
980
+ data, other_session_key, other_session_dir, payload={}, cap=remaining,
981
+ )
982
+ remaining -= sum(1 for outcome in outcomes if outcome.newly_analyzed)
983
+
984
+ digest = data.setdefault("digest", {"pending": []})
985
+ pending = list(digest.get("pending", []))
986
+ own_root_pending = [qualified for qualified in pending if qualified.startswith(root_prefix)]
987
+ if not own_root_pending:
988
+ return
989
+
990
+ summaries = []
991
+ for qualified in own_root_pending:
992
+ session_key, _, unit_id = qualified.partition(_DIGEST_SEPARATOR)
993
+ entry = data.get("sessions", {}).get(session_key, {}).get("units", {}).get(unit_id)
994
+ if entry is not None and entry.get("state") == "analyzed":
995
+ summaries.append(entry)
996
+ # Every gathered entry of THIS root counts as digested now; another
997
+ # root's still-pending entries must survive untouched (docs/
998
+ # DESIGN.md 7.5's D3).
999
+ digest["pending"] = [
1000
+ qualified for qualified in pending if not qualified.startswith(root_prefix)
1001
+ ]
1002
+
1003
+ if not summaries:
1004
+ return
1005
+
1006
+ rank = {"CLEAN": 0, "DEGRADED": 1, "FAILED": 2}
1007
+
1008
+ def _verdict_rank(entry: dict) -> int:
1009
+ return rank.get(str(entry.get("verdict", "CLEAN")).split(" ", 1)[0], 0)
1010
+
1011
+ worst = max(summaries, key=_verdict_rank)
1012
+ wasted_seconds = sum(entry.get("wasted_seconds", 0.0) for entry in summaries)
1013
+ plural = "" if len(summaries) == 1 else "s"
1014
+ redactor = Redactor()
1015
+ message = (
1016
+ redactor(
1017
+ f"{len(summaries)} run{plural} analyzed since last digest: "
1018
+ f"worst {worst.get('verdict', 'CLEAN')}, "
1019
+ f"{report.format_duration(wasted_seconds)} wasted. "
1020
+ )
1021
+ + f"{_POSTMORTEM_HINT} {redactor(str(worst.get('run_id', '')))}"
1022
+ )
1023
+
1024
+ if message:
1025
+ print(json.dumps({"systemMessage": message}))
1026
+
1027
+
1028
+ # --- hook session-end --------------------------------------------------
1029
+
1030
+
1031
+ def _cmd_session_end(payload: dict) -> None:
1032
+ session_dir = _session_dir_plain(payload)
1033
+ if session_dir is None:
1034
+ return
1035
+ session_id = _resolve_session_id(payload)
1036
+ if session_id is None:
1037
+ return
1038
+ transcript = payload.get("transcript_path")
1039
+ main_transcript = Path(transcript)
1040
+ root = session_dir.parent.parent
1041
+ session_key = _session_state_key(root, session_id)
1042
+
1043
+ try:
1044
+ # docs/DESIGN.md 7.2's D5: the main thread only, no children -- a
1045
+ # session's own subagents are each their own unit that `check`
1046
+ # already analyzes on its own.
1047
+ run = reader.load_session_view(main_transcript, [])
1048
+ except reader.NotARun:
1049
+ return
1050
+ findings = detectors.run_tooling_detectors(run)
1051
+ # docs/DESIGN.md 4.10/7.2: the same shown/silenced split _check_one_unit
1052
+ # applies -- this unit's cached verdict feeds session-start's own
1053
+ # "worst verdict" digest ranking, so it must never be swayed by a
1054
+ # detector the user silenced either.
1055
+ shown, _silenced = config.partition_silenced(findings, config.load_config())
1056
+ verdict = report.verdict_word(shown, run.status, terminal_failed=report.lifecycle_failed(run))
1057
+ wasted_seconds = sum(
1058
+ f.metrics.get("wasted_seconds", 0.0) for f in findings if f.detector == "wasted_time"
1059
+ )
1060
+
1061
+ with state.transaction() as data:
1062
+ sessions = data.setdefault("sessions", {})
1063
+ session_entry = sessions.setdefault(session_key, {"units": {}, "last_check": None})
1064
+ unit_states = session_entry.setdefault("units", {})
1065
+ digest = data.setdefault("digest", {"pending": []})
1066
+ pending = digest.setdefault("pending", [])
1067
+
1068
+ # docs/DESIGN.md 7.2's D5: dedupe against every unit's reported ids
1069
+ # in this session, not just main's own -- a finding reachable
1070
+ # through two units must notify once.
1071
+ already_reported: set[str] = set()
1072
+ for unit in unit_states.values():
1073
+ already_reported.update(unit.get("reported", []))
1074
+ prior_main_reported = set(unit_states.get("main", {}).get("reported", []))
1075
+ new_ids = {f.finding_id for f in shown} - already_reported
1076
+
1077
+ unit_states["main"] = {
1078
+ "fingerprint": _fingerprint(_unit_files("session_main", main_transcript)),
1079
+ "state": "analyzed",
1080
+ "stable_checks": 0,
1081
+ "reported": sorted(prior_main_reported | new_ids),
1082
+ "verdict": verdict,
1083
+ "wasted_seconds": wasted_seconds,
1084
+ "run_id": run.run_id,
1085
+ }
1086
+ qualified = f"{session_key}{_DIGEST_SEPARATOR}main"
1087
+ if qualified not in pending:
1088
+ pending.append(qualified)
1089
+ session_entry["last_check"] = state.now_iso()
1090
+ # docs/DESIGN.md 7.2: session-end prints nothing.
1091
+
1092
+
1093
+ # --- hook workflow-launched ----------------------------------------------
1094
+
1095
+
1096
+ def _extract_run_id(tool_response: object) -> str | None:
1097
+ """docs/DESIGN.md 7.2 (release finding F12): whichever way a candidate
1098
+ run id was found -- the structured `runId` field, or the regex fallback
1099
+ over free text -- it is accepted only when it full-matches the same
1100
+ canonical run id grammar reader.py's own ingestion enforces
1101
+ (reader._WORKFLOW_RUN_ID_RE: 'wf_' + 8 hex + '-' + 3 hex). A structured
1102
+ `runId` previously accepted any nonempty string verbatim, including one
1103
+ carrying a newline or trailing text that could inject content into the
1104
+ additionalContext hint; requiring a full match closes that off for
1105
+ both extraction paths at once."""
1106
+ candidate: str | None = None
1107
+ if isinstance(tool_response, dict):
1108
+ raw = tool_response.get("runId")
1109
+ if isinstance(raw, str) and raw:
1110
+ candidate = raw
1111
+ if candidate is None:
1112
+ text = tool_response if isinstance(tool_response, str) else ""
1113
+ if not text and tool_response is not None:
1114
+ try:
1115
+ text = json.dumps(tool_response, ensure_ascii=False)
1116
+ except (TypeError, ValueError):
1117
+ text = ""
1118
+ match = _RUN_ID_RE.search(text)
1119
+ candidate = match.group(1) if match else None
1120
+ if candidate is None or not reader._WORKFLOW_RUN_ID_RE.fullmatch(candidate):
1121
+ return None
1122
+ return candidate
1123
+
1124
+
1125
+ def _extract_transcript_dir(tool_response: object, session_dir: Path) -> str | None:
1126
+ """docs/DESIGN.md 7.2's D8: the transcriptDir the Workflow tool's own
1127
+ response names, if any -- the locator `check` falls back to when the
1128
+ conventional subagents/workflows/<run id> path does not exist yet.
1129
+
1130
+ Release finding F12: an untrusted transcriptDir is honored only when,
1131
+ after normalization, it names a location inside this session's own
1132
+ subagents/workflows/ directory (the same session_dir `check` derives
1133
+ from transcript_path, docs/DESIGN.md 7.2's D4) -- it need not exist on
1134
+ disk yet (D8's whole point). Anything else (a relative path, or one
1135
+ that normalizes outside that directory) is dropped exactly as if
1136
+ transcriptDir had never been given at all: only the untrusted locator
1137
+ is refused, never the registration itself."""
1138
+ if not isinstance(tool_response, dict):
1139
+ return None
1140
+ candidate = tool_response.get("transcriptDir")
1141
+ if not isinstance(candidate, str) or not candidate:
1142
+ return None
1143
+ if not os.path.isabs(candidate):
1144
+ return None
1145
+ normalized = os.path.normpath(candidate)
1146
+ boundary = os.path.normpath(str(session_dir / "subagents" / "workflows"))
1147
+ if not normalized.startswith(boundary + os.sep):
1148
+ return None
1149
+ return normalized
1150
+
1151
+
1152
+ def _cmd_workflow_launched(payload: dict) -> None:
1153
+ session_dir = _session_dir_plain(payload)
1154
+ if session_dir is None:
1155
+ return
1156
+ session_id = _resolve_session_id(payload)
1157
+ if session_id is None:
1158
+ return
1159
+ run_id = _extract_run_id(payload.get("tool_response"))
1160
+ if not run_id:
1161
+ return
1162
+ unit_id = f"subagents/workflows/{run_id}"
1163
+ transcript_dir = _extract_transcript_dir(payload.get("tool_response"), session_dir)
1164
+ root = session_dir.parent.parent
1165
+ session_key = _session_state_key(root, session_id)
1166
+
1167
+ with state.transaction() as data:
1168
+ sessions = data.setdefault("sessions", {})
1169
+ session_entry = sessions.setdefault(session_key, {"units": {}, "last_check": None})
1170
+ unit_states = session_entry.setdefault("units", {})
1171
+ entry = unit_states.get(unit_id)
1172
+ if entry is None:
1173
+ new_entry = {
1174
+ "fingerprint": [], "state": "running", "stable_checks": 0,
1175
+ "reported": [], "priority": True,
1176
+ }
1177
+ if transcript_dir:
1178
+ new_entry["transcript_dir"] = transcript_dir
1179
+ unit_states[unit_id] = new_entry
1180
+ else:
1181
+ entry["priority"] = True
1182
+ if transcript_dir:
1183
+ entry["transcript_dir"] = transcript_dir
1184
+
1185
+ # .reviews/proposals/2026-09-v0.2-guided-rescue.md section 7 (docs/
1186
+ # DESIGN.md 7.2): the registration side effect above happens either way;
1187
+ # this is the one added sentence, emitted only when a run id could be
1188
+ # read (already established above) and not opted out of. It arms
1189
+ # nothing itself (R2) -- the session decides whether to actually run the
1190
+ # command.
1191
+ if os.environ.get("AGENT_POSTMORTEM_WATCH_HINT", "").strip() != "0":
1192
+ message = (
1193
+ f"Workflow {run_id} launched. To notice a killed or failed agent within "
1194
+ f"seconds instead of hours, arm a Monitor now with this command: "
1195
+ f"agent-postmortem watch {run_id}"
1196
+ )
1197
+ print(
1198
+ json.dumps(
1199
+ {
1200
+ "hookSpecificOutput": {
1201
+ "hookEventName": "PostToolUse",
1202
+ "additionalContext": message,
1203
+ }
1204
+ }
1205
+ )
1206
+ )
1207
+
1208
+
1209
+ # --- hook capabilities ---------------------------------------------------
1210
+
1211
+
1212
+ def _cmd_capabilities() -> None:
1213
+ try:
1214
+ sys.stdin.read()
1215
+ except Exception:
1216
+ pass
1217
+ try:
1218
+ version = metadata.version("agent-postmortem")
1219
+ except metadata.PackageNotFoundError:
1220
+ version = "0.0.0"
1221
+ print(json.dumps({"protocol": 1, "version": version}))
1222
+
1223
+
1224
+ # --- dispatch ------------------------------------------------------------
1225
+
1226
+ _EVENT_FOR_COMMAND = {
1227
+ "check": ("Stop", "StopFailure"),
1228
+ "session-start": ("SessionStart",),
1229
+ "session-end": ("SessionEnd",),
1230
+ "workflow-launched": ("PostToolUse",),
1231
+ }
1232
+
1233
+
1234
+ def dispatch(command: str) -> int:
1235
+ """Run one `hook <command>`, reading its payload from stdin. Always
1236
+ returns 0 (docs/DESIGN.md 7.2: "the hook never fails a turn"); any
1237
+ exception, including StateLocked, is swallowed after writing nothing,
1238
+ with a traceback to stderr only when AGENT_POSTMORTEM_DEBUG is set."""
1239
+ try:
1240
+ if command == "capabilities":
1241
+ _cmd_capabilities()
1242
+ return 0
1243
+
1244
+ payload = _read_payload()
1245
+ if payload is None or _is_subagent_context(payload):
1246
+ return 0
1247
+
1248
+ event = payload.get("hook_event_name")
1249
+ expected = _EVENT_FOR_COMMAND.get(command)
1250
+ if expected is None or event not in expected:
1251
+ return 0
1252
+ if command == "workflow-launched" and payload.get("tool_name") != "Workflow":
1253
+ return 0
1254
+
1255
+ if command == "check":
1256
+ _cmd_check(payload)
1257
+ elif command == "session-start":
1258
+ _cmd_session_start(payload)
1259
+ elif command == "session-end":
1260
+ _cmd_session_end(payload)
1261
+ elif command == "workflow-launched":
1262
+ _cmd_workflow_launched(payload)
1263
+ return 0
1264
+ except Exception:
1265
+ if os.environ.get("AGENT_POSTMORTEM_DEBUG"):
1266
+ # Redacted like the analysis traceback (7.2's D10): share level,
1267
+ # a path outside home and /tmp as path-N.
1268
+ print(Redactor().scrub_leftover_paths(traceback.format_exc()), file=sys.stderr)
1269
+ return 0