agent-postmortem 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)