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,2278 @@
1
+ """postmortem: turn a Claude Code Workflow run directory into a verdict.
2
+
3
+ Subcommands: scan (a whole fleet of runs), run (one run), rules (what to do
4
+ about the findings; propose/review are fleet-wide verbs under it), apply
5
+ (print a rule's diff against a file you maintain -- never writes it), trend
6
+ (weekly counts and before/after rates). See docs/DESIGN.md section 4.7/9 for
7
+ the full CLI contract and exit codes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import functools
15
+ import json
16
+ import math
17
+ import os
18
+ import re
19
+ import sys
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta
23
+ from importlib import resources
24
+ from pathlib import Path
25
+
26
+ from . import (
27
+ __version__,
28
+ argerrors,
29
+ config,
30
+ detectors,
31
+ disclosure,
32
+ hooks_cli,
33
+ observe,
34
+ reader,
35
+ report,
36
+ rescue,
37
+ rules,
38
+ state,
39
+ trend,
40
+ watch,
41
+ )
42
+ from .disclosure import FIRST_LINE, DisclosureContext
43
+ from .models import Finding, Run
44
+ from .redact import Redactor
45
+
46
+ # docs/DESIGN.md 8.2: the demo run ships inside the package (no second copy
47
+ # under tests/fixtures/), so `agent-postmortem demo` needs no --root and
48
+ # works from a fresh install with no real run data on disk.
49
+ #
50
+ # docs/DESIGN.md 8.2's D4: resolved through importlib.resources rather than
51
+ # Path(__file__).parent, and turned into a real directory only at the point
52
+ # of use (_demo_project_dir) -- a package unpacked in site-packages is a real
53
+ # filesystem path already, but reader.load_run's iterdir()/open() calls have
54
+ # nothing to open when the package is imported straight out of a wheel .whl
55
+ # (itself a zip). importlib.resources.as_file extracts a directory only from
56
+ # Python 3.12 on; on 3.11 such an import gets a clear message (release review
57
+ # of 0.3.0, finding 1).
58
+ # N3b S12: the demo renders with a home directory that matches no account on
59
+ # any machine, so the bare-account-name rule never rewrites a word of it.
60
+ _DEMO_HOME = Path("/zz-demo-home/zz-no-account")
61
+ # The demo is a run in the standard layout (docs/DESIGN.md 4.2, 8.2): the
62
+ # project directory holds the parent session transcript and the run's
63
+ # <session>/subagents/workflows/ tree. The project directory is materialized
64
+ # as a whole, so a zipped package still has the parent transcript next to the
65
+ # run; _DEMO_RUN_PARTS is the run's path inside it.
66
+ _DEMO_PROJECT_REF = resources.files("agent_postmortem") / "demo" / "-home-user-project"
67
+ _DEMO_RUN_PARTS = (
68
+ "00000000-0000-4000-8000-000000000000", "subagents", "workflows", "wf_00000000-d30",
69
+ )
70
+
71
+
72
+ # importlib.resources.as_file extracts a directory traversable only from Python 3.12 on.
73
+ _AS_FILE_TAKES_DIRECTORIES = sys.version_info >= (3, 12)
74
+
75
+
76
+ class DemoUnavailable(Exception):
77
+ """The packaged demo cannot be read as a directory on this interpreter."""
78
+
79
+
80
+ @contextlib.contextmanager
81
+ def _demo_project_dir(ref) -> Iterator[Path]:
82
+ """The packaged demo project directory `ref` (an importlib.resources
83
+ traversable) as a real directory (docs/DESIGN.md 8.2, D4). A package on
84
+ the filesystem, which every pip, uv, and pipx install is, is used in
85
+ place. A package imported from a zip needs importlib.resources.as_file,
86
+ which extracts a directory only from Python 3.12 on; on 3.11 the demo
87
+ says so instead of failing (the tool writes nothing itself outside its
88
+ state directory, so it does not copy the tree out on its own)."""
89
+ if isinstance(ref, Path):
90
+ yield ref
91
+ return
92
+ if not _AS_FILE_TAKES_DIRECTORIES:
93
+ raise DemoUnavailable(
94
+ "the demo needs agent-postmortem installed on disk on Python 3.11; "
95
+ "install the wheel with pip, uv, or pipx instead of importing it from the zip"
96
+ )
97
+ with resources.as_file(ref) as project:
98
+ yield project
99
+
100
+
101
+ def _default_root() -> Path:
102
+ """~/.claude/projects, overridable by CLAUDE_CONFIG_DIR -- the first
103
+ entry when it is comma-separated -- for parity with ccusage (docs/
104
+ DESIGN.md 4.8). Read fresh on every call (not a module-level constant):
105
+ _build_parser() calls this once per invocation, so a test that sets the
106
+ env var before calling main() is honored without reimporting anything.
107
+ """
108
+ config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "")
109
+ first_entry = config_dir.split(",", 1)[0].strip()
110
+ if first_entry:
111
+ return Path(first_entry).expanduser() / "projects"
112
+ return Path("~/.claude/projects").expanduser()
113
+
114
+
115
+ def _build_context(level: str, *, repo_root: str | None = None) -> DisclosureContext:
116
+ """N3b D-a: the one disclosure context a command builds, once, from its
117
+ parsed level. No alias book in Part 1a (Part 1b registers identities and
118
+ attaches one); `repo_root` stays None until a command that already made
119
+ an observation passes it (D-n)."""
120
+ return DisclosureContext(level=level, repo_root=repo_root)
121
+
122
+
123
+ def _render_guarded(
124
+ *, level: str, identities: list[tuple[str, str]], repo_root: str | None,
125
+ command: str, render,
126
+ ) -> str:
127
+ """N3b P8/P9/D-b/D-c for a one-shot text command: render the whole output
128
+ to a string with one registered book.
129
+
130
+ At share the command registers every identity it collected in one locked
131
+ registration, renders, and, if the book was available when rendering
132
+ started and degraded during it (a slow-path registration failed), renders
133
+ the whole output once more with a positional-only book built from the
134
+ same identities, so one output never mixes stable and positional aliases.
135
+ When the final book is unavailable the one-line diagnostic goes to
136
+ stderr, never stdout. local and raw render no alias, so they register
137
+ nothing and print no diagnostic. A share render with nothing collected
138
+ (a sink whose text can show no identity) registers nothing either: there
139
+ is no alias to make stable, so no state is created and no diagnostic is
140
+ printed for it.
141
+
142
+ `render(redactor)` returns the output text."""
143
+ if level != "share" or not identities:
144
+ context = DisclosureContext(
145
+ level=level, repo_root=repo_root, identities=tuple(identities),
146
+ )
147
+ return render(Redactor(context=context))
148
+
149
+ def build(book: disclosure.AliasBook) -> str:
150
+ context = DisclosureContext(
151
+ level=level, book=book, repo_root=repo_root, identities=tuple(identities),
152
+ )
153
+ return render(Redactor(context=context))
154
+
155
+ book = disclosure.register(identities)
156
+ started_available = book.available
157
+ output = build(book)
158
+ if started_available and not book.available:
159
+ book = disclosure.fallback_book(identities)
160
+ output = build(book)
161
+ if not book.available:
162
+ print(f"agent-postmortem {command}: {disclosure.UNAVAILABLE_DIAGNOSTIC}", file=sys.stderr)
163
+ return output
164
+
165
+
166
+ def _json_guarded(
167
+ *, level: str, identities: list[tuple[str, str]], repo_root: str | None,
168
+ command: str, build,
169
+ ) -> str:
170
+ """N5a-1: a versioned --json payload rendered through the same guarded
171
+ registration its text sibling uses (one locked registration at share, one
172
+ positional re-render when the book degrades mid-render, the one stderr
173
+ diagnostic), so a JSON output never mixes stable and positional aliases.
174
+ `build(redactor)` returns the payload dict; the result is the text to
175
+ print."""
176
+ return _render_guarded(
177
+ level=level, identities=identities, repo_root=repo_root, command=command,
178
+ render=lambda redactor: json.dumps(build(redactor), indent=2) + "\n",
179
+ )
180
+
181
+
182
+ def _level_of(args: argparse.Namespace) -> str:
183
+ """The parsed disclosure level of a command that takes a level flag."""
184
+ return args.level
185
+
186
+
187
+ def _add_level_flags(parser: argparse.ArgumentParser) -> None:
188
+ """N3b P12: the disclosure level flags of a command that had --no-redact
189
+ in v0.2. One mutually exclusive group; the parsed level lands in
190
+ args.level ("share" when no flag is given). --no-redact stays as an
191
+ alias of --raw."""
192
+ group = parser.add_mutually_exclusive_group()
193
+ group.add_argument(
194
+ "--local", dest="level", action="store_const", const="local",
195
+ help=(
196
+ "Print real labels and agent ids but never an absolute path, for your own "
197
+ "eyes. Do not paste the result."
198
+ ),
199
+ )
200
+ group.add_argument(
201
+ "--raw", dest="level", action="store_const", const="raw",
202
+ help="Print raw paths, ids, and names with no redaction. Do not paste the result.",
203
+ )
204
+ group.add_argument(
205
+ "--no-redact", dest="level", action="store_const", const="raw",
206
+ help="Alias of --raw.",
207
+ )
208
+ parser.set_defaults(level="share")
209
+
210
+
211
+ def _redactor_for(args: argparse.Namespace) -> Redactor:
212
+ return Redactor(context=_build_context(_level_of(args)))
213
+
214
+
215
+ class _Parser(argparse.ArgumentParser):
216
+ """An ArgumentParser whose own usage errors exit 1, not argparse's default 2.
217
+
218
+ Exit code 2 is reserved for "no runs found" (docs/DESIGN.md section 4.7);
219
+ a bad flag is a usage error and must exit 1.
220
+
221
+ N3b P13: `error_renderer` (an argerrors.ErrorRenderer) rewrites the
222
+ message before it is printed, so a usage error never echoes what the user
223
+ typed at share or local. Every nested subparser is built with the same
224
+ renderer, so `rules propose` and `rescue plan` errors render the same way
225
+ as a top-level one.
226
+ """
227
+
228
+ def __init__(self, *args, error_renderer: argerrors.ErrorRenderer | None = None, **kwargs):
229
+ super().__init__(*args, **kwargs)
230
+ self._error_renderer = error_renderer
231
+
232
+ def add_subparsers(self, **kwargs):
233
+ if self._error_renderer is not None:
234
+ kwargs.setdefault(
235
+ "parser_class",
236
+ functools.partial(_Parser, error_renderer=self._error_renderer),
237
+ )
238
+ return super().add_subparsers(**kwargs)
239
+
240
+ def error(self, message: str) -> None:
241
+ if self._error_renderer is not None:
242
+ message = self._error_renderer.render(message)
243
+ self.print_usage(sys.stderr)
244
+ self.exit(1, f"{self.prog}: error: {message}\n")
245
+
246
+
247
+ def _collect_known_words(parser: argparse.ArgumentParser, known: set[str]) -> None:
248
+ """The tool's own vocabulary: option strings, subcommand names, choices.
249
+ These are never replaced in an error message."""
250
+ for action in parser._actions:
251
+ known.update(action.option_strings)
252
+ if isinstance(action, argparse._SubParsersAction):
253
+ for name, sub in action.choices.items():
254
+ known.add(name)
255
+ _collect_known_words(sub, known)
256
+ elif action.choices:
257
+ known.update(str(choice) for choice in action.choices)
258
+
259
+
260
+ def _bounded_float(flag: str, low: float, high: float):
261
+ """docs/DESIGN.md 4.12 (release finding F11): an argparse `type=`
262
+ callable for a watch flag that must be a finite float within
263
+ [low, high]. Raising argparse.ArgumentTypeError makes _Parser.error
264
+ (above) turn an out-of-range or non-finite value into a plain usage
265
+ error -- exit 1, a message naming the flag -- rather than letting it
266
+ reach watch_run at all (a zero or negative --interval would busy-spin
267
+ or raise inside time.sleep; a huge one could defeat the 60s no-journal
268
+ limit or the --max-hours deadline)."""
269
+
270
+ def _parse(raw: str) -> float:
271
+ try:
272
+ value = float(raw)
273
+ except ValueError as exc:
274
+ raise argparse.ArgumentTypeError(f"{flag} must be a number") from exc
275
+ if not math.isfinite(value) or not (low <= value <= high):
276
+ raise argparse.ArgumentTypeError(
277
+ f"{flag} must be a finite number between {low} and {high}"
278
+ )
279
+ return value
280
+
281
+ return _parse
282
+
283
+
284
+ _RUN_ID_HELP = (
285
+ "A run id (for example wf_b0000000-002), a unique prefix of one, a session UUID, an "
286
+ "agent transcript stem (agent-<id>), an agent alias (a:<alias>, as printed by a share "
287
+ "report), or a path to a run directory. An alias or UUID that matches more than one "
288
+ "run is an error listing the candidates."
289
+ )
290
+
291
+ _SCAN_EXIT_CODES = """\
292
+ exit codes:
293
+ exit 0 every run scanned is CLEAN or DEGRADED and complete
294
+ exit 1 usage error
295
+ exit 2 no runs found under --root
296
+ exit 3 some run's verdict is FAILED (3 wins when 4 also applies)
297
+ exit 4 some run is INCOMPLETE and none is FAILED
298
+ """
299
+
300
+
301
+ # docs/DESIGN.md 4.8 ("Exit codes in --help", field milestone F P11): the epilogs below are
302
+ # the contract's text verbatim; tests/test_fc_help.py pins each one against DESIGN.
303
+ _RUN_EXIT_CODES = """\
304
+ exit codes:
305
+ exit 0 the status is complete and the verdict is CLEAN or DEGRADED
306
+ exit 1 usage error
307
+ exit 2 no such run, or not a run
308
+ exit 3 the verdict is FAILED (3 wins when 4 also applies)
309
+ exit 4 the status is INCOMPLETE and the verdict is not FAILED
310
+ """
311
+
312
+ _RESCUE_PLAN_EXIT_CODES = """\
313
+ exit codes (the first condition that applies wins):
314
+ exit 1 usage error, or a configuration error
315
+ exit 2 no such run
316
+ exit 4 the run may still be running, there is nothing to rescue yet, a finished
317
+ run's risk population is not empty or not complete, the lifecycle
318
+ terminal is unknown, or any UNKNOWN fact is in the packet
319
+ exit 0 otherwise
320
+ """
321
+
322
+ _RESCUE_STATUS_EXIT_CODES = """\
323
+ exit codes (the first condition that applies wins):
324
+ exit 1 usage error, or a configuration error
325
+ exit 2 no such run
326
+ exit 3 any row is NONZERO_OBSERVED
327
+ exit 4 the lifecycle terminal is unknown, any row is NOT_OBSERVED or
328
+ WRITE_AFTER_CHECK, the parent transcript could not be read, any
329
+ NEVER-list match, or no unit at all in a run that is not finished with
330
+ every attempt completed
331
+ exit 0 otherwise
332
+ """
333
+
334
+ _WATCH_EXIT_CODES = """\
335
+ exit codes (the first condition that applies wins):
336
+ exit 1 usage error
337
+ exit 2 no journal appeared within 60 seconds
338
+ exit 3 DONE with verdict FAILED
339
+ exit 4 LIMIT (--max-hours, or a malformed journal), or DONE with the lifecycle
340
+ terminal unknown and the verdict not FAILED
341
+ exit 0 DONE otherwise (verdict CLEAN or DEGRADED, with or without the
342
+ (INCOMPLETE) suffix)
343
+ """
344
+
345
+
346
+ def _build_parser(tokens: list[str] | None = None) -> argparse.ArgumentParser:
347
+ """`tokens` is the original argv; it fixes the level and the words a
348
+ usage error must not echo (argerrors). Without it (tests that only parse)
349
+ errors render at share with nothing to replace."""
350
+ known: set[str] = set()
351
+ renderer = argerrors.ErrorRenderer(
352
+ tokens or [], known, argerrors.scan_level(tokens or []),
353
+ )
354
+ parser = _Parser(
355
+ error_renderer=renderer,
356
+ prog="postmortem",
357
+ description=(
358
+ "Turn the run directories a Claude Code Workflow leaves behind into a "
359
+ "verdict, evidence, and the rule that would have prevented the failure."
360
+ ),
361
+ )
362
+ parser.add_argument(
363
+ "--version", action="version", version=f"agent-postmortem {__version__}",
364
+ )
365
+ sub = parser.add_subparsers(dest="command", required=True)
366
+
367
+ scan = sub.add_parser(
368
+ "scan",
369
+ help=(
370
+ "Scan every run under --root and print the runs that need attention. "
371
+ "--all prints the full one-line-per-run list instead."
372
+ ),
373
+ formatter_class=argparse.RawDescriptionHelpFormatter,
374
+ epilog=_SCAN_EXIT_CODES,
375
+ )
376
+ scan.add_argument(
377
+ "--root", type=Path, default=_default_root(),
378
+ help="Directory to search for runs (default: ~/.claude/projects).",
379
+ )
380
+ scan.add_argument(
381
+ "--since", default=None, metavar="YYYY-MM-DD",
382
+ help="Only include runs that started on or after this date.",
383
+ )
384
+ scan.add_argument(
385
+ "--kind", choices=("workflow", "session"), default=None,
386
+ help="Only include runs of this kind (default: both).",
387
+ )
388
+ scan.add_argument(
389
+ "--all", action="store_true",
390
+ help="Print one line per run, chronological, instead of the NEEDS ATTENTION triage list.",
391
+ )
392
+ scan.add_argument(
393
+ "--limit", type=int, default=10, metavar="N",
394
+ help=(
395
+ "Show at most N rows in each of the NEEDS ATTENTION and LIVE NOW lists "
396
+ "(default: 10; ignored with --all)."
397
+ ),
398
+ )
399
+ scan.add_argument(
400
+ "--here", action="store_true",
401
+ help=(
402
+ "Only include runs that worked in this directory or a parent of it (a run's "
403
+ "own transcripts name the directories it worked in)."
404
+ ),
405
+ )
406
+ scan.add_argument(
407
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
408
+ )
409
+ _add_level_flags(scan)
410
+
411
+ run = sub.add_parser(
412
+ "run", help="Diagnose one run: verdict, findings with evidence, and the next action.",
413
+ formatter_class=argparse.RawDescriptionHelpFormatter,
414
+ epilog=_RUN_EXIT_CODES,
415
+ )
416
+ run.add_argument(
417
+ "run_id", metavar="RUN_ID|PATH", nargs="?", default=None, help=_RUN_ID_HELP,
418
+ )
419
+ run.add_argument(
420
+ "--here", action="store_true",
421
+ help=(
422
+ "Diagnose the session you are working in (CLAUDE_CODE_SESSION_ID), else the most "
423
+ "recently active run that worked in this directory or a parent of it, instead of "
424
+ "naming one (give either RUN_ID or --here)."
425
+ ),
426
+ )
427
+ run.add_argument(
428
+ "--root", type=Path, default=_default_root(),
429
+ help="Directory to search when RUN_ID is not itself a path (default: ~/.claude/projects).",
430
+ )
431
+ run.add_argument(
432
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
433
+ )
434
+ _add_level_flags(run)
435
+ run.add_argument(
436
+ "--bug-report", action="store_true",
437
+ help="Render a markdown bug report instead of the default text report.",
438
+ )
439
+ run.add_argument(
440
+ "--observe", action="store_true",
441
+ help=(
442
+ "Ask git (read-only) whether the paths a killed or failed attempt wrote are "
443
+ "still dirty, clean, or missing. Off by default (docs/DESIGN.md 4.9)."
444
+ ),
445
+ )
446
+ run.add_argument(
447
+ "--verbose", action="store_true",
448
+ help=(
449
+ "Show info-severity findings and the full v0.1-style FINDINGS list, "
450
+ "including hidden agents' stories."
451
+ ),
452
+ )
453
+ run.add_argument(
454
+ "--evidence", action="store_true",
455
+ help=(
456
+ "Show up to two path:line evidence excerpts under each story (text), or "
457
+ "keep excerpts in each finding's evidence (JSON, --local and --raw only)."
458
+ ),
459
+ )
460
+
461
+ rules = sub.add_parser(
462
+ "rules", help="List suggested rules and how many findings back each one."
463
+ )
464
+ rules.add_argument(
465
+ "--run", dest="run_id", metavar="RUN_ID|PATH", default=None,
466
+ help="Limit the count to one run instead of a full scan. " + _RUN_ID_HELP,
467
+ )
468
+ rules.add_argument(
469
+ "--root", type=Path, default=_default_root(),
470
+ help="Directory to search for runs (default: ~/.claude/projects).",
471
+ )
472
+ rules.add_argument(
473
+ "--all", action="store_true",
474
+ help="List every rule in the library, not just those with findings.",
475
+ )
476
+ rules.add_argument(
477
+ "--show", metavar="RULE_ID", default=None, help="Print the full text of one rule."
478
+ )
479
+ rules.add_argument(
480
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
481
+ )
482
+ _add_level_flags(rules)
483
+
484
+ rules_sub = rules.add_subparsers(dest="rules_command", required=False)
485
+
486
+ rules_propose = rules_sub.add_parser(
487
+ "propose",
488
+ help=(
489
+ "Print, per rule backed by findings in at least 3 runs on 2 distinct "
490
+ "days, the evidence and where it belongs. Proposals only; writes nothing."
491
+ ),
492
+ )
493
+ rules_propose.add_argument(
494
+ "--root", type=Path, default=_default_root(),
495
+ help="Directory to search for runs (default: ~/.claude/projects).",
496
+ )
497
+ rules_propose.add_argument(
498
+ "--since", metavar="YYYY-MM-DD", default=None,
499
+ help="Only consider runs started on or after this date.",
500
+ )
501
+ rules_propose.add_argument(
502
+ "--run", dest="run_id", metavar="RUN_ID|PATH", default=None,
503
+ help=(
504
+ "Count only this one run and list every rule a finding in it backs "
505
+ "(cannot be combined with --since). " + _RUN_ID_HELP
506
+ ),
507
+ )
508
+ rules_propose.add_argument(
509
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
510
+ )
511
+ _add_level_flags(rules_propose)
512
+
513
+ rules_review = rules_sub.add_parser(
514
+ "review",
515
+ help=(
516
+ "Print each adopted rule's detector rate before and after adoption, "
517
+ "and list rules to consider removing. Read-only; the owner records "
518
+ "an adoption in their own config."
519
+ ),
520
+ )
521
+ rules_review.add_argument(
522
+ "--root", type=Path, default=_default_root(),
523
+ help="Directory to search for runs (default: ~/.claude/projects).",
524
+ )
525
+ rules_review.add_argument(
526
+ "--weeks", type=int, default=4,
527
+ help="Weeks before and after adoption to compare (default: 4).",
528
+ )
529
+ rules_review.add_argument(
530
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
531
+ )
532
+ _add_level_flags(rules_review)
533
+
534
+ apply_cmd = sub.add_parser(
535
+ "apply",
536
+ help=(
537
+ "Print the rule's managed block as a diff against a target file. "
538
+ "Never writes the target; a human applies the diff themselves."
539
+ ),
540
+ )
541
+ apply_cmd.add_argument(
542
+ "rule_id", metavar="RULE_ID", help="The rule to apply, for example R-RETRY-SAFE."
543
+ )
544
+ apply_cmd.add_argument(
545
+ "--target", type=Path, required=True,
546
+ help="File the rule's managed block would be added to.",
547
+ )
548
+ _add_level_flags(apply_cmd)
549
+
550
+ trend = sub.add_parser(
551
+ "trend", help="Show weekly detector counts and before/after rates for applied rules."
552
+ )
553
+ trend.add_argument(
554
+ "--root", type=Path, default=_default_root(),
555
+ help="Directory to search for runs (default: ~/.claude/projects).",
556
+ )
557
+ trend.add_argument(
558
+ "--weeks", type=int, default=8, help="Number of ISO weeks to show (default: 8)."
559
+ )
560
+ trend.add_argument(
561
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
562
+ )
563
+ _add_level_flags(trend)
564
+
565
+ demo = sub.add_parser(
566
+ "demo", help="Render the packaged synthetic demo run; needs no --root and no real run data."
567
+ )
568
+ demo.add_argument(
569
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
570
+ )
571
+
572
+ rescue = sub.add_parser(
573
+ "rescue",
574
+ help=(
575
+ "Guided rescue: a read-only decision packet (plan) and observed check "
576
+ "status (status) for a killed or failed run. Never edits, runs a check, "
577
+ "or touches git beyond what --observe already does."
578
+ ),
579
+ )
580
+ rescue_sub = rescue.add_subparsers(dest="rescue_command", required=True)
581
+
582
+ rescue_plan = rescue_sub.add_parser(
583
+ "plan",
584
+ help=(
585
+ "Print, per unit of work, the task, what it wrote, its repo state, "
586
+ "declared checks, and the steps a human or the main session should take."
587
+ ),
588
+ formatter_class=argparse.RawDescriptionHelpFormatter,
589
+ epilog=_RESCUE_PLAN_EXIT_CODES,
590
+ )
591
+ rescue_plan.add_argument("run_id", metavar="RUN_ID|PATH", help=_RUN_ID_HELP)
592
+ rescue_plan.add_argument(
593
+ "--root", type=Path, default=_default_root(),
594
+ help="Directory to search when RUN_ID is not itself a path (default: ~/.claude/projects).",
595
+ )
596
+ rescue_plan.add_argument(
597
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
598
+ )
599
+ rescue_plan.add_argument(
600
+ "--evidence", action="store_true",
601
+ help=(
602
+ "Quote the task prompt (plan) or the command excerpt (status) at --local "
603
+ "and --raw, in text and in JSON; the default level prints one note (text) "
604
+ "or no excerpt key (JSON)."
605
+ ),
606
+ )
607
+ _add_level_flags(rescue_plan)
608
+
609
+ rescue_status = rescue_sub.add_parser(
610
+ "status",
611
+ help=(
612
+ "Print, per unit of work, which declared check the main session's "
613
+ "transcript shows was run after the run ended, and what it observed."
614
+ ),
615
+ formatter_class=argparse.RawDescriptionHelpFormatter,
616
+ epilog=_RESCUE_STATUS_EXIT_CODES,
617
+ )
618
+ rescue_status.add_argument("run_id", metavar="RUN_ID|PATH", help=_RUN_ID_HELP)
619
+ rescue_status.add_argument(
620
+ "--root", type=Path, default=_default_root(),
621
+ help="Directory to search when RUN_ID is not itself a path (default: ~/.claude/projects).",
622
+ )
623
+ rescue_status.add_argument(
624
+ "--json", action="store_true", help="Print machine-readable JSON instead of text."
625
+ )
626
+ rescue_status.add_argument(
627
+ "--evidence", action="store_true",
628
+ help=(
629
+ "Quote the task prompt (plan) or the command excerpt (status) at --local "
630
+ "and --raw, in text and in JSON; the default level prints one note (text) "
631
+ "or no excerpt key (JSON)."
632
+ ),
633
+ )
634
+ _add_level_flags(rescue_status)
635
+
636
+ watch_cmd = sub.add_parser(
637
+ "watch",
638
+ help=(
639
+ "Read-only follower for one running workflow: prints START, then KILL/FAILED/OK/QUIET "
640
+ "as they happen, then DONE or LIMIT. Designed to be a Claude Code Monitor command."
641
+ ),
642
+ formatter_class=argparse.RawDescriptionHelpFormatter,
643
+ epilog=_WATCH_EXIT_CODES,
644
+ )
645
+ watch_cmd.add_argument(
646
+ "run_id", metavar="RUN_ID|PATH",
647
+ help="A run id, a unique prefix of one, or a path to a run directory.",
648
+ )
649
+ watch_cmd.add_argument(
650
+ "--root", type=Path, default=_default_root(),
651
+ help="Directory to search when RUN_ID is not itself a path (default: ~/.claude/projects).",
652
+ )
653
+ watch_cmd.add_argument(
654
+ "--interval", type=_bounded_float("--interval", 0.2, 300), default=watch.DEFAULT_INTERVAL,
655
+ help="Seconds between polls, 0.2 to 300 (default: 5).",
656
+ )
657
+ watch_cmd.add_argument(
658
+ "--max-hours", type=_bounded_float("--max-hours", 0.01, 168),
659
+ default=watch.DEFAULT_MAX_HOURS,
660
+ help=(
661
+ "Give up and exit 4 if the run has not finished after this many hours, "
662
+ "0.01 to 168 (default: 6)."
663
+ ),
664
+ )
665
+ watch_cmd.add_argument(
666
+ "--quiet-after", type=_bounded_float("--quiet-after", 10, 86400),
667
+ default=watch.DEFAULT_QUIET_AFTER,
668
+ help=(
669
+ "Seconds of no transcript activity before an open attempt is QUIET, "
670
+ "10 to 86400 (default: 300)."
671
+ ),
672
+ )
673
+ watch_cmd.add_argument(
674
+ "--done-grace", type=_bounded_float("--done-grace", 0, 3600),
675
+ default=watch.DEFAULT_DONE_GRACE,
676
+ help=(
677
+ "Seconds every key must stay resolved, with no new start, before DONE is "
678
+ "printed, 0 to 3600; survives the gap between sequential phases (default: 30)."
679
+ ),
680
+ )
681
+ watch_cmd.add_argument(
682
+ "--json", action="store_true", help="Print one JSON object per line instead of text."
683
+ )
684
+
685
+ hook = sub.add_parser(
686
+ "hook", help="Claude Code plugin hook entry point; reads hook JSON from stdin."
687
+ )
688
+ hook.add_argument(
689
+ "hook_command",
690
+ choices=("check", "session-start", "session-end", "workflow-launched", "capabilities"),
691
+ metavar="{check,session-start,session-end,workflow-launched,capabilities}",
692
+ help="Which hook event this invocation is for.",
693
+ )
694
+
695
+ _collect_known_words(parser, known)
696
+ return parser
697
+
698
+
699
+ class _AmbiguousRun(LookupError):
700
+ """A run id argument that matches more than one run. str(exc) is v0.2's
701
+ message (what raw prints); the parts let share and local build theirs from
702
+ fixed text and spans (N3b P13). `kind` is the dispatch form that matched:
703
+ "prefix", "uuid", "agent_stem", or "alias" (N6), which picks the advice."""
704
+
705
+ def __init__(
706
+ self, prefix: str, root: Path, run_ids: list[str], kind: str = "prefix",
707
+ ) -> None:
708
+ super().__init__(
709
+ f"'{prefix}' matches more than one run under {root}: {', '.join(run_ids)}"
710
+ )
711
+ self.prefix = prefix
712
+ self.root = root
713
+ self.run_ids = run_ids
714
+ self.kind = kind
715
+
716
+
717
+ class _AliasUnavailable(LookupError):
718
+ """An `a:<alias>` RUN_ID while stable aliases are unavailable on this machine
719
+ (docs/DESIGN.md 4.13.6, N6): the alias cannot be resolved, never a traceback."""
720
+
721
+
722
+ _ALIAS_FORM = re.compile(r"a:[0-9a-f]{12,64}")
723
+ _STEM_PREFIX = "agent-"
724
+
725
+
726
+ def _runs_for_stem(agent_id: str, refs: list[reader.RunRef]) -> list[reader.RunRef]:
727
+ """The runs whose loaded attempts hold this agent id (candidates, then confirmed)."""
728
+ return reader.runs_holding_agent(refs, agent_id)
729
+
730
+
731
+ def _runs_for_alias(
732
+ alias_hex: str, refs: list[reader.RunRef],
733
+ ) -> list[reader.RunRef] | None:
734
+ """The runs holding an agent whose registered alias is exactly `alias_hex`, or None
735
+ when stable aliases are unavailable (a read-only lookup, disclosure.lookup_agent_alias)."""
736
+ ids_by_ref = {ref: reader.agent_id_candidates(ref) for ref in refs}
737
+ matched = disclosure.lookup_agent_alias(alias_hex, ids_by_ref)
738
+ if matched is None:
739
+ return None
740
+ return [
741
+ ref for ref in refs if ref in matched and reader.run_holds_agent(ref, matched[ref])
742
+ ]
743
+
744
+
745
+ def _one_run(found: list[reader.RunRef], typed: str, root: Path, kind: str) -> Path:
746
+ if not found:
747
+ raise FileNotFoundError(typed)
748
+ if len(found) > 1:
749
+ raise _AmbiguousRun(typed, root, [ref.run_id for ref in found], kind)
750
+ return found[0].path
751
+
752
+
753
+ def _resolve_run_path(run_id_or_path: str, root: Path) -> Path:
754
+ """Resolve a RUN_ID|PATH argument to exactly one run path. Forms, in order
755
+ (docs/DESIGN.md 4.8, 4.13.6): an existing path, returned as-is; `a:` and 12 to 64
756
+ lowercase hex (an agent alias); a session UUID; `agent-` and an agent id (a transcript
757
+ stem); anything else is an id prefix of either kind of run. The special forms never
758
+ fall through to the prefix rule.
759
+
760
+ Raises FileNotFoundError if nothing matches, _AmbiguousRun (message lists the
761
+ candidates) if more than one run matches, and _AliasUnavailable for an alias while
762
+ stable aliases are unavailable. A direct path that exists but is not actually a run
763
+ (an arbitrary file, an unrelated directory) is returned as-is; reader.load_run raises
764
+ NotARun for it and the caller turns that into exit 2 (docs/DESIGN.md 4.2), the same
765
+ path an unrecognized RUN_ID takes.
766
+ """
767
+ direct = Path(run_id_or_path)
768
+ if direct.exists():
769
+ return direct
770
+
771
+ if _ALIAS_FORM.fullmatch(run_id_or_path):
772
+ found = _runs_for_alias(run_id_or_path[2:], reader.discover_runs(root))
773
+ if found is None:
774
+ raise _AliasUnavailable(run_id_or_path)
775
+ return _one_run(found, run_id_or_path, root, "alias")
776
+ if reader._UUID_RE.fullmatch(run_id_or_path):
777
+ sessions = reader.discover_runs(root, kinds=frozenset({"session"}))
778
+ found = [ref for ref in sessions if ref.path.stem == run_id_or_path]
779
+ return _one_run(found, run_id_or_path, root, "uuid")
780
+ if run_id_or_path.startswith(_STEM_PREFIX) and reader._AGENT_ID_RE.fullmatch(
781
+ run_id_or_path[len(_STEM_PREFIX):]
782
+ ):
783
+ found = _runs_for_stem(
784
+ run_id_or_path[len(_STEM_PREFIX):], reader.discover_runs(root),
785
+ )
786
+ return _one_run(found, run_id_or_path, root, "agent_stem")
787
+
788
+ candidates = [
789
+ ref for ref in reader.discover_runs(root) if ref.run_id.startswith(run_id_or_path)
790
+ ]
791
+ return _one_run(candidates, run_id_or_path, root, "prefix")
792
+
793
+
794
+ _RUN_EXPECTED = (
795
+ "expected a run id, a unique prefix of one, a session UUID, an agent-<id> "
796
+ "transcript stem, a:<alias>, or the path of a run directory"
797
+ )
798
+ _ALIAS_EXPECTED = (
799
+ "expected a run id, a session UUID, an agent-<id> transcript stem, or a:<alias>"
800
+ )
801
+ _PREFIX_ADVICE = "expected a longer prefix or the path of a run directory"
802
+ _EXACT_ADVICE = "expected one of these run ids or the path of a run directory"
803
+ _RUN_ID_SHAPE = re.compile(r"wf_[0-9a-f]{8}-[0-9a-f]{3}|s_[0-9a-f]{10,12}")
804
+ _HERE_TAIL = "worked in this directory or a parent of it"
805
+ # docs/DESIGN.md 4.8 (0.3.4): the first-run empty state. The same lines at every level: they
806
+ # name no path or id of the person's own.
807
+ _EMPTY_STATE_GUIDANCE = (
808
+ " agent-postmortem reads the transcripts Claude Code saves there: Workflow runs, and "
809
+ "sessions with the subagents they launched.\n"
810
+ " next: agent-postmortem demo (a sample report on a packaged synthetic run)\n"
811
+ " If Claude Code saves its data somewhere else, pass --root <dir> or set CLAUDE_CONFIG_DIR. "
812
+ "It deletes transcripts older than 30 days by default."
813
+ )
814
+ _HERE_ELSEWHERE_GUIDANCE = (
815
+ " agent-postmortem scan lists every run it found; agent-postmortem run <run-id> "
816
+ "diagnoses one."
817
+ )
818
+ _STANDARD_ROOT = Path("~/.claude/projects")
819
+
820
+
821
+ def _shown_root(args: argparse.Namespace, root: Path, errors) -> str:
822
+ """The root as an error names it (docs/DESIGN.md 4.8): at raw the path itself; at share
823
+ and local the standard location `~/.claude/projects` home-relative, since it is the same
824
+ on every machine and not private, and any other root as a span (path-N), as every path
825
+ the person typed is (N3b P13)."""
826
+ if getattr(args, "level", "share") == "raw":
827
+ return str(root)
828
+ try:
829
+ standard = Path(root).expanduser() == _STANDARD_ROOT.expanduser()
830
+ except (OSError, RuntimeError):
831
+ standard = False
832
+ return str(_STANDARD_ROOT) if standard else errors.span(str(root), "path")
833
+
834
+
835
+ def _root_has_no_runs(root: Path | None) -> bool:
836
+ """docs/DESIGN.md 4.8: nothing under the root that could be a run. Asked only on an error
837
+ path, so it globs and never parses (reader.has_run_candidates)."""
838
+ return root is not None and not reader.has_run_candidates(root)
839
+
840
+
841
+ def _no_runs_text(
842
+ args: argparse.Namespace, root: Path, unreadable_files: int = 0,
843
+ unreadable_runs: list[str] | None = None,
844
+ ) -> str:
845
+ """The empty state: where the tool looked (and whether it exists), what it reads, and
846
+ what to run next. Exit code 2 is unchanged (4.6)."""
847
+ level = getattr(args, "level", "share")
848
+ shown = _shown_root(args, root, _errors_for(args) if level != "raw" else None)
849
+ try:
850
+ missing = not Path(root).exists()
851
+ except OSError:
852
+ missing = False
853
+ tail = " (it does not exist yet)" if missing else ""
854
+ extra = ""
855
+ if unreadable_files:
856
+ extra += (
857
+ f" {unreadable_files} transcript {_plural_word(unreadable_files, 'file')} there "
858
+ f"{'had' if unreadable_files == 1 else 'have'} no readable line (not JSON, or nested "
859
+ "too deeply)\n"
860
+ )
861
+ if unreadable_runs:
862
+ extra += (
863
+ f" {len(unreadable_runs)} {_plural_word(len(unreadable_runs), 'run')} there could not "
864
+ f"be read ({_some_ids(unreadable_runs)})\n"
865
+ )
866
+ return f"postmortem: no runs found under {shown}{tail}\n{extra}{_EMPTY_STATE_GUIDANCE}"
867
+
868
+
869
+ def _resolve_or_report(
870
+ args: argparse.Namespace, root: Path,
871
+ ) -> tuple[Path | None, int | None]:
872
+ """(path, None) on success, (None, exit code) after printing the error: the one
873
+ place run, rescue plan, rescue status, and rules --run turn a RUN_ID into a path
874
+ (docs/DESIGN.md 4.8: rescue and rules resolve exactly as run does)."""
875
+ try:
876
+ return _resolve_run_path(args.run_id, root), None
877
+ except FileNotFoundError:
878
+ print(_run_error(args, "no_match", root=root), file=sys.stderr)
879
+ return None, 2
880
+ except _AmbiguousRun as exc:
881
+ print(_run_error(args, "ambiguous", exc=exc), file=sys.stderr)
882
+ return None, 1
883
+ except _AliasUnavailable:
884
+ print(_run_error(args, "alias_unavailable"), file=sys.stderr)
885
+ return None, 2
886
+
887
+
888
+ def _alias_unavailable_text(shown: str) -> str:
889
+ return (
890
+ f"postmortem: stable aliases unavailable on this machine, so '{shown}' cannot be "
891
+ f"resolved ({_ALIAS_EXPECTED})"
892
+ )
893
+
894
+
895
+ def _run_error(
896
+ args: argparse.Namespace, kind: str, *, root: Path | None = None,
897
+ exc: _AmbiguousRun | None = None, unreadable_files: int = 0,
898
+ unreadable_runs: list[str] | None = None,
899
+ ) -> str:
900
+ """The stderr text of a handler-level run error (N3b P13). At raw, v0.2's
901
+ message exactly. At share and local, fixed text around spans of what the
902
+ user supplied (path-N, value-N, uuid:<last4>, agent-N), and the kind of
903
+ argument expected; a run id listed as a candidate is not private (it is the
904
+ id `scan` prints) unless it does not have a run id's shape."""
905
+ level = getattr(args, "level", "share")
906
+ if kind in ("no_match", "here_no_match") and _root_has_no_runs(root):
907
+ kind = "no_runs" # docs/DESIGN.md 4.8: nothing under the root at all is the empty state
908
+ if kind == "no_runs":
909
+ assert root is not None
910
+ return _no_runs_text(args, root, unreadable_files, unreadable_runs or [])
911
+ if level == "raw":
912
+ return {
913
+ "no_match": lambda: f"postmortem: no run matches '{args.run_id}' under {root}",
914
+ "ambiguous": lambda: f"postmortem: {exc}",
915
+ "not_a_run": lambda: f"postmortem: '{args.run_id}' is not a run",
916
+ "alias_unavailable": lambda: _alias_unavailable_text(args.run_id),
917
+ "here_no_match": lambda: (
918
+ f"postmortem: no run under {root} {_HERE_TAIL}\n{_HERE_ELSEWHERE_GUIDANCE}"
919
+ ),
920
+ "scan_here_no_match": lambda: (
921
+ f"postmortem: no run under {root} that matches the requested filters "
922
+ f"{_HERE_TAIL}"
923
+ ),
924
+ }[kind]()
925
+ errors = _errors_for(args)
926
+ if kind == "no_match":
927
+ return (
928
+ f"postmortem: no run matches '{errors.span(args.run_id)}' under "
929
+ f"{_shown_root(args, root, errors)} ({_RUN_EXPECTED})"
930
+ )
931
+ if kind == "ambiguous":
932
+ assert exc is not None
933
+ shown_root = errors.span(str(exc.root), "path")
934
+ shown_prefix = errors.span(exc.prefix)
935
+ ids = ", ".join(
936
+ run_id if _RUN_ID_SHAPE.fullmatch(run_id) else errors.span(run_id, "value")
937
+ for run_id in exc.run_ids
938
+ )
939
+ advice = _PREFIX_ADVICE if exc.kind == "prefix" else _EXACT_ADVICE
940
+ return (
941
+ f"postmortem: '{shown_prefix}' matches more than one run under {shown_root}: "
942
+ f"{ids} ({advice})"
943
+ )
944
+ if kind == "alias_unavailable":
945
+ return _alias_unavailable_text(errors.span(args.run_id))
946
+ if kind == "here_no_match":
947
+ return (
948
+ f"postmortem: no run under {_shown_root(args, root, errors)} {_HERE_TAIL}\n"
949
+ f"{_HERE_ELSEWHERE_GUIDANCE}"
950
+ )
951
+ if kind == "scan_here_no_match":
952
+ return (
953
+ f"postmortem: no run under {_shown_root(args, root, errors)} that matches the "
954
+ f"requested filters {_HERE_TAIL}"
955
+ )
956
+ if kind == "not_a_run":
957
+ return (
958
+ f"postmortem: '{errors.span(args.run_id)}' is not a run "
959
+ "(expected a workflow run directory or a session transcript)"
960
+ )
961
+ raise ValueError(f"unknown run error kind: {kind}")
962
+
963
+
964
+ def _since_error(args: argparse.Namespace) -> str:
965
+ """The bad `--since` date message (N3b P13): the format is named, the typed
966
+ value is a span at share and local."""
967
+ level = getattr(args, "level", "share")
968
+ shown = args.since if level == "raw" else _errors_for(args).span(args.since, "value")
969
+ return f"postmortem: --since must be YYYY-MM-DD, got '{shown}'"
970
+
971
+
972
+ def _run_findings(run: Run) -> list[Finding]:
973
+ return detectors.run_detectors(run)
974
+
975
+
976
+ def _load_config_checked(redactor: Redactor) -> tuple[config.Config, int | None]:
977
+ """Load the user config (docs/DESIGN.md 4.10), printing any warnings to
978
+ stderr through the active Redactor; returns (config, exit_code) where
979
+ exit_code is None to keep going or 1 when [detectors].off named a
980
+ critical detector id -- a silenced critical would change the exit code
981
+ (docs/DESIGN.md 4.6), so the CLI refuses to run at all rather than
982
+ quietly drop the id and proceed (a hook takes the opposite path: it
983
+ just never lands in config.detectors_off, silently, since a hook must
984
+ never fail a turn -- docs/DESIGN.md 7.2)."""
985
+ cfg = config.load_config()
986
+ # each known path is rendered once and the text between the paths is scanned piece by piece;
987
+ # the finished warning is printed as it is (docs/DESIGN.md 5.1, F fix 7)
988
+ for warning in cfg.shown_warnings(redactor.display_path, redactor.scrub_leftover_paths):
989
+ print(warning, file=sys.stderr)
990
+ if cfg.critical_rejected:
991
+ ids = ", ".join(sorted(cfg.critical_rejected))
992
+ print(
993
+ redactor(
994
+ f"postmortem: [detectors].off names a critical detector ({ids}); "
995
+ "critical detectors can never be silenced"
996
+ ),
997
+ file=sys.stderr,
998
+ )
999
+ return cfg, 1
1000
+ return cfg, None
1001
+
1002
+
1003
+ def _run_files_for_liveness(run: Run) -> list[Path]:
1004
+ """Every file whose mtime counts toward unit M3b's 'possibly live' rule
1005
+ (docs/DESIGN.md 4.7's scan triage, rule 1a): for a workflow run, every
1006
+ file directly inside its wf_* directory (journal, meta, transcripts --
1007
+ the same population hooks_cli._unit_files uses for kind='workflow');
1008
+ for a session run, its own main .jsonl plus every file under its
1009
+ subagents/ directory, when one exists. Never recursive beyond that one
1010
+ level: today's layout never nests a run's own files any deeper."""
1011
+ if run.kind == "workflow":
1012
+ try:
1013
+ return [p for p in run.path.iterdir() if p.is_file()]
1014
+ except OSError:
1015
+ return []
1016
+ files = [run.path]
1017
+ subagents_dir = run.path.parent / run.path.stem / "subagents"
1018
+ if subagents_dir.is_dir():
1019
+ try:
1020
+ files.extend(p for p in subagents_dir.iterdir() if p.is_file())
1021
+ except OSError:
1022
+ pass
1023
+ return files
1024
+
1025
+
1026
+ def _newest_run_mtime(run: Run) -> float | None:
1027
+ """The newest mtime among _run_files_for_liveness(run), or None when no
1028
+ file could be statted at all (docs/DESIGN.md 4.7's scan triage rule 1a)."""
1029
+ newest: float | None = None
1030
+ for path in _run_files_for_liveness(run):
1031
+ try:
1032
+ mtime = path.stat().st_mtime
1033
+ except OSError:
1034
+ continue
1035
+ if newest is None or mtime > newest:
1036
+ newest = mtime
1037
+ return newest
1038
+
1039
+
1040
+ def _top_detector(findings: list[Finding]) -> str | None:
1041
+ """The detector with the most major-or-critical findings, or None when
1042
+ there are none (docs/DESIGN.md 4.7: a CLEAN run, by definition, has no
1043
+ major or critical finding, so scan shows '-' for it rather than
1044
+ whichever info detector -- wasted_time on every run -- happened to have
1045
+ a finding)."""
1046
+ counts: dict[str, int] = {}
1047
+ for finding in findings:
1048
+ if finding.severity not in ("major", "critical"):
1049
+ continue
1050
+ counts[finding.detector] = counts.get(finding.detector, 0) + 1
1051
+ if not counts:
1052
+ return None
1053
+ return max(counts, key=lambda detector_id: counts[detector_id])
1054
+
1055
+
1056
+ _RUN_USAGE_BOTH = "postmortem: run takes either RUN_ID or --here, not both"
1057
+ _RUN_USAGE_NEITHER = "postmortem: run needs one of RUN_ID or --here"
1058
+
1059
+
1060
+ _CALLER_SESSION_ENV = "CLAUDE_CODE_SESSION_ID"
1061
+
1062
+
1063
+ def _caller_session_id() -> str | None:
1064
+ """The caller's own session id (docs/DESIGN.md 4.13.6, field milestone F): the value of
1065
+ CLAUDE_CODE_SESSION_ID when it is a canonical UUID, lowercased, else None. A hint, not a
1066
+ security boundary: it only chooses among runs that already matched the caller's directory."""
1067
+ value = os.environ.get(_CALLER_SESSION_ENV)
1068
+ if isinstance(value, str) and reader._UUID_RE.fullmatch(value):
1069
+ return value.lower()
1070
+ return None
1071
+
1072
+
1073
+ def _activity_of(run: Run) -> datetime | None:
1074
+ return run.ended_at if run.ended_at is not None else run.started_at
1075
+
1076
+
1077
+ def _here_key(ref: reader.RunRef, run: Run) -> tuple:
1078
+ """The `--here` fallback order (docs/DESIGN.md 4.13.6), ascending: a run with an activity
1079
+ time beats one without, then the activity time, the run id, and the path."""
1080
+ activity = _activity_of(run)
1081
+ stamp = report._aware(activity).timestamp() if activity is not None else 0.0
1082
+ return (activity is not None, stamp, run.run_id, str(ref.path))
1083
+
1084
+
1085
+ @dataclass
1086
+ class _HereChoice:
1087
+ ref: reader.RunRef
1088
+ run: Run
1089
+ reason: str # "your current session" | "most recent activity"
1090
+ others: list[tuple[reader.RunRef, Run]] # every other matching run, best first
1091
+ matched: int
1092
+
1093
+
1094
+ def _pick_run_here(root: Path) -> _HereChoice | None:
1095
+ """The run `--here` shows (docs/DESIGN.md 4.8, 4.13.6): among the runs whose cwd set holds
1096
+ the caller's directory or a parent of it, (1) the session run whose transcript stem equals
1097
+ the caller's own session id, else (2) the most recent activity. None when nothing matches
1098
+ or the caller's own directory cannot be read."""
1099
+ caller = _working_directory()
1100
+ if caller is None:
1101
+ return None
1102
+ matches: list[tuple[reader.RunRef, Run]] = []
1103
+ # docs/DESIGN.md 4.13.6 (G5): the workflows of the caller's own session join the set
1104
+ owned = reader.caller_workflow_paths(root, _caller_session_id(), caller)
1105
+ unreadable: list[str] = []
1106
+ for ref in reader.discover_runs(root):
1107
+ run = _load_listed_run(ref, unreadable)
1108
+ if run is None:
1109
+ continue
1110
+ if ref.path in owned or reader.run_matches_cwd(run, caller):
1111
+ matches.append((ref, run))
1112
+ _print_left_out(unreadable)
1113
+ if not matches:
1114
+ return None
1115
+ matches.sort(key=lambda pair: _here_key(*pair), reverse=True)
1116
+ chosen = 0
1117
+ reason = "most recent activity"
1118
+ own = _caller_session_id()
1119
+ if own is not None:
1120
+ for index, (ref, run) in enumerate(matches):
1121
+ if run.kind == "session" and ref.path.stem.lower() == own:
1122
+ chosen, reason = index, "your current session"
1123
+ break
1124
+ ref, run = matches[chosen]
1125
+ return _HereChoice(ref, run, reason, matches[:chosen] + matches[chosen + 1:], len(matches))
1126
+
1127
+
1128
+ def _here_line(args: argparse.Namespace, choice: _HereChoice) -> str:
1129
+ """The one stderr line of a `run --here` that matched more than one run (docs/DESIGN.md
1130
+ 4.13.6): run ids and ages only, never a directory, a path, or a session UUID."""
1131
+ now = report._now()
1132
+ level = getattr(args, "level", "share")
1133
+
1134
+ def shown_id(run_id: str) -> str:
1135
+ if level == "raw" or _RUN_ID_SHAPE.fullmatch(run_id):
1136
+ return run_id
1137
+ return _errors_for(args).span(run_id, "value")
1138
+
1139
+ def age(run: Run) -> str:
1140
+ activity = _activity_of(run)
1141
+ return "unknown" if activity is None else report._age_string(activity, now, short=True)
1142
+
1143
+ also = ", ".join(
1144
+ f"{shown_id(run.run_id)} ({age(run)})" for _ref, run in choice.others[:3]
1145
+ )
1146
+ return (
1147
+ f"postmortem: {choice.matched} runs worked here; showing {shown_id(choice.run.run_id)} "
1148
+ f"({choice.reason}); also: {also}"
1149
+ )
1150
+
1151
+
1152
+ def _pick_run_path(
1153
+ args: argparse.Namespace, root: Path,
1154
+ ) -> tuple[Path | None, Run | None, int | None]:
1155
+ """`run`'s run path: RUN_ID resolved as every run-resolving command does, or, with
1156
+ --here, the run docs/DESIGN.md 4.13.6 selects together with the run already loaded to
1157
+ choose it (rendered as is: no second load, so a run that vanishes between choosing and
1158
+ rendering cannot fail the command)."""
1159
+ if not args.here:
1160
+ path, failure = _resolve_or_report(args, root)
1161
+ return path, None, failure
1162
+ picked = _pick_run_here(root)
1163
+ if picked is None:
1164
+ print(_run_error(args, "here_no_match", root=root), file=sys.stderr)
1165
+ return None, None, 2
1166
+ if picked.matched > 1:
1167
+ print(_here_line(args, picked), file=sys.stderr)
1168
+ return picked.ref.path, picked.run, None
1169
+
1170
+
1171
+ def _cmd_run(args: argparse.Namespace) -> int:
1172
+ if args.here and args.run_id is not None:
1173
+ print(_RUN_USAGE_BOTH, file=sys.stderr)
1174
+ return 1
1175
+ if not args.here and args.run_id is None:
1176
+ print(_RUN_USAGE_NEITHER, file=sys.stderr)
1177
+ return 1
1178
+ root = args.root.expanduser()
1179
+ redactor = _redactor_for(args)
1180
+ cfg, config_exit_code = _load_config_checked(redactor)
1181
+ if config_exit_code is not None:
1182
+ return config_exit_code
1183
+ run_path, run, failure = _pick_run_path(args, root)
1184
+ if run_path is None:
1185
+ return failure
1186
+
1187
+ if run is None:
1188
+ try:
1189
+ run = reader.load_run(run_path)
1190
+ except reader.NotARun:
1191
+ print(_run_error(args, "not_a_run"), file=sys.stderr)
1192
+ return 2
1193
+
1194
+ # docs/DESIGN.md 12's D-C: `run` always computes and prints findings and
1195
+ # the verdict, even for a workflow that has not finished (a started key
1196
+ # with no result yet) -- an unfinished-but-retried run is exactly the
1197
+ # kind of run a user most wants to inspect, and hiding it behind a bare
1198
+ # "no verdict yet" line hid that. render_run_text itself adds the
1199
+ # "(unfinished)" header tag (docs/DESIGN.md 4.7); --json and
1200
+ # --bug-report keep their own structured contracts, unchanged.
1201
+ findings = _run_findings(run)
1202
+
1203
+ # docs/DESIGN.md 4.9: --observe is the only way observe_groups ever runs here; a
1204
+ # plain `run` (the default) never starts a git process at all, and text/
1205
+ # JSON output is byte-for-byte what it was before this flag existed.
1206
+ observation = observe.observe_groups(run) if args.observe else None
1207
+
1208
+ # N3b P9: identities a text render may show, collected once and
1209
+ # registered once (see _render_guarded); JSON registers nothing (D-k).
1210
+ identities = report.collect_identities(run, findings)
1211
+ repo_root = observation.repo_root if observation is not None else None
1212
+
1213
+ if args.bug_report:
1214
+ # docs/DESIGN.md 4.10: the bug report is unchanged by this unit --
1215
+ # it has its own section order and is meant to show everything, so a
1216
+ # silenced detector's finding still appears there.
1217
+ print(
1218
+ _render_guarded(
1219
+ level=_level_of(args), identities=identities, repo_root=repo_root,
1220
+ command="run",
1221
+ render=lambda r: report.render_bug_report(run, findings, r, args.evidence),
1222
+ ),
1223
+ end="",
1224
+ )
1225
+ elif args.json:
1226
+ print(
1227
+ _json_guarded(
1228
+ level=_level_of(args), identities=identities, repo_root=repo_root,
1229
+ command="run",
1230
+ build=lambda r: report.to_json(
1231
+ run, findings, r, observation, cfg.detectors_off, evidence=args.evidence,
1232
+ ),
1233
+ ),
1234
+ end="",
1235
+ )
1236
+ else:
1237
+ print(
1238
+ _render_guarded(
1239
+ level=_level_of(args), identities=identities, repo_root=repo_root,
1240
+ command="run",
1241
+ render=lambda r: report.render_run_text(
1242
+ run, findings, r, observation,
1243
+ verbose=args.verbose, evidence=args.evidence,
1244
+ silenced_detectors=cfg.detectors_off,
1245
+ ),
1246
+ ),
1247
+ end="",
1248
+ )
1249
+
1250
+ # docs/DESIGN.md 4.6, 5.3b: exit 3 means the verdict is FAILED (a failed
1251
+ # lifecycle terminal or a critical finding), 4 means INCOMPLETE without
1252
+ # FAILED; 3 wins when both apply. Over every finding, silenced or not.
1253
+ if report.is_failed(run, findings):
1254
+ return 3
1255
+ if run.status == "INCOMPLETE":
1256
+ return 4
1257
+ return 0
1258
+
1259
+
1260
+ def _resolve_run_or_exit(
1261
+ args: argparse.Namespace, root: Path, redactor: Redactor,
1262
+ ) -> tuple[Run | None, int | None]:
1263
+ """(run, None) on success, (None, exit_code) when the caller should
1264
+ return immediately -- the run-resolution steps `rescue plan`/`rescue
1265
+ status` share verbatim with `_cmd_run` (docs/DESIGN.md 4.8: 'RUN_ID
1266
+ resolution as for run')."""
1267
+ run_path, failure = _resolve_or_report(args, root)
1268
+ if run_path is None:
1269
+ return None, failure
1270
+
1271
+ try:
1272
+ return reader.load_run(run_path), None
1273
+ except reader.NotARun:
1274
+ print(_run_error(args, "not_a_run"), file=sys.stderr)
1275
+ return None, 2
1276
+
1277
+
1278
+ def _cmd_rescue_plan(args: argparse.Namespace) -> int:
1279
+ root = args.root.expanduser()
1280
+ redactor = _redactor_for(args)
1281
+ cfg, config_exit_code = _load_config_checked(redactor)
1282
+ if config_exit_code is not None:
1283
+ return config_exit_code
1284
+
1285
+ run, exit_code = _resolve_run_or_exit(args, root, redactor)
1286
+ if run is None:
1287
+ return exit_code
1288
+
1289
+ findings = _run_findings(run)
1290
+ # docs/DESIGN.md 4.11: rescue plan always runs observe_groups, read-only,
1291
+ # the same way `run --observe` does -- there is no flag to turn it off.
1292
+ observation = observe.observe_groups(run)
1293
+ now = report._now()
1294
+ newest_mtime = _newest_run_mtime(run)
1295
+ live = report.is_possibly_live(run, newest_mtime, now, hooks_cli._UNKNOWN_QUIET_SECONDS)
1296
+
1297
+ plan = rescue.build_plan(run, observation, findings, cfg, possibly_live=live)
1298
+
1299
+ if args.json:
1300
+ print(
1301
+ _json_guarded(
1302
+ level=_level_of(args), identities=report.collect_identities(run, findings),
1303
+ repo_root=observation.repo_root, command="rescue plan",
1304
+ build=lambda r: rescue.plan_to_json(run, plan, r, evidence=args.evidence),
1305
+ ),
1306
+ end="",
1307
+ )
1308
+ else:
1309
+ # N3b P9, D-n: the run's identities registered once; the repository
1310
+ # root is the one the observation above already resolved.
1311
+ print(
1312
+ _render_guarded(
1313
+ level=_level_of(args), identities=report.collect_identities(run, findings),
1314
+ repo_root=observation.repo_root, command="rescue plan",
1315
+ render=lambda r: rescue.render_plan_text(
1316
+ run, plan, r, evidence=args.evidence, caller_session=_caller_session_id(),
1317
+ ),
1318
+ ),
1319
+ end="",
1320
+ )
1321
+
1322
+ return plan.exit_code
1323
+
1324
+
1325
+ def _cmd_rescue_status(args: argparse.Namespace) -> int:
1326
+ root = args.root.expanduser()
1327
+ redactor = _redactor_for(args)
1328
+ cfg, config_exit_code = _load_config_checked(redactor)
1329
+ if config_exit_code is not None:
1330
+ return config_exit_code
1331
+ # docs/DESIGN.md 4.11 (N5c-2, plan S7): two `[[repos]]` tables with one normalized path
1332
+ # cannot be attributed to either, so it is a configuration error, reported before the
1333
+ # run is looked for.
1334
+ for duplicated in rescue.duplicate_roots(cfg):
1335
+ print(
1336
+ f"postmortem: [[repos]] names the same repository twice: "
1337
+ f"{redactor.display_path(duplicated)}; each repository may be declared once",
1338
+ file=sys.stderr,
1339
+ )
1340
+ return 1
1341
+
1342
+ run, exit_code = _resolve_run_or_exit(args, root, redactor)
1343
+ if run is None:
1344
+ return exit_code
1345
+
1346
+ observation = observe.observe_groups(run)
1347
+ status = rescue.build_status(run, cfg, observation)
1348
+
1349
+ if args.json:
1350
+ print(
1351
+ _json_guarded(
1352
+ level=_level_of(args), identities=report.collect_identities(run, []),
1353
+ repo_root=observation.repo_root, command="rescue status",
1354
+ build=lambda r: rescue.status_to_json(run, status, r, evidence=args.evidence),
1355
+ ),
1356
+ end="",
1357
+ )
1358
+ else:
1359
+ print(
1360
+ _render_guarded(
1361
+ level=_level_of(args), identities=report.collect_identities(run, []),
1362
+ repo_root=observation.repo_root, command="rescue status",
1363
+ render=lambda r: rescue.render_status_text(run, status, r, evidence=args.evidence),
1364
+ ),
1365
+ end="",
1366
+ )
1367
+
1368
+ return status.exit_code
1369
+
1370
+
1371
+ def _cmd_demo(args: argparse.Namespace) -> int:
1372
+ # docs/DESIGN.md 8.2: rendered through the normal run path -- same
1373
+ # report renderer, same redaction default, same exit-code rule (D5) --
1374
+ # just pointed at the run packaged inside agent_postmortem/demo/
1375
+ # instead of a caller-supplied --root, so a first run needs no data on
1376
+ # disk. as_file (D4) is what makes the packaged directory readable even
1377
+ # when the package itself is a zip; the context manager only needs to
1378
+ # span the parse, since findings and the rendered text no longer touch
1379
+ # the filesystem afterward.
1380
+ redactor = Redactor(context=_build_context("share"))
1381
+ cfg, config_exit_code = _load_config_checked(redactor)
1382
+ if config_exit_code is not None:
1383
+ return config_exit_code
1384
+ try:
1385
+ with _demo_project_dir(_DEMO_PROJECT_REF) as demo_project_path:
1386
+ run = reader.load_run(demo_project_path.joinpath(*_DEMO_RUN_PARTS))
1387
+ findings = _run_findings(run)
1388
+ except DemoUnavailable as exc:
1389
+ print(f"agent-postmortem demo: {exc}", file=sys.stderr)
1390
+ return 2
1391
+ # N3b D-i: the demo's identities in one fixed order, registered in memory
1392
+ # under the public demo secret. Never the state directory, never the lock,
1393
+ # never the unavailable diagnostic. The JSON uses the same book as the
1394
+ # text, so an alias is the same string in both.
1395
+ identities = report.collect_identities(run, findings)
1396
+ book = disclosure.register_in_memory(identities, disclosure.DEMO_SECRET)
1397
+ redactor = Redactor(
1398
+ context=DisclosureContext(
1399
+ level="share", home=_DEMO_HOME, book=book, identities=tuple(identities),
1400
+ ),
1401
+ )
1402
+ if args.json:
1403
+ print(
1404
+ json.dumps(
1405
+ report.to_json(run, findings, redactor, silenced_detectors=cfg.detectors_off),
1406
+ indent=2,
1407
+ )
1408
+ )
1409
+ else:
1410
+ print(
1411
+ report.render_run_text(
1412
+ run, findings, redactor, show_age=False, silenced_detectors=cfg.detectors_off,
1413
+ ),
1414
+ end="",
1415
+ )
1416
+ # docs/DESIGN.md 4.6, 5.3b: exit 3 means the verdict is FAILED (a failed
1417
+ # lifecycle terminal or a critical finding), 4 means INCOMPLETE without
1418
+ # FAILED; 3 wins when both apply. Over every finding, silenced or not.
1419
+ if report.is_failed(run, findings):
1420
+ return 3
1421
+ if run.status == "INCOMPLETE":
1422
+ return 4
1423
+ return 0
1424
+
1425
+
1426
+ def _empty_scan_totals() -> dict:
1427
+ # docs/DESIGN.md 4.7: totals are stratified by kind, never one combined
1428
+ # count -- a workflow's "runs with retries" and a session's "subagents"
1429
+ # answer different questions and must not be averaged together.
1430
+ return {
1431
+ "workflow": {"runs": 0, "runs_with_retries": 0, "attempts": 0, "wasted_hours": 0.0},
1432
+ "session": {"runs": 0, "subagents": 0, "interrupted": 0},
1433
+ }
1434
+
1435
+
1436
+ def _accumulate_scan_totals(totals: dict, run: Run, findings: list[Finding]) -> None:
1437
+ if run.kind == "session":
1438
+ totals["session"]["runs"] += 1
1439
+ totals["session"]["subagents"] += len(run.attempts)
1440
+ totals["session"]["interrupted"] += sum(
1441
+ 1 for attempt in run.attempts if attempt.interrupt is not None
1442
+ )
1443
+ else:
1444
+ totals["workflow"]["runs"] += 1
1445
+ totals["workflow"]["attempts"] += len(run.attempts)
1446
+ if report.silent_retry_count(run) > 0:
1447
+ totals["workflow"]["runs_with_retries"] += 1
1448
+ for finding in findings:
1449
+ if finding.detector == "wasted_time":
1450
+ totals["workflow"]["wasted_hours"] += (
1451
+ finding.metrics.get("wasted_seconds", 0.0) / 3600.0
1452
+ )
1453
+
1454
+
1455
+ def _scan_payload(rows: list[dict], totals: dict, redactor: Redactor) -> dict:
1456
+ """docs/DESIGN.md 5.3b `scan --json`: every field structurally redacted as
1457
+ before, except `project`, which is the identity form of 4.13.5 (the alias
1458
+ at share, the directory name at local and raw)."""
1459
+ shown_rows = []
1460
+ for row in rows:
1461
+ shown = {}
1462
+ for key, value in row.items():
1463
+ if key == "project":
1464
+ shown[key] = report.project_text(value, redactor)
1465
+ else:
1466
+ shown[key] = report.redact_value(value, redactor)
1467
+ shown_rows.append(shown)
1468
+ return {
1469
+ "schema": report.SCHEMA_VERSION,
1470
+ "disclosure": redactor.level,
1471
+ "rows": shown_rows,
1472
+ "totals": report.redact_value(totals, redactor),
1473
+ }
1474
+
1475
+
1476
+ def _plural_word(n: int, word: str) -> str:
1477
+ return word if n == 1 else f"{word}s"
1478
+
1479
+
1480
+ def _some_ids(ids: list[str]) -> str:
1481
+ shown = ", ".join(ids[:3])
1482
+ return shown if len(ids) <= 3 else f"{shown} and {len(ids) - 3} more"
1483
+
1484
+
1485
+ def _scan_skipped_line(
1486
+ skipped_lines: list[tuple[str, int]], unreadable_files: int, meta_files: int,
1487
+ unreadable_runs: list[str],
1488
+ ) -> str:
1489
+ """docs/DESIGN.md 4.8 (0.3.4): one text line saying what the scan could not read; empty when
1490
+ it read everything. Run ids are the ids scan prints anyway; no path is named."""
1491
+ clauses: list[str] = []
1492
+ if skipped_lines:
1493
+ lines = sum(count for _run_id, count in skipped_lines)
1494
+ runs = len(skipped_lines)
1495
+ clauses.append(
1496
+ f"{lines} unreadable {_plural_word(lines, 'line')} in {runs} "
1497
+ f"{_plural_word(runs, 'run')} ({_some_ids([run_id for run_id, _ in skipped_lines])}), "
1498
+ "still scanned"
1499
+ )
1500
+ if unreadable_files:
1501
+ clauses.append(
1502
+ f"{unreadable_files} transcript {_plural_word(unreadable_files, 'file')} with no "
1503
+ "readable line"
1504
+ )
1505
+ if meta_files:
1506
+ clauses.append(
1507
+ f"{meta_files} agent meta {_plural_word(meta_files, 'file')} that could not be read"
1508
+ )
1509
+ if unreadable_runs:
1510
+ clauses.append(
1511
+ f"{len(unreadable_runs)} {_plural_word(len(unreadable_runs), 'run')} that could not "
1512
+ f"be read ({_some_ids(unreadable_runs)})"
1513
+ )
1514
+ if not clauses:
1515
+ return ""
1516
+ return (
1517
+ "SKIPPED " + "; ".join(clauses)
1518
+ + "; agent-postmortem run <run-id> --json lists each line under diagnostics"
1519
+ )
1520
+
1521
+
1522
+ # docs/DESIGN.md 4.8 (0.3.4): what a command says about a run it could not read at all
1523
+ _UNREADABLE_RUN = "could not be read (its data is nested too deeply to follow); nothing was judged"
1524
+
1525
+
1526
+ def _load_listed_run(ref: reader.RunRef, unreadable: list[str]) -> Run | None:
1527
+ """One discovered run for a command that reads many (docs/DESIGN.md 4.2, 4.8): None for a
1528
+ candidate that is not a run, and for a run that cannot be read, whose id joins
1529
+ `unreadable`. One bad candidate never aborts the whole command."""
1530
+ try:
1531
+ return reader.load_run(ref)
1532
+ except reader.NotARun:
1533
+ return None
1534
+ except reader.RunUnreadable as exc:
1535
+ unreadable.append(exc.run_id)
1536
+ return None
1537
+
1538
+
1539
+ def _print_left_out(unreadable: list[str]) -> None:
1540
+ """docs/DESIGN.md 4.8 (0.3.4): the runs a command could not read are named on stderr, so
1541
+ JSON on stdout stays one document."""
1542
+ if unreadable:
1543
+ count = len(unreadable)
1544
+ print(
1545
+ f"postmortem: {count} {_plural_word(count, 'run')} could not be read and "
1546
+ f"{'was' if count == 1 else 'were'} left out ({_some_ids(unreadable)})",
1547
+ file=sys.stderr,
1548
+ )
1549
+
1550
+
1551
+ def _cmd_scan(args: argparse.Namespace) -> int:
1552
+ root = args.root.expanduser()
1553
+ redactor = _redactor_for(args)
1554
+ cfg, config_exit_code = _load_config_checked(redactor)
1555
+ if config_exit_code is not None:
1556
+ return config_exit_code
1557
+ kinds = frozenset({args.kind}) if args.kind else frozenset({"workflow", "session"})
1558
+ # docs/DESIGN.md 4.8 (0.3.4): what scan could not read is said, never silently dropped
1559
+ unreadable_files: list[Path] = []
1560
+ run_refs = reader.discover_runs(root, kinds=kinds, skipped=unreadable_files)
1561
+ unreadable_runs: list[str] = []
1562
+ skipped_lines: list[tuple[str, int]] = []
1563
+ skipped_meta_files = 0
1564
+
1565
+ since_date = None
1566
+ if args.since:
1567
+ try:
1568
+ since_date = datetime.strptime(args.since, "%Y-%m-%d").date()
1569
+ except ValueError:
1570
+ print(_since_error(args), file=sys.stderr)
1571
+ return 1
1572
+
1573
+ # docs/DESIGN.md 4.13.6 (N6): --here is a filter on the runs themselves, applied before
1574
+ # rows, totals, the 7-day totals, and the triage candidates are computed.
1575
+ here_caller = _working_directory() if args.here else None
1576
+ # docs/DESIGN.md 4.13.6 (G5): the workflows of the caller's own session join the set
1577
+ here_owned = (
1578
+ reader.caller_workflow_paths(root, _caller_session_id(), here_caller)
1579
+ if args.here else frozenset()
1580
+ )
1581
+
1582
+ now = report._now()
1583
+ seven_days_ago = now - timedelta(days=7)
1584
+
1585
+ rows: list[dict] = []
1586
+ started_at_by_row: dict[int, tuple[int, float]] = {}
1587
+ # docs/DESIGN.md 4.7: totals stratified by kind, over every run matching
1588
+ # --since/--kind ("ALL TIME" in the triage view, and the JSON payload's
1589
+ # own 'totals', unchanged in shape from before unit M3b). totals_7d is
1590
+ # the same shape, accumulated only for a run whose ended_at (or
1591
+ # started_at when ended_at is None) falls in the last 7 days -- the
1592
+ # triage view's "LAST 7 DAYS" line.
1593
+ totals = _empty_scan_totals()
1594
+ totals["findings_per_detector"] = {}
1595
+ totals_7d = _empty_scan_totals()
1596
+ # unit M3b: every NEEDS ATTENTION candidate found while scanning, kept
1597
+ # alongside the Run/Finding objects the triage renderer and the ranking
1598
+ # key both need; sorted and truncated to --limit only once scanning is
1599
+ # done, so the header's "k of N" always reports the true count.
1600
+ candidates: list[dict] = []
1601
+ # docs/DESIGN.md 4.7 (FB2): possibly-live runs that are not candidates, for LIVE NOW.
1602
+ live_now: list[dict] = []
1603
+
1604
+ for ref in run_refs:
1605
+ # discover_runs promises a RunRef for anything matching the layout glob, not that every
1606
+ # candidate is fully loadable; one bad candidate must not abort the whole fleet scan
1607
+ # (docs/DESIGN.md 4.2), and a run that cannot be read is counted (4.8, 0.3.4)
1608
+ run = _load_listed_run(ref, unreadable_runs)
1609
+ if run is None:
1610
+ continue
1611
+ run_started = run.started_at
1612
+ if since_date is not None and run_started is not None and run_started.date() < since_date:
1613
+ continue
1614
+ if args.here and (
1615
+ here_caller is None
1616
+ or (ref.path not in here_owned and not reader.run_matches_cwd(run, here_caller))
1617
+ ):
1618
+ continue
1619
+
1620
+ findings = _run_findings(run)
1621
+ if run.skipped_lines:
1622
+ skipped_lines.append((run.run_id, run.skipped_lines))
1623
+ skipped_meta_files += run.skipped_files
1624
+ # docs/DESIGN.md 4.10: a silenced detector's findings never inform
1625
+ # scan's own verdict, top detector, or NEEDS ATTENTION reason (the
1626
+ # same rule `run`'s text/JSON follow) -- but they still count toward
1627
+ # `totals`/`findings_per_detector` below, exactly like trend.
1628
+ shown, _silenced = config.partition_silenced(findings, cfg)
1629
+ newest_mtime = _newest_run_mtime(run)
1630
+ live = report.is_possibly_live(run, newest_mtime, now, hooks_cli._UNKNOWN_QUIET_SECONDS)
1631
+ paths_at_risk = report.scan_paths_at_risk(run)
1632
+ paths_at_risk_complete = report.scan_paths_at_risk_complete(run)
1633
+ age = report.scan_age_string(run, now, live)
1634
+ verdict = report.verdict_of(run, shown)
1635
+ attention = report.is_attention_candidate(run, shown, now, live)
1636
+ reason = report.attention_reason(run, shown) if attention else None
1637
+
1638
+ # release review of 0.3.0: rows follow the instant a run started (in UTC, whatever offset
1639
+ # its timestamps were written with), oldest first, with an undated run before every dated
1640
+ # one, so the last row is the newest run that has a start time
1641
+ started_at_by_row[len(rows)] = (
1642
+ (1, report._aware(run.started_at).timestamp()) if run.started_at else (0, 0.0)
1643
+ )
1644
+ rows.append({
1645
+ "run_id": run.run_id,
1646
+ "kind": run.kind,
1647
+ "date": run.started_at.date().isoformat() if run.started_at else "unknown",
1648
+ "project": run.project_slug,
1649
+ "agents": len(run.agents),
1650
+ "attempts": len(run.attempts),
1651
+ "verdict": verdict,
1652
+ "top_detector": _top_detector(shown),
1653
+ "finished": run.finished,
1654
+ "attention": attention,
1655
+ "attention_reason": reason,
1656
+ "age": age,
1657
+ "possibly_live": live,
1658
+ "paths_at_risk": paths_at_risk,
1659
+ "paths_at_risk_complete": paths_at_risk_complete,
1660
+ "unfinished_state": report.unfinished_state(run, live),
1661
+ })
1662
+
1663
+ if attention:
1664
+ candidates.append({
1665
+ "run": run,
1666
+ "age": age,
1667
+ "verdict": verdict,
1668
+ "reason": reason,
1669
+ "command": report.attention_next_command(run, now),
1670
+ "sort_key": report.attention_sort_key(run, shown, live, paths_at_risk, now),
1671
+ })
1672
+
1673
+ elif live:
1674
+ live_now.append({
1675
+ "run": run,
1676
+ "verdict": verdict,
1677
+ "command": report.attention_next_command(run, now),
1678
+ "sort_key": _here_key(ref, run),
1679
+ })
1680
+
1681
+ _accumulate_scan_totals(totals, run, findings)
1682
+ window_ts = run.ended_at or run.started_at
1683
+ if window_ts is not None and report._aware(window_ts) >= seven_days_ago:
1684
+ _accumulate_scan_totals(totals_7d, run, findings)
1685
+
1686
+ for finding in findings:
1687
+ totals["findings_per_detector"][finding.detector] = (
1688
+ totals["findings_per_detector"].get(finding.detector, 0) + 1
1689
+ )
1690
+
1691
+ if not rows:
1692
+ kind = "scan_here_no_match" if args.here else "no_runs"
1693
+ print(
1694
+ _run_error(
1695
+ args, kind, root=root,
1696
+ unreadable_files=len(unreadable_files), unreadable_runs=unreadable_runs,
1697
+ ),
1698
+ file=sys.stderr,
1699
+ )
1700
+ if kind != "no_runs":
1701
+ _print_left_out(unreadable_runs) # the empty state already names them
1702
+ # docs/DESIGN.md 4.8 (0.3.4): a run that could not be read leaves the scan INCOMPLETE
1703
+ return 4 if unreadable_runs else 2
1704
+
1705
+ order = sorted(range(len(rows)), key=lambda i: (started_at_by_row[i], rows[i]["run_id"]))
1706
+ rows = [rows[i] for i in order]
1707
+
1708
+ if args.json:
1709
+ print(
1710
+ _json_guarded(
1711
+ level=_level_of(args),
1712
+ identities=report.collect_project_identities(row["project"] for row in rows),
1713
+ repo_root=None, command="scan",
1714
+ build=lambda r: _scan_payload(rows, totals, r),
1715
+ ),
1716
+ end="",
1717
+ )
1718
+ elif args.all:
1719
+ # N3b P9: scan --all prints one project per row; the triage view
1720
+ # below prints no identity, so it collects (and registers) nothing.
1721
+ print(
1722
+ _render_guarded(
1723
+ level=_level_of(args),
1724
+ identities=report.collect_project_identities(row["project"] for row in rows),
1725
+ repo_root=None, command="scan",
1726
+ render=lambda r: report.render_scan_text(
1727
+ rows, totals, r, silenced_detectors=cfg.detectors_off,
1728
+ ),
1729
+ ),
1730
+ end="",
1731
+ )
1732
+ else:
1733
+ candidates.sort(key=lambda candidate: candidate["sort_key"])
1734
+ shown_candidates = candidates[: args.limit]
1735
+ live_now.sort(key=lambda entry: entry["sort_key"], reverse=True)
1736
+ print(
1737
+ _render_guarded(
1738
+ level=_level_of(args), identities=[], repo_root=None, command="scan",
1739
+ render=lambda r: report.render_scan_triage_text(
1740
+ shown_candidates, len(candidates), len(rows), totals_7d, totals, r,
1741
+ silenced_detectors=cfg.detectors_off,
1742
+ live_now=live_now[: args.limit], total_live_now=len(live_now),
1743
+ both_kinds=args.kind is None,
1744
+ ),
1745
+ ),
1746
+ end="",
1747
+ )
1748
+
1749
+ skipped_line = _scan_skipped_line(
1750
+ skipped_lines, len(unreadable_files), skipped_meta_files, unreadable_runs,
1751
+ )
1752
+ if skipped_line:
1753
+ # with --json, stdout stays one JSON document
1754
+ print(skipped_line, file=sys.stderr if args.json else sys.stdout)
1755
+
1756
+ # docs/DESIGN.md 4.6/4.7: scan's own exit code must reflect the worst
1757
+ # verdict across every included run, the same as 'run' does for one run
1758
+ # -- a caller scripting against scan otherwise has no way to detect that
1759
+ # any run in the fleet was FAILED or only INCOMPLETE-ly inspected.
1760
+ any_failed = any(row["verdict"].startswith("FAILED") for row in rows)
1761
+ any_incomplete = any("(INCOMPLETE)" in row["verdict"] for row in rows) or bool(
1762
+ unreadable_runs
1763
+ )
1764
+ if any_failed:
1765
+ return 3
1766
+ if any_incomplete:
1767
+ return 4
1768
+ return 0
1769
+
1770
+
1771
+ def _cmd_rules(args: argparse.Namespace) -> int:
1772
+ # docs/DESIGN.md 9: `rules propose`/`rules review` are separate verbs
1773
+ # under the same `rules` subparser (their own flags, no --run/--all/
1774
+ # --show), dispatched before anything below touches an attribute only
1775
+ # the bare `rules` namespace carries.
1776
+ if getattr(args, "rules_command", None) == "propose":
1777
+ return _cmd_rules_propose(args)
1778
+ if getattr(args, "rules_command", None) == "review":
1779
+ return _cmd_rules_review(args)
1780
+
1781
+ # docs/DESIGN.md 5.2's D2: rules prints error messages, rule text, and
1782
+ # rows through the same active Redactor as every other command -- a
1783
+ # --run target or a --root default can carry a real path just like
1784
+ # run/scan/apply's can.
1785
+ redactor = _redactor_for(args)
1786
+
1787
+ if args.show:
1788
+ rule = rules.RULES.get(args.show)
1789
+ if rule is None:
1790
+ print(_no_such_rule(args, args.show), file=sys.stderr)
1791
+ return 1
1792
+ if args.json:
1793
+ payload = {"disclosure": args.level, **rules.rule_to_json(rule)}
1794
+ print(json.dumps(payload, indent=2))
1795
+ else:
1796
+ # the template path is rendered once by display_path; the rule's own words are fixed
1797
+ # text, and the assembled text is not scanned again (docs/DESIGN.md 5.1, F fix 7)
1798
+ print(rules.render_rule_show(rule, redactor.display_path), end="")
1799
+ return 0
1800
+
1801
+ root = args.root.expanduser()
1802
+ if args.run_id:
1803
+ run_path, failure = _resolve_or_report(args, root)
1804
+ if run_path is None:
1805
+ return failure
1806
+ try:
1807
+ findings = _run_findings(reader.load_run(run_path))
1808
+ except reader.NotARun:
1809
+ print(_run_error(args, "not_a_run"), file=sys.stderr)
1810
+ return 2
1811
+ else:
1812
+ run_refs = reader.discover_runs(root)
1813
+ if not run_refs and not args.all:
1814
+ print(_run_error(args, "no_runs", root=root), file=sys.stderr)
1815
+ return 2
1816
+ findings = []
1817
+ unreadable: list[str] = []
1818
+ for ref in run_refs:
1819
+ run = _load_listed_run(ref, unreadable)
1820
+ if run is not None:
1821
+ findings.extend(_run_findings(run))
1822
+ _print_left_out(unreadable)
1823
+
1824
+ if args.all:
1825
+ backing = {rule.id: count for rule, count in rules.rules_for(findings)}
1826
+ rows = [(rules.RULES[rule_id], backing.get(rule_id, 0)) for rule_id in sorted(rules.RULES)]
1827
+ else:
1828
+ rows = rules.rules_for(findings)
1829
+
1830
+ if args.json:
1831
+ # docs/DESIGN.md 5.3: schema 2 wraps what used to be the bare list
1832
+ # in a {"schema", "rules"} object so this command can carry the same
1833
+ # top-level version marker as run/scan/trend/demo's JSON.
1834
+ payload = {
1835
+ "schema": report.SCHEMA_VERSION,
1836
+ "disclosure": args.level,
1837
+ "rules": [
1838
+ {"rule_id": rule.id, "title": rule.title, "count": count} for rule, count in rows
1839
+ ],
1840
+ }
1841
+ print(json.dumps(payload, indent=2))
1842
+ else:
1843
+ print(redactor(rules.render_rules_text(rows)), end="")
1844
+ return 0
1845
+
1846
+
1847
+ def _cmd_rules_propose(args: argparse.Namespace) -> int:
1848
+ """docs/DESIGN.md 9: scan the fleet like `scan` does and print the rules
1849
+ backed by enough evidence to propose adopting. Read-only; writes
1850
+ nothing, the same as every other `rules` verb."""
1851
+ root = args.root.expanduser()
1852
+ redactor = _redactor_for(args)
1853
+
1854
+ if args.run_id and args.since:
1855
+ # docs/DESIGN.md 9 (N8): one run has no date range to filter.
1856
+ print(
1857
+ "postmortem: --run cannot be combined with --since (a single run has no date range)",
1858
+ file=sys.stderr,
1859
+ )
1860
+ return 1
1861
+
1862
+ since_date = None
1863
+ if args.since:
1864
+ try:
1865
+ since_date = datetime.strptime(args.since, "%Y-%m-%d").date()
1866
+ except ValueError:
1867
+ print(_since_error(args), file=sys.stderr)
1868
+ return 1
1869
+
1870
+ runs_with_findings: list[tuple[Run, list[Finding]]] = []
1871
+ run_id_shown = None
1872
+ if args.run_id:
1873
+ run_path, failure = _resolve_or_report(args, root)
1874
+ if run_path is None:
1875
+ return failure
1876
+ try:
1877
+ run = reader.load_run(run_path)
1878
+ except reader.NotARun:
1879
+ print(_run_error(args, "not_a_run"), file=sys.stderr)
1880
+ return 2
1881
+ runs_with_findings.append((run, _run_findings(run)))
1882
+ run_id_shown = run.run_id
1883
+ else:
1884
+ run_refs = reader.discover_runs(root)
1885
+ if not run_refs:
1886
+ print(_run_error(args, "no_runs", root=root), file=sys.stderr)
1887
+ return 2
1888
+
1889
+ unreadable: list[str] = []
1890
+ for ref in run_refs:
1891
+ run = _load_listed_run(ref, unreadable)
1892
+ if run is None:
1893
+ continue
1894
+ run_started = run.started_at
1895
+ if (
1896
+ since_date is not None
1897
+ and run_started is not None
1898
+ and run_started.date() < since_date
1899
+ ):
1900
+ continue
1901
+ runs_with_findings.append((run, _run_findings(run)))
1902
+ _print_left_out(unreadable)
1903
+
1904
+ # docs/DESIGN.md 9 (N8): an adopted rule is a status row, never proposed
1905
+ # again. Only the owner's config [[adopted]] records name one.
1906
+ rows = rules.propose_rules(
1907
+ runs_with_findings, config.load_config().adopted, single_run=bool(args.run_id),
1908
+ )
1909
+
1910
+ if args.json:
1911
+ payload = {
1912
+ "schema": report.SCHEMA_VERSION,
1913
+ "disclosure": args.level,
1914
+ "proposals": [rules.propose_row_to_json(row) for row in rows],
1915
+ }
1916
+ print(json.dumps(payload, indent=2))
1917
+ else:
1918
+ print(redactor(rules.render_propose_text(rows, run_id=run_id_shown)), end="")
1919
+ return 0
1920
+
1921
+
1922
+ def _adopted_entries(cfg: config.Config) -> list[dict]:
1923
+ """docs/DESIGN.md 9/7.5: the owner's `[[adopted]]` config rows, plus any
1924
+ legacy v0.1 state-store `applied` entry (id, target, applied_at) that
1925
+ isn't already the same adoption declared in config -- v0.1 users keep
1926
+ their history even though nothing writes a new `applied` entry anymore.
1927
+ A filesystem error or a state.json newer than this build understands is
1928
+ treated as "no legacy entries" rather than failing the whole command:
1929
+ the config's own [[adopted]] rows still review just fine."""
1930
+ entries = [
1931
+ {"rule_id": item.rule, "target": item.target, "applied_at": item.date}
1932
+ for item in cfg.adopted
1933
+ ]
1934
+ seen = {(e["rule_id"], e["applied_at"][:10], e["target"]) for e in entries}
1935
+
1936
+ try:
1937
+ legacy = state.load_state().get("applied", [])
1938
+ except (OSError, state.StateTooNew):
1939
+ legacy = []
1940
+
1941
+ for entry in legacy:
1942
+ if not isinstance(entry, dict):
1943
+ continue
1944
+ rule_id, target = entry.get("rule_id"), entry.get("target")
1945
+ applied_at = entry.get("applied_at") or ""
1946
+ key = (rule_id, applied_at[:10], target)
1947
+ if key in seen:
1948
+ continue
1949
+ seen.add(key)
1950
+ entries.append({"rule_id": rule_id, "target": target, "applied_at": applied_at})
1951
+ return entries
1952
+
1953
+
1954
+ def _cmd_rules_review(args: argparse.Namespace) -> int:
1955
+ """docs/DESIGN.md 9: the owner's adopted rules' detector rate, N weeks
1956
+ before and after adoption. Read-only: reviewing never removes a rule,
1957
+ it only lists which ones show no improvement."""
1958
+ redactor = _redactor_for(args)
1959
+ cfg = config.load_config()
1960
+ adopted = _adopted_entries(cfg)
1961
+
1962
+ if not adopted:
1963
+ if args.json:
1964
+ payload = {
1965
+ "schema": report.SCHEMA_VERSION, "disclosure": args.level, "reviews": [],
1966
+ "consider_removing": [],
1967
+ }
1968
+ print(json.dumps(payload, indent=2))
1969
+ else:
1970
+ print("no adopted rule to review")
1971
+ return 0
1972
+
1973
+ root = args.root.expanduser()
1974
+ run_refs = reader.discover_runs(root)
1975
+ if not run_refs:
1976
+ print(_run_error(args, "no_runs", root=root), file=sys.stderr)
1977
+ return 2
1978
+
1979
+ runs_with_findings: list[tuple[Run, list[Finding]]] = []
1980
+ unreadable: list[str] = []
1981
+ for ref in run_refs:
1982
+ run = _load_listed_run(ref, unreadable)
1983
+ if run is not None:
1984
+ runs_with_findings.append((run, _run_findings(run)))
1985
+ _print_left_out(unreadable)
1986
+
1987
+ rows = trend.review_rows(adopted, runs_with_findings, args.weeks)
1988
+
1989
+ if args.json:
1990
+ print(json.dumps(trend.review_to_json(rows, redactor), indent=2))
1991
+ else:
1992
+ print(redactor(trend.render_review_text(rows)), end="")
1993
+ return 0
1994
+
1995
+
1996
+ def _target_looks_like_a_run_directory(target: Path) -> bool:
1997
+ marker = "/subagents/workflows/"
1998
+ candidates = {str(target)}
1999
+ try:
2000
+ candidates.add(str(target.resolve()))
2001
+ except OSError:
2002
+ pass
2003
+ return any(marker in candidate for candidate in candidates)
2004
+
2005
+
2006
+ def _errors_for(args: argparse.Namespace) -> argerrors.ErrorRenderer:
2007
+ """N3b P13: the one span renderer a handler uses for the values the user
2008
+ typed, at the command's own level. Built once per command so the numbering
2009
+ (path-1, value-1) is shared by every span it prints."""
2010
+ renderer = getattr(args, "_handler_errors", None)
2011
+ if renderer is None:
2012
+ renderer = argerrors.ErrorRenderer([], set(), getattr(args, "level", "share"))
2013
+ args._handler_errors = renderer
2014
+ return renderer
2015
+
2016
+
2017
+ def _working_directory() -> str | None:
2018
+ try:
2019
+ return os.getcwd()
2020
+ except OSError:
2021
+ return None
2022
+
2023
+
2024
+ def _no_such_rule(args: argparse.Namespace, typed: str) -> str:
2025
+ """The unknown-rule-id error of `apply` and `rules --show` (N3b P13)."""
2026
+ if getattr(args, "level", "share") == "raw":
2027
+ return f"postmortem: no such rule '{typed}'"
2028
+ return (
2029
+ f"postmortem: no such rule '{_errors_for(args).span(typed, 'value')}' "
2030
+ "(expected a rule id such as R-RETRY-SAFE; 'agent-postmortem rules --all' lists them)"
2031
+ )
2032
+
2033
+
2034
+ _APPLY_SHARE_NOTE = (
2035
+ "note: the actual patch needs --local or --raw (it quotes the target file, "
2036
+ "which the default level never prints)"
2037
+ )
2038
+ _APPLY_SHARE_CLOSING = (
2039
+ "Apply it yourself: paste the directive into the target file's managed block."
2040
+ )
2041
+ _APPLY_LOCAL_CLOSING = (
2042
+ "Apply it yourself: paste the block into the target file. This diff is for reading; "
2043
+ "--raw prints one git apply can use."
2044
+ )
2045
+ _APPLY_CLOSING = (
2046
+ "Apply it yourself: paste the block into the target file, or save this "
2047
+ "output and run git apply."
2048
+ )
2049
+
2050
+
2051
+ def _cmd_apply(args: argparse.Namespace) -> int:
2052
+ """`apply` (docs/DESIGN.md 9; N3b P18, C-4, P19).
2053
+
2054
+ share prints no byte derived from the target file: the target as path-N,
2055
+ the requested rule's canonical directive, one note line saying the actual
2056
+ patch needs --local or --raw; the already-present message and every error
2057
+ name the target as path-N too. local prints a unified diff with 3 context
2058
+ lines whose header is the P19 form (apply makes no observation, so no
2059
+ repository root: a path inside home is ~/.../<basename>, any other path-N,
2060
+ never absolute). raw is today's output, usable with `git apply`."""
2061
+ level = _level_of(args)
2062
+ redactor = _redactor_for(args)
2063
+ errors = _errors_for(args)
2064
+ if args.rule_id not in rules.RULES:
2065
+ print(_no_such_rule(args, args.rule_id), file=sys.stderr)
2066
+ return 1
2067
+
2068
+ target = args.target.expanduser()
2069
+ if _target_looks_like_a_run_directory(target):
2070
+ shown = str(target) if level == "raw" else errors.span(str(args.target), "path")
2071
+ print(
2072
+ f"postmortem: refusing to target a run directory ({shown}); "
2073
+ "apply targets a file you maintain, never a run directory",
2074
+ file=sys.stderr,
2075
+ )
2076
+ return 1
2077
+
2078
+ names = redactor.fresh_names(detailed=True)
2079
+ display = names.path(str(target), _working_directory()) or str(target)
2080
+
2081
+ rule = rules.RULES[args.rule_id]
2082
+ try:
2083
+ if level == "local":
2084
+ diff = rules.apply_rule(args.rule_id, target, label=display)
2085
+ else:
2086
+ diff = rules.apply_rule(args.rule_id, target)
2087
+ except (ValueError, OSError) as exc:
2088
+ # Covers an unterminated rules block and a target that could not be
2089
+ # read (permissions, a broken symlink, and the like) -- apply_rule
2090
+ # only ever reads target, it never writes it (docs/DESIGN.md 9, R3).
2091
+ if level == "share":
2092
+ # No byte of the target and no OS-reported path: a fixed sentence
2093
+ # per kind of failure, the target named as path-N.
2094
+ if isinstance(exc, UnicodeDecodeError):
2095
+ reason = "it is not valid UTF-8 text"
2096
+ elif isinstance(exc, OSError):
2097
+ reason = f"could not read it ({exc.strerror or 'read error'})"
2098
+ else:
2099
+ reason = "it has an unterminated agent-postmortem rules block"
2100
+ print(f"postmortem: target {display}: {reason}", file=sys.stderr)
2101
+ else:
2102
+ print(redactor(f"postmortem: {exc}"), file=sys.stderr)
2103
+ return 1
2104
+
2105
+ # docs/DESIGN.md 5.2's D2: apply routes its diff, its message, and its
2106
+ # hook-install note through the same active Redactor as its own error
2107
+ # messages. At share nothing printed here comes from the target file
2108
+ # except the one bit the already-present message states.
2109
+ if level == "share":
2110
+ if diff:
2111
+ print(f"rule {args.rule_id} would add this directive to {display}:")
2112
+ print(rules.directive_line(args.rule_id))
2113
+ print(_APPLY_SHARE_NOTE)
2114
+ else:
2115
+ print(f"postmortem: {args.rule_id} already present in {display}; nothing to add")
2116
+ elif diff:
2117
+ print(redactor(diff), end="")
2118
+ else:
2119
+ # `display` is the P19 form of the target, rendered once: the line is not scanned again
2120
+ print(f"postmortem: {args.rule_id} already present in {display}; nothing to add")
2121
+
2122
+ note = rules.render_hook_install_note(rule, redactor.display_path)
2123
+ if note:
2124
+ print(note, end="") # the template path was rendered once; the note is not rescanned
2125
+
2126
+ # docs/DESIGN.md 9/R3: apply never writes target itself, with or without
2127
+ # a diff to show -- this closing sentence is the last line every time,
2128
+ # so the tool never leaves the impression it just did the pasting for you.
2129
+ print({
2130
+ "share": _APPLY_SHARE_CLOSING, "local": _APPLY_LOCAL_CLOSING, "raw": _APPLY_CLOSING,
2131
+ }[level])
2132
+ return 0
2133
+
2134
+
2135
+ def _cmd_trend(args: argparse.Namespace) -> int:
2136
+ root = args.root.expanduser()
2137
+ redactor = _redactor_for(args)
2138
+ run_refs = reader.discover_runs(root)
2139
+ if not run_refs:
2140
+ print(_run_error(args, "no_runs", root=root), file=sys.stderr)
2141
+ return 2
2142
+
2143
+ runs_with_findings: list[tuple[Run, list[Finding]]] = []
2144
+ unreadable: list[str] = []
2145
+ for ref in run_refs:
2146
+ run = _load_listed_run(ref, unreadable)
2147
+ if run is not None:
2148
+ runs_with_findings.append((run, _run_findings(run)))
2149
+ _print_left_out(unreadable)
2150
+
2151
+ weekly = trend.weekly_counts(runs_with_findings, args.weeks)
2152
+ try:
2153
+ applied = state.load_state().get("applied", [])
2154
+ except (OSError, state.StateTooNew) as exc:
2155
+ # A filesystem failure, or a state.json newer than this build
2156
+ # understands, is not "no rules applied": report it plainly rather
2157
+ # than silently showing an empty before/after section that looks
2158
+ # like no rule ever ran.
2159
+ print(
2160
+ f"postmortem: could not read the state file: {redactor.exception_text(exc)}",
2161
+ file=sys.stderr,
2162
+ )
2163
+ return 1
2164
+ before_after_rows = trend.before_after(applied, runs_with_findings)
2165
+
2166
+ if args.json:
2167
+ print(json.dumps(trend.to_json(weekly, before_after_rows, args.weeks, redactor), indent=2))
2168
+ else:
2169
+ print(trend.render_trend_text(weekly, before_after_rows, args.weeks, redactor), end="")
2170
+ return 0
2171
+
2172
+
2173
+ def _cmd_watch(args: argparse.Namespace) -> int:
2174
+ root = args.root.expanduser()
2175
+ return watch.watch_run(
2176
+ args.run_id, root,
2177
+ interval=args.interval, max_hours=args.max_hours, quiet_after=args.quiet_after,
2178
+ done_grace=args.done_grace, json_mode=args.json, out=sys.stdout, err=sys.stderr,
2179
+ )
2180
+
2181
+
2182
+ class _FirstLineStdout:
2183
+ """Wrap stdout so the first non-empty write is preceded by the P15
2184
+ label line. Every print path of a text command goes through write(), so
2185
+ no command has to remember to print the label itself."""
2186
+
2187
+ def __init__(self, stream, label: str) -> None:
2188
+ self._stream = stream
2189
+ self._label = label
2190
+ self._emitted = False
2191
+
2192
+ def write(self, text: str) -> int:
2193
+ if not self._emitted and text:
2194
+ self._emitted = True
2195
+ self._stream.write(self._label + "\n")
2196
+ return self._stream.write(text)
2197
+
2198
+ def __getattr__(self, name: str):
2199
+ return getattr(self._stream, name)
2200
+
2201
+
2202
+ def main(argv: list[str] | None = None) -> int:
2203
+ tokens = list(sys.argv[1:] if argv is None else argv)
2204
+ parser = _build_parser(tokens)
2205
+ args = parser.parse_args(tokens)
2206
+ try:
2207
+ return _run(args, parser)
2208
+ except reader.RunUnreadable as exc:
2209
+ # docs/DESIGN.md 4.8 (0.3.4): the one run a command was asked about could not be read
2210
+ print(f"postmortem: {exc.run_id} {_UNREADABLE_RUN}", file=sys.stderr)
2211
+ return 4
2212
+ except Exception as exc: # noqa: BLE001 - the last resort: nothing escapes as a traceback
2213
+ return _report_uncaught(exc, args)
2214
+
2215
+
2216
+ def _run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2217
+ # N3b P15: at local and raw, text output starts with the disclosure
2218
+ # label. JSON never carries it, and a command that writes nothing to
2219
+ # stdout (an error that only writes to stderr) prints no label either.
2220
+ level = getattr(args, "level", "share")
2221
+ if level in FIRST_LINE and not getattr(args, "json", False):
2222
+ real_stdout = sys.stdout
2223
+ sys.stdout = _FirstLineStdout(real_stdout, FIRST_LINE[level])
2224
+ try:
2225
+ return _dispatch(args, parser)
2226
+ finally:
2227
+ sys.stdout = real_stdout
2228
+ return _dispatch(args, parser)
2229
+
2230
+
2231
+ def _report_uncaught(exc: Exception, args: argparse.Namespace) -> int:
2232
+ """F fix 8 (W8-5, docs/DESIGN.md 5.1): an exception no command handled prints one line, never
2233
+ the interpreter's traceback (which carries the raw path an OSError names). The text is
2234
+ `Redactor.exception_text` at the active level; if even that fails, the class name alone.
2235
+ An interrupt or an exit is not an `Exception` and is not caught."""
2236
+ name = type(exc).__name__
2237
+ try:
2238
+ redactor = Redactor(context=_build_context(getattr(args, "level", "share")))
2239
+ shown = redactor.exception_text(exc)
2240
+ line = f"agent-postmortem: unexpected {name}: {shown}"
2241
+ except Exception: # noqa: BLE001 - the report of a failure must never fail in turn
2242
+ line = f"agent-postmortem: unexpected {name}"
2243
+ try:
2244
+ print(line, file=sys.stderr)
2245
+ except Exception: # noqa: BLE001 - a closed stderr is not a reason to raise
2246
+ pass
2247
+ return 1
2248
+
2249
+
2250
+ def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
2251
+ if args.command == "scan":
2252
+ return _cmd_scan(args)
2253
+ if args.command == "run":
2254
+ return _cmd_run(args)
2255
+ if args.command == "rules":
2256
+ return _cmd_rules(args)
2257
+ if args.command == "apply":
2258
+ return _cmd_apply(args)
2259
+ if args.command == "trend":
2260
+ return _cmd_trend(args)
2261
+ if args.command == "demo":
2262
+ return _cmd_demo(args)
2263
+ if args.command == "rescue":
2264
+ if args.rescue_command == "plan":
2265
+ return _cmd_rescue_plan(args)
2266
+ if args.rescue_command == "status":
2267
+ return _cmd_rescue_status(args)
2268
+ if args.command == "watch":
2269
+ return _cmd_watch(args)
2270
+ if args.command == "hook":
2271
+ return hooks_cli.dispatch(args.hook_command)
2272
+
2273
+ parser.print_help(sys.stderr)
2274
+ return 1
2275
+
2276
+
2277
+ if __name__ == "__main__":
2278
+ sys.exit(main())