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,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
|