agent-postmortem 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
agent_postmortem/cli.py
ADDED
|
@@ -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())
|