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,3010 @@
1
+ """Render a Run and its Findings as text or JSON.
2
+
3
+ Verdict-first, small functions. render_bug_report drafts an upstream bug
4
+ report from the same Run and Findings render_run_text uses, in the section
5
+ order docs/DESIGN.md section 4.4 specifies: Title, Environment, What
6
+ happened, Reproduction, Evidence, Suggested fixes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import platform
13
+ import re
14
+ import shlex
15
+ import textwrap
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ from . import commands, disclosure, ledger, reader, severity
20
+ from .detectors.base import attempt_seconds, format_duration, wasted_attempts
21
+ from .models import Attempt, Finding, Run, TitleParts, effective_end, raw_diagnostic
22
+ from .redact import Pseudonyms, Redactor, RenderedCommit
23
+ from .rules import RULES, rules_for
24
+
25
+ _SEVERITIES = ("critical", "major", "minor", "info")
26
+
27
+ # docs/DESIGN.md 4.7 (unit M3a): the age computed for the header's "ended ...
28
+ # ago" clause, and every "within 72 hours" question WORK AT RISK/WHAT TO DO
29
+ # ask, use this instead of datetime.now(...) directly, the same pattern
30
+ # detectors/claim_contradiction.py already uses -- a test monkeypatches
31
+ # report._now to pin the answer instead of racing the real clock.
32
+ def _now() -> datetime:
33
+ return datetime.now(timezone.utc)
34
+
35
+
36
+ def _aware(value: datetime) -> datetime:
37
+ """value, given a UTC tzinfo if it somehow has none (defensive: every
38
+ real reader-produced timestamp is already aware, but a hand-built test
39
+ Run is not required to bother)."""
40
+ return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
41
+
42
+
43
+ def _plural(count: int, word: str) -> str:
44
+ return word if count == 1 else f"{word}s"
45
+
46
+
47
+ _STALE_AFTER = timedelta(hours=72)
48
+
49
+
50
+ def _ended_recently(run: Run, now: datetime) -> bool:
51
+ """docs/DESIGN.md 4.7 (unit M3a): the same 72-hour rule observe.py's own
52
+ staleness guard uses (4.9) -- a run with no known end time is treated as
53
+ recent, since nothing says it is old."""
54
+ if run.ended_at is None:
55
+ return True
56
+ return (now - _aware(run.ended_at)) <= _STALE_AFTER
57
+
58
+
59
+ def _ended_within_72h_strict(run: Run, now: datetime) -> bool:
60
+ """unit M3b's NEEDS ATTENTION candidate rules 1(b)/1(c): unlike
61
+ _ended_recently's "an unknown end time counts as recent" convention
62
+ (used everywhere else a run's own report reasons about its own age), a
63
+ candidate rule keyed off a real elapsed age must not fire just because
64
+ the age happens to be unknown -- both rules spell out "ended_at not
65
+ None" explicitly."""
66
+ if run.ended_at is None:
67
+ return False
68
+ return (now - _aware(run.ended_at)) <= _STALE_AFTER
69
+
70
+ # A fixed sentence for a detector whose findings carry no rule_id, because
71
+ # the failure is a runtime defect rather than something a prompt can route
72
+ # around (docs/DESIGN.md section 4.3). Used by _bug_suggested_fixes_lines.
73
+ _DETECTOR_BUG_SENTENCES = {
74
+ "tool_json_error": (
75
+ "This is a runtime defect, not something a prompt can route around: the "
76
+ "tool's own input failed to parse as JSON. File it upstream with the "
77
+ "evidence below."
78
+ ),
79
+ "classifier_unavailable": (
80
+ "The safety classifier the runtime relies on for Bash was temporarily "
81
+ "unavailable. This is a transient runtime issue, not something the "
82
+ "agent's prompt controls."
83
+ ),
84
+ }
85
+
86
+
87
+ def verdict_word(
88
+ findings: list[Finding], status: str = "COMPLETE", *, terminal_failed: bool = False,
89
+ ) -> str:
90
+ """FAILED (the run's own lifecycle terminal is `failed`, or any finding is
91
+ critical), DEGRADED (any major, not FAILED), CLEAN (otherwise), always
92
+ suffixed with ' (INCOMPLETE)' when status is INCOMPLETE (docs/DESIGN.md
93
+ 4.6, 4.13.4). A caller that has a Run passes `verdict_of` the run instead,
94
+ so the lifecycle terminal can never be forgotten."""
95
+ severities = {finding.severity for finding in findings}
96
+ if terminal_failed or "critical" in severities:
97
+ word = "FAILED"
98
+ elif "major" in severities:
99
+ word = "DEGRADED"
100
+ else:
101
+ word = "CLEAN"
102
+ if status == "INCOMPLETE":
103
+ word = f"{word} (INCOMPLETE)"
104
+ return word
105
+
106
+
107
+ def lifecycle_failed(run: Run) -> bool:
108
+ """The run's own lifecycle terminal (4.13.3) is `failed`: the harness itself
109
+ reported the run failed. A session has no lifecycle, so never."""
110
+ return run.lifecycle is not None and run.lifecycle.terminal == "failed"
111
+
112
+
113
+ def verdict_of(run: Run, findings: list[Finding]) -> str:
114
+ """The verdict word of one run over the findings its caller shows."""
115
+ return verdict_word(findings, run.status, terminal_failed=lifecycle_failed(run))
116
+
117
+
118
+ def is_failed(run: Run, findings: list[Finding]) -> bool:
119
+ """Whether the verdict is FAILED: the condition behind exit code 3 (4.6)."""
120
+ return verdict_of(run, findings).split(" ", 1)[0] == "FAILED"
121
+
122
+
123
+ def _attempt_cwd(attempt: Attempt) -> str | None:
124
+ facts = attempt.facts
125
+ if facts is not None and isinstance(facts.cwds, list):
126
+ for cwd in facts.cwds:
127
+ if isinstance(cwd, str) and cwd:
128
+ return cwd
129
+ return None
130
+
131
+
132
+ def _register_observation_paths(observation, redactor: Redactor, run: Run | None = None) -> None:
133
+ """docs/DESIGN.md 4.9/5.1 (N5c-1): every repository, observed path, and
134
+ unobserved path a RunObservation carries gets its own spelling in the one
135
+ "path" namespace (path-N at share, the P19 answer at local), the same one a
136
+ ledger entry's `path` and `repo` use, so one path has one display in one
137
+ render. Called before either the REPO STATE block or the JSON export is
138
+ built."""
139
+ if observation is None:
140
+ return
141
+ for group in observation.groups:
142
+ if group.repo is not None:
143
+ redactor.names.path(group.repo)
144
+ for path in group.paths:
145
+ redactor.names.path(path)
146
+ for path in group.unobserved_paths:
147
+ redactor.names.path(path)
148
+
149
+
150
+ def _register_names(run: Run, redactor: Redactor, *, detailed: bool = False) -> None:
151
+ """docs/DESIGN.md 5.1: every public renderer entry point builds a fresh
152
+ Pseudonyms map for its own render (so numbering restarts at agent-1
153
+ every time and two reports can never be linked by a repeated pseudonym),
154
+ then registers every child attempt's label, meta description, phase,
155
+ and agent_type before rendering anything. Doing the full pass up front
156
+ -- not lazily, the first time each renderer happens to touch a
157
+ particular attempt -- is what lets Finding.title (redactor.title())
158
+ catch a name that only ever appears embedded in a title sentence, not
159
+ as a structured field of its own. run.main is deliberately excluded:
160
+ its label is the fixed literal 'main', never user text, and 5.3 already
161
+ excludes it from export.
162
+
163
+ Also registers every attempt's own report_path claim, if it has one
164
+ (docs/DESIGN.md 5.1, M1 review finding 1): unlike the four name fields
165
+ above, a registered path is substituted everywhere the plain Redactor
166
+ renders text (Redactor.__call__ itself, not only title()), so this pass
167
+ is what lets a report_path embedded in a title or evidence line -- not
168
+ only the structured reported/metrics fields that call
169
+ redactor.names.path() directly at export time -- still get the same
170
+ path-N pseudonym in a text or bug report render, which never calls
171
+ export."""
172
+ redactor.fresh_names(detailed=detailed)
173
+ for attempt in run.attempts:
174
+ redactor.names.agent(attempt.label)
175
+ meta = attempt.meta if isinstance(attempt.meta, dict) else {}
176
+ description = meta.get("description")
177
+ if isinstance(description, str):
178
+ redactor.names.description(description, label=attempt.label)
179
+ redactor.names.phase(attempt.phase)
180
+ redactor.names.type(attempt.agent_type)
181
+ reported = attempt.reported
182
+ if isinstance(reported, dict):
183
+ claims = reported.get("claims")
184
+ if isinstance(claims, dict):
185
+ report_path = claims.get("report_path")
186
+ if isinstance(report_path, str) and report_path:
187
+ redactor.names.path(report_path, _attempt_cwd(attempt))
188
+
189
+
190
+ def _register_facts_paths(run: Run, redactor: Redactor) -> None:
191
+ """docs/DESIGN.md 5.1/5.3 (F4 hardening): register every attempt's own
192
+ facts.paths_written, facts.in_flight[].path, and facts.cwds through the
193
+ same "path" namespace report_path and observation paths already share,
194
+ so `_export_attempt`'s plain-Redactor pass (redact_value, same treatment
195
+ as `reported`) substitutes a path-N pseudonym instead of leaving a
196
+ repo-relative path, or an absolute path outside home/tmp, unchanged.
197
+
198
+ Deliberately a separate function from `_register_names` above, called
199
+ only by `export_run` (the one place these raw values are actually
200
+ printed, in `run --json`'s facts export): `_register_names` is also
201
+ called directly by rescue.py, which has its own, repo-relative
202
+ "display_key" registration for the very same underlying files
203
+ (`_register_plan_paths`/`_register_status_paths`) -- folding this in
204
+ there too would burn path-N ordinals on the raw absolute form before
205
+ rescue.py ever gets to register its own, preferred relative one, so the
206
+ same file could end up with two different pseudonyms in one rescue
207
+ render."""
208
+ for attempt in run.attempts:
209
+ facts = attempt.facts
210
+ if facts is None:
211
+ continue
212
+ if isinstance(facts.paths_written, list):
213
+ for facts_path in facts.paths_written:
214
+ if isinstance(facts_path, str) and facts_path:
215
+ redactor.names.path(facts_path)
216
+ if isinstance(facts.in_flight, list):
217
+ for entry in facts.in_flight:
218
+ if isinstance(entry, dict):
219
+ in_flight_path = entry.get("path")
220
+ if isinstance(in_flight_path, str) and in_flight_path:
221
+ redactor.names.path(in_flight_path)
222
+ if isinstance(facts.cwds, list):
223
+ for cwd in facts.cwds:
224
+ if isinstance(cwd, str) and cwd:
225
+ redactor.names.path(cwd)
226
+
227
+
228
+ def _register_ledger_paths(run: Run, redactor: Redactor) -> None:
229
+ """docs/DESIGN.md 5.3b: every ledger entry's own `path` and `repo` goes
230
+ through the same "path" namespace as facts and observation paths, so one
231
+ path has one display in one render. Called after `_register_facts_paths`,
232
+ which leaves the facts numbering unchanged."""
233
+ for attempt in run.attempts:
234
+ ledger = attempt.ledger
235
+ if ledger is None:
236
+ continue
237
+ for entry in ledger.entries:
238
+ for value in (entry.path, entry.repo):
239
+ if isinstance(value, str) and value:
240
+ redactor.names.path(value)
241
+
242
+
243
+ def redact_value(value, redactor: Redactor):
244
+ """Recursively apply redactor to every string in a JSON-shaped value."""
245
+ if isinstance(value, RenderedCommit):
246
+ return value
247
+ if isinstance(value, str):
248
+ return redactor(value)
249
+ if isinstance(value, list):
250
+ return [redact_value(item, redactor) for item in value]
251
+ if isinstance(value, dict):
252
+ return {key: redact_value(item, redactor) for key, item in value.items()}
253
+ return value
254
+
255
+
256
+ def number_claim_commits(claims, redactor: Redactor):
257
+ """docs/DESIGN.md 4.13.5, 5.1 (M9): a result's own `claims.commits` list holds reported
258
+ commit ids; each goes through the per-render commit mapping (`commit-N` at share, as written
259
+ at local and raw). Every other key is returned unchanged; the keys are never renamed."""
260
+ if isinstance(claims, dict) and isinstance(claims.get("commits"), list):
261
+ return {
262
+ **claims,
263
+ "commits": [
264
+ redactor.names.commit(c) if isinstance(c, str) else c for c in claims["commits"]
265
+ ],
266
+ }
267
+ return claims
268
+
269
+
270
+ # --- N3b: identity collection and evidence pointers (P9, P10, P16) -----------
271
+ #
272
+ # A pointer is "<file>:<line>" or "<file>:<line>: <excerpt>", the format every
273
+ # detector writes (detectors/base.py evidence_line, plus the two pointer-only
274
+ # forms). It is parsed with one strict pattern: a bare file name (letters,
275
+ # digits, dot, underscore, hyphen; no directory part), a line number, an
276
+ # optional excerpt. At share the file name must name a canonical identity
277
+ # (agent-<17 hex>.jsonl or .meta.json, <session uuid>.jsonl or .meta.json) or
278
+ # be journal.jsonl; anything else renders a fixed placeholder, never its text.
279
+
280
+ _POINTER_RE = re.compile(
281
+ r"(?P<file>[A-Za-z0-9._-]+):(?P<line>[0-9]+)(?:: (?P<excerpt>.*))?", re.DOTALL
282
+ )
283
+ _AGENT_FILE_RE = re.compile(r"agent-(?P<id>[0-9a-f]{17})(?P<ext>\.jsonl|\.meta\.json)")
284
+ _SESSION_FILE_RE = re.compile(
285
+ r"(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
286
+ r"(?P<ext>\.jsonl|\.meta\.json)"
287
+ )
288
+ _JOURNAL_FILE = "journal.jsonl"
289
+
290
+ EXCERPT_NOTE = (
291
+ "note: excerpts need --local or --raw; the default level prints pointers and "
292
+ "marker codes only"
293
+ )
294
+
295
+
296
+ def parse_pointer(text: object) -> tuple[str, int, str | None] | None:
297
+ """(file name, line, excerpt or None) of a well-formed evidence string,
298
+ else None."""
299
+ if not isinstance(text, str):
300
+ return None
301
+ match = _POINTER_RE.fullmatch(text)
302
+ if match is None:
303
+ return None
304
+ return match.group("file"), int(match.group("line")), match.group("excerpt")
305
+
306
+
307
+ def _file_identity(name: str) -> tuple[str, str, str] | None:
308
+ """(kind, real id, extension) when `name` is a canonical transcript or
309
+ meta file name: kind "agent" or "session"."""
310
+ match = _AGENT_FILE_RE.fullmatch(name)
311
+ if match is not None and disclosure.is_canonical_id("agent", match.group("id")):
312
+ return "agent", match.group("id"), match.group("ext")
313
+ match = _SESSION_FILE_RE.fullmatch(name)
314
+ if match is not None and disclosure.is_canonical_id("session", match.group("id")):
315
+ return "session", match.group("id"), match.group("ext")
316
+ return None
317
+
318
+
319
+ def _all_attempts(run: Run) -> list[Attempt]:
320
+ attempts = [run.main] if run.main is not None else []
321
+ attempts.extend(run.attempts)
322
+ return attempts
323
+
324
+
325
+ def collect_identities(run: Run, findings: list[Finding] | None = None) -> list[tuple[str, str]]:
326
+ """P9: every (kind, real id) a text render of this run may show, in one
327
+ deterministic order: the project directory name, then session ids (the
328
+ run's own, then those named by transcript or evidence file names), then
329
+ agent ids (attempts in run order, then finding and evidence ids). Only
330
+ canonical ids are returned (disclosure.register rejects the rest; an
331
+ attempt without a canonical agent id is named positionally at render)."""
332
+ seen: set[tuple[str, str]] = set()
333
+ ordered: list[tuple[str, str]] = []
334
+
335
+ def add(kind: str, value: object) -> None:
336
+ if isinstance(value, str) and disclosure.is_canonical_id(kind, value):
337
+ key = (kind, value)
338
+ if key not in seen:
339
+ seen.add(key)
340
+ ordered.append(key)
341
+
342
+ add("project", run.project_slug)
343
+ add("session", run.session_id)
344
+ attempts = _all_attempts(run)
345
+ names: list[str] = []
346
+ for attempt in attempts:
347
+ if attempt.transcript_path is not None:
348
+ names.append(attempt.transcript_path.name)
349
+ for finding in findings or []:
350
+ for evidence in finding.evidence:
351
+ parsed = parse_pointer(evidence)
352
+ if parsed is not None:
353
+ names.append(parsed[0])
354
+ for name in names:
355
+ identity = _file_identity(name)
356
+ if identity is not None and identity[0] == "session":
357
+ add("session", identity[1])
358
+ for attempt in attempts:
359
+ add("agent", attempt.agent_id)
360
+ for finding in findings or []:
361
+ add("agent", finding.agent_id)
362
+ for name in names:
363
+ identity = _file_identity(name)
364
+ if identity is not None and identity[0] == "agent":
365
+ add("agent", identity[1])
366
+ return ordered
367
+
368
+
369
+ def collect_project_identities(slugs) -> list[tuple[str, str]]:
370
+ """P9 for a sink that renders only project names (`scan --all`): each
371
+ distinct canonical project directory name, in the order given."""
372
+ seen: set[str] = set()
373
+ ordered: list[tuple[str, str]] = []
374
+ for slug in slugs:
375
+ if slug in seen or not disclosure.is_canonical_id("project", slug):
376
+ continue
377
+ if isinstance(slug, str):
378
+ seen.add(slug)
379
+ ordered.append(("project", slug))
380
+ return ordered
381
+
382
+
383
+ def project_text(slug: str, redactor: Redactor) -> str:
384
+ """P10/P11, the project in any text sink: at share `project:<alias>`
385
+ (positional on fallback); at local and raw the project directory name as
386
+ it is on disk (the string `local` prints, 4.13.5)."""
387
+ if not slug:
388
+ return ""
389
+ if redactor.level == "share":
390
+ return redactor.identity_text("project", slug)
391
+ if redactor.level == "local":
392
+ return redactor.names.local_text(slug)
393
+ return slug
394
+
395
+
396
+ def identity_value(kind: str, value: str | None, redactor: Redactor) -> str | None:
397
+ """5.3b's `agent_id` (and the like) in JSON: at share the registry alias in
398
+ its rendered form (`a:<alias>`; positional `agent-N` on fallback and for an
399
+ id that is not canonical, never the real value), at local the real value
400
+ with control characters and paths neutralized, at raw the value as is."""
401
+ if value is None:
402
+ return None
403
+ if redactor.level == "share":
404
+ return redactor.identity_text(kind, value)
405
+ if redactor.level == "local":
406
+ return redactor.names.local_text(value)
407
+ return value
408
+
409
+
410
+ class EvidenceView:
411
+ """How one render prints an evidence string (P10, P16).
412
+
413
+ share: the pointer with an aliased file name plus the marker code when
414
+ the line is a MarkerHit's, never an excerpt; a string that is not a
415
+ pointer with a canonical file name renders evidence-N; a pointer into an
416
+ attempt without a canonical agent id is omitted (None).
417
+ local, raw: today's `file:line: excerpt` only when `show_excerpts`
418
+ (--evidence); otherwise the pointer (real file name) plus the code.
419
+ """
420
+
421
+ def __init__(self, run: Run, redactor: Redactor, *, show_excerpts: bool) -> None:
422
+ self.redactor = redactor
423
+ self.level = redactor.level
424
+ self.show_excerpts = show_excerpts and self.level != "share"
425
+ self._placeholders: dict[str, str] = {}
426
+ self._codes: dict[tuple[str, int], list[str]] = {}
427
+ self._no_identity: set[str] = set()
428
+ for attempt in _all_attempts(run):
429
+ if attempt.transcript_path is None:
430
+ continue
431
+ name = attempt.transcript_path.name
432
+ if attempt is not run.main and not disclosure.is_canonical_id(
433
+ "agent", attempt.agent_id
434
+ ):
435
+ self._no_identity.add(name)
436
+ for hit in attempt.marker_hits:
437
+ if not hit.anchored or hit.in_payload:
438
+ continue
439
+ for line in {hit.event_line, hit.result_line}:
440
+ if line is not None:
441
+ codes = self._codes.setdefault((name, line), [])
442
+ if hit.marker not in codes:
443
+ codes.append(hit.marker)
444
+
445
+ def _placeholder(self, text: str) -> str:
446
+ if text not in self._placeholders:
447
+ self._placeholders[text] = f"evidence-{len(self._placeholders) + 1}"
448
+ return self._placeholders[text]
449
+
450
+ def _share_file(self, name: str) -> str | None:
451
+ if name == _JOURNAL_FILE:
452
+ return name
453
+ identity = _file_identity(name)
454
+ if identity is None:
455
+ return None
456
+ kind, real_id, extension = identity
457
+ alias = self.redactor.book.alias(kind, real_id)
458
+ return disclosure.file_stem(kind, alias) + extension
459
+
460
+ def pointer(self, name: str, line: int) -> str | None:
461
+ """A bare `file:line` pointer with no marker code (rescue's TASK
462
+ line): the aliased file name at share, the real one at local and raw;
463
+ None for an attempt without a canonical agent id at share."""
464
+ if self.level != "share":
465
+ return f"{name}:{line}"
466
+ if name in self._no_identity:
467
+ return None
468
+ shown = self._share_file(name)
469
+ if shown is None:
470
+ return self._placeholder(f"{name}:{line}")
471
+ return f"{shown}:{line}"
472
+
473
+ def ledger_line(self, line: str) -> str:
474
+ """A ledger entry's own `<transcript file name>:<line>` (docs/DESIGN.md
475
+ 5.3b's `line` convention): at share the file half is its aliased
476
+ spelling, an `evidence-N` placeholder when the name is no canonical
477
+ identity or the attempt has no canonical agent id (never a real name);
478
+ at local and raw the real file name, with no marker code."""
479
+ parsed = parse_pointer(line)
480
+ if self.level != "share":
481
+ if parsed is None:
482
+ return self.redactor.names.local_text(line) if self.level == "local" else line
483
+ return f"{parsed[0]}:{parsed[1]}"
484
+ if parsed is None:
485
+ return self._placeholder(line)
486
+ name, number, _excerpt = parsed
487
+ if name in self._no_identity:
488
+ return self._placeholder(line)
489
+ shown = self._share_file(name)
490
+ if shown is None:
491
+ return self._placeholder(line)
492
+ return f"{shown}:{number}"
493
+
494
+ def _code_suffix(self, name: str, line: int) -> str:
495
+ codes = self._codes.get((name, line))
496
+ return f" [{','.join(sorted(codes))}]" if codes else ""
497
+
498
+ def line(self, raw: str) -> str | None:
499
+ parsed = parse_pointer(raw)
500
+ if self.level == "share":
501
+ if parsed is None:
502
+ return self._placeholder(raw)
503
+ name, line, _excerpt = parsed
504
+ if name in self._no_identity:
505
+ return None
506
+ shown = self._share_file(name)
507
+ if shown is None:
508
+ return self._placeholder(raw)
509
+ return f"{shown}:{line}{self._code_suffix(name, line)}"
510
+ if self.show_excerpts:
511
+ if parsed is None:
512
+ return self.redactor(raw)
513
+ name, line, excerpt = parsed
514
+ pointer = f"{name}:{line}"
515
+ return pointer if excerpt is None else f"{pointer}: {self.redactor(excerpt)}"
516
+ if parsed is None:
517
+ return self._placeholder(raw)
518
+ name, line, _excerpt = parsed
519
+ return f"{name}:{line}{self._code_suffix(name, line)}"
520
+
521
+ def note(self) -> str | None:
522
+ """The one share-level note a --evidence request earns."""
523
+ return EXCERPT_NOTE if self.level == "share" else None
524
+
525
+
526
+ def _shown_evidence(
527
+ evidence: list[str], view: "EvidenceView | None", redactor: Redactor,
528
+ ) -> list[str]:
529
+ """The rendered evidence lines of one finding or story, in first order,
530
+ each distinct line once at share and local (N3b S13): two entries that
531
+ differed only by an excerpt the level does not print render identically
532
+ and show once. raw keeps v0.2's count and order (R2-3). A line the view
533
+ omits (None) is skipped."""
534
+ dedupe = redactor.level != "raw"
535
+ shown: list[str] = []
536
+ for line in evidence:
537
+ text = view.line(line) if view is not None else redactor(line)
538
+ if text is not None and (not dedupe or text not in shown):
539
+ shown.append(text)
540
+ return shown
541
+
542
+
543
+ def _format_range(start: datetime | None, end: datetime | None) -> str:
544
+ if start is None or end is None:
545
+ return "no timestamps recorded"
546
+ # Convert to UTC first: a timestamp that kept a non-UTC offset would
547
+ # otherwise print its local wall-clock value with a "Z" suffix that
548
+ # falsely claims it is UTC.
549
+ start = start.astimezone(timezone.utc)
550
+ end = end.astimezone(timezone.utc)
551
+ start_str = start.strftime("%Y-%m-%d %H:%MZ")
552
+ end_str = (
553
+ end.strftime("%H:%MZ") if start.date() == end.date() else end.strftime("%Y-%m-%d %H:%MZ")
554
+ )
555
+ return f"{start_str} to {end_str}"
556
+
557
+
558
+ def silent_retry_count(run: Run) -> int:
559
+ """How many attempts were thrown away by a restart: for every logical
560
+ agent, every attempt but the last one. Attempts with no key (an unkeyed
561
+ 'started' journal event) are not part of any LogicalAgent and must never
562
+ be counted here; subtracting len(run.attempts) - len(run.agents) would
563
+ silently attribute one to an agent that was never actually retried."""
564
+ return sum(len(agent.attempts) - 1 for agent in run.agents if agent.attempts)
565
+
566
+
567
+ def _summary_line(run: Run) -> str:
568
+ agent_count = len(run.agents)
569
+ attempt_count = len(run.attempts)
570
+ retries = silent_retry_count(run)
571
+ wasted_seconds = sum(
572
+ attempt_seconds(attempt)
573
+ for agent in run.agents
574
+ for attempt in sorted(agent.attempts, key=lambda a: a.ordinal)[:-1]
575
+ )
576
+ killed = sum(1 for attempt in run.attempts if attempt.outcome == "killed")
577
+ return (
578
+ f"{agent_count} agents became {attempt_count} attempts; "
579
+ f"{retries} silent retries burned {format_duration(wasted_seconds)}; "
580
+ f"{killed} kills recorded as user interrupts"
581
+ )
582
+
583
+
584
+ def _session_summary_line(run: Run) -> str:
585
+ """docs/DESIGN.md 4.7: a session's summary reports subagents, not
586
+ logical agents becoming attempts -- a session's subagents are never
587
+ retried in a way v0.1 can observe, so it says so plainly instead of
588
+ printing a 'silent retries' count that would always read zero."""
589
+ run_seconds = 0.0
590
+ if run.started_at is not None and run.ended_at is not None:
591
+ run_seconds = max((run.ended_at - run.started_at).total_seconds(), 0.0)
592
+ subagents = len(run.attempts)
593
+ interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
594
+ unattributed = sum(
595
+ 1 for attempt in run.attempts
596
+ if attempt.interrupt is not None and attempt.interrupt.cause == "unattributed"
597
+ )
598
+ return (
599
+ f"session ran {format_duration(run_seconds)}; {subagents} subagents, "
600
+ f"{interrupted} interrupted ({unattributed} unattributed); retries not observable"
601
+ )
602
+
603
+
604
+ def _summary_line_for(run: Run) -> str:
605
+ return _session_summary_line(run) if run.kind == "session" else _summary_line(run)
606
+
607
+
608
+ def summary_line(run: Run) -> str:
609
+ """The one-line run summary docs/DESIGN.md 4.7 renders first inside
610
+ render_run_text/render_bug_report: 'N agents became M attempts; ...' for
611
+ a workflow run, 'session ran T; N subagents, ...' for a session. Public
612
+ so a caller outside this module (hooks_cli.py's notification lines,
613
+ docs/DESIGN.md 7.2) reuses the exact same sentence instead of
614
+ re-deriving it."""
615
+ return _summary_line_for(run)
616
+
617
+
618
+ def _project_line(run: Run, redactor: Redactor) -> str:
619
+ project = redactor(run.project_slug)
620
+ version = run.claude_version or "unknown"
621
+ time_range = _format_range(run.started_at, run.ended_at)
622
+ return f"project {project} claude {version} {time_range}"
623
+
624
+
625
+ # --- unit M3a: the redesigned `run` text report -----------------------------
626
+ #
627
+ # docs/DESIGN.md 4.7 (rewritten by this unit): header, account, WORK AT RISK,
628
+ # WHAT TO DO, AGENT table, STORIES. The old v0.1 header/summary-line/
629
+ # project-line/FINDINGS/SUGGESTED RULES/NEXT ACTION shape is gone from
630
+ # render_run_text; render_bug_report keeps the v0.1 section order untouched
631
+ # (it has its own _bug_* helpers and never calls anything below).
632
+
633
+ _AGENT_END_KILLED_CAUSE = {"parent_stop": "stopped by parent", "human": "interrupted"}
634
+
635
+
636
+ def _age_string(ended_at: datetime, now: datetime, short: bool = False) -> str:
637
+ """'<N> days|hours|minutes ago', floor-rounded, for the header's 'ended
638
+ ... ago' clause (docs/DESIGN.md 4.7). short=True (unit M3b's scan triage
639
+ age column) abbreviates the unit ('<N>d/h/m ago') instead of spelling it
640
+ out; the rounding and threshold logic is exactly the same either way, so
641
+ a triage row's age and the run report's header age can never disagree
642
+ about which unit a given duration falls into."""
643
+ seconds = max((now - _aware(ended_at)).total_seconds(), 0.0)
644
+ days = int(seconds // 86400)
645
+ if days >= 1:
646
+ return f"{days}d ago" if short else f"{days} {_plural(days, 'day')} ago"
647
+ hours = int(seconds // 3600)
648
+ if hours >= 1:
649
+ return f"{hours}h ago" if short else f"{hours} {_plural(hours, 'hour')} ago"
650
+ minutes = int(seconds // 60)
651
+ return f"{minutes}m ago" if short else f"{minutes} {_plural(minutes, 'minute')} ago"
652
+
653
+
654
+ def _uncertain_terminal(run: Run) -> bool:
655
+ """docs/DESIGN.md 4.13.3 (v0.3 N2): true when nothing says this run has
656
+ actually stopped -- a session (no lifecycle model at all, D11) or a
657
+ workflow whose lifecycle terminal is 'none' (no generation evidence, or
658
+ a pending one, ever reached a terminal value). Every other workflow
659
+ terminal (completed/failed/stopped/journal_resolved/unknown) is a real,
660
+ if sometimes unclear, word from the harness, so 'ended' stays honest."""
661
+ if run.kind == "session":
662
+ return True
663
+ return run.lifecycle is not None and run.lifecycle.terminal == "none"
664
+
665
+
666
+ def _activity_recent(run: Run, now: datetime) -> bool:
667
+ """docs/DESIGN.md 4.13.3's activity rule, for the header's own 'possibly
668
+ running' suffix: a workflow's own Lifecycle.activity (mtime-based, set
669
+ once at load time) for a workflow; the same 10-minute freshness rule
670
+ applied to the session's own last recorded event for a session (no
671
+ Lifecycle to carry a precomputed activity field, D11)."""
672
+ if run.kind == "workflow":
673
+ return run.lifecycle is not None and run.lifecycle.activity == "recent"
674
+ if run.ended_at is None:
675
+ return False
676
+ return reader.activity_word(_aware(run.ended_at).timestamp(), now.timestamp()) == "recent"
677
+
678
+
679
+ def _run_header_line(run: Run, findings: list[Finding], show_age: bool = True) -> str:
680
+ """'RUN <id> <VERDICT>[ (INCOMPLETE)][ (unfinished)] ended <ts> (<age>)'
681
+ -- 'SESSION' instead of 'RUN' for a session run (docs/DESIGN.md 4.7 rule
682
+ 1). ended_at is never redacted (it carries no user text, same as a run
683
+ id). (v0.3, N2, 4.13.3) When nothing certifies this run has actually
684
+ stopped (a session, or a workflow whose lifecycle terminal is 'none'),
685
+ the clause reads 'last activity <ts> (<age>)' instead of 'ended ... ',
686
+ plus a trailing 'possibly running' when activity is recent -- 'ended'
687
+ would otherwise claim a certainty nothing here actually has. The
688
+ '(unfinished)' tag is driven by Run.finished being exactly False (never
689
+ None: a session's own null finished never earns the tag, D11)."""
690
+ kind_word = "SESSION" if run.kind == "session" else "RUN"
691
+ verdict = verdict_of(run, findings)
692
+ unfinished_tag = " (unfinished)" if run.finished is False else ""
693
+ uncertain = _uncertain_terminal(run)
694
+ label = "last activity" if uncertain else "ended"
695
+ if run.ended_at is None:
696
+ clause = f"{label}: unknown"
697
+ else:
698
+ ended_utc = _aware(run.ended_at).astimezone(timezone.utc)
699
+ clause = f"{label} {ended_utc.strftime('%Y-%m-%d %H:%MZ')}"
700
+ if show_age:
701
+ clause += f" ({_age_string(run.ended_at, _now())})"
702
+ if uncertain and _activity_recent(run, _now()):
703
+ clause += " possibly running"
704
+ return f"{kind_word} {run.run_id} {verdict}{unfinished_tag} {clause}"
705
+
706
+
707
+ # docs/DESIGN.md 4.2/4.7: the phrase each attempt_failed cause renders as,
708
+ # shared by the account's failed-sentence, WHAT TO DO's relaunch guidance,
709
+ # and a story's own attempt_failed clause -- one table instead of three
710
+ # copies of the same wording.
711
+ _CAUSE_PHRASES = {
712
+ "usage_limit_session": "session usage limit reached",
713
+ "usage_limit_weekly": "weekly usage limit reached",
714
+ "machine_sleep": "the machine went to sleep",
715
+ "api_error": "a server error occurred",
716
+ "interrupted": "interrupted before finishing",
717
+ "unattributed": "cause not recorded in the transcript",
718
+ # docs/DESIGN.md 4.3 (G1c, v0.3.1): a session subagent that reported failure
719
+ "launch_result_failed": "its launch result reported failure",
720
+ "task_notification_failed": "its task notification reported failure",
721
+ }
722
+
723
+
724
+ def _cause_phrase(cause: str) -> str:
725
+ return _CAUSE_PHRASES.get(cause, cause)
726
+
727
+
728
+ def _workflow_account_lines(run: Run, findings: list[Finding]) -> list[str]:
729
+ """docs/DESIGN.md 4.7 rule 2: 1 to 3(+) plain sentences, verdict already
730
+ said in the header -- this is the 'what happened' account. Any sentence
731
+ whose count is 0 is omitted outright, never printed as '0 ...'."""
732
+ agent_count = len(run.agents)
733
+ attempt_count = len(run.attempts)
734
+ sentences = [
735
+ f"{agent_count} {_plural(agent_count, 'agent')} took "
736
+ f"{attempt_count} {_plural(attempt_count, 'attempt')}."
737
+ ]
738
+
739
+ runtime = 0
740
+ parent_stop = 0
741
+ human = 0
742
+ unattributed = 0
743
+ for attempt in run.attempts:
744
+ interrupt = attempt.interrupt
745
+ if interrupt is None:
746
+ continue
747
+ if interrupt.cause == "runtime":
748
+ runtime += 1
749
+ elif interrupt.cause == "parent_stop":
750
+ parent_stop += 1
751
+ elif interrupt.cause == "human":
752
+ human += 1
753
+ elif interrupt.cause == "unattributed":
754
+ unattributed += 1
755
+
756
+ if runtime:
757
+ was_were = "was" if runtime == 1 else "were"
758
+ sentences.append(
759
+ f"{runtime} {_plural(runtime, 'attempt')} {was_were} killed by "
760
+ "the runtime and restarted."
761
+ )
762
+ if parent_stop:
763
+ was_were = "was" if parent_stop == 1 else "were"
764
+ sentences.append(f"{parent_stop} {was_were} stopped by the parent session.")
765
+ if human:
766
+ was_were = "was" if human == 1 else "were"
767
+ sentences.append(f"{human} {was_were} interrupted with the parent session.")
768
+ if unattributed:
769
+ was_were = "was" if unattributed == 1 else "were"
770
+ sentences.append(f"{unattributed} {was_were} interrupted, cause unattributed.")
771
+
772
+ failed_sentence = _failed_clause(findings)
773
+ if failed_sentence:
774
+ sentences.append(failed_sentence)
775
+
776
+ wasted_sentence = _wasted_clause(findings)
777
+ if wasted_sentence:
778
+ sentences.append(wasted_sentence)
779
+
780
+ return sentences
781
+
782
+
783
+ def _wasted_clause(findings: list[Finding]) -> str | None:
784
+ """docs/DESIGN.md 4.7 (FA5): '<W> was spent on retries and attempts that ended
785
+ without a result.' from `wasted_time`'s own `wasted_seconds` (workflow and session
786
+ alike), or None when it is 0 or there is no such finding."""
787
+ wasted_seconds = 0.0
788
+ for finding in findings:
789
+ if finding.detector == "wasted_time":
790
+ wasted_seconds = finding.metrics.get("wasted_seconds", 0.0) or 0.0
791
+ if not wasted_seconds:
792
+ return None
793
+ return (
794
+ f"{format_duration(wasted_seconds)} was spent on retries and attempts that ended "
795
+ "without a result."
796
+ )
797
+
798
+
799
+ def _failed_clause(findings: list[Finding]) -> str | None:
800
+ """'<F> failed (<causes, comma separated, with counts>).' (docs/
801
+ DESIGN.md 4.7 rule 2), or None when there are no attempt_failed findings
802
+ at all -- true of every session run today (attempt_failed is workflow-
803
+ only), so the session account line simply never grows this clause."""
804
+ failed = [f for f in findings if f.detector == "attempt_failed"]
805
+ if not failed:
806
+ return None
807
+ causes: list[list] = []
808
+ index_by_cause: dict[str, int] = {}
809
+ for finding in failed:
810
+ cause = finding.metrics.get("cause", "unattributed")
811
+ if cause not in index_by_cause:
812
+ index_by_cause[cause] = len(causes)
813
+ causes.append([cause, 0])
814
+ causes[index_by_cause[cause]][1] += 1
815
+ cause_text = ", ".join(f"{_cause_phrase(cause)} ({count})" for cause, count in causes)
816
+ return f"{len(failed)} failed ({cause_text})."
817
+
818
+
819
+ def _session_account_lines(run: Run, findings: list[Finding]) -> list[str]:
820
+ """docs/DESIGN.md 4.7 rule 2: the existing session summary sentence, plus
821
+ the failed sentence when one applies (attempt_failed is workflow-only
822
+ today, so this never actually fires yet -- kept so the two account
823
+ builders share the same failed-clause code path)."""
824
+ sentences = [f"{_session_summary_line(run)}."]
825
+ failed_sentence = _failed_clause(findings)
826
+ if failed_sentence:
827
+ sentences.append(failed_sentence)
828
+ wasted_sentence = _wasted_clause(findings)
829
+ if wasted_sentence:
830
+ sentences.append(wasted_sentence)
831
+ return sentences
832
+
833
+
834
+ def _account_lines(run: Run, findings: list[Finding]) -> list[str]:
835
+ if run.kind == "session":
836
+ return _session_account_lines(run, findings)
837
+ return _workflow_account_lines(run, findings)
838
+
839
+
840
+ # docs/DESIGN.md 4.9/4.7: the outcomes whose facts feed WORK AT RISK, the
841
+ # AGENT table's PATHS/COMMITS columns, and a story's own facts clause --
842
+ # the same set observe.py's own _CANDIDATE_OUTCOMES already uses.
843
+ _RISK_OUTCOMES = frozenset({"killed", "failed", "superseded"})
844
+
845
+
846
+ def _risk_paths_and_commits(run: Run) -> tuple[set[str], set[str]]:
847
+ """The union of paths_written/commits over every killed, failed, or
848
+ superseded attempt of the run (the same population _work_at_risk_lines
849
+ and _what_to_do_items already walk) -- shared by unit M3b's scan triage
850
+ (the NEEDS ATTENTION candidate rules, the paths_at_risk JSON field, and
851
+ the --observe next-command decision) so there is exactly one definition
852
+ of "paths at risk" in this module."""
853
+ qualifying = [a for a in run.attempts if a.outcome in _RISK_OUTCOMES]
854
+ paths: set[str] = set()
855
+ commits: set[str] = set()
856
+ for attempt in qualifying:
857
+ facts = attempt.facts
858
+ if facts is None:
859
+ continue
860
+ if isinstance(facts.paths_written, list):
861
+ paths.update(facts.paths_written)
862
+ if isinstance(facts.commits, list):
863
+ commits.update(facts.commits)
864
+ return paths, commits
865
+
866
+
867
+ _RUN_REASON_WORDS = {
868
+ "discovery_incomplete": "some run files could not be read or matched",
869
+ }
870
+
871
+ # docs/DESIGN.md 4.7 (field milestone F, P6): why an attempt could not be placed in a launch.
872
+ _UNASSIGNABLE_REASON_WORDS = {
873
+ "before_launch": "it started before the run was launched",
874
+ "no_usable_timestamp": "an early line has no usable timestamp",
875
+ "launch_times_unusable": "the launch times are missing or out of order",
876
+ "no_launch_record": "no launch of this run was found in the parent transcript",
877
+ "launch_unusable": "a launch of this run was found but could not be used",
878
+ }
879
+ _MAX_UNASSIGNABLE_NAMED = 3
880
+ _NOT_PLACED = "could not be placed in a launch of this run"
881
+
882
+ # Ledger reasons the per-attempt clauses explain by their own counts, and the
883
+ # run-level reasons the population line reports once (docs/DESIGN.md 4.7).
884
+ _EXPLAINED_REASONS = frozenset({
885
+ "shell_context_unverified", "possible_opaque_command", "possible_delegation",
886
+ "possible_background_process", "possible_unknown_tool", "unassignable_attempt",
887
+ "discovery_incomplete",
888
+ })
889
+
890
+ _MAX_HINTS = 3
891
+
892
+
893
+ def _id_list(ids: list[str], more: int) -> str:
894
+ """`a`, `a and b`, `a, b and c`, `a, b, c and 2 more`."""
895
+ items = list(ids)
896
+ if more > 0:
897
+ items.append(f"{more} more")
898
+ if len(items) <= 1:
899
+ return "".join(items)
900
+ return f"{', '.join(items[:-1])} and {items[-1]}"
901
+
902
+
903
+ def _unassignable_words(run: Run, redactor: Redactor) -> str:
904
+ """P6: `<id> could not be placed in a launch of this run (<reason>)`. Attempts with the same
905
+ reason share one phrase (`<id>, <id> and <id> could not ...`, at most three named per reason,
906
+ in `Run.attempts` order, then `and <k> more`); phrases of different reasons join with "; "."""
907
+ groups: dict[str, list[Attempt]] = {}
908
+ for attempt in run.attempts:
909
+ if ledger.is_unassignable(run, attempt):
910
+ reason = ledger.unassignable_reason(run, attempt)
911
+ groups.setdefault(reason or "before_launch", []).append(attempt)
912
+ phrases = []
913
+ for reason, attempts in groups.items():
914
+ named = [
915
+ _attempt_ident(attempt, run, redactor, with_label=False)
916
+ for attempt in attempts[:_MAX_UNASSIGNABLE_NAMED]
917
+ ]
918
+ words = _UNASSIGNABLE_REASON_WORDS.get(reason, _UNASSIGNABLE_REASON_WORDS["before_launch"])
919
+ phrases.append(
920
+ f"{_id_list(named, len(attempts) - len(named))} {_NOT_PLACED} ({words})"
921
+ )
922
+ return "; ".join(phrases)
923
+
924
+
925
+ def _run_reason_words(run: Run, redactor: Redactor) -> str:
926
+ reasons = list(getattr(run, "risk_population_completeness_reasons", []) or [])
927
+ words = []
928
+ for reason in reasons:
929
+ if reason == "unassignable_attempt":
930
+ text = _unassignable_words(run, redactor)
931
+ if text:
932
+ words.append(text)
933
+ elif reason in _RUN_REASON_WORDS:
934
+ words.append(_RUN_REASON_WORDS[reason])
935
+ if not words:
936
+ words = [_RUN_REASON_WORDS["discovery_incomplete"]]
937
+ return "; ".join(words)
938
+
939
+
940
+ def _attempt_ident(
941
+ attempt: Attempt, run: Run, redactor: Redactor, *, with_label: bool = True,
942
+ ) -> str:
943
+ """The attempt's id in WORK AT RISK: `main` for a session main thread, else
944
+ the agent id as the level shows it (a:<alias> at share)."""
945
+ if attempt is run.main:
946
+ return "main"
947
+ ident = identity_value("agent", attempt.agent_id, redactor) or "agent"
948
+ # docs/DESIGN.md 4.7 (G6, v0.3.1): one name per agent, as the AGENT table names it
949
+ label = redactor.names.agent(attempt.label) if with_label and attempt.label else ""
950
+ return f"{ident} ({label})" if label else ident
951
+
952
+
953
+ def _attempt_clauses(attempt: Attempt) -> list[str]:
954
+ """docs/DESIGN.md 4.7: what one population attempt wrote and what it may have
955
+ written, from its ledger and its calls."""
956
+ built = attempt.ledger
957
+ entries = built.entries if built is not None else []
958
+
959
+ def distinct(effect: str) -> int:
960
+ return len({entry.path for entry in entries if entry.effect == effect and entry.path})
961
+
962
+ clauses: list[str] = []
963
+ wrote = distinct("observed")
964
+ if wrote:
965
+ clauses.append(f"wrote {wrote} {_plural(wrote, 'path')} (file tools)")
966
+ named = distinct("claimed_target")
967
+ if named:
968
+ clauses.append(f"named {named} {_plural(named, 'path')} in write commands")
969
+ tried = distinct("attempted")
970
+ if tried:
971
+ clauses.append(f"tried {tried} {_plural(tried, 'path')} with no confirmed result")
972
+ shell = sum(1 for call in attempt.tool_calls if call.tool_name in ledger.SHELL_TOOLS)
973
+ if shell:
974
+ clauses.append(
975
+ f"ran {shell} shell {_plural(shell, 'command')} that may have written more"
976
+ )
977
+ unknown_tools = sum(
978
+ 1 for entry in entries if entry.effect == "possible" and entry.basis == "unknown_tool"
979
+ )
980
+ if unknown_tools:
981
+ clauses.append(
982
+ f"called {unknown_tools} unrecognized {_plural(unknown_tools, 'tool')} "
983
+ "that may have written"
984
+ )
985
+ delegated = sum(1 for call in attempt.tool_calls if call.tool_name in commands.DELEGATION_TOOLS)
986
+ if delegated:
987
+ clauses.append(f"delegated {delegated} {_plural(delegated, 'call')} that may have written")
988
+ reasons = built.reasons if built is not None else ["transcript_missing"]
989
+ attempt_level = [reason for reason in reasons if reason not in _EXPLAINED_REASONS]
990
+ if attempt_level:
991
+ clauses.append(f"ledger incomplete ({', '.join(attempt_level)})")
992
+ if not clauses:
993
+ clauses.append("wrote nothing the ledger names")
994
+ return clauses
995
+
996
+
997
+ def _unreturned_subagents(run: Run, population: list[Attempt]) -> list[Attempt]:
998
+ """docs/DESIGN.md 4.13.1 (S11): a session's subagents outside the risk
999
+ population that did not return a result to their parent (the guard behind a
1000
+ session's `none`)."""
1001
+ if run.kind != "session":
1002
+ return []
1003
+ inside = {id(attempt) for attempt in population}
1004
+ return [
1005
+ attempt for attempt in run.attempts
1006
+ if id(attempt) not in inside and attempt.return_state != "returned"
1007
+ ]
1008
+
1009
+
1010
+ # docs/DESIGN.md 4.7 (W1, v0.3.1): the classes of a session subagent with no established return,
1011
+ # in breakdown order: (key, phrase)
1012
+ RETURN_CLASSES = (
1013
+ ("reported_failed", "reported failed"),
1014
+ ("reported_killed", "reported killed"),
1015
+ ("possibly_running", "possibly running"),
1016
+ ("no_return_evidence", "no return evidence found"),
1017
+ ("not_established", "return could not be established"),
1018
+ )
1019
+
1020
+
1021
+ def transcript_recent(attempt: Attempt) -> bool:
1022
+ """The child's transcript was modified within UNKNOWN_QUIET_SECONDS (600 s) of now. A file
1023
+ that cannot be statted is not recent."""
1024
+ path = attempt.transcript_path
1025
+ if path is None:
1026
+ return False
1027
+ try:
1028
+ mtime = path.stat().st_mtime
1029
+ except OSError:
1030
+ return False
1031
+ return reader.activity_word(mtime) == "recent"
1032
+
1033
+
1034
+ def return_class(attempt: Attempt) -> str:
1035
+ """The W1 class of a session subagent whose return_state is not `returned`."""
1036
+ state = attempt.return_state
1037
+ if state == "reported_failed":
1038
+ return "reported_failed"
1039
+ if state == "reported_killed":
1040
+ return "reported_killed"
1041
+ if state in ("pending", "unknown") and transcript_recent(attempt):
1042
+ return "possibly_running"
1043
+ if state == "pending" or (state == "unknown" and attempt.return_reason == "no_evidence"):
1044
+ return "no_return_evidence"
1045
+ return "not_established"
1046
+
1047
+
1048
+ def _is_nested(attempt: Attempt) -> bool:
1049
+ return isinstance(attempt.meta, dict) and isinstance(attempt.meta.get("parentAgentId"), str)
1050
+
1051
+
1052
+ def return_reason_phrase(attempt: Attempt) -> str:
1053
+ klass = return_class(attempt)
1054
+ phrase = dict(RETURN_CLASSES)[klass]
1055
+ if klass == "no_return_evidence" and _is_nested(attempt):
1056
+ phrase += " in its parent agent"
1057
+ return phrase
1058
+
1059
+
1060
+ def unestablished_returns(run: Run) -> list[Attempt]:
1061
+ """Every session subagent whose return_state is not `returned` (empty for a workflow)."""
1062
+ if run.kind != "session":
1063
+ return []
1064
+ return [attempt for attempt in run.attempts if attempt.return_state != "returned"]
1065
+
1066
+
1067
+ def return_breakdown(attempts: list[Attempt]) -> dict[str, int]:
1068
+ counts = {key: 0 for key, _phrase in RETURN_CLASSES}
1069
+ for attempt in attempts:
1070
+ counts[return_class(attempt)] += 1
1071
+ return counts
1072
+
1073
+
1074
+ def breakdown_text(counts: dict[str, int]) -> str:
1075
+ return ", ".join(f"{counts[key]} {phrase}" for key, phrase in RETURN_CLASSES if counts[key])
1076
+
1077
+
1078
+ def _have(k: int) -> str:
1079
+ return "has" if k == 1 else "have"
1080
+
1081
+
1082
+ def _shell_arg(text: str, level: str) -> str:
1083
+ """A repository spelling as a command argument: quoted at raw (a real path),
1084
+ quoted at the other levels only when it holds characters a shell would split."""
1085
+ if level == "raw":
1086
+ return shlex.quote(text)
1087
+ return text if re.fullmatch(r"[\w@%+=:,./~-]+", text) else shlex.quote(text)
1088
+
1089
+
1090
+ def _unknown_group_hints(run: Run, group, redactor: Redactor) -> list[str]:
1091
+ refs = ledger.attempt_refs(run)
1092
+ by_ref = {ref: attempt for attempt in _all_attempts(run) for ref in [refs[id(attempt)]]}
1093
+ seen: list[str] = []
1094
+ for ref in group.attempts:
1095
+ attempt = by_ref.get(ref)
1096
+ if attempt is None:
1097
+ continue
1098
+ for call in attempt.tool_calls:
1099
+ cwd = call.cwd
1100
+ if isinstance(cwd, str) and cwd.startswith("/") and cwd not in seen:
1101
+ seen.append(cwd)
1102
+ return [
1103
+ f" hint: an attempt ran in {redactor.names.path(cwd)}" for cwd in seen[:_MAX_HINTS]
1104
+ ]
1105
+
1106
+
1107
+ def _observation_state_lines(run: Run, grouped, observation, redactor: Redactor) -> list[str]:
1108
+ """docs/DESIGN.md 4.7 item 3 of the WORK AT RISK block."""
1109
+ if observation is not None:
1110
+ lines: list[str] = []
1111
+ checked = [group for group in observation.groups if group.checked]
1112
+ if checked:
1113
+ states = [obs.state for group in checked for obs in group.paths.values()]
1114
+ dirty = states.count("DIRTY_NOW")
1115
+ lines.append(
1116
+ f" {dirty} {_plural(dirty, 'path')} dirty now, "
1117
+ f"{states.count('MISSING_NOW')} missing, {states.count('CLEAN_NOW')} clean"
1118
+ )
1119
+ reasons: list[str] = []
1120
+ for group in observation.groups:
1121
+ if not group.checked and not group.refused and group.reason not in reasons:
1122
+ reasons.append(group.reason or "not checked")
1123
+ for reason in reasons:
1124
+ lines.append(f" repo state not checked ({redactor(reason)})")
1125
+ return lines
1126
+ if not any(not group.refused and group.paths for group in grouped.repos.values()):
1127
+ return []
1128
+ if _ended_recently(run, _now()):
1129
+ return [" repo state: run with --observe"]
1130
+ return [" historical: repo state not checked (run older than 72h)"]
1131
+
1132
+
1133
+ def _manual_command_text(repo: str, redactor: Redactor) -> str:
1134
+ """The manual `run yourself: git ... status --short` command for one repository, in the
1135
+ spelling the level allows (docs/DESIGN.md 4.7 item 4; REPO STATE reuses it, 4.9)."""
1136
+ shown = redactor.names.path(repo)
1137
+ if redactor.level == "share":
1138
+ return f"run yourself: git status --short in {shown}; --raw prints the exact command"
1139
+ return f"run yourself: git -C {_shell_arg(shown, redactor.level)} status --short"
1140
+
1141
+
1142
+ def _manual_command_lines(run: Run, grouped, redactor: Redactor, observation=None) -> list[str]:
1143
+ """docs/DESIGN.md 4.7 item 4: a refused group runs no git, so the block ends
1144
+ with the manual command; so does (N5 fix 1, m3) every observed group that was not
1145
+ checked or holds unobserved paths, whatever the reason, so the person is always
1146
+ told to look."""
1147
+ needs: set[str] = {repo for repo, group in grouped.repos.items() if group.refused}
1148
+ if observation is not None:
1149
+ needs.update(
1150
+ group.repo for group in observation.groups
1151
+ if group.repo is not None and (not group.checked or group.unobserved_paths)
1152
+ )
1153
+ lines: list[str] = []
1154
+ for repo in grouped.repos:
1155
+ if repo not in needs:
1156
+ continue
1157
+ lines.append(" " + _manual_command_text(repo, redactor))
1158
+ unknown = grouped.unknown
1159
+ if unknown.attempts or unknown.paths:
1160
+ lines.append(" run yourself: git status --short in the repository the agent worked in")
1161
+ if redactor.level != "share":
1162
+ lines.extend(_unknown_group_hints(run, unknown, redactor))
1163
+ return lines
1164
+
1165
+
1166
+ @dataclass(frozen=True)
1167
+ class _RiskCounts:
1168
+ attempts: int
1169
+ paths: frozenset[str]
1170
+ partial: bool
1171
+ commit_ids: tuple[str, ...]
1172
+ commit_commands_without_id: int
1173
+
1174
+
1175
+ def _work_at_risk_counts(run: Run, population) -> _RiskCounts:
1176
+ """docs/DESIGN.md 4.7: the one definition of the WORK AT RISK count line's numbers (attempt
1177
+ count, ledger paths of effect observed, claimed_target or attempted, the partial flag, commit
1178
+ ids, commit commands without an id). The run report and both scan reasons (runtime kills and
1179
+ rule (c)) read it, so the three can never disagree."""
1180
+ paths: set[str] = set()
1181
+ commit_ids: list[str] = []
1182
+ for attempt in population:
1183
+ if attempt.ledger is not None:
1184
+ paths.update(
1185
+ entry.path for entry in attempt.ledger.entries
1186
+ if entry.path and entry.effect in ("observed", "claimed_target", "attempted")
1187
+ )
1188
+ facts = attempt.facts
1189
+ if facts is not None and isinstance(facts.commits, list):
1190
+ commit_ids.extend(c for c in facts.commits if c not in commit_ids)
1191
+ partial = run.risk_population_completeness != "complete" or any(
1192
+ attempt.ledger is None or attempt.ledger.completeness != "complete"
1193
+ for attempt in population
1194
+ )
1195
+ return _RiskCounts(
1196
+ len(population), frozenset(paths), partial, tuple(commit_ids),
1197
+ _commit_commands_without_id(population),
1198
+ )
1199
+
1200
+
1201
+ def _work_at_risk_lines(run: Run, observation, redactor: Redactor) -> list[str]:
1202
+ """docs/DESIGN.md 4.7 (N5c-1; plan S4 and S11): WORK AT RISK from the write
1203
+ ledger and the repository groups."""
1204
+ population = ledger.risk_population(run)
1205
+ unreturned = _unreturned_subagents(run, population)
1206
+ risk = severity.work_risk(run)
1207
+ if risk == "none" and not unreturned:
1208
+ # P5: an empty population never claims a kill or a failure.
1209
+ if not population:
1210
+ return ["WORK AT RISK none: no attempt was killed, failed, or left unfinished."]
1211
+ return ["WORK AT RISK none: the attempts at risk wrote no paths."]
1212
+
1213
+ def subagent_lines() -> list[str]:
1214
+ return [
1215
+ f" {_attempt_ident(attempt, run, redactor)} {return_reason_phrase(attempt)}"
1216
+ for attempt in unreturned
1217
+ ]
1218
+
1219
+ if not population and run.risk_population_completeness == "complete":
1220
+ k = len(unreturned)
1221
+ verb = "it" if k == 1 else "they"
1222
+ breakdown = breakdown_text(return_breakdown(unreturned))
1223
+ return [
1224
+ f"WORK AT RISK unknown: {k} {_plural(k, 'subagent')} {_have(k)} no established "
1225
+ f"return ({breakdown}); the tool cannot say what {verb} wrote.",
1226
+ *subagent_lines(),
1227
+ ]
1228
+ if not population:
1229
+ return [
1230
+ "WORK AT RISK unknown: the tool could not establish which attempts were at risk: "
1231
+ f"{_run_reason_words(run, redactor)}."
1232
+ ]
1233
+
1234
+ grouped = ledger.group_by_repo(run)
1235
+ counts = _work_at_risk_counts(run, population)
1236
+ n, p, c = counts.attempts, len(counts.paths), len(counts.commit_ids)
1237
+ partial, k = counts.partial, counts.commit_commands_without_id
1238
+ first = (
1239
+ f"WORK AT RISK {n} {_plural(n, 'attempt')} at risk: {p} {_plural(p, 'path')} named, "
1240
+ f"{c} {_plural(c, 'commit')} reported"
1241
+ )
1242
+ if k:
1243
+ first += f" ({k} {_plural(k, 'call')} with a commit command reported no id)"
1244
+ first += "; the tool cannot see all of what was written." if partial else "."
1245
+ lines = [first]
1246
+ for attempt in population:
1247
+ lines.append(
1248
+ f" {_attempt_ident(attempt, run, redactor)} {'; '.join(_attempt_clauses(attempt))}"
1249
+ )
1250
+ if run.risk_population_completeness != "complete":
1251
+ lines.append(
1252
+ " the list of attempts at risk may be incomplete: "
1253
+ f"{_run_reason_words(run, redactor)}."
1254
+ )
1255
+ lines.extend(_observation_state_lines(run, grouped, observation, redactor))
1256
+ lines.extend(_manual_command_lines(run, grouped, redactor, observation))
1257
+ if unreturned:
1258
+ k = len(unreturned)
1259
+ ids = ", ".join(_attempt_ident(attempt, run, redactor) for attempt in unreturned)
1260
+ lines.append(
1261
+ f" {k} {_plural(k, 'subagent')} outside this list {_have(k)} no established "
1262
+ f"return: {ids}"
1263
+ )
1264
+ return lines
1265
+
1266
+
1267
+ def _finding_ids_by_detector(findings: list[Finding], detector: str) -> list[Finding]:
1268
+ return [f for f in findings if f.detector == detector]
1269
+
1270
+
1271
+ _ABSENT_FILE_DETECTORS = frozenset({"missing_artifact", "claim_contradiction"})
1272
+
1273
+
1274
+ def _absent_files_item(run: Run, findings: list[Finding]) -> dict | None:
1275
+ """docs/DESIGN.md 4.13.4, 4.7 item 3: one `[look]` item for however many
1276
+ shown info-severity `missing_artifact` and `claim_contradiction` findings
1277
+ exist, only while the run's own last activity is known and within 72
1278
+ hours. A rendering hint, never a severity change; no path in its text."""
1279
+ count = sum(
1280
+ 1 for finding in findings
1281
+ if finding.detector in _ABSENT_FILE_DETECTORS and finding.severity == "info"
1282
+ )
1283
+ if count == 0 or not _ended_within_72h_strict(run, _now()):
1284
+ return None
1285
+ if count == 1:
1286
+ text = "1 expected file absent now: check whether the run wrote it"
1287
+ else:
1288
+ text = f"{count} expected files absent now: check whether the run wrote them"
1289
+ return {
1290
+ "grade": "look",
1291
+ "text": text,
1292
+ "command": f"agent-postmortem run {run.run_id} --verbose",
1293
+ }
1294
+
1295
+
1296
+ def _what_to_do_items(
1297
+ run: Run, findings: list[Finding], observation, observe_given: bool,
1298
+ ) -> list[dict]:
1299
+ """docs/DESIGN.md 4.7 rule 4: deterministic, at most 3 items, each
1300
+ {'grade': 'look'|'decide', 'text': str, 'command': str|None}."""
1301
+ items: list[dict] = []
1302
+
1303
+ # docs/DESIGN.md 4.7 item 1 (N5c-1): only a named repository group that is
1304
+ # not refused is ever observed, so only one earns the suggestion.
1305
+ observable = any(
1306
+ not group.refused and group.paths for group in ledger.group_by_repo(run).repos.values()
1307
+ )
1308
+ if observable and not observe_given and _ended_recently(run, _now()):
1309
+ items.append({
1310
+ "grade": "look",
1311
+ "text": "see which paths are dirty",
1312
+ "command": f"agent-postmortem run {run.run_id} --observe",
1313
+ })
1314
+
1315
+ failed = _finding_ids_by_detector(findings, "attempt_failed")
1316
+ wait_item = None
1317
+ safe_item = None
1318
+ for finding in failed:
1319
+ if finding.severity != "major":
1320
+ continue # the key was already retried to completion; nothing to decide
1321
+ retry_now = finding.metrics.get("retry_now")
1322
+ reset_time = finding.metrics.get("reset_time")
1323
+ if wait_item is None and retry_now == "no" and reset_time:
1324
+ wait_item = {
1325
+ "grade": "decide",
1326
+ "text": f"wait until {reset_time} before relaunching",
1327
+ "command": None,
1328
+ }
1329
+ if safe_item is None and retry_now == "yes":
1330
+ cause = finding.metrics.get("cause", "unattributed")
1331
+ safe_item = {
1332
+ "grade": "decide",
1333
+ "text": f"relaunch is safe now: {_cause_phrase(cause)}",
1334
+ "command": None,
1335
+ }
1336
+ if len(items) < 3:
1337
+ if wait_item is not None:
1338
+ items.append(wait_item)
1339
+ elif safe_item is not None:
1340
+ items.append(safe_item)
1341
+
1342
+ absent_item = _absent_files_item(run, findings)
1343
+ if absent_item is not None and len(items) < 3:
1344
+ items.append(absent_item)
1345
+
1346
+ if len(items) < 3:
1347
+ # docs/DESIGN.md 4.7 rule 4c / 9 (unit M6): `run` never scans other
1348
+ # runs to learn a real r-of-R rate, so this always points at the
1349
+ # fleet-wide proposal command instead of naming this one run --
1350
+ # `rules --run <id>` still exists, just no longer what `run` prints.
1351
+ backing_rule_ids = {
1352
+ rule_id
1353
+ for finding in findings
1354
+ if finding.severity in ("major", "critical")
1355
+ for rule_id in finding.rule_ids
1356
+ }
1357
+ if backing_rule_ids:
1358
+ items.append({
1359
+ "grade": "decide",
1360
+ "text": "rules that would have prevented this",
1361
+ "command": "agent-postmortem rules propose",
1362
+ })
1363
+
1364
+ # docs/DESIGN.md 4.7, 4.13.8: no generic "see the findings hidden here"
1365
+ # item. The default view hides only info findings, and the trailing
1366
+ # "N info findings not shown (--verbose)" line is already the pointer.
1367
+ return items[:3]
1368
+
1369
+
1370
+ def _what_to_do_content(item: dict) -> str:
1371
+ grade, text, command = item["grade"], item["text"], item["command"]
1372
+ if grade == "look":
1373
+ return f"{command} {text}"
1374
+ if command:
1375
+ return f"{text}: {command}"
1376
+ return text
1377
+
1378
+
1379
+ def _what_to_do_lines(items: list[dict]) -> list[str]:
1380
+ lines = ["WHAT TO DO"]
1381
+ for index, item in enumerate(items, start=1):
1382
+ tag_field = f"[{item['grade']}]"
1383
+ lines.append(f" {index}. {tag_field:<9}{_what_to_do_content(item)}")
1384
+ return lines
1385
+
1386
+
1387
+ def _agent_end(final: Attempt | None, run: Run) -> str:
1388
+ """docs/DESIGN.md 4.7 rule 5: 'completed', 'killed', 'failed', 'stopped
1389
+ by parent', 'interrupted', 'superseded', 'no result', or 'unknown',
1390
+ derived from the final attempt's own outcome and interrupt cause.
1391
+ (v0.3, N2, 4.13.3) An attempt still open in a workflow journal (no
1392
+ 'result'/'failed' event ever closed it) shows 'no result' rather than
1393
+ the stale 'completed' _compute_outcome falls back to for a transcript
1394
+ that simply has not (yet) ended in the kill sentinel."""
1395
+ if final is None:
1396
+ return "unknown"
1397
+ if final.return_state is not None:
1398
+ # G1 (v0.3.1): a session subagent ends as its effective end says; a proven state is
1399
+ # never changed by the modification time
1400
+ end = effective_end(final)
1401
+ if end in ("open", "unknown"):
1402
+ return "possibly running" if transcript_recent(final) else "unknown"
1403
+ if end == "killed" and final.outcome == "killed":
1404
+ cause = final.interrupt.cause if final.interrupt is not None else None
1405
+ return _AGENT_END_KILLED_CAUSE.get(cause, "killed")
1406
+ return end
1407
+ if (
1408
+ final.outcome == "completed"
1409
+ and run.kind == "workflow"
1410
+ and reader.attempt_unresolved_in_journal(run.journal, final.key, final.ordinal)
1411
+ ):
1412
+ return "no result"
1413
+ if final.outcome != "killed":
1414
+ return final.outcome
1415
+ cause = final.interrupt.cause if final.interrupt is not None else None
1416
+ return _AGENT_END_KILLED_CAUSE.get(cause, "killed")
1417
+
1418
+
1419
+ _ALL_PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
1420
+
1421
+
1422
+ def _agent_paths(agent) -> tuple[int | None, bool]:
1423
+ """(count, partial) for the AGENT table's PATHS column (docs/DESIGN.md 4.7 rule 5, 4.13.8): the
1424
+ distinct ledger paths of effect observed, claimed_target, or attempted over every attempt of
1425
+ this logical agent, plus `facts.paths_written`, each normalized path once. `partial` is true
1426
+ when any attempt's ledger is partial, a missing transcript included (the count is then a
1427
+ lower bound); the count is None when no attempt has a ledger and no attempt's facts could be
1428
+ established at all."""
1429
+ known = False
1430
+ partial = False
1431
+ paths: set[str] = set()
1432
+ for attempt in agent.attempts:
1433
+ built = attempt.ledger
1434
+ if built is not None:
1435
+ # a ledger with no transcript (N8 fix 1, S1) names nothing, but it makes the count a
1436
+ # lower bound all the same; only a ledger built from a transcript establishes it
1437
+ if "transcript_missing" not in built.reasons:
1438
+ known = True
1439
+ if built.completeness == "partial":
1440
+ partial = True
1441
+ if attempt.facts is not None and isinstance(attempt.facts.paths_written, list):
1442
+ known = True
1443
+ paths.update(ledger.attempt_ledger_paths(
1444
+ attempt, _ALL_PATH_EFFECTS, include_facts_written=True,
1445
+ ))
1446
+ return (len(paths) if known else None), partial
1447
+
1448
+
1449
+ def _agent_facts_totals(agent) -> tuple[str, int]:
1450
+ """(paths display, commit count) for the AGENT table's PATHS/COMMITS
1451
+ columns: PATHS is `_agent_paths` (`N`, `N+` for a partial ledger, `?` when
1452
+ nothing could be established, docs/DESIGN.md 4.7 rule 5); COMMITS is the
1453
+ union of `commits` over every attempt of this logical agent and has no
1454
+ such marker, it simply counts what is known."""
1455
+ count, partial = _agent_paths(agent)
1456
+ commits: set[str] = set()
1457
+ for attempt in agent.attempts:
1458
+ facts = attempt.facts
1459
+ if facts is not None and isinstance(facts.commits, list):
1460
+ commits.update(facts.commits)
1461
+ if count is None:
1462
+ return "?", len(commits)
1463
+ return f"{count}{'+' if partial else ''}", len(commits)
1464
+
1465
+
1466
+ def _commit_commands_without_id(attempts) -> int:
1467
+ """FA6: the sum of `commit_commands_without_id` over attempts whose facts say a
1468
+ number (an unknown or missing fact adds nothing: the lower-bound mark is for a
1469
+ call the tool SAW contain a commit command and report no id)."""
1470
+ total = 0
1471
+ for attempt in attempts:
1472
+ facts = attempt.facts
1473
+ value = getattr(facts, "commit_commands_without_id", 0) if facts is not None else 0
1474
+ if isinstance(value, int) and not isinstance(value, bool):
1475
+ total += value
1476
+ return total
1477
+
1478
+
1479
+ def _commits_words(known: int, without_id: int) -> str:
1480
+ """docs/DESIGN.md 4.7 (G4b): a commit count in a short clause, by the COMMITS cell's rule:
1481
+ `<c>+ commits` when some id is known and a call with a commit command reported none,
1482
+ `commit count unknown` when no id is known at all."""
1483
+ if without_id > 0:
1484
+ return f"{known}+ commits" if known else "commit count unknown"
1485
+ return f"{known} {_plural(known, 'commit')}"
1486
+
1487
+
1488
+ def _reported_commits(known: int, without_id: int) -> str:
1489
+ """docs/DESIGN.md 4.7 (G4b): the commit part of the STORIES facts clause."""
1490
+ if without_id > 0 and not known:
1491
+ return "commit count unknown (a commit command reported no id)"
1492
+ return f"reported {_commits_words(known, without_id)}"
1493
+
1494
+
1495
+ def _commits_display(agent, commits_count: int) -> str:
1496
+ """The AGENT table's COMMITS cell: the count of known commit ids; when any attempt of the
1497
+ agent has a completed call that contains a commit command and reported no id (a commit may
1498
+ have happened, docs/DESIGN.md 4.7, FA6, G4): `?` when no id is known, `N+` otherwise."""
1499
+ if _commit_commands_without_id(agent.attempts) > 0:
1500
+ return "?" if commits_count == 0 else f"{commits_count}+"
1501
+ return str(commits_count)
1502
+
1503
+
1504
+ def _runtime_owned_kill(attempt) -> bool:
1505
+ """docs/DESIGN.md 4.13.8: an attempt's interrupt is a kill the runtime
1506
+ may own only when its cause is neither `parent_stop` nor `human` (that is,
1507
+ `runtime` or `unattributed`). The AGENT table's KILLED column is the only
1508
+ per-agent kill count; the account sentences split causes themselves."""
1509
+ interrupt = attempt.interrupt
1510
+ return interrupt is not None and interrupt.cause not in ("parent_stop", "human")
1511
+
1512
+
1513
+ _MAX_LABEL = 40
1514
+ _MAX_PHASE = 22
1515
+ _LOWER_BOUND_LEGEND = "+ is a lower bound: incomplete evidence may hide more paths or commits."
1516
+ _COMMIT_UNKNOWN_LEGEND = (
1517
+ "COMMITS ? a completed shell call contained a commit command but reported no commit id"
1518
+ )
1519
+
1520
+
1521
+ def _truncate_shown(text: str, limit: int) -> str:
1522
+ """docs/DESIGN.md 4.7 (P3): a shown label longer than `limit` keeps its first
1523
+ `limit - 3` characters and ends `...`."""
1524
+ return text if len(text) <= limit else text[: limit - 3] + "..."
1525
+
1526
+
1527
+ def _agent_table(run: Run, redactor: Redactor) -> list[str]:
1528
+ """docs/DESIGN.md 4.7 rule 5: AGENT, [PHASE], ATTEMPTS, END, KILLED,
1529
+ PATHS, COMMITS, WASTED. PHASE is kept only when at least one logical
1530
+ agent of this run actually carries one (a session's subagents never
1531
+ do)."""
1532
+ show_phase = any(agent.phase for agent in run.agents)
1533
+ # docs/DESIGN.md 4.7 (field milestone F, P3): the shown label and phase are truncated, and
1534
+ # each column is as wide as its longest shown value plus two separator spaces (floors 10
1535
+ # and 11, today's widths, which the share labels never exceed).
1536
+ # The names layer has already replaced any path in a name (and stripped control characters),
1537
+ # so truncation can never cut a path in half; the assembled row is still scrubbed whole
1538
+ # below. Agent by agent, label then phase: the order the paths were numbered in before.
1539
+ labels: list[str] = []
1540
+ phases: list[str] = []
1541
+ for agent in run.agents:
1542
+ labels.append(_truncate_shown(redactor.names.agent(agent.label), _MAX_LABEL))
1543
+ phases.append(_truncate_shown(redactor.names.phase(agent.phase), _MAX_PHASE))
1544
+ label_width = max([10, *(len(label) + 2 for label in labels)])
1545
+ phase_width = max([11, *(len(phase) + 2 for phase in phases)])
1546
+ header = f"{'AGENT':<{label_width}}"
1547
+ if show_phase:
1548
+ header += f"{'PHASE':<{phase_width}}"
1549
+ # docs/DESIGN.md 4.7 (G9, v0.3.1): a RESUMES column only when some subagent was resumed
1550
+ show_resumes = run.kind == "session" and any(a.resumes > 0 for a in run.attempts)
1551
+ header += f"{'ATTEMPTS':<10}"
1552
+ if show_resumes:
1553
+ header += f"{'RESUMES':<9}"
1554
+ header += f"{'END':<19}{'KILLED':<8}{'PATHS':<7}{'COMMITS':<9}WASTED"
1555
+ lines = [header]
1556
+ lower_bound = False
1557
+ commit_unknown = False
1558
+
1559
+ for agent, label, phase in zip(run.agents, labels, phases):
1560
+ attempts = sorted(agent.attempts, key=lambda a: a.ordinal)
1561
+ final = attempts[-1] if attempts else None
1562
+ wasted_seconds = sum(attempt_seconds(a) for a in wasted_attempts(attempts))
1563
+ killed = sum(1 for a in attempts if _runtime_owned_kill(a))
1564
+ paths_display, commits_count = _agent_facts_totals(agent)
1565
+ commits_display = _commits_display(agent, commits_count)
1566
+ lower_bound = lower_bound or paths_display.endswith("+") or commits_display.endswith("+")
1567
+ commit_unknown = commit_unknown or commits_display == "?"
1568
+
1569
+ row = f"{label:<{label_width}}"
1570
+ if show_phase:
1571
+ row += f"{phase:<{phase_width}}"
1572
+ row += f"{len(attempts):<10}"
1573
+ if show_resumes:
1574
+ resumes = sum(a.resumes for a in attempts)
1575
+ row += f"{resumes if resumes else '':<9}"
1576
+ row += (
1577
+ f"{_agent_end(final, run):<19}{killed:<8}"
1578
+ f"{paths_display:<7}{commits_display:<9}{format_duration(wasted_seconds)}"
1579
+ )
1580
+ lines.append(redactor.scrub_local_line(row))
1581
+ if lower_bound: # P4
1582
+ lines.append(_LOWER_BOUND_LEGEND)
1583
+ if commit_unknown: # G4
1584
+ lines.append(_COMMIT_UNKNOWN_LEGEND)
1585
+ return lines
1586
+
1587
+
1588
+ _REFUSED_WORDS = (
1589
+ "its ledger is partial: incomplete evidence may hide more writes, so the tool does not "
1590
+ "run status for it"
1591
+ )
1592
+
1593
+
1594
+ def _repo_state_section(observation, redactor: Redactor) -> list[str]:
1595
+ """docs/DESIGN.md 4.9 (N5c-1): per group `repository <repo> checked: ...`,
1596
+ then per observed path its state, later write yes/no, and the ordinals of the
1597
+ attempts that wrote it, and per unobserved path `not observed`. Only ever
1598
+ called when the caller actually ran --observe (observation is not None)."""
1599
+ if not observation.groups: # P8: the scope, on one line
1600
+ return ["REPO STATE (no repository holds a path at risk)"]
1601
+ lines = ["REPO STATE"]
1602
+ for group in observation.groups:
1603
+ repo = redactor.names.path(group.repo) if group.repo is not None else "unknown"
1604
+ if group.checked:
1605
+ lines.append(f" repository {repo} checked: yes")
1606
+ elif group.refused and group.repo is not None:
1607
+ # P7: say why, then the manual command (the JSON reason code stays `refused`)
1608
+ lines.append(f" repository {repo} checked: no ({_REFUSED_WORDS})")
1609
+ lines.append(" " + _manual_command_text(group.repo, redactor))
1610
+ else:
1611
+ reason = redactor(group.reason) if group.reason else "not checked"
1612
+ lines.append(f" repository {repo} checked: no ({reason})")
1613
+ for path, path_observation in group.paths.items():
1614
+ later = "yes" if path_observation.later_write_observed else "no"
1615
+ ordinals = ", ".join(str(entry["ordinal"]) for entry in path_observation.attempts)
1616
+ lines.append(
1617
+ f" {redactor.names.path(path)} {path_observation.state} "
1618
+ f"later write: {later} attempts: {ordinals}"
1619
+ )
1620
+ for path in group.unobserved_paths:
1621
+ lines.append(f" {redactor.names.path(path)} not observed")
1622
+ return lines
1623
+
1624
+
1625
+ def _finding_title(finding: Finding, redactor: Redactor) -> str:
1626
+ """The title of a finding as a sink prints it (docs/DESIGN.md 4.13.4, 5.1; F fix 7, W7-2): a
1627
+ title that names a path renders from its structured parts (the path rendered once, the
1628
+ tool's own words never scanned); any other title goes through `Redactor.title`."""
1629
+ if finding.title_parts is not None:
1630
+ return redactor.title_parts(finding.title_parts)
1631
+ return redactor.title(finding.title)
1632
+
1633
+
1634
+ def _findings_section(
1635
+ findings: list[Finding], redactor: Redactor, view: "EvidenceView | None" = None,
1636
+ ) -> list[str]:
1637
+ counts = {severity: 0 for severity in _SEVERITIES}
1638
+ for finding in findings:
1639
+ counts[finding.severity] = counts.get(finding.severity, 0) + 1
1640
+
1641
+ lines = [
1642
+ f"FINDINGS (critical {counts['critical']}, major {counts['major']}, "
1643
+ f"minor {counts['minor']})"
1644
+ ]
1645
+ for finding in findings:
1646
+ title = _finding_title(finding, redactor)
1647
+ lines.append(f"[{finding.severity}] {finding.detector} {title}")
1648
+ for shown in _shown_evidence(finding.evidence, view, redactor):
1649
+ lines.append(f" {shown}")
1650
+ return lines
1651
+
1652
+
1653
+ _TOOLING_STORY_PHRASES = {
1654
+ "bash_backgrounded": "Bash commands backgrounded",
1655
+ "bash_timeout": "Bash commands timed out",
1656
+ "tool_json_error": "tool input JSON errors",
1657
+ "structured_output_failure": "structured output failures",
1658
+ "codex_write_denied": "sandbox write denials",
1659
+ "codex_unavailable": "Codex call(s) could not start",
1660
+ "classifier_unavailable": "classifier outages",
1661
+ "git_cwd_mismatch": "git cwd mismatches",
1662
+ "permission_denied": "tool calls denied",
1663
+ }
1664
+
1665
+ _CLAIM_CLAUSE_PHRASES = {
1666
+ "ok_false": "ok is false",
1667
+ "ready_false": "ready is false",
1668
+ "report_path_missing": "a report_path that does not exist",
1669
+ "self_reported_blocked": "being blocked",
1670
+ }
1671
+
1672
+
1673
+ def _claim_contradiction_phrases(metrics: dict) -> list[str]:
1674
+ """The human phrases backing a claim_contradiction finding's clauses
1675
+ (docs/DESIGN.md 4.7 rule 6), rebuilt from Finding.metrics (clause ids
1676
+ plus the claims dict) rather than re-parsing the finding's own title --
1677
+ the same information the detector used to build its title in the first
1678
+ place (detectors/claim_contradiction.py's _clauses)."""
1679
+ claims = metrics.get("claims") if isinstance(metrics.get("claims"), dict) else {}
1680
+ clause_ids = metrics.get("clauses") or []
1681
+ phrases = []
1682
+ for clause_id in clause_ids:
1683
+ if clause_id == "tests_failed":
1684
+ n = claims.get("tests_failed", 0)
1685
+ phrases.append(f"{n} failed {_plural(n, 'test')}")
1686
+ elif clause_id == "suite_failed":
1687
+ n = claims.get("suite_failed", 0)
1688
+ phrases.append(f"{n} failed suite {_plural(n, 'check')}")
1689
+ elif clause_id == "blockers":
1690
+ n = claims.get("blockers_count", 0)
1691
+ phrases.append(f"{n} {_plural(n, 'blocker')}")
1692
+ elif clause_id == "self_reported_failure":
1693
+ n = metrics.get("self_reported_failure_counts", 0)
1694
+ phrases.append(f"{n} {_plural(n, 'failure count')}")
1695
+ else:
1696
+ phrases.append(_CLAIM_CLAUSE_PHRASES.get(clause_id, clause_id))
1697
+ return phrases or ["a contradiction"]
1698
+
1699
+
1700
+ def _finding_ordinal(finding: Finding, attempts_sorted: list[Attempt]) -> int:
1701
+ for attempt in attempts_sorted:
1702
+ if attempt.agent_id == finding.agent_id:
1703
+ return attempt.ordinal
1704
+ return 1
1705
+
1706
+
1707
+ def _agent_findings_pairs(
1708
+ run: Run, findings: list[Finding],
1709
+ ) -> tuple[list[tuple], list[Finding]]:
1710
+ """[(logical agent, its findings)] for every agent of the run, in
1711
+ run.agents order, plus the findings with no agent_id at all (docs/
1712
+ DESIGN.md 4.7 rule 6: these form the one 'run' story). A finding whose
1713
+ agent_id names no attempt of this run (should not happen in practice) is
1714
+ folded into the run-level list too, rather than silently dropped."""
1715
+ owner_by_agent_id: dict[str, object] = {}
1716
+ for agent in run.agents:
1717
+ for attempt in agent.attempts:
1718
+ owner_by_agent_id[attempt.agent_id] = agent
1719
+ findings_by_agent: dict[int, list[Finding]] = {id(agent): [] for agent in run.agents}
1720
+ run_level: list[Finding] = []
1721
+ for finding in findings:
1722
+ owner = owner_by_agent_id.get(finding.agent_id) if finding.agent_id else None
1723
+ if owner is None:
1724
+ run_level.append(finding)
1725
+ else:
1726
+ findings_by_agent[id(owner)].append(finding)
1727
+ pairs = [(agent, findings_by_agent[id(agent)]) for agent in run.agents]
1728
+ return pairs, run_level
1729
+
1730
+
1731
+ def _agent_story_pieces(agent, agent_findings: list[Finding]) -> list:
1732
+ """docs/DESIGN.md 4.7 rule 6: one deterministic sentence per clause type
1733
+ present, in a fixed order, joined into a paragraph, as pieces (`_sentences_pieces`).
1734
+ Not yet redacted or wrapped -- the caller does both, once, the same way for text and JSON."""
1735
+ by_detector: dict[str, list[Finding]] = {}
1736
+ for finding in agent_findings:
1737
+ by_detector.setdefault(finding.detector, []).append(finding)
1738
+
1739
+ attempts_sorted = sorted(agent.attempts, key=lambda a: a.ordinal)
1740
+ non_final = attempts_sorted[:-1]
1741
+ final = attempts_sorted[-1] if attempts_sorted else None
1742
+ mislabeled_by_agent_id = {f.agent_id: f for f in by_detector.get("mislabeled_kill", [])}
1743
+
1744
+ clauses: list = []
1745
+
1746
+ lead = None
1747
+ silent_retry_findings = by_detector.get("silent_retry", [])
1748
+ if silent_retry_findings:
1749
+ k = silent_retry_findings[0].metrics.get("attempts", len(attempts_sorted)) - 1
1750
+ k = max(k, 0)
1751
+ silences = []
1752
+ all_runtime = bool(non_final)
1753
+ for attempt in non_final:
1754
+ mf = mislabeled_by_agent_id.get(attempt.agent_id)
1755
+ if mf is not None and mf.metrics.get("cause") == "runtime":
1756
+ silence = mf.metrics.get("silence_seconds")
1757
+ silences.append(f"{int(round(silence))}s" if silence is not None else "unknown")
1758
+ else:
1759
+ all_runtime = False
1760
+ if all_runtime and silences:
1761
+ silence_text = ", ".join(silences)
1762
+ lead = f"killed {k} {_plural(k, 'time')} by the runtime ({silence_text} of silence)"
1763
+ else:
1764
+ lead = f"restarted {k} {_plural(k, 'time')}"
1765
+
1766
+ parent_stop = False
1767
+ if final is not None:
1768
+ mf = mislabeled_by_agent_id.get(final.agent_id)
1769
+ parent_stop = mf is not None and mf.metrics.get("cause") == "parent_stop"
1770
+
1771
+ if lead is not None and parent_stop:
1772
+ clauses.append(f"{lead}, then stopped by the parent")
1773
+ elif lead is not None:
1774
+ clauses.append(lead)
1775
+ elif parent_stop:
1776
+ clauses.append("stopped by the parent")
1777
+
1778
+ for finding in by_detector.get("attempt_failed", []):
1779
+ ordinal = _finding_ordinal(finding, attempts_sorted)
1780
+ phrase = _cause_phrase(finding.metrics.get("cause", "unattributed"))
1781
+ reset_time = finding.metrics.get("reset_time")
1782
+ text = f"attempt {ordinal} failed: {phrase}"
1783
+ if reset_time:
1784
+ text += f", resets {reset_time}"
1785
+ clauses.append(text)
1786
+
1787
+ for finding in by_detector.get("claim_contradiction", []):
1788
+ ordinal = _finding_ordinal(finding, attempts_sorted)
1789
+ phrases = _claim_contradiction_phrases(finding.metrics)
1790
+ clauses.append(f"attempt {ordinal} completed but reported {'; '.join(phrases)}")
1791
+
1792
+ _paths_display, commits_count = _agent_facts_totals(agent)
1793
+ path_count, paths_partial = _agent_paths(agent)
1794
+ facts_known = path_count is not None
1795
+ paths_count = path_count or 0
1796
+ was_killed = final is not None and final.outcome == "killed"
1797
+ without_id = _commit_commands_without_id(agent.attempts)
1798
+ if paths_count or commits_count or without_id:
1799
+ # a partial ledger makes the count a lower bound (docs/DESIGN.md 4.13.8)
1800
+ paths_word = (
1801
+ f"{paths_count}+ paths" if paths_partial
1802
+ else f"{paths_count} {_plural(paths_count, 'path')}"
1803
+ )
1804
+ clauses.append(f"Wrote {paths_word}, {_reported_commits(commits_count, without_id)}.")
1805
+ elif facts_known and was_killed and not paths_partial:
1806
+ clauses.append("Wrote no paths.")
1807
+
1808
+ tooling_bits = []
1809
+ for detector_id, phrase in _TOOLING_STORY_PHRASES.items():
1810
+ for finding in by_detector.get(detector_id, []):
1811
+ count = finding.metrics.get("count", 1)
1812
+ if detector_id == "permission_denied":
1813
+ phrase = f"tool {_plural(count, 'call')} denied"
1814
+ elif detector_id == "codex_unavailable":
1815
+ phrase = f"Codex {_plural(count, 'call')} could not start"
1816
+ tooling_bits.append(f"{count} {phrase}")
1817
+ if tooling_bits:
1818
+ clauses.append("; ".join(tooling_bits))
1819
+
1820
+ if not clauses and agent_findings:
1821
+ clauses.append(agent_findings[0].title_parts or agent_findings[0].title)
1822
+
1823
+ return _sentences_pieces(clauses)
1824
+
1825
+
1826
+ def _sentences(clauses: list[str]) -> str:
1827
+ """Join clauses as sentences: each starts with a capital and the whole
1828
+ ends with one period. Finding titles start lowercase by convention
1829
+ (docs/DESIGN.md 4.3), so this is where a story gets its capitals."""
1830
+ parts = [clause.rstrip(".") for clause in clauses if clause]
1831
+ text = ". ".join(c[:1].upper() + c[1:] for c in parts)
1832
+ return f"{text}." if text else ""
1833
+
1834
+
1835
+ def _sentences_pieces(clauses: list) -> list:
1836
+ """`_sentences` as pieces (F fix 7, W7-2): a clause is a string or the `TitleParts` of a
1837
+ finding title that names a path. With no `TitleParts` the result is the one string
1838
+ `_sentences` returns (or no piece for no text). With one, the template is capitalized and
1839
+ stripped of a final period like any clause, and the pieces are the strings around it (merged)
1840
+ and the `TitleParts` themselves, so `_render_pieces` can render the path once and never scan
1841
+ the tool's own words."""
1842
+ if not any(isinstance(clause, TitleParts) for clause in clauses):
1843
+ text = _sentences(clauses)
1844
+ return [text] if text else []
1845
+ parts: list = []
1846
+ for clause in clauses:
1847
+ if not clause:
1848
+ continue
1849
+ if isinstance(clause, TitleParts):
1850
+ head, placeholder, tail = clause.template.partition("{path}")
1851
+ template = head + placeholder + tail.rstrip(".")
1852
+ parts.append(TitleParts(template[:1].upper() + template[1:], clause.path))
1853
+ else:
1854
+ text = clause.rstrip(".")
1855
+ parts.append(text[:1].upper() + text[1:])
1856
+ pieces: list = []
1857
+ for index, part in enumerate(parts):
1858
+ for piece in ((". ",) if index else ()) + (part,):
1859
+ if isinstance(piece, str) and pieces and isinstance(pieces[-1], str):
1860
+ pieces[-1] += piece
1861
+ else:
1862
+ pieces.append(piece)
1863
+ if pieces and isinstance(pieces[-1], str):
1864
+ pieces[-1] += "."
1865
+ else:
1866
+ pieces.append(".")
1867
+ return pieces
1868
+
1869
+
1870
+ def _pieces_raw(pieces: list) -> str:
1871
+ """The pieces with the real path (the raw text of a story)."""
1872
+ return "".join(piece.raw() if isinstance(piece, TitleParts) else piece for piece in pieces)
1873
+
1874
+
1875
+ def _render_pieces(pieces: list, redactor: Redactor) -> str:
1876
+ """The pieces as a sink prints them: a string through `Redactor.title` (labels, paths and the
1877
+ rest of the scanner), a `TitleParts` through `Redactor.title_parts` (its path rendered once,
1878
+ its words never scanned)."""
1879
+ return "".join(
1880
+ redactor.title_parts(piece) if isinstance(piece, TitleParts) else redactor.title(piece)
1881
+ for piece in pieces
1882
+ )
1883
+
1884
+
1885
+ def _story_records(run: Run, findings: list[Finding], verbose: bool) -> list[dict]:
1886
+ """docs/DESIGN.md 4.7 rule 6: one record per logical agent that earns a
1887
+ story (at least one non-info finding, or --verbose with any finding at
1888
+ all), worst severity first then by (pre-pseudonym) label, plus one
1889
+ trailing 'run' record for findings with no agent_id. Each record is
1890
+ {'agent': label_or_"run", 'severity': str, 'text': str,
1891
+ 'finding_ids': [...], 'findings': [...]} -- 'findings' is
1892
+ report-internal (kept for evidence lookups) and is stripped before this
1893
+ goes anywhere public (text rendering, JSON export)."""
1894
+ pairs, run_level = _agent_findings_pairs(run, findings)
1895
+
1896
+ entries = []
1897
+ for agent, agent_findings in pairs:
1898
+ if not agent_findings:
1899
+ continue
1900
+ non_info = [f for f in agent_findings if f.severity != "info"]
1901
+ if not non_info and not verbose:
1902
+ continue
1903
+ relevant = agent_findings if verbose else non_info
1904
+ rank = min(_SEVERITIES.index(f.severity) for f in relevant)
1905
+ pieces = _agent_story_pieces(agent, relevant)
1906
+ entries.append({
1907
+ "agent": agent.label,
1908
+ "severity": _SEVERITIES[rank],
1909
+ "text": _pieces_raw(pieces),
1910
+ "pieces": pieces,
1911
+ "finding_ids": [f.finding_id for f in relevant],
1912
+ "findings": relevant,
1913
+ "_rank": rank,
1914
+ })
1915
+ # Worst severity first; ties broken by wasted time (biggest loss first),
1916
+ # then by the agent's position in the run, which is the order the AGENT
1917
+ # table and the agent-N pseudonyms follow. Sorting by the raw label would
1918
+ # look random once the redactor has replaced it.
1919
+ position = {agent.label: index for index, agent in enumerate(run.agents)}
1920
+
1921
+ def wasted(entry: dict) -> float:
1922
+ return sum(
1923
+ (f.metrics.get("wasted_seconds", 0) or 0)
1924
+ for f in entry["findings"] if f.detector == "silent_retry"
1925
+ )
1926
+
1927
+ entries.sort(
1928
+ key=lambda entry: (entry["_rank"], -wasted(entry), position.get(entry["agent"], 1 << 30))
1929
+ )
1930
+ for entry in entries:
1931
+ del entry["_rank"]
1932
+
1933
+ run_relevant = [f for f in run_level if verbose or f.severity != "info"]
1934
+ if run_relevant:
1935
+ rank = min(_SEVERITIES.index(f.severity) for f in run_relevant)
1936
+ pieces = _sentences_pieces([f.title_parts or f.title for f in run_relevant])
1937
+ entries.append({
1938
+ "agent": "run",
1939
+ "severity": _SEVERITIES[rank],
1940
+ "text": _pieces_raw(pieces),
1941
+ "pieces": pieces,
1942
+ "finding_ids": [f.finding_id for f in run_relevant],
1943
+ "findings": run_relevant,
1944
+ })
1945
+
1946
+ return entries
1947
+
1948
+
1949
+ def _wrap_story(label: str, text: str) -> list[str]:
1950
+ """Story text wrapped at 78 columns, at most 2 lines (docs/DESIGN.md 4.7
1951
+ rule 8), the first line prefixed by the (already pseudonymized/redacted)
1952
+ label, continuation lines indented to the same column."""
1953
+ indent = " " * (len(label) + 2)
1954
+ width = max(78 - len(indent), 20)
1955
+ wrapped = textwrap.wrap(text, width=width) or [text]
1956
+ wrapped = wrapped[:2]
1957
+ lines = [f" {label} {wrapped[0]}"]
1958
+ for extra in wrapped[1:]:
1959
+ lines.append(f"{indent}{extra}")
1960
+ return lines
1961
+
1962
+
1963
+ def _stories_section(
1964
+ run: Run, findings: list[Finding], redactor: Redactor, verbose: bool, evidence: bool,
1965
+ view: "EvidenceView | None" = None,
1966
+ ) -> list[str]:
1967
+ entries = _story_records(run, findings, verbose)
1968
+ lines = [f"STORIES ({len(entries)}) details: --verbose evidence: --evidence"]
1969
+ if evidence and view is not None and view.note() is not None:
1970
+ lines.append(f" {view.note()}")
1971
+ for entry in entries:
1972
+ label = "run" if entry["agent"] == "run" else redactor.names.agent(entry["agent"])
1973
+ text = _render_pieces(entry["pieces"], redactor)
1974
+ lines.extend(_wrap_story(label, text))
1975
+ if evidence:
1976
+ story_lines: list[str] = []
1977
+ for finding in entry["findings"]:
1978
+ for line in finding.evidence:
1979
+ if len(story_lines) >= 2:
1980
+ break
1981
+ text = view.line(line) if view is not None else redactor(line)
1982
+ if text is not None and (redactor.level == "raw" or text not in story_lines):
1983
+ story_lines.append(text)
1984
+ if len(story_lines) >= 2:
1985
+ break
1986
+ lines.extend(f" {text}" for text in story_lines)
1987
+ return lines, entries
1988
+
1989
+
1990
+ def _silenced_detectors_line(silenced_detectors: frozenset) -> str | None:
1991
+ """docs/DESIGN.md 4.7/4.10: the last line of `run`'s and `scan`'s text
1992
+ output, printed whenever [detectors].off silences at least one detector
1993
+ -- a config-level fact, independent of whether that detector actually
1994
+ fired on this particular run or scan."""
1995
+ if not silenced_detectors:
1996
+ return None
1997
+ ids = ", ".join(sorted(silenced_detectors))
1998
+ n = len(silenced_detectors)
1999
+ return f"{n} {_plural(n, 'detector')} silenced by config: {ids}"
2000
+
2001
+
2002
+ def render_run_text(
2003
+ run: Run,
2004
+ findings: list[Finding],
2005
+ redactor: Redactor,
2006
+ observation=None,
2007
+ verbose: bool = False,
2008
+ evidence: bool = False,
2009
+ show_age: bool = True,
2010
+ silenced_detectors: frozenset = frozenset(),
2011
+ ) -> str:
2012
+ """docs/DESIGN.md 4.7 (rewritten by unit M3a): header, account, WORK AT
2013
+ RISK, WHAT TO DO, AGENT table, [REPO STATE], STORIES, then either the
2014
+ hidden-info-count line or (--verbose) the full v0.1 FINDINGS list, then
2015
+ (unit M3c, 4.10) a last line naming every detector [detectors].off
2016
+ silenced, when there is at least one.
2017
+ show_age=False drops the header's "(N days ago)", which depends on the
2018
+ wall clock; `demo` uses it so README's byte-compared demo block stays
2019
+ stable from one day to the next.
2020
+
2021
+ `silenced_detectors` (docs/DESIGN.md 4.6/4.7): findings from these
2022
+ detector ids are dropped before anything else in this function looks at
2023
+ `findings` -- the header's verdict, the account sentences, WORK AT RISK,
2024
+ WHAT TO DO, the AGENT table's story-adjacent totals, STORIES, and the
2025
+ --verbose FINDINGS list all see only the shown (non-silenced) findings,
2026
+ exactly like a caller that filtered before calling ever existed. The
2027
+ caller (config.partition_silenced) still has the full list for --json."""
2028
+ findings = [f for f in findings if f.detector not in silenced_detectors]
2029
+
2030
+ _register_names(run, redactor, detailed=True)
2031
+ _register_observation_paths(observation, redactor, run)
2032
+ view = EvidenceView(run, redactor, show_excerpts=evidence)
2033
+
2034
+ info_count = sum(1 for finding in findings if finding.severity == "info")
2035
+ hidden_count = 0 if verbose else info_count
2036
+
2037
+ lines = [_run_header_line(run, findings, show_age)]
2038
+ lines.extend(redactor(sentence) for sentence in _account_lines(run, findings))
2039
+ lines.append("")
2040
+ lines.extend(redactor(line) for line in _work_at_risk_lines(run, observation, redactor))
2041
+ lines.append("")
2042
+ items = _what_to_do_items(run, findings, observation, observation is not None)
2043
+ if items: # docs/DESIGN.md 4.7: the heading is never printed empty
2044
+ lines.extend(redactor(line) for line in _what_to_do_lines(items))
2045
+ lines.append("")
2046
+ lines.extend(_agent_table(run, redactor))
2047
+ lines.append("")
2048
+ # docs/DESIGN.md 4.9 rule 10: the REPO STATE block appears only when the
2049
+ # caller actually ran --observe; without it, text output carries no
2050
+ # trace of observation at all.
2051
+ if observation is not None:
2052
+ lines.extend(_repo_state_section(observation, redactor))
2053
+ lines.append("")
2054
+ stories_lines, _entries = _stories_section(run, findings, redactor, verbose, evidence, view)
2055
+ lines.extend(stories_lines)
2056
+
2057
+ if verbose:
2058
+ lines.append("")
2059
+ lines.extend(_findings_section(findings, redactor, view))
2060
+ elif hidden_count:
2061
+ lines.append("")
2062
+ hidden_word = _plural(hidden_count, "finding")
2063
+ lines.append(f"{hidden_count} info {hidden_word} not shown (--verbose)")
2064
+
2065
+ silenced_line = _silenced_detectors_line(silenced_detectors)
2066
+ if silenced_line is not None:
2067
+ lines.append(silenced_line)
2068
+
2069
+ return "\n".join(lines).rstrip("\n") + "\n"
2070
+
2071
+
2072
+ # --- unit M3b: scan triage (NEEDS ATTENTION is the default; --all keeps ---
2073
+ # --- the row-per-run list above/below unchanged) ---------------------------
2074
+ #
2075
+ # A run needing attention is ranked, not merely listed: possibly-live runs
2076
+ # first, then runs whose killed/failed attempts left the most paths behind,
2077
+ # then by verdict, then by wasted time, then by recency. Every function here
2078
+ # is pure (no filesystem access): the CLI stats a run's own files once and
2079
+ # hands the result in as `newest_mtime`, the same "inject the clock" pattern
2080
+ # _now()/_age_string already use for the wall clock.
2081
+
2082
+ _VERDICT_RANK = {"FAILED": 0, "DEGRADED": 1, "CLEAN": 2}
2083
+
2084
+
2085
+ def _verdict_bare(run: Run, findings: list[Finding]) -> str:
2086
+ """verdict_of() without its trailing ' (INCOMPLETE)' suffix, for a
2087
+ ranking or a candidate rule that only cares about FAILED/DEGRADED/CLEAN
2088
+ itself, never whether the run's own inspection was complete."""
2089
+ return verdict_of(run, findings).split(" ", 1)[0]
2090
+
2091
+
2092
+ def is_possibly_live(
2093
+ run: Run, newest_mtime: float | None, now: datetime, quiet_seconds: float,
2094
+ ) -> bool:
2095
+ """unit M3b / docs/DESIGN.md 7.2's UNKNOWN_QUIET_SECONDS: the best a cold
2096
+ `scan` can say about liveness without hook state or a live task list --
2097
+ a run that has not finished and was touched inside the quiet window
2098
+ might still be running. **(fixed after cross-model review, N1N2 review
2099
+ finding 3)** This also applies to a session: a session's own
2100
+ Run.finished is always None (never anything the `run.finished` check
2101
+ below rejects, since v0.3 N2, D11), and its own newest mtime is already
2102
+ computed correctly by the caller (cli._run_files_for_liveness handles
2103
+ both kinds); a workflow scan already known to have finished is the only
2104
+ thing this function still rules out by `run.finished` alone. `newest_
2105
+ mtime` is None when no file could be statted at all (already gone by the
2106
+ time this runs), which is never "recently touched"."""
2107
+ if run.finished:
2108
+ return False
2109
+ if newest_mtime is None:
2110
+ return False
2111
+ return (now.timestamp() - newest_mtime) < quiet_seconds
2112
+
2113
+
2114
+ def scan_age_string(run: Run, now: datetime, live: bool) -> str:
2115
+ """The triage age column and the JSON `age` field: 'possibly live' when
2116
+ live, else the abbreviated '<N>d/h/m ago' built from ended_at, or
2117
+ 'unknown' when ended_at itself is unknown (never None -- age is always a
2118
+ str)."""
2119
+ if live:
2120
+ return "possibly live"
2121
+ if run.ended_at is None:
2122
+ return "unknown"
2123
+ return _age_string(run.ended_at, now, short=True)
2124
+
2125
+
2126
+ def scan_paths_at_risk(run: Run) -> int:
2127
+ """The JSON `paths_at_risk` field and the ranking key's own path count
2128
+ (docs/DESIGN.md 4.13.8): the distinct ledger paths over the run's 4.13.1
2129
+ risk population (a completed attempt in a failed generation counts)."""
2130
+ paths, _complete = ledger.risk_paths(run)
2131
+ return len(paths)
2132
+
2133
+
2134
+ def scan_paths_at_risk_complete(run: Run) -> bool:
2135
+ """The JSON `paths_at_risk_complete` field: true only when the risk
2136
+ population and every ledger in it are complete; `paths_at_risk` is a lower
2137
+ bound otherwise."""
2138
+ _paths, complete = ledger.risk_paths(run)
2139
+ return complete
2140
+
2141
+
2142
+ def unfinished_state(run: Run, live: bool) -> str | None:
2143
+ """docs/DESIGN.md 5.3b (v0.3, N2): 'possibly running' | 'no terminal
2144
+ record' | 'failed' | 'stopped' | 'unknown' | None for a workflow row;
2145
+ 'possibly running' | None for a session row. 'abandoned' is removed:
2146
+ derived only from the lifecycle, never from per-key journal state, so a
2147
+ run whose journal happens to record a per-key 'failed' event no longer
2148
+ shows up here at all once the run's own lifecycle resolved cleanly
2149
+ (journal_resolved) -- that per-key story belongs to attempt_failed, not
2150
+ this field.
2151
+
2152
+ `live` is unit M3b's own hook-free 'possibly live' heuristic (a run not
2153
+ yet Run.finished whose files were touched inside the quiet window); it
2154
+ still takes priority when true, for both kinds, since it is strictly
2155
+ fresher evidence than a workflow's own (necessarily somewhat stale,
2156
+ lifecycle is built once at load time) activity field."""
2157
+ if live:
2158
+ return "possibly running"
2159
+ if run.kind != "workflow":
2160
+ return None
2161
+ lifecycle = run.lifecycle
2162
+ if lifecycle is None:
2163
+ return None
2164
+ if lifecycle.terminal in ("completed", "journal_resolved"):
2165
+ return None
2166
+ if lifecycle.terminal in ("failed", "stopped", "unknown"):
2167
+ return lifecycle.terminal
2168
+ # lifecycle.terminal == "none"
2169
+ return "possibly running" if lifecycle.activity == "recent" else "no terminal record"
2170
+
2171
+
2172
+ def is_attention_candidate(run: Run, findings: list[Finding], now: datetime, live: bool) -> bool:
2173
+ """unit M3b rule 1, rules (b) to (d) (docs/DESIGN.md 4.7; field milestone F, FB2): rule
2174
+ (a), "possibly live", alone no longer makes a candidate; such a run is listed under LIVE
2175
+ NOW instead, and is a candidate only through (b) to (d). `live` stays in the signature
2176
+ because the caller computes it once for the row, the ranking key, and the LIVE NOW group."""
2177
+ del live
2178
+ verdict = _verdict_bare(run, findings)
2179
+ if verdict == "FAILED": # (d) verdict FAILED regardless of age
2180
+ return True
2181
+ if _ended_within_72h_strict(run, now):
2182
+ if verdict == "DEGRADED": # (b) ended recently, FAILED already handled above
2183
+ return True
2184
+ paths, _commits = _risk_paths_and_commits(run)
2185
+ if paths: # (c) ended recently, a killed/failed/superseded attempt wrote a path
2186
+ return True
2187
+ return False
2188
+
2189
+
2190
+ def _scan_top_detector(findings: list[Finding]) -> str | None:
2191
+ """The same rule cli._top_detector applies (docs/DESIGN.md 4.7's unit
2192
+ M1d: an info-only finding, present on every run, is never the "top"
2193
+ detector), duplicated here rather than imported so this module never
2194
+ depends on cli.py."""
2195
+ counts: dict[str, int] = {}
2196
+ for finding in findings:
2197
+ if finding.severity not in ("major", "critical"):
2198
+ continue
2199
+ counts[finding.detector] = counts.get(finding.detector, 0) + 1
2200
+ if not counts:
2201
+ return None
2202
+ return max(counts, key=lambda detector_id: counts[detector_id])
2203
+
2204
+
2205
+ def attention_reason(run: Run, findings: list[Finding]) -> str:
2206
+ """unit M3b rule 3: the one-line NEEDS ATTENTION reason, deterministic,
2207
+ first that applies."""
2208
+ failed = [f for f in findings if f.detector == "attempt_failed"]
2209
+ if failed:
2210
+ k = len(failed)
2211
+ causes: list[str] = []
2212
+ for finding in failed:
2213
+ phrase = _cause_phrase(finding.metrics.get("cause", "unattributed"))
2214
+ if phrase not in causes:
2215
+ causes.append(phrase)
2216
+ text = f"{k} {_plural(k, 'attempt')} failed: {', '.join(causes)}"
2217
+ reset_time = next(
2218
+ (f.metrics.get("reset_time") for f in failed if f.metrics.get("reset_time")), None,
2219
+ )
2220
+ if reset_time:
2221
+ text += f", resets {reset_time}"
2222
+ return text
2223
+
2224
+ runtime_kills = [
2225
+ f for f in findings
2226
+ if f.detector == "mislabeled_kill" and f.metrics.get("cause") == "runtime"
2227
+ ]
2228
+ if runtime_kills:
2229
+ k = len(runtime_kills)
2230
+ counts = _work_at_risk_counts(run, ledger.risk_population(run))
2231
+ n_paths = len(counts.paths)
2232
+ # docs/DESIGN.md 4.7: the WORK AT RISK count line's numbers, `N+` when it is partial.
2233
+ paths_word = (
2234
+ f"{n_paths}+ paths" if counts.partial else f"{n_paths} {_plural(n_paths, 'path')}"
2235
+ )
2236
+ return (
2237
+ f"{k} {_plural(k, 'attempt')} killed by the runtime, "
2238
+ f"{paths_word} named, "
2239
+ f"{_commits_words(len(counts.commit_ids), counts.commit_commands_without_id)}"
2240
+ )
2241
+
2242
+ top = _scan_top_detector(findings)
2243
+ if top is not None:
2244
+ count = sum(
2245
+ 1 for f in findings if f.detector == top and f.severity in ("major", "critical")
2246
+ )
2247
+ return f"{top} x{count}"
2248
+
2249
+ if run.kind == "session":
2250
+ interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
2251
+ if interrupted:
2252
+ return f"{interrupted} {_plural(interrupted, 'subagent')} interrupted"
2253
+
2254
+ # docs/DESIGN.md 4.7 (M3b rule 3): a rule (c) candidate, same counts and wording as the
2255
+ # WORK AT RISK line of the run report.
2256
+ population = ledger.risk_population(run)
2257
+ counts = _work_at_risk_counts(run, population)
2258
+ if population and (counts.paths or ledger.risk_paths(run)[0]):
2259
+ n, n_paths = counts.attempts, len(counts.paths)
2260
+ paths_word = (
2261
+ f"{n_paths}+ paths" if counts.partial else f"{n_paths} {_plural(n_paths, 'path')}"
2262
+ )
2263
+ return (
2264
+ f"{n} {_plural(n, 'attempt')} at risk: {paths_word} named, "
2265
+ f"{_commits_words(len(counts.commit_ids), counts.commit_commands_without_id)}"
2266
+ )
2267
+
2268
+ return "clean" # defensive: a candidate has a finding, an interrupt, or a path at risk
2269
+
2270
+
2271
+ def attention_next_command(run: Run, now: datetime) -> str:
2272
+ """unit M3b rule 3's second line: --observe only when a killed or
2273
+ failed attempt is known to have written a path and the run is recent
2274
+ enough that a repo might still show it -- the same "unknown end counts
2275
+ as recent" convention _what_to_do_items already uses for this exact
2276
+ decision on the `run` report."""
2277
+ paths, _commits = _risk_paths_and_commits(run)
2278
+ if paths and _ended_recently(run, now):
2279
+ return f"agent-postmortem run {run.run_id} --observe"
2280
+ return f"agent-postmortem run {run.run_id}"
2281
+
2282
+
2283
+ def attention_sort_key(
2284
+ run: Run, findings: list[Finding], live: bool, paths_at_risk: int, now: datetime,
2285
+ ) -> tuple:
2286
+ """unit M3b rule 1's ranking key, ascending: possibly live first, then
2287
+ runs that ended within 72 hours (a rescue is only possible while the
2288
+ working tree still holds the work), then paths at risk descending, then
2289
+ verdict (FAILED, DEGRADED, CLEAN), then wasted seconds descending, then
2290
+ ended_at descending (unknown last)."""
2291
+ recent = _ended_within_72h_strict(run, now)
2292
+ verdict_rank = _VERDICT_RANK.get(_verdict_bare(run, findings), 3)
2293
+ wasted_seconds = 0.0
2294
+ for finding in findings:
2295
+ if finding.detector == "wasted_time":
2296
+ wasted_seconds = finding.metrics.get("wasted_seconds", 0.0) or 0.0
2297
+ ended_ts = _aware(run.ended_at).timestamp() if run.ended_at is not None else float("-inf")
2298
+ return (
2299
+ 0 if live else 1, 0 if recent else 1, -paths_at_risk, verdict_rank,
2300
+ -wasted_seconds, -ended_ts,
2301
+ )
2302
+
2303
+
2304
+ def _scan_triage_totals_line(label: str, totals: dict) -> str:
2305
+ """One LAST 7 DAYS / ALL TIME line (unit M3b rule 5): label padded to
2306
+ 14 columns, then the same workflow/session numbers _scan_totals_lines
2307
+ prints, condensed onto one line with format_duration instead of a
2308
+ '%.1fh' float -- wasted_hours is recovered back to seconds rather than
2309
+ threading a parallel seconds accumulator through _cmd_scan, since
2310
+ totals['workflow'] is also the JSON payload and test_cli_smoke.py's
2311
+ test_scan_totals_are_stratified_by_kind pins its exact key set."""
2312
+ workflow = totals.get("workflow", {})
2313
+ session = totals.get("session", {})
2314
+ wasted_seconds = workflow.get("wasted_hours", 0.0) * 3600.0
2315
+ return (
2316
+ f"{label:<14}{workflow.get('runs', 0)} workflow runs, "
2317
+ f"{workflow.get('runs_with_retries', 0)} with retries, "
2318
+ f"{format_duration(wasted_seconds)} wasted; "
2319
+ f"{session.get('runs', 0)} session runs, "
2320
+ f"{session.get('interrupted', 0)} subagents interrupted"
2321
+ )
2322
+
2323
+
2324
+ def render_scan_triage_text(
2325
+ shown_candidates: list[dict],
2326
+ total_candidates: int,
2327
+ total_runs: int,
2328
+ totals_7d: dict,
2329
+ totals_all: dict,
2330
+ redactor: Redactor,
2331
+ silenced_detectors: frozenset = frozenset(),
2332
+ live_now: list[dict] | None = None,
2333
+ total_live_now: int = 0,
2334
+ both_kinds: bool = True,
2335
+ ) -> str:
2336
+ """unit M3b: the new default `scan` text output. `shown_candidates` is
2337
+ already sorted and already truncated to --limit by the caller;
2338
+ `total_candidates` is the true count before truncation, so the header
2339
+ can say "(3 of 172 runs)" even when only 10 are printed. Each candidate
2340
+ dict carries 'run', 'age', 'verdict', 'reason', 'command' (attention_*
2341
+ above build these; the caller assembles the dict once per row so this
2342
+ renderer stays a pure formatter, the same division of labor
2343
+ render_run_text keeps with its own _work_at_risk_lines/_what_to_do_items
2344
+ callers)."""
2345
+ redactor.names = Pseudonyms(enabled=redactor.enabled)
2346
+ # docs/DESIGN.md 4.7 (FB2): a truncated list says so in its own header.
2347
+ truncated = len(shown_candidates) < total_candidates
2348
+ note = (
2349
+ f"; showing {len(shown_candidates)}, use --limit N or --all for more" if truncated else ""
2350
+ )
2351
+ lines = [f"NEEDS ATTENTION ({total_candidates} of {total_runs} runs{note})"]
2352
+ if not shown_candidates and not total_candidates: # P12: never a bare header
2353
+ lines.append(" nothing needs attention")
2354
+ workflow_runs = totals_all.get("workflow", {}).get("runs", 0)
2355
+ session_children = totals_all.get("session", {}).get("subagents", 0)
2356
+ if both_kinds and not workflow_runs and not session_children:
2357
+ # docs/DESIGN.md 4.8 (0.3.4): sessions with no subagent transcript and no Workflow
2358
+ # run give the tool nothing to judge; say what the scan observed (a launch whose
2359
+ # transcript is missing is not "no launch"), never more.
2360
+ lines.append(
2361
+ " this scan found no subagent transcript and no Workflow run, which is what "
2362
+ "agent-postmortem checks; agent-postmortem demo shows a sample report"
2363
+ )
2364
+ for candidate in shown_candidates:
2365
+ run = candidate["run"]
2366
+ lines.append(
2367
+ f" {run.run_id} {candidate['age']} {candidate['verdict']} "
2368
+ f"{redactor(candidate['reason'])}"
2369
+ )
2370
+ lines.append(f" -> {redactor(candidate['command'])}")
2371
+ if total_live_now:
2372
+ shown_live = live_now or []
2373
+ live_note = (
2374
+ f"; showing {len(shown_live)} of {total_live_now}, use --limit N or --all for more"
2375
+ if len(shown_live) < total_live_now else ""
2376
+ )
2377
+ lines.append(f"LIVE NOW ({total_live_now} of {total_runs} runs{live_note})")
2378
+ for entry in shown_live:
2379
+ lines.append(
2380
+ f" {entry['run'].run_id} possibly live {entry['verdict']} "
2381
+ f"{redactor(entry['command'])}"
2382
+ )
2383
+ lines.append(_scan_triage_totals_line("LAST 7 DAYS", totals_7d))
2384
+ lines.append(_scan_triage_totals_line("ALL TIME", totals_all))
2385
+ lines.append(" full list: agent-postmortem scan --all")
2386
+ silenced_line = _silenced_detectors_line(silenced_detectors)
2387
+ if silenced_line is not None:
2388
+ lines.append(silenced_line)
2389
+ return "\n".join(lines).rstrip("\n") + "\n"
2390
+
2391
+
2392
+ def _scan_row_line(row: dict, redactor: Redactor) -> str:
2393
+ # docs/DESIGN.md 4.7: id, date, project, kind, agents, attempts,
2394
+ # verdict, top detector, in that order.
2395
+ project = project_text(row.get("project", ""), redactor)
2396
+ kind = row.get("kind", "workflow")
2397
+ # docs/DESIGN.md 12's D-C: the verdict is left exactly as computed (a
2398
+ # real critical finding stays visible) with "unfinished" appended
2399
+ # after it, not substituted for it, when this run has not finished --
2400
+ # so `latest` can skip it, but a run can be both genuinely broken and
2401
+ # still in progress and the row says both.
2402
+ verdict = row.get("verdict", "")
2403
+ if row.get("finished") is False:
2404
+ verdict = f"{verdict} unfinished"
2405
+ elif row.get("finished") is None and row.get("unfinished_state") == "possibly running":
2406
+ # a session has no lifecycle; recent activity marks it as maybe still going, so `latest`
2407
+ # can skip it too (release review of 0.3.0)
2408
+ verdict = f"{verdict} possibly running"
2409
+ line = (
2410
+ f"{row.get('run_id', '')} {row.get('date', '')} {project} {kind} "
2411
+ f"{row.get('agents', 0)} agents {row.get('attempts', 0)} attempts "
2412
+ f"{verdict} {row.get('top_detector') or '-'}"
2413
+ )
2414
+ return line
2415
+
2416
+
2417
+ def _scan_totals_lines(totals: dict) -> list[str]:
2418
+ """docs/DESIGN.md 4.7: totals stratified by kind -- workflow runs, runs
2419
+ with retries, attempts, wasted hours; session runs, subagents,
2420
+ interrupted -- then findings per detector (each already counted only
2421
+ over the runs of the kinds that detector applies to, since a detector
2422
+ never fires on a run outside its own kinds)."""
2423
+ workflow = totals.get("workflow", {})
2424
+ session = totals.get("session", {})
2425
+ lines = [
2426
+ f"{workflow.get('runs', 0)} workflow runs, "
2427
+ f"{workflow.get('runs_with_retries', 0)} with retries, "
2428
+ f"{workflow.get('attempts', 0)} attempts, {workflow.get('wasted_hours', 0.0):.1f}h wasted",
2429
+ f"{session.get('runs', 0)} session runs, {session.get('subagents', 0)} subagents, "
2430
+ f"{session.get('interrupted', 0)} interrupted",
2431
+ ]
2432
+ findings_per_detector = totals.get("findings_per_detector", {})
2433
+ if findings_per_detector: # P9: a finding is not an event
2434
+ lines.append("findings per detector (one finding can cover several events):")
2435
+ for detector_id in sorted(findings_per_detector):
2436
+ lines.append(f" {detector_id}: {findings_per_detector[detector_id]}")
2437
+ return lines
2438
+
2439
+
2440
+ def render_scan_text(
2441
+ rows: list[dict], totals: dict, redactor: Redactor, silenced_detectors: frozenset = frozenset(),
2442
+ ) -> str:
2443
+ # docs/DESIGN.md 5.1: every public renderer entry point gets a fresh
2444
+ # Pseudonyms map, even one like this that never prints a per-agent
2445
+ # name (scan lists runs, not agents) -- a stale mapping left over from
2446
+ # an earlier render must never leak into this one.
2447
+ redactor.names = Pseudonyms(enabled=redactor.enabled)
2448
+ lines = [_scan_row_line(row, redactor) for row in rows]
2449
+ lines.append("")
2450
+ lines.extend(_scan_totals_lines(totals))
2451
+ silenced_line = _silenced_detectors_line(silenced_detectors)
2452
+ if silenced_line is not None:
2453
+ lines.append(silenced_line)
2454
+ return "\n".join(lines).rstrip("\n") + "\n"
2455
+
2456
+
2457
+ def _iso(value: datetime | None) -> str | None:
2458
+ return value.isoformat() if value is not None else None
2459
+
2460
+
2461
+ def _key_digest(key: str | None) -> str | None:
2462
+ """sha256(key)[:8]: an opaque, one-way stand-in for a journal key
2463
+ (docs/DESIGN.md 5.3). Unconditional on the redactor: a workflow key
2464
+ often embeds the attempt's own label verbatim (e.g. "v2:silent-alpha"),
2465
+ so the export never carries the raw key, --no-redact or not."""
2466
+ if key is None:
2467
+ return None
2468
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
2469
+
2470
+
2471
+ def _export_ledger(attempt: Attempt, redactor: Redactor, view: "EvidenceView") -> dict:
2472
+ """docs/DESIGN.md 5.3b: `{completeness, reasons, entries}` on every
2473
+ attempt. `path` and `repo` are null when the entry never named one
2474
+ (never the string "UNKNOWN"), else `path-N` at share and the P19 answer
2475
+ at local; `line` per the `line` convention. An attempt the reader never
2476
+ gave a ledger (a hand-built one) is partial for the one reason that says
2477
+ no ledger could be built."""
2478
+ ledger = attempt.ledger
2479
+ if ledger is None:
2480
+ return {"completeness": "partial", "reasons": ["transcript_missing"], "entries": []}
2481
+
2482
+ def shown(value: str | None) -> str | None:
2483
+ return redactor.names.path(value) if isinstance(value, str) and value else None
2484
+
2485
+ return {
2486
+ "completeness": ledger.completeness,
2487
+ "reasons": list(ledger.reasons),
2488
+ "entries": [
2489
+ {
2490
+ "effect": entry.effect,
2491
+ "basis": entry.basis,
2492
+ "completion": entry.completion,
2493
+ "path": shown(entry.path),
2494
+ "repo": shown(entry.repo),
2495
+ "line": view.ledger_line(entry.line),
2496
+ }
2497
+ for entry in ledger.entries
2498
+ ],
2499
+ }
2500
+
2501
+
2502
+ def _export_attempt(attempt: Attempt, redactor: Redactor, view: "EvidenceView") -> dict:
2503
+ """docs/DESIGN.md 5.3's Attempt allowlist. Calling redactor.names here
2504
+ (rather than the plain redactor()) both returns the pseudonym and
2505
+ registers it, so a finding title or evidence line rendered afterward
2506
+ with the same redactor picks up the same substitution via apply()."""
2507
+ interrupt = None
2508
+ if attempt.interrupt is not None:
2509
+ interrupt = {
2510
+ "cause": attempt.interrupt.cause,
2511
+ "silence_seconds": attempt.interrupt.silence_seconds,
2512
+ }
2513
+ agent_type = redactor.names.type(attempt.agent_type) if attempt.agent_type else None
2514
+ # docs/DESIGN.md 5.3: reported is not a structured name field (it never
2515
+ # embeds a label the way a title does), so it goes through the plain
2516
+ # Redactor via redact_value -- structural redaction only, same as an
2517
+ # evidence line or a metrics value. This is what redacts a report_path
2518
+ # claim (a path) without a pseudonym ever touching it.
2519
+ shown_reported = attempt.reported
2520
+ if isinstance(shown_reported, dict) and isinstance(shown_reported.get("keys"), list):
2521
+ # docs/DESIGN.md 5.3 (G-B1, GB-8): result keys are user-authored identifiers; at share
2522
+ # they print as key-N, in order of first appearance within this render.
2523
+ shown_reported = {
2524
+ **shown_reported, "keys": [redactor.names.key(key) for key in shown_reported["keys"]],
2525
+ }
2526
+ if isinstance(shown_reported, dict) and isinstance(shown_reported.get("claims"), dict):
2527
+ # DESIGN 4.13.5 (M9): the result's own commit list prints as commit-N at share
2528
+ shown_reported = {
2529
+ **shown_reported, "claims": number_claim_commits(shown_reported["claims"], redactor),
2530
+ }
2531
+ reported = redact_value(shown_reported, redactor) if shown_reported is not None else None
2532
+ # docs/DESIGN.md 5.3's v0.2 reader changes: facts is exported, tool_calls
2533
+ # is not (a command is private, never leaves the tool). facts holds
2534
+ # paths (paths_written, in_flight[].path, cwds) that go through the same
2535
+ # plain Redactor as any other path, exactly like `reported` above -- not
2536
+ # a structured name field, so never through Pseudonyms. Built from
2537
+ # AttemptFacts' own fields directly, the same way `interrupt` above is
2538
+ # built from Interrupt's fields, never through its private complete-dict
2539
+ # method (that form is for tests only -- report.py never calls it).
2540
+ facts = None
2541
+ if attempt.facts is not None:
2542
+ facts = redact_value(
2543
+ {
2544
+ "paths_written": attempt.facts.paths_written,
2545
+ "in_flight": attempt.facts.in_flight,
2546
+ "commits": (
2547
+ [redactor.names.commit(c) if isinstance(c, str) else c
2548
+ for c in attempt.facts.commits]
2549
+ if isinstance(attempt.facts.commits, list) else attempt.facts.commits
2550
+ ),
2551
+ "commit_commands_without_id": attempt.facts.commit_commands_without_id,
2552
+ "cwds": attempt.facts.cwds,
2553
+ "background_unfinished": attempt.facts.background_unfinished,
2554
+ "last_tool": attempt.facts.last_tool,
2555
+ "seconds_since_last_tool_at_end": attempt.facts.seconds_since_last_tool_at_end,
2556
+ },
2557
+ redactor,
2558
+ )
2559
+ return {
2560
+ "agent_id": identity_value("agent", attempt.agent_id, redactor),
2561
+ "key": _key_digest(attempt.key),
2562
+ "label": redactor.names.agent(attempt.label),
2563
+ "phase": redactor.names.phase(attempt.phase),
2564
+ "agent_type": agent_type,
2565
+ "ordinal": attempt.ordinal,
2566
+ # docs/DESIGN.md 5.3b (E1): a session subagent's exported outcome is its effective end
2567
+ "outcome": effective_end(attempt),
2568
+ "return_state": attempt.return_state,
2569
+ "return_reason": attempt.return_reason,
2570
+ "resumes": attempt.resumes,
2571
+ "first_ts": _iso(attempt.first_ts),
2572
+ "last_ts": _iso(attempt.last_ts),
2573
+ "usage": attempt.usage,
2574
+ "event_count": len(attempt.events),
2575
+ "interrupt": interrupt,
2576
+ "reported": reported,
2577
+ "facts": facts,
2578
+ "ledger": _export_ledger(attempt, redactor, view),
2579
+ }
2580
+
2581
+
2582
+ def _export_finding(
2583
+ finding: Finding, redactor: Redactor, silenced_detectors: frozenset,
2584
+ view: "EvidenceView | None" = None,
2585
+ ) -> dict:
2586
+ """docs/DESIGN.md 5.3: 'all fields, evidence redacted'. title goes
2587
+ through redactor.title() (structural redact() plus pseudonym
2588
+ substitution: a title routinely embeds a label verbatim, since a
2589
+ detector formats it straight into a sentence). evidence and metrics go
2590
+ through the plain redactor only -- structural redact(), never a
2591
+ pseudonym substitution: docs/DESIGN.md 5.2 keeps evidence (and a
2592
+ metrics value that can carry raw text of its own, e.g.
2593
+ bash_backgrounded's commands) a faithful quote of transcript text.
2594
+ rule_ids (unit M1d) carries no user text, so it needs no redaction, the
2595
+ same as rule_id.
2596
+
2597
+ `silenced` (unit M3c, docs/DESIGN.md 4.10): true when this finding's
2598
+ detector is named in [detectors].off in the user's config file. A
2599
+ silenced finding is never dropped from --json -- only from text
2600
+ rendering and hook notification -- so this bool is what lets a JSON
2601
+ consumer tell the two apart.
2602
+
2603
+ Schema 3 (N5a-1, docs/DESIGN.md 5.3b): `agent_id` is the alias, and
2604
+ `evidence` is the text renderer's evidence view (pointers with aliased
2605
+ file names at share, excerpts only at local and raw with --evidence)."""
2606
+ return {
2607
+ "detector": finding.detector,
2608
+ "severity": finding.severity,
2609
+ "title": _finding_title(finding, redactor),
2610
+ "run_id": finding.run_id,
2611
+ "agent_id": identity_value("agent", finding.agent_id, redactor),
2612
+ "evidence": _shown_evidence(finding.evidence, view, redactor),
2613
+ "metrics": redact_value(
2614
+ _metrics_with_numbered_commits(finding.metrics, redactor), redactor,
2615
+ ),
2616
+ "rule_id": finding.rule_id,
2617
+ "rule_ids": list(finding.rule_ids),
2618
+ "finding_id": finding.finding_id,
2619
+ "silenced": finding.detector in silenced_detectors,
2620
+ }
2621
+
2622
+
2623
+ def _metrics_with_numbered_commits(metrics, redactor: Redactor):
2624
+ """DESIGN 4.13.5 (M9): a finding's `metrics.claims` is a copy of a result's claims, so its
2625
+ `commits` list is numbered like the attempt's own `reported.claims.commits`."""
2626
+ if isinstance(metrics, dict) and isinstance(metrics.get("claims"), dict):
2627
+ return {**metrics, "claims": number_claim_commits(metrics["claims"], redactor)}
2628
+ return metrics
2629
+
2630
+
2631
+ def export_observation_groups(
2632
+ observation, redactor: Redactor, *, omit_empty_unknown: bool = False,
2633
+ ) -> list[dict]:
2634
+ """docs/DESIGN.md 5.3b (N5c-1, plan S6): the group objects, exactly `group`
2635
+ (the index in this list), `repo`, `refused`, `checked`, `reason`, `paths`, and
2636
+ `unobserved_paths`. `repo`, every `paths` key, and every `unobserved_paths`
2637
+ element use the path namespace of the level; `repo_root`, `head`, and
2638
+ `git_version` are internal and never exported. `omit_empty_unknown` leaves the
2639
+ unknown group out when it names no path (rescue plan, S7)."""
2640
+ exported: list[dict] = []
2641
+ for group in observation.groups:
2642
+ if omit_empty_unknown and group.repo is None and not group.unobserved_paths:
2643
+ continue
2644
+ paths = {}
2645
+ for path, path_observation in group.paths.items():
2646
+ paths[redactor.names.path(path)] = {
2647
+ "state": path_observation.state,
2648
+ "reason": (
2649
+ redactor(path_observation.reason)
2650
+ if path_observation.reason is not None else None
2651
+ ),
2652
+ "later_write_observed": path_observation.later_write_observed,
2653
+ "attempts": [
2654
+ {
2655
+ **attempt,
2656
+ "agent_id": (
2657
+ attempt["agent_id"] if attempt.get("agent_id") == "main"
2658
+ else identity_value("agent", attempt.get("agent_id"), redactor)
2659
+ ),
2660
+ }
2661
+ for attempt in path_observation.attempts
2662
+ ],
2663
+ }
2664
+ exported.append({
2665
+ "group": len(exported),
2666
+ "repo": redactor.names.path(group.repo) if group.repo is not None else None,
2667
+ "refused": group.refused,
2668
+ "checked": group.checked,
2669
+ "reason": redactor(group.reason) if group.reason is not None else None,
2670
+ "paths": paths,
2671
+ "unobserved_paths": [redactor.names.path(path) for path in group.unobserved_paths],
2672
+ })
2673
+ return exported
2674
+
2675
+
2676
+ def _export_observation(observation, redactor: Redactor) -> dict:
2677
+ """docs/DESIGN.md 5.3b: `observation` is `{groups: [...]}`."""
2678
+ return {"groups": export_observation_groups(observation, redactor)}
2679
+
2680
+
2681
+ def _diagnostic_for_export(diagnostic: str, redactor: Redactor) -> str:
2682
+ """One Run.diagnostics entry for the JSON export (N3b P10b, D-h). Only a
2683
+ raw sink (the redactor is off) asks for the raw form, and it does so
2684
+ before the redactor sees the text; every other level gets the diagnostic's
2685
+ own plain string, which never carries a journal key."""
2686
+ if not redactor.enabled:
2687
+ return redactor(raw_diagnostic(diagnostic))
2688
+ return redactor(str(diagnostic))
2689
+
2690
+
2691
+ def _export_lifecycle(run: Run) -> dict | None:
2692
+ """docs/DESIGN.md 5.3b: the four public Lifecycle fields; null for a
2693
+ session (no lifecycle model to report, D11)."""
2694
+ lifecycle = run.lifecycle
2695
+ if lifecycle is None:
2696
+ return None
2697
+ return {
2698
+ "terminal": lifecycle.terminal,
2699
+ "source": lifecycle.source,
2700
+ "activity": lifecycle.activity,
2701
+ "generations": lifecycle.generations,
2702
+ }
2703
+
2704
+
2705
+ def export_run(
2706
+ run: Run,
2707
+ findings: list[Finding],
2708
+ redactor: Redactor,
2709
+ observation=None,
2710
+ silenced_detectors: frozenset = frozenset(),
2711
+ *,
2712
+ evidence: bool = False,
2713
+ ) -> dict:
2714
+ """The JSON export allowlist (docs/DESIGN.md 5.3): {run, findings}, with
2715
+ run holding exactly run_id, kind, status, claude_version, started_at,
2716
+ ended_at, counts, diagnostics, and attempts (each attempt allowlisted by
2717
+ _export_attempt). No meta, no cwd, no branch, no event list, no
2718
+ transcript text -- and this never reaches for a dataclass's own private,
2719
+ complete dict form (that form exists for tests only).
2720
+
2721
+ _register_names runs first (docs/DESIGN.md 5.1): a fresh Pseudonyms map
2722
+ registered with every attempt's label/meta description/phase/agent_type
2723
+ before either attempts or findings are exported, so a finding title
2724
+ that embeds one of those verbatim (an ordinary detector does exactly
2725
+ that) is pseudonymized correctly regardless of which findings happen to
2726
+ reference which attempt.
2727
+
2728
+ `silenced_detectors` (unit M3c, docs/DESIGN.md 4.10) never removes a
2729
+ finding from `findings` here -- every finding a silenced detector fired
2730
+ still exports, with `silenced: true` (5.3) -- callers that must not
2731
+ show a silenced finding (the text renderers, the verdict, stories,
2732
+ next) filter beforehand with config.partition_silenced() instead.
2733
+ """
2734
+ _register_names(run, redactor, detailed=True)
2735
+ _register_facts_paths(run, redactor)
2736
+ _register_ledger_paths(run, redactor)
2737
+ _register_observation_paths(observation, redactor, run)
2738
+ view = EvidenceView(run, redactor, show_excerpts=evidence)
2739
+ attempts = [_export_attempt(attempt, redactor, view) for attempt in run.attempts]
2740
+ exported_findings = [
2741
+ _export_finding(finding, redactor, silenced_detectors, view) for finding in findings
2742
+ ]
2743
+ interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
2744
+
2745
+ payload = {
2746
+ "run": {
2747
+ "run_id": run.run_id,
2748
+ "kind": run.kind,
2749
+ "status": run.status,
2750
+ "claude_version": run.claude_version,
2751
+ "started_at": _iso(run.started_at),
2752
+ "ended_at": _iso(run.ended_at),
2753
+ "counts": {
2754
+ "agents": len(run.agents),
2755
+ "attempts": len(run.attempts),
2756
+ "interrupted": interrupted,
2757
+ },
2758
+ "diagnostics": [_diagnostic_for_export(d, redactor) for d in run.diagnostics],
2759
+ "attempts": attempts,
2760
+ "lifecycle": _export_lifecycle(run),
2761
+ },
2762
+ "findings": exported_findings,
2763
+ }
2764
+ # docs/DESIGN.md 4.9 rule 10: the key is present at all only when the
2765
+ # caller actually ran --observe; without it, JSON output is unchanged.
2766
+ if observation is not None:
2767
+ payload["observation"] = _export_observation(observation, redactor)
2768
+ return payload
2769
+
2770
+
2771
+ def _export_story(entry: dict, redactor: Redactor) -> dict:
2772
+ """docs/DESIGN.md 4.7 rule 9/5.3: {agent, severity, text, finding_ids}.
2773
+ agent goes through the same Pseudonyms.agent() the AGENT table and text
2774
+ STORIES section use (the literal 'run' label for a run-level story is
2775
+ never a user-authored name and is left alone); text through
2776
+ redactor.title() (5.1/5.2: a story sentence routinely embeds a label
2777
+ verbatim, the same reason Finding.title does)."""
2778
+ agent = "run" if entry["agent"] == "run" else redactor.names.agent(entry["agent"])
2779
+ return {
2780
+ "agent": agent,
2781
+ "severity": entry["severity"],
2782
+ "text": _render_pieces(entry["pieces"], redactor),
2783
+ "finding_ids": list(entry["finding_ids"]),
2784
+ }
2785
+
2786
+
2787
+ def _export_next(item: dict, redactor: Redactor) -> dict:
2788
+ """docs/DESIGN.md 4.7 rule 9/5.3: {grade, text, command}. Neither field
2789
+ is a structured name field (a command names no user, only a run id, and
2790
+ 'text' is one of this module's own fixed phrases), so both go through
2791
+ the plain Redactor exactly like a metrics value -- structural redact()
2792
+ only."""
2793
+ return {
2794
+ "grade": item["grade"],
2795
+ "text": redactor(item["text"]),
2796
+ "command": redactor(item["command"]) if item["command"] else None,
2797
+ }
2798
+
2799
+
2800
+ SCHEMA_VERSION = 3
2801
+
2802
+
2803
+ def to_json(
2804
+ run: Run,
2805
+ findings: list[Finding],
2806
+ redactor: Redactor | None = None,
2807
+ observation=None,
2808
+ silenced_detectors: frozenset = frozenset(),
2809
+ *,
2810
+ evidence: bool = False,
2811
+ ) -> dict:
2812
+ """docs/DESIGN.md 4.7/5.3b: {schema, disclosure, run, findings, rules,
2813
+ verdict, status, stories, next}, run and findings built by export_run (5.3's
2814
+ allowlist), rules and verdict derived the same way the text report
2815
+ derives them, status duplicated at the top level for a consumer that
2816
+ does not want to dig into run. observation (v0.2, unit M2b) is forwarded
2817
+ to export_run unchanged and is absent from the payload entirely unless
2818
+ the caller passed one.
2819
+
2820
+ stories/next (unit M3a) are the same records render_run_text derives,
2821
+ exported through the allowlist of docs/DESIGN.md 4.7 rule 9/5.3 rather
2822
+ than report.py's internal, wrapped/truncated text-rendering shape:
2823
+ stories always computed with verbose=False, so an info-only agent never
2824
+ backs a JSON story either, same as the default text report.
2825
+
2826
+ `silenced_detectors` (unit M3c, docs/DESIGN.md 4.10): `findings` itself
2827
+ is exported whole -- every finding, silenced or not, each getting its
2828
+ own `silenced` bool (5.3) -- but `verdict`, `rules`, `stories`, and
2829
+ `next` are all derived from the shown (non-silenced) findings only, the
2830
+ same rule the text report follows (docs/DESIGN.md 4.6/4.7): a silenced
2831
+ detector must never flip the verdict, suggest its own rule, or narrate
2832
+ its own story, even though its raw finding is still right there in
2833
+ `findings` for a caller that wants it.
2834
+
2835
+ Defaults to redaction on (docs/DESIGN.md 5.2/5.3): a caller that wants
2836
+ raw output must pass an explicit Redactor(enabled=False), never get it
2837
+ by omission."""
2838
+ redactor = redactor or Redactor(enabled=True)
2839
+ payload = export_run(
2840
+ run, findings, redactor, observation, silenced_detectors, evidence=evidence,
2841
+ )
2842
+ payload["schema"] = SCHEMA_VERSION
2843
+ payload["disclosure"] = redactor.level
2844
+ shown = [finding for finding in findings if finding.detector not in silenced_detectors]
2845
+ payload["rules"] = [{"rule_id": rule.id, "count": count} for rule, count in rules_for(shown)]
2846
+ payload["verdict"] = verdict_of(run, shown)
2847
+ payload["status"] = run.status
2848
+
2849
+ # export_run already ran _register_names/_register_observation_paths, so
2850
+ # every pseudonym _export_story below needs is already registered.
2851
+ story_entries = _story_records(run, shown, verbose=False)
2852
+ payload["stories"] = [_export_story(entry, redactor) for entry in story_entries]
2853
+
2854
+ next_items = _what_to_do_items(run, shown, observation, observation is not None)
2855
+ payload["next"] = [_export_next(item, redactor) for item in next_items]
2856
+ return payload
2857
+
2858
+
2859
+ def _bug_title(run: Run, findings: list[Finding], redactor: Redactor) -> str:
2860
+ verdict = verdict_of(run, findings)
2861
+ summary = redactor(_summary_line_for(run))
2862
+ return f"# {redactor(run.run_id)} ({verdict}): {summary}"
2863
+
2864
+
2865
+ def _bug_environment_lines(run: Run) -> list[str]:
2866
+ return [
2867
+ "## Environment",
2868
+ f"- Claude Code version: {run.claude_version or 'unknown'}",
2869
+ f"- OS: {platform.platform()}",
2870
+ ]
2871
+
2872
+
2873
+ def _bug_what_happened_lines(run: Run, findings: list[Finding], redactor: Redactor) -> list[str]:
2874
+ counts = {severity: 0 for severity in _SEVERITIES}
2875
+ for finding in findings:
2876
+ counts[finding.severity] = counts.get(finding.severity, 0) + 1
2877
+ return [
2878
+ "## What happened",
2879
+ f"{verdict_of(run, findings)}: {redactor(_summary_line_for(run))}",
2880
+ "",
2881
+ f"- agents: {len(run.agents)}",
2882
+ f"- attempts: {len(run.attempts)}",
2883
+ f"- findings: critical {counts['critical']}, major {counts['major']}, "
2884
+ f"minor {counts['minor']}, info {counts['info']}",
2885
+ ]
2886
+
2887
+
2888
+ def _bug_project_text(run: Run, redactor: Redactor) -> str:
2889
+ """P10/P11: the project at share is project:<alias>; at local and raw the
2890
+ project directory name as it is on disk."""
2891
+ return project_text(run.project_slug, redactor)
2892
+
2893
+
2894
+ def _bug_reproduction_lines(run: Run, redactor: Redactor) -> list[str]:
2895
+ lines = [
2896
+ "## Reproduction",
2897
+ f"project {_bug_project_text(run, redactor)}, {len(run.agents)} logical agent(s):",
2898
+ ]
2899
+ for agent in run.agents:
2900
+ attempts = sorted(agent.attempts, key=lambda a: a.ordinal)
2901
+ models = sorted({attempt.agent_type for attempt in attempts if attempt.agent_type})
2902
+ pseudo_models = sorted({redactor.names.type(model) for model in models})
2903
+ model_str = ", ".join(pseudo_models) if pseudo_models else "unknown model"
2904
+ label = redactor.names.agent(agent.label)
2905
+ phase = redactor.names.phase(agent.phase)
2906
+ lines.append(redactor.scrub_local_line(
2907
+ f"- {label} (phase {phase}, {model_str}): {len(attempts)} attempt(s)"
2908
+ ))
2909
+ return lines
2910
+
2911
+
2912
+ def _bug_evidence_lines(
2913
+ run: Run, findings: list[Finding], redactor: Redactor, view: "EvidenceView | None" = None,
2914
+ evidence_flag: bool = False,
2915
+ ) -> list[str]:
2916
+ lines = ["## Evidence", f"From run {redactor(run.run_id)}:"]
2917
+ if evidence_flag and view is not None and view.note() is not None:
2918
+ lines.append(view.note())
2919
+ if not findings:
2920
+ lines.append("(no findings)")
2921
+ return lines
2922
+ for finding in findings:
2923
+ lines.append(
2924
+ f"- [{finding.severity}] {finding.detector}: {_finding_title(finding, redactor)}"
2925
+ )
2926
+ for shown in _shown_evidence(finding.evidence, view, redactor):
2927
+ lines.append(f" - {shown}")
2928
+ return lines
2929
+
2930
+
2931
+ def _bug_suggested_fixes_lines(findings: list[Finding]) -> list[str]:
2932
+ # See _suggested_rules_section: counts a finding once per rule in
2933
+ # rule_ids, not just its first rule_id, so a finding backing more than
2934
+ # one rule is never silently dropped from every rule but its first.
2935
+ lines = ["## Suggested fixes"]
2936
+ rule_ids = sorted({rule_id for finding in findings for rule_id in finding.rule_ids})
2937
+ detector_ids_without_rule = sorted(
2938
+ {
2939
+ finding.detector
2940
+ for finding in findings
2941
+ if not finding.rule_ids and finding.detector in _DETECTOR_BUG_SENTENCES
2942
+ }
2943
+ )
2944
+ if not rule_ids and not detector_ids_without_rule:
2945
+ lines.append("(no suggested fix in this build)")
2946
+ return lines
2947
+ for rule_id in rule_ids:
2948
+ rule = RULES.get(rule_id)
2949
+ if rule is None:
2950
+ continue
2951
+ count = sum(1 for finding in findings if rule_id in finding.rule_ids)
2952
+ plural = "" if count == 1 else "s"
2953
+ lines.append(f"- {rule_id} (backed by {count} finding{plural}): {rule.text}")
2954
+ for detector_id in detector_ids_without_rule:
2955
+ lines.append(f"- {detector_id}: {_DETECTOR_BUG_SENTENCES[detector_id]}")
2956
+ return lines
2957
+
2958
+
2959
+ # docs/DESIGN.md 5.2's D2, made true per level by N3b P16: the closing note
2960
+ # says what the report above actually carries.
2961
+ _BUG_REPORT_FOOTERS = {
2962
+ "share": (
2963
+ "Redacted: paths, ids, emails, account names, agent names. "
2964
+ "Evidence lines are pointers and marker codes only; no transcript excerpts are included."
2965
+ ),
2966
+ "local": (
2967
+ "Local level: real names, ids, and the project directory name are shown; "
2968
+ "paths are repo-relative or numbered. {evidence} Do not paste this report."
2969
+ ),
2970
+ "raw": (
2971
+ "Raw level: real names, ids, and paths are shown; nothing is redacted. "
2972
+ "{evidence} Do not paste this report."
2973
+ ),
2974
+ }
2975
+ _BUG_EVIDENCE_WITH = "Evidence lines include transcript excerpts (--evidence); read them first."
2976
+ _BUG_EVIDENCE_WITHOUT = (
2977
+ "Evidence lines are pointers and marker codes only; add --evidence for transcript excerpts."
2978
+ )
2979
+
2980
+
2981
+ def _bug_footer(level: str, evidence: bool) -> str:
2982
+ template = _BUG_REPORT_FOOTERS[level]
2983
+ if level == "share":
2984
+ return template
2985
+ return template.format(evidence=_BUG_EVIDENCE_WITH if evidence else _BUG_EVIDENCE_WITHOUT)
2986
+
2987
+
2988
+ def render_bug_report(
2989
+ run: Run, findings: list[Finding], redactor: Redactor, evidence: bool = False,
2990
+ ) -> str:
2991
+ """Markdown bug report: Title, Environment, What happened, Reproduction,
2992
+ Evidence, Suggested fixes, in that order (docs/DESIGN.md section 4.4),
2993
+ then a footer (5.2's D2) spelling out what this level prints. `evidence`
2994
+ is --evidence: at local and raw it adds transcript excerpts to the
2995
+ pointers, at share it earns the one note line (P16)."""
2996
+ _register_names(run, redactor, detailed=True)
2997
+ view = EvidenceView(run, redactor, show_excerpts=evidence)
2998
+ lines = [_bug_title(run, findings, redactor), ""]
2999
+ lines.extend(_bug_environment_lines(run))
3000
+ lines.append("")
3001
+ lines.extend(_bug_what_happened_lines(run, findings, redactor))
3002
+ lines.append("")
3003
+ lines.extend(_bug_reproduction_lines(run, redactor))
3004
+ lines.append("")
3005
+ lines.extend(_bug_evidence_lines(run, findings, redactor, view, evidence))
3006
+ lines.append("")
3007
+ lines.extend(_bug_suggested_fixes_lines(findings))
3008
+ lines.append("")
3009
+ lines.append(_bug_footer(redactor.level, evidence))
3010
+ return "\n".join(lines).rstrip("\n") + "\n"