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,435 @@
|
|
|
1
|
+
"""User configuration: quiet a detector without losing it from JSON or trend
|
|
2
|
+
(docs/DESIGN.md 4.10), and declare per-repo rescue checks (docs/DESIGN.md
|
|
3
|
+
4.11, unit M4).
|
|
4
|
+
|
|
5
|
+
`${XDG_CONFIG_HOME:-~/.config}/agent-postmortem/config.toml`, overridable by
|
|
6
|
+
`AGENT_POSTMORTEM_CONFIG` (a path, tests only). Section `[detectors]`, key
|
|
7
|
+
`off = ["<detector id>", ...]`. A silenced detector still runs: its findings
|
|
8
|
+
still populate `--json` (each finding gains a `silenced` bool) and `trend`'s
|
|
9
|
+
counts; text renderers (`run`, `scan`'s reason lines and top detector,
|
|
10
|
+
`demo`) drop them entirely -- never a STORIES entry, never in `--verbose`
|
|
11
|
+
FINDINGS, never in WHAT TO DO, never in the account sentence, never in the
|
|
12
|
+
verdict -- and a hook never notifies one or counts it toward the hook
|
|
13
|
+
verdict word (docs/DESIGN.md 4.6/4.7/7.2).
|
|
14
|
+
|
|
15
|
+
A critical-severity detector id in `off` is never actually silenced -- a
|
|
16
|
+
silenced critical would change the exit code (docs/DESIGN.md 4.6), which is
|
|
17
|
+
the one thing a config file must never be able to do quietly. `load_config()`
|
|
18
|
+
drops it from `detectors_off` unconditionally and records it in
|
|
19
|
+
`critical_rejected` instead of `warnings`, so a caller can tell the two
|
|
20
|
+
apart: the CLI (`run`/`scan`/`demo`) turns a non-empty `critical_rejected`
|
|
21
|
+
into a stderr message and exit 1 (refusing to run with a config that tried
|
|
22
|
+
to do this at all); a hook just proceeds without those ids ever landing in
|
|
23
|
+
`detectors_off`, silently, since a hook must never fail a turn.
|
|
24
|
+
|
|
25
|
+
Missing file: nothing silenced, no warnings. Malformed TOML, a non-list
|
|
26
|
+
`off`, a non-string entry, or an unknown detector id: a warning on stderr
|
|
27
|
+
(the caller's job to print, redacted), nothing silenced for that entry;
|
|
28
|
+
these never raise and never change any exit code.
|
|
29
|
+
|
|
30
|
+
`[[repos]]` tables (docs/DESIGN.md 4.11), each `{path = "...", checks =
|
|
31
|
+
[...]}`: the commands `rescue plan`/`rescue status` treat as this repo's
|
|
32
|
+
declared checks, matched by comparing the observed git toplevel (normalized,
|
|
33
|
+
`~`-expanded) against `path`, normalized the same way at load time. A
|
|
34
|
+
declared check that contains `&&`, `||`, `;`, `|`, a newline, or starts with
|
|
35
|
+
`cd ` is a compound or directory-changing command this tool must never be
|
|
36
|
+
handed to run verbatim from a fixed repo root; it is rejected at load with a
|
|
37
|
+
warning naming it and is never listed as declared. `checks_for_repo()` is the
|
|
38
|
+
one place that match happens; nothing else in this module or rescue.py
|
|
39
|
+
re-derives it.
|
|
40
|
+
|
|
41
|
+
`[[adopted]]` tables (docs/DESIGN.md 9, unit M6), each `{rule = "R-...",
|
|
42
|
+
date = "YYYY-MM-DD", target = "..."}`: a rule the owner has already put into
|
|
43
|
+
a prompt, a hook, or a project's own instructions, by hand, outside this
|
|
44
|
+
tool -- `rules review` uses the date to split a run's own detector rate into
|
|
45
|
+
before/after windows. `rule` must name a rule in `rules.RULES`; `date` must
|
|
46
|
+
parse as a calendar date; `target` is free text (where the owner put it),
|
|
47
|
+
never checked against the filesystem. A malformed table, or one naming an
|
|
48
|
+
unknown rule or an unparsable date, adds one warning and is dropped; every
|
|
49
|
+
other well-formed `[[adopted]]` table is still kept.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
import tomllib
|
|
56
|
+
from collections.abc import Callable
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
from datetime import datetime
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
from . import detectors as _detectors
|
|
62
|
+
from .models import Finding
|
|
63
|
+
from .rules import RULES
|
|
64
|
+
|
|
65
|
+
_CONFIG_ENV = "AGENT_POSTMORTEM_CONFIG"
|
|
66
|
+
_XDG_ENV = "XDG_CONFIG_HOME"
|
|
67
|
+
|
|
68
|
+
# docs/DESIGN.md 4.11: a declared check containing any of these, or starting
|
|
69
|
+
# with "cd ", is rejected at load -- this tool runs a declared check as one
|
|
70
|
+
# fixed argument list from the repo root (rescue's own protocol step 3), and
|
|
71
|
+
# a compound or directory-changing command could route around that.
|
|
72
|
+
_FORBIDDEN_CHECK_FRAGMENTS = ("&&", "||", ";", "|", "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_is_rejected(check: str) -> bool:
|
|
76
|
+
if any(fragment in check for fragment in _FORBIDDEN_CHECK_FRAGMENTS):
|
|
77
|
+
return True
|
|
78
|
+
return check.lstrip().startswith("cd ")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class WarningPart:
|
|
83
|
+
"""One piece of a config warning (docs/DESIGN.md 5.1; F fix 8, W8-10). `kind` is `text` (the
|
|
84
|
+
tool's own fixed words, kept as they are), `path` (the config file or a `[[repos]]` path field,
|
|
85
|
+
rendered once as one value by `display_path`) or `untrusted` (a value that came from the file:
|
|
86
|
+
a rule id, a detector id, a date, a parser message, a rejected command; it goes through the
|
|
87
|
+
free-text scan as one piece)."""
|
|
88
|
+
|
|
89
|
+
kind: str
|
|
90
|
+
value: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ConfigWarning(str):
|
|
94
|
+
"""A warning as the text it is plus the typed parts it was built from. It IS the assembled
|
|
95
|
+
string (so a caller that only prints or compares it is unchanged); `Config.shown_warnings`
|
|
96
|
+
renders from `parts` and never splits the assembled text."""
|
|
97
|
+
|
|
98
|
+
parts: tuple[WarningPart, ...]
|
|
99
|
+
|
|
100
|
+
def __new__(cls, *parts: WarningPart) -> "ConfigWarning":
|
|
101
|
+
warning = super().__new__(cls, "".join(part.value for part in parts))
|
|
102
|
+
warning.parts = tuple(parts)
|
|
103
|
+
return warning
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _t(value: object) -> WarningPart:
|
|
107
|
+
return WarningPart("text", str(value))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _p(value: object) -> WarningPart:
|
|
111
|
+
return WarningPart("path", str(value))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _u(value: object) -> WarningPart:
|
|
115
|
+
return WarningPart("untrusted", str(value))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _warn(path: object, *rest: WarningPart) -> ConfigWarning:
|
|
119
|
+
"""`agent-postmortem: <config path>: <rest>`, the shape of every warning."""
|
|
120
|
+
return ConfigWarning(_t("agent-postmortem: "), _p(path), _t(": "), *rest)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class RepoConfig:
|
|
125
|
+
"""One `[[repos]]` table (docs/DESIGN.md 4.11): `path`, normalized and
|
|
126
|
+
`~`-expanded at load time so `checks_for_repo` is a plain string
|
|
127
|
+
comparison, and `checks`, already filtered of any rejected command."""
|
|
128
|
+
|
|
129
|
+
path: str
|
|
130
|
+
checks: tuple[str, ...] = ()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class AdoptedRule:
|
|
135
|
+
"""One `[[adopted]]` table (docs/DESIGN.md 9): a rule the owner already
|
|
136
|
+
put somewhere by hand, outside this tool. `date` is a plain "YYYY-MM-DD"
|
|
137
|
+
string, already validated as a real calendar date at load time."""
|
|
138
|
+
|
|
139
|
+
rule: str
|
|
140
|
+
date: str
|
|
141
|
+
target: str = ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class Config:
|
|
146
|
+
detectors_off: frozenset[str]
|
|
147
|
+
warnings: list[str] = field(default_factory=list)
|
|
148
|
+
# A critical detector id named in [detectors].off, never honored (see
|
|
149
|
+
# module docstring). Kept separate from `warnings` so a caller can tell
|
|
150
|
+
# "silence this unknown id anyway, just warn" from "refuse to run".
|
|
151
|
+
critical_rejected: tuple[str, ...] = ()
|
|
152
|
+
# docs/DESIGN.md 4.11: every successfully parsed [[repos]] table, a
|
|
153
|
+
# rejected check already dropped from its own `checks` (with a warning
|
|
154
|
+
# recorded above, not here).
|
|
155
|
+
repos: tuple[RepoConfig, ...] = ()
|
|
156
|
+
# docs/DESIGN.md 9: every successfully parsed [[adopted]] table --
|
|
157
|
+
# `rules review`'s own input, alongside the state store's legacy
|
|
158
|
+
# `applied` entries (7.5).
|
|
159
|
+
adopted: tuple[AdoptedRule, ...] = ()
|
|
160
|
+
# The config file the warnings name (N3b C4): lets a renderer replace it,
|
|
161
|
+
# and every [[repos]] path, by its own display before the text is shown.
|
|
162
|
+
config_file: str = ""
|
|
163
|
+
|
|
164
|
+
def shown_warnings(
|
|
165
|
+
self, display: Callable[[str], str], scan: Callable[[str], str] | None = None,
|
|
166
|
+
) -> list[str]:
|
|
167
|
+
"""`warnings` as they are shown (docs/DESIGN.md 5.1; F fix 8, W8-10): a warning is built
|
|
168
|
+
from typed parts when the parser creates it (`ConfigWarning`). A `text` part is kept, a
|
|
169
|
+
`path` part (the config file, a `[[repos]]` path) is rendered once as one value by
|
|
170
|
+
display(path), and an `untrusted` part goes through `scan` as one piece when one is given.
|
|
171
|
+
The assembled warning is never split again and never scanned as a whole, so a repository
|
|
172
|
+
path that is a prefix of a longer untrusted path cannot leave a tail unscanned, and a
|
|
173
|
+
rendered `~/name` is not read as a path start that hides the words after it. A warning
|
|
174
|
+
with no parts (a hand-built one) is one untrusted piece."""
|
|
175
|
+
return ["".join(self._shown_piece(part, display, scan) for part in _parts_of(warning))
|
|
176
|
+
for warning in self.warnings]
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _shown_piece(
|
|
180
|
+
part: WarningPart, display: Callable[[str], str], scan: Callable[[str], str] | None,
|
|
181
|
+
) -> str:
|
|
182
|
+
if part.kind == "path":
|
|
183
|
+
return display(part.value)
|
|
184
|
+
if part.kind == "untrusted" and scan is not None and part.value:
|
|
185
|
+
return scan(part.value)
|
|
186
|
+
return part.value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _parts_of(warning: str) -> tuple[WarningPart, ...]:
|
|
190
|
+
if isinstance(warning, ConfigWarning):
|
|
191
|
+
return warning.parts
|
|
192
|
+
return (WarningPart("untrusted", str(warning)),)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_EMPTY = Config(detectors_off=frozenset(), warnings=[])
|
|
196
|
+
|
|
197
|
+
# Process-level cache keyed by the resolved config path (the same pattern
|
|
198
|
+
# reader.ParentIndex uses for a parent transcript): re-reading the same path
|
|
199
|
+
# twice in one process is wasted work, but a different path (a test's own
|
|
200
|
+
# tmp_path, via AGENT_POSTMORTEM_CONFIG) always gets its own fresh parse.
|
|
201
|
+
_cache: dict[str, Config] = {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def config_path() -> Path:
|
|
205
|
+
"""`$AGENT_POSTMORTEM_CONFIG`, else
|
|
206
|
+
`${XDG_CONFIG_HOME:-~/.config}/agent-postmortem/config.toml` (docs/
|
|
207
|
+
DESIGN.md 4.8/4.10)."""
|
|
208
|
+
override = os.environ.get(_CONFIG_ENV, "").strip()
|
|
209
|
+
if override:
|
|
210
|
+
return Path(override).expanduser()
|
|
211
|
+
xdg = os.environ.get(_XDG_ENV, "").strip()
|
|
212
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
213
|
+
return base / "agent-postmortem" / "config.toml"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _known_detector_ids() -> frozenset[str]:
|
|
217
|
+
return frozenset(detector.id for detector in _detectors.all_detectors())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _critical_detector_ids() -> frozenset[str]:
|
|
221
|
+
return frozenset(
|
|
222
|
+
detector.id for detector in _detectors.all_detectors()
|
|
223
|
+
if detector.severity_default == "critical"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_repos(data: dict, path: Path, warnings: list[str]) -> list[RepoConfig]:
|
|
228
|
+
"""Every usable `[[repos]]` table (docs/DESIGN.md 4.11): a malformed
|
|
229
|
+
table, a malformed `path`, a malformed `checks` list, or an individual
|
|
230
|
+
rejected check each add one warning and are dropped -- never raise,
|
|
231
|
+
never abort parsing the rest of the file."""
|
|
232
|
+
raw_repos = data.get("repos")
|
|
233
|
+
if raw_repos is None:
|
|
234
|
+
return []
|
|
235
|
+
if not isinstance(raw_repos, list):
|
|
236
|
+
warnings.append(_warn(path, _t("[[repos]] must be an array of tables")))
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
repos: list[RepoConfig] = []
|
|
240
|
+
for entry in raw_repos:
|
|
241
|
+
if not isinstance(entry, dict):
|
|
242
|
+
warnings.append(_warn(path, _t("a [[repos]] entry must be a table")))
|
|
243
|
+
continue
|
|
244
|
+
raw_path = entry.get("path")
|
|
245
|
+
if not isinstance(raw_path, str) or not raw_path:
|
|
246
|
+
warnings.append(_warn(path, _t("a [[repos]] table needs a non-empty 'path'")))
|
|
247
|
+
continue
|
|
248
|
+
norm_path = os.path.normpath(os.path.expanduser(raw_path))
|
|
249
|
+
|
|
250
|
+
raw_checks = entry.get("checks", [])
|
|
251
|
+
if not isinstance(raw_checks, list):
|
|
252
|
+
warnings.append(_warn(
|
|
253
|
+
path, _t("[[repos]] 'checks' must be a list of strings ("), _p(norm_path), _t(")"),
|
|
254
|
+
))
|
|
255
|
+
raw_checks = []
|
|
256
|
+
|
|
257
|
+
checks: list[str] = []
|
|
258
|
+
for check in raw_checks:
|
|
259
|
+
if not isinstance(check, str) or not check.strip():
|
|
260
|
+
warnings.append(_warn(
|
|
261
|
+
path, _t("a declared check must be a non-empty string ("), _p(norm_path),
|
|
262
|
+
_t(")"),
|
|
263
|
+
))
|
|
264
|
+
continue
|
|
265
|
+
if _check_is_rejected(check):
|
|
266
|
+
warnings.append(_warn(
|
|
267
|
+
path,
|
|
268
|
+
_t("declared check rejected, compound or directory-changing command: "),
|
|
269
|
+
_u(repr(check)),
|
|
270
|
+
))
|
|
271
|
+
continue
|
|
272
|
+
checks.append(check)
|
|
273
|
+
|
|
274
|
+
repos.append(RepoConfig(path=norm_path, checks=tuple(checks)))
|
|
275
|
+
return repos
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_adopted(data: dict, path: Path, warnings: list[str]) -> list[AdoptedRule]:
|
|
279
|
+
"""Every usable `[[adopted]]` table (docs/DESIGN.md 9): a malformed
|
|
280
|
+
table, an unknown rule id, or an unparsable date each add one warning
|
|
281
|
+
and are dropped -- never raise, never abort parsing the rest of the
|
|
282
|
+
file."""
|
|
283
|
+
raw_adopted = data.get("adopted")
|
|
284
|
+
if raw_adopted is None:
|
|
285
|
+
return []
|
|
286
|
+
if not isinstance(raw_adopted, list):
|
|
287
|
+
warnings.append(_warn(path, _t("[[adopted]] must be an array of tables")))
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
adopted: list[AdoptedRule] = []
|
|
291
|
+
for entry in raw_adopted:
|
|
292
|
+
if not isinstance(entry, dict):
|
|
293
|
+
warnings.append(_warn(path, _t("an [[adopted]] entry must be a table")))
|
|
294
|
+
continue
|
|
295
|
+
rule_id = entry.get("rule")
|
|
296
|
+
if not isinstance(rule_id, str) or not rule_id:
|
|
297
|
+
warnings.append(_warn(path, _t("an [[adopted]] table needs a non-empty 'rule'")))
|
|
298
|
+
continue
|
|
299
|
+
if rule_id not in RULES:
|
|
300
|
+
warnings.append(_warn(
|
|
301
|
+
path, _t("unknown rule id '"), _u(rule_id), _t("' in [[adopted]]"),
|
|
302
|
+
))
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
date = entry.get("date")
|
|
306
|
+
if not isinstance(date, str):
|
|
307
|
+
warnings.append(_warn(
|
|
308
|
+
path, _t("an [[adopted]] table needs 'date' as YYYY-MM-DD ("), _u(rule_id), _t(")"),
|
|
309
|
+
))
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
datetime.strptime(date, "%Y-%m-%d")
|
|
313
|
+
except ValueError:
|
|
314
|
+
warnings.append(_warn(
|
|
315
|
+
path, _t("[[adopted]] 'date' is not a valid YYYY-MM-DD date ("), _u(rule_id),
|
|
316
|
+
_t(": "), _u(repr(date)), _t(")"),
|
|
317
|
+
))
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
target = entry.get("target", "")
|
|
321
|
+
if not isinstance(target, str):
|
|
322
|
+
warnings.append(_warn(
|
|
323
|
+
path, _t("[[adopted]] 'target' must be a string ("), _u(rule_id), _t(")"),
|
|
324
|
+
))
|
|
325
|
+
target = ""
|
|
326
|
+
|
|
327
|
+
adopted.append(AdoptedRule(rule=rule_id, date=date, target=target))
|
|
328
|
+
return adopted
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _parse(path: Path) -> Config:
|
|
332
|
+
try:
|
|
333
|
+
raw = path.read_bytes()
|
|
334
|
+
except OSError:
|
|
335
|
+
return _EMPTY
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
339
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError) as exc:
|
|
340
|
+
return Config(
|
|
341
|
+
detectors_off=frozenset(),
|
|
342
|
+
warnings=[_warn(path, _t("could not parse config: "), _u(exc))],
|
|
343
|
+
config_file=str(path),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
warnings: list[str] = []
|
|
347
|
+
critical_rejected: list[str] = []
|
|
348
|
+
kept: set[str] = set()
|
|
349
|
+
|
|
350
|
+
detectors_section = data.get("detectors")
|
|
351
|
+
if detectors_section is not None:
|
|
352
|
+
if not isinstance(detectors_section, dict):
|
|
353
|
+
warnings.append(_warn(path, _t("[detectors] must be a table")))
|
|
354
|
+
else:
|
|
355
|
+
off = detectors_section.get("off", [])
|
|
356
|
+
if not isinstance(off, list):
|
|
357
|
+
warnings.append(_warn(path, _t("[detectors].off must be a list of strings")))
|
|
358
|
+
else:
|
|
359
|
+
known = _known_detector_ids()
|
|
360
|
+
critical = _critical_detector_ids()
|
|
361
|
+
for item in off:
|
|
362
|
+
if not isinstance(item, str) or not item:
|
|
363
|
+
warnings.append(_warn(
|
|
364
|
+
path, _t("[detectors].off entries must be non-empty strings"),
|
|
365
|
+
))
|
|
366
|
+
continue
|
|
367
|
+
if item in critical:
|
|
368
|
+
critical_rejected.append(item)
|
|
369
|
+
continue
|
|
370
|
+
if item not in known:
|
|
371
|
+
warnings.append(_warn(
|
|
372
|
+
path, _t("unknown detector id '"), _u(item), _t("' in [detectors].off"),
|
|
373
|
+
))
|
|
374
|
+
continue
|
|
375
|
+
kept.add(item)
|
|
376
|
+
|
|
377
|
+
repos = _parse_repos(data, path, warnings)
|
|
378
|
+
adopted = _parse_adopted(data, path, warnings)
|
|
379
|
+
|
|
380
|
+
return Config(
|
|
381
|
+
detectors_off=frozenset(kept),
|
|
382
|
+
warnings=warnings,
|
|
383
|
+
critical_rejected=tuple(critical_rejected),
|
|
384
|
+
repos=tuple(repos),
|
|
385
|
+
adopted=tuple(adopted),
|
|
386
|
+
config_file=str(path),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def load_config() -> Config:
|
|
391
|
+
"""The active Config, cached per process by resolved path (docs/
|
|
392
|
+
DESIGN.md 4.10). Never raises: a missing file, a malformed one, or one
|
|
393
|
+
naming an unknown or critical detector id all return a usable Config,
|
|
394
|
+
the problem (if any) recorded in `warnings`/`critical_rejected` for the
|
|
395
|
+
caller to print."""
|
|
396
|
+
path = config_path()
|
|
397
|
+
key = str(path)
|
|
398
|
+
cached = _cache.get(key)
|
|
399
|
+
if cached is not None:
|
|
400
|
+
return cached
|
|
401
|
+
parsed = _parse(path)
|
|
402
|
+
_cache[key] = parsed
|
|
403
|
+
return parsed
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def partition_silenced(
|
|
407
|
+
findings: list[Finding], config: Config,
|
|
408
|
+
) -> tuple[list[Finding], list[Finding]]:
|
|
409
|
+
"""(shown, silenced): findings whose detector id is in
|
|
410
|
+
config.detectors_off go to `silenced`, everything else to `shown`, both
|
|
411
|
+
in the findings' original order. The one split every renderer (docs/
|
|
412
|
+
DESIGN.md 4.6/4.7) and every hook (7.2) uses, so "what counts as
|
|
413
|
+
silenced" is never redefined twice."""
|
|
414
|
+
shown: list[Finding] = []
|
|
415
|
+
silenced: list[Finding] = []
|
|
416
|
+
for finding in findings:
|
|
417
|
+
if finding.detector in config.detectors_off:
|
|
418
|
+
silenced.append(finding)
|
|
419
|
+
else:
|
|
420
|
+
shown.append(finding)
|
|
421
|
+
return shown, silenced
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def checks_for_repo(cfg: Config, repo_root: str) -> list[str]:
|
|
425
|
+
"""The declared checks for repo_root (docs/DESIGN.md 4.11): the observed
|
|
426
|
+
git toplevel, normalized and `~`-expanded the same way `_parse_repos`
|
|
427
|
+
normalized every `[[repos]]` table's own `path` at load time, so this is
|
|
428
|
+
a plain string comparison. `[]` when no `[[repos]]` table names this
|
|
429
|
+
repo -- rescue.py's own "no checks declared" wording, not this
|
|
430
|
+
function's job."""
|
|
431
|
+
norm_root = os.path.normpath(os.path.expanduser(repo_root))
|
|
432
|
+
for repo in cfg.repos:
|
|
433
|
+
if repo.path == norm_root:
|
|
434
|
+
return list(repo.checks)
|
|
435
|
+
return []
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-01-06T19:00:00.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":null,"agentId":"ac200000000000001","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"Implement the silent-alpha module and add a test."}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-01-06T19:00:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000001","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Running the test suite first."},{"type":"tool_use","id":"toolu_silent_c_1","name":"Bash","input":{"command":"pytest tests/test_silent_alpha.py -q"}}],"usage":{"input_tokens":100,"output_tokens":30,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
3
|
+
{"type":"user","timestamp":"2026-01-06T19:00:06.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000001","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_silent_c_1","content":"1 passed in 0.02s","is_error":false}]}}
|
|
4
|
+
{"type":"assistant","timestamp":"2026-01-06T19:03:04.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000001","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Tests pass. Writing the implementation now."}],"usage":{"input_tokens":90,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
5
|
+
{"type":"user","timestamp":"2026-01-06T19:10:00.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000001","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"[Request interrupted by user]"}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-01-06T19:10:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":null,"agentId":"ac200000000000002","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"You may be a retry after a killed attempt. Implement the silent-alpha module and add a test."}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-01-06T19:25:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000002","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Done. Implemented silent-alpha with a passing test."}],"usage":{"input_tokens":160,"output_tokens":45,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-01-06T19:30:00.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":null,"agentId":"ac200000000000003","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"Implement the silent-beta module and add a test."}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-01-06T19:30:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000003","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Running the test suite first."},{"type":"tool_use","id":"toolu_silent_e_1","name":"Bash","input":{"command":"pytest tests/test_silent_beta.py -q"}}],"usage":{"input_tokens":100,"output_tokens":30,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
3
|
+
{"type":"user","timestamp":"2026-01-06T19:30:06.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000003","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_silent_e_1","content":"1 passed in 0.02s","is_error":false}]}}
|
|
4
|
+
{"type":"assistant","timestamp":"2026-01-06T19:33:04.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000003","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Tests pass. Writing the implementation now."}],"usage":{"input_tokens":90,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
5
|
+
{"type":"user","timestamp":"2026-01-06T19:40:00.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000003","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"[Request interrupted by user]"}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"type":"user","timestamp":"2026-01-06T19:40:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":null,"agentId":"ac200000000000004","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"user","content":"You may be a retry after a killed attempt. Implement the silent-beta module and add a test."}}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-01-06T19:55:05.000Z","uuid":"00000000-0000-4000-8000-000000000000","parentUuid":"00000000-0000-4000-8000-000000000000","agentId":"ac200000000000004","cwd":"/home/user/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","message":{"role":"assistant","content":[{"type":"text","text":"Done. Implemented silent-beta with a passing test."}],"usage":{"input_tokens":160,"output_tokens":45,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"type":"launched","timestamp":"2026-01-06T18:59:59.000Z"}
|
|
2
|
+
{"type":"started","timestamp":"2026-01-06T19:00:00.000Z","key":"v2:silent-alpha","agentId":"ac200000000000001","label":"impl:silent-alpha","phase":"Implement"}
|
|
3
|
+
{"type":"started","timestamp":"2026-01-06T19:10:05.000Z","key":"v2:silent-alpha","agentId":"ac200000000000002","label":"impl:silent-alpha","phase":"Implement"}
|
|
4
|
+
{"type":"result","timestamp":"2026-01-06T19:25:05.000Z","agentId":"ac200000000000002","key":"v2:silent-alpha","outcome":"completed"}
|
|
5
|
+
{"type":"started","timestamp":"2026-01-06T19:30:00.000Z","key":"v2:silent-beta","agentId":"ac200000000000003","label":"impl:silent-beta","phase":"Implement"}
|
|
6
|
+
{"type":"started","timestamp":"2026-01-06T19:40:05.000Z","key":"v2:silent-beta","agentId":"ac200000000000004","label":"impl:silent-beta","phase":"Implement"}
|
|
7
|
+
{"type":"result","timestamp":"2026-01-06T19:55:05.000Z","agentId":"ac200000000000004","key":"v2:silent-beta","outcome":"completed"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{"type":"user","message":{"role":"user","content":"Implement the silent-alpha and silent-beta modules."},"sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","cwd":"/home/user/project","timestamp":"2026-01-06T18:59:40.000Z"}
|
|
2
|
+
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_demo0001","name":"Workflow","input":{"description":"implement silent modules"}}],"usage":{"input_tokens":12,"output_tokens":6}},"sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","cwd":"/home/user/project","timestamp":"2026-01-06T18:59:50.000Z"}
|
|
3
|
+
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_demo0001","content":"Launched workflow. Run ID: wf_00000000-d30"}]},"toolUseResult":{"runId":"wf_00000000-d30","taskId":"demo-task-1","status":"async_launched"},"sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","cwd":"/home/user/project","timestamp":"2026-01-06T18:59:58.000Z"}
|
|
4
|
+
{"type":"user","message":{"role":"user","content":"<task-notification>\n<task-id>demo-task-1</task-id>\n<tool-use-id>toolu_demo0001</tool-use-id>\n<output-file>/tmp/demo-output</output-file>\n<status>completed</status>\n</task-notification>"},"origin":{"kind":"task-notification"},"sessionId":"00000000-0000-4000-8000-000000000000","version":"2.1.267","cwd":"/home/user/project","timestamp":"2026-01-06T19:56:00.000Z"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Detector registry.
|
|
2
|
+
|
|
3
|
+
Adding a detector means adding a module to this package that exports a
|
|
4
|
+
module-level DETECTOR instance; nothing here needs to change. all_detectors()
|
|
5
|
+
imports every module in the package and collects the ones that export it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import pkgutil
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from .base import Detector
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..models import Finding, Run
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Detector", "all_detectors", "run_detectors", "run_tooling_detectors", "TOOLING_DETECTOR_IDS",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# docs/DESIGN.md 4.3: the eight tooling detectors, scanned by a session's
|
|
24
|
+
# main thread as well as every child attempt. Named here once so a caller
|
|
25
|
+
# that must run only these (docs/DESIGN.md 7.2's session-end hook, D5) does
|
|
26
|
+
# not have to spell the list out itself.
|
|
27
|
+
TOOLING_DETECTOR_IDS: frozenset[str] = frozenset({
|
|
28
|
+
"bash_backgrounded",
|
|
29
|
+
"bash_timeout",
|
|
30
|
+
"tool_json_error",
|
|
31
|
+
"structured_output_failure",
|
|
32
|
+
"codex_write_denied",
|
|
33
|
+
"classifier_unavailable",
|
|
34
|
+
"git_cwd_mismatch",
|
|
35
|
+
"codex_unavailable",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# docs/DESIGN.md 4.13.7 step 1 (N7): discovery runs once per process, not once
|
|
40
|
+
# per run; `scan` used to repeat it for every run it analyzed.
|
|
41
|
+
_REGISTRY: list[Detector] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def all_detectors() -> list[Detector]:
|
|
45
|
+
"""Return every registered detector, sorted by id.
|
|
46
|
+
|
|
47
|
+
Discovered on the first call and cached for the process; each call hands
|
|
48
|
+
out a new list, so a caller that edits its list cannot change what the next
|
|
49
|
+
caller sees. Returns an empty list when the package holds no detector
|
|
50
|
+
modules (v0 ships this package with base.py only; detector modules land
|
|
51
|
+
later).
|
|
52
|
+
"""
|
|
53
|
+
global _REGISTRY
|
|
54
|
+
if _REGISTRY is None:
|
|
55
|
+
found: list[Detector] = []
|
|
56
|
+
for module_info in pkgutil.iter_modules(__path__):
|
|
57
|
+
if module_info.name in ("base", "__init__"):
|
|
58
|
+
continue
|
|
59
|
+
module = importlib.import_module(f"{__name__}.{module_info.name}")
|
|
60
|
+
detector = getattr(module, "DETECTOR", None)
|
|
61
|
+
if detector is not None:
|
|
62
|
+
found.append(detector)
|
|
63
|
+
_REGISTRY = sorted(found, key=lambda detector: detector.id)
|
|
64
|
+
return list(_REGISTRY)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _reset_registry() -> None:
|
|
68
|
+
"""Forget the cached discovery, so the next all_detectors() call discovers
|
|
69
|
+
again. A documented test hook (docs/DESIGN.md 4.13.11 "N7"); production code
|
|
70
|
+
never calls it."""
|
|
71
|
+
global _REGISTRY
|
|
72
|
+
_REGISTRY = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_detectors(run: "Run") -> list["Finding"]:
|
|
76
|
+
"""Every finding from every registered detector whose kinds include
|
|
77
|
+
run.kind (docs/DESIGN.md 4.3): the one place cli.py, report.py, and
|
|
78
|
+
trend.py all get findings from, so a run kind's applicable detectors are
|
|
79
|
+
never decided in more than one place.
|
|
80
|
+
"""
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
for detector in all_detectors():
|
|
83
|
+
if run.kind in detector.kinds:
|
|
84
|
+
findings.extend(detector.run(run))
|
|
85
|
+
return findings
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_tooling_detectors(run: "Run") -> list["Finding"]:
|
|
89
|
+
"""Every finding from just the eight tooling detectors (docs/
|
|
90
|
+
DESIGN.md 4.3, TOOLING_DETECTOR_IDS), for a caller that must not run
|
|
91
|
+
mislabeled_kill or missing_artifact -- docs/DESIGN.md 7.2's session-end
|
|
92
|
+
hook (D5), which analyzes only the main thread and has no children to
|
|
93
|
+
attribute an interrupt or a missing artifact to."""
|
|
94
|
+
findings: list[Finding] = []
|
|
95
|
+
for detector in all_detectors():
|
|
96
|
+
if detector.id in TOOLING_DETECTOR_IDS and run.kind in detector.kinds:
|
|
97
|
+
findings.extend(detector.run(run))
|
|
98
|
+
return findings
|