agent-postmortem 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""agent-postmortem: post-mortem for multi-agent coding runs.
|
|
2
|
+
|
|
3
|
+
__version__ is the single source of truth for the package version; it must
|
|
4
|
+
stay equal to pyproject.toml's [project].version and to
|
|
5
|
+
plugin/.claude-plugin/plugin.json's version (docs/DESIGN.md 7.1's D1,
|
|
6
|
+
enforced by tests/test_plugin.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.3.4"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Parser and usage errors that never echo what the user typed (N3b P13,
|
|
2
|
+
docs/DESIGN.md 4.13.5).
|
|
3
|
+
|
|
4
|
+
A usage error is printed before any command has built its disclosure
|
|
5
|
+
context, so the level is decided from the raw argv alone: scan_level().
|
|
6
|
+
Every original argv token that is not part of the tool's own vocabulary (a
|
|
7
|
+
known option string, a subcommand name, or an option choice) is classified
|
|
8
|
+
and replaced in the message as a span: ErrorRenderer.render() replaces the
|
|
9
|
+
exact token (longest first, delimited so a short value never rewrites a
|
|
10
|
+
letter inside a word), never re-tokenizing argparse's prose. At raw the
|
|
11
|
+
message passes through unchanged.
|
|
12
|
+
|
|
13
|
+
Stdlib only. Part 2 reuses ErrorRenderer for handler-level errors (no run
|
|
14
|
+
found, ambiguous run, bad date).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
from . import disclosure
|
|
23
|
+
from .redact import _UUID_RE
|
|
24
|
+
|
|
25
|
+
# Commands that take --local/--raw/--no-redact (P12). watch, demo, and hook
|
|
26
|
+
# take no level flag, so an error from one of them is always rendered at
|
|
27
|
+
# share, whatever the argv says.
|
|
28
|
+
LEVEL_COMMANDS = frozenset({"scan", "run", "rules", "apply", "trend", "rescue"})
|
|
29
|
+
|
|
30
|
+
_LEVEL_FLAGS = {"--local": "local", "--raw": "raw", "--no-redact": "raw"}
|
|
31
|
+
|
|
32
|
+
# A character that would make a token a fragment of a longer word, option,
|
|
33
|
+
# number, or path in a message.
|
|
34
|
+
_LEFT = r"(?<![\w./~-])"
|
|
35
|
+
_RIGHT = r"(?![\w./~-])"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_malformed_level_spelling(token: str) -> bool:
|
|
39
|
+
"""--raw=1, --local=yes, --loc, --no-red: spellings that are not exactly
|
|
40
|
+
one of the three level flags but could be read as one."""
|
|
41
|
+
base = token.split("=", 1)[0]
|
|
42
|
+
if base in _LEVEL_FLAGS:
|
|
43
|
+
return "=" in token
|
|
44
|
+
return len(base) > 2 and any(flag.startswith(base) for flag in _LEVEL_FLAGS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_level(tokens: Iterable[str]) -> str:
|
|
48
|
+
"""The disclosure level an error message is rendered at: "share",
|
|
49
|
+
"local", or "raw". Reads tokens before a bare `--` only. Exactly one of
|
|
50
|
+
--local, --raw, --no-redact (written exactly) and nothing malformed fixes
|
|
51
|
+
its level; two of them, a malformed spelling, no flag at all, or a
|
|
52
|
+
command that takes no level flag (watch, demo, hook) fixes share."""
|
|
53
|
+
found: list[str] = []
|
|
54
|
+
malformed = False
|
|
55
|
+
command: str | None = None
|
|
56
|
+
for token in tokens:
|
|
57
|
+
if token == "--":
|
|
58
|
+
break
|
|
59
|
+
if command is None and not token.startswith("-"):
|
|
60
|
+
command = token
|
|
61
|
+
if token in _LEVEL_FLAGS:
|
|
62
|
+
found.append(token)
|
|
63
|
+
elif token.startswith("--") and _is_malformed_level_spelling(token):
|
|
64
|
+
malformed = True
|
|
65
|
+
if command not in LEVEL_COMMANDS or malformed or len(found) != 1:
|
|
66
|
+
return "share"
|
|
67
|
+
return _LEVEL_FLAGS[found[0]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ErrorRenderer:
|
|
71
|
+
"""Replace the user's own words in an error message. `tokens` is the
|
|
72
|
+
original argv; `known` is the set of the tool's own words (option
|
|
73
|
+
strings, subcommand names, choices), shared by reference so a parser
|
|
74
|
+
tree can fill it after the renderer is built."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, tokens: Iterable[str], known: set[str], level: str) -> None:
|
|
77
|
+
self.tokens = list(tokens)
|
|
78
|
+
self.known = known
|
|
79
|
+
self.level = level
|
|
80
|
+
# numbering shared by every span() of one command (handler errors)
|
|
81
|
+
self._span_counters: dict[str, int] = {}
|
|
82
|
+
self._span_seen: dict[str, str] = {}
|
|
83
|
+
|
|
84
|
+
def _classify(
|
|
85
|
+
self, token: str, counters: dict[str, int], seen: dict[str, str],
|
|
86
|
+
forced: str | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""The replacement for one user-supplied token. A path (contains a
|
|
89
|
+
slash, or starts with ~ or .) is path-N (the share path rule; at
|
|
90
|
+
local the same for now, P19's full rule arrives with Part 1b and is
|
|
91
|
+
never absolute); a UUID is uuid:<last4>; a canonical agent id is
|
|
92
|
+
agent-N (positional: no alias book exists for an error); anything
|
|
93
|
+
else is value-N."""
|
|
94
|
+
if token in seen:
|
|
95
|
+
return seen[token]
|
|
96
|
+
if forced == "path":
|
|
97
|
+
kind = "path"
|
|
98
|
+
elif forced is None and ("/" in token or token.startswith(("~", "."))):
|
|
99
|
+
kind = "path"
|
|
100
|
+
elif _UUID_RE.fullmatch(token):
|
|
101
|
+
replacement = f"uuid:{token[-4:]}"
|
|
102
|
+
seen[token] = replacement
|
|
103
|
+
return replacement
|
|
104
|
+
elif forced is None and disclosure.is_canonical_id("agent", token):
|
|
105
|
+
kind = "agent"
|
|
106
|
+
else:
|
|
107
|
+
kind = "value"
|
|
108
|
+
counters[kind] = counters.get(kind, 0) + 1
|
|
109
|
+
seen[token] = f"{kind}-{counters[kind]}"
|
|
110
|
+
return seen[token]
|
|
111
|
+
|
|
112
|
+
def span(self, token: str, kind: str | None = None) -> str:
|
|
113
|
+
"""Handler-level errors (N3b P13): one value the user supplied, as it
|
|
114
|
+
may appear in a message at this level. At raw the value itself; at
|
|
115
|
+
share and local its replacement (path-N, uuid:<last4>, agent-N,
|
|
116
|
+
value-N), numbered across every span of one command. `kind` "path"
|
|
117
|
+
forces the path form for a value known to be a path whatever its
|
|
118
|
+
spelling (a bare directory name). The caller builds the rest of the
|
|
119
|
+
message from fixed text, so no prose is ever re-tokenized."""
|
|
120
|
+
if self.level == "raw":
|
|
121
|
+
return token
|
|
122
|
+
return self._classify(token, self._span_counters, self._span_seen, kind)
|
|
123
|
+
|
|
124
|
+
def _mapping(self) -> dict[str, str]:
|
|
125
|
+
counters: dict[str, int] = {}
|
|
126
|
+
seen: dict[str, str] = {}
|
|
127
|
+
mapping: dict[str, str] = {}
|
|
128
|
+
for token in self.tokens:
|
|
129
|
+
if token == "" or token == "--" or token in self.known:
|
|
130
|
+
continue
|
|
131
|
+
if token.startswith("--") and "=" in token:
|
|
132
|
+
name, _, value = token.partition("=")
|
|
133
|
+
# The value part of every `--option=value` is the user's, whether
|
|
134
|
+
# or not the name part is an exact option string (argparse also
|
|
135
|
+
# accepts an unambiguous abbreviation such as `--verb=...`, and
|
|
136
|
+
# echoes the value alone).
|
|
137
|
+
if value and value not in self.known:
|
|
138
|
+
mapping[value] = self._classify(value, counters, seen)
|
|
139
|
+
if name in self.known:
|
|
140
|
+
continue
|
|
141
|
+
mapping[token] = self._classify(token, counters, seen)
|
|
142
|
+
return mapping
|
|
143
|
+
|
|
144
|
+
def render(self, message: str) -> str:
|
|
145
|
+
if self.level == "raw" or not message:
|
|
146
|
+
return message
|
|
147
|
+
mapping = self._mapping()
|
|
148
|
+
if not mapping:
|
|
149
|
+
return message
|
|
150
|
+
spellings: dict[str, str] = {}
|
|
151
|
+
for token, replacement in mapping.items():
|
|
152
|
+
spellings[token] = replacement
|
|
153
|
+
quoted = repr(token)[1:-1] # how argparse's %r spells it
|
|
154
|
+
spellings.setdefault(quoted, replacement)
|
|
155
|
+
pattern = re.compile(
|
|
156
|
+
_LEFT + "(?:" + "|".join(
|
|
157
|
+
re.escape(spelling) for spelling in sorted(spellings, key=len, reverse=True)
|
|
158
|
+
) + ")" + _RIGHT
|
|
159
|
+
)
|
|
160
|
+
return pattern.sub(lambda match: spellings[match.group(0)], message)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What a returned result says about its own failures (docs/DESIGN.md 4.3, 4.13.4; G3, v0.3.1).
|
|
2
|
+
|
|
3
|
+
One shared predicate, used by the reader, the claim_contradiction detector, the audit and the
|
|
4
|
+
corpus check, so none of them can disagree about it. It reads the top level of a journal `result`
|
|
5
|
+
dict only: no free-text key, no nested value. Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# The keys the legacy claim_contradiction clauses read; the predicate leaves them to those clauses.
|
|
13
|
+
LEGACY_FAILED_KEYS = frozenset({"tests_failed", "suite_failed"})
|
|
14
|
+
_FAILED_SUFFIX = "_failed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SelfReport:
|
|
19
|
+
"""`failed_counts`: how many top-level keys ending in `_failed` hold an int above 0 (a bool is
|
|
20
|
+
not one; the legacy keys are excluded). `blocked`: the key `blocked` is exactly `true`."""
|
|
21
|
+
|
|
22
|
+
failed_counts: int = 0
|
|
23
|
+
blocked: bool = False
|
|
24
|
+
|
|
25
|
+
def __bool__(self) -> bool:
|
|
26
|
+
return self.failed_counts > 0 or self.blocked
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def self_reported_problems(result: object) -> SelfReport:
|
|
30
|
+
"""The SelfReport of one journal `result` value (empty for anything but a dict)."""
|
|
31
|
+
if not isinstance(result, dict):
|
|
32
|
+
return SelfReport()
|
|
33
|
+
counts = 0
|
|
34
|
+
for key, value in result.items():
|
|
35
|
+
if (
|
|
36
|
+
isinstance(key, str)
|
|
37
|
+
and key.endswith(_FAILED_SUFFIX)
|
|
38
|
+
and key not in LEGACY_FAILED_KEYS
|
|
39
|
+
and type(value) is int
|
|
40
|
+
and value > 0
|
|
41
|
+
):
|
|
42
|
+
counts += 1
|
|
43
|
+
return SelfReport(failed_counts=counts, blocked=result.get("blocked") is True)
|