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,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,8 @@
1
+ {
2
+ "agentType": "general-purpose",
3
+ "description": "Implement the silent-alpha module.",
4
+ "workflowPhase": "Implement",
5
+ "spawnDepth": "1",
6
+ "requestShape": "single",
7
+ "requestNonInteractive": "true"
8
+ }
@@ -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,8 @@
1
+ {
2
+ "agentType": "general-purpose",
3
+ "description": "Implement the silent-alpha module.",
4
+ "workflowPhase": "Implement",
5
+ "spawnDepth": "1",
6
+ "requestShape": "single",
7
+ "requestNonInteractive": "true"
8
+ }
@@ -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,8 @@
1
+ {
2
+ "agentType": "general-purpose",
3
+ "description": "Implement the silent-beta module.",
4
+ "workflowPhase": "Implement",
5
+ "spawnDepth": "1",
6
+ "requestShape": "single",
7
+ "requestNonInteractive": "true"
8
+ }
@@ -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,8 @@
1
+ {
2
+ "agentType": "general-purpose",
3
+ "description": "Implement the silent-beta module.",
4
+ "workflowPhase": "Implement",
5
+ "spawnDepth": "1",
6
+ "requestShape": "single",
7
+ "requestNonInteractive": "true"
8
+ }
@@ -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