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,391 @@
1
+ """Weekly detector counts, and the fire rate before versus after an applied
2
+ rule.
3
+
4
+ This module counts; it never claims causation. Output wording is strictly
5
+ "before" and "after". See docs/DESIGN.md section 4.6.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import PurePosixPath
12
+
13
+ from .detectors import all_detectors
14
+ from .models import Finding, Run
15
+ from .redact import Pseudonyms, Redactor
16
+ from .report import SCHEMA_VERSION
17
+ from .rules import RULES
18
+
19
+ WeeklyCounts = dict[str, dict[str, int]]
20
+
21
+
22
+ def _detector_kinds() -> dict[str, frozenset[str]]:
23
+ """detector id -> the run kinds it applies to (docs/DESIGN.md 4.3),
24
+ looked up fresh each call so a test's own detector registrations (there
25
+ are none in practice, but nothing here should assume otherwise) are
26
+ never stale."""
27
+ return {detector.id: detector.kinds for detector in all_detectors()}
28
+
29
+
30
+ def _rule_kinds(
31
+ rule_id: str | None, kinds_by_detector: dict[str, frozenset[str]]
32
+ ) -> frozenset[str]:
33
+ """The run kinds a rule's finding-backed detectors apply to, unioned
34
+ across every detector that backs it. Empty for an unknown rule id or one
35
+ with no detector backing it in v0 (docs/DESIGN.md 4.7): there is nothing
36
+ to count, so no kind applies."""
37
+ rule = RULES.get(rule_id) if rule_id else None
38
+ if rule is None:
39
+ return frozenset()
40
+ kinds: set[str] = set()
41
+ for detector_id in rule.detectors:
42
+ kinds |= set(kinds_by_detector.get(detector_id, ()))
43
+ return frozenset(kinds)
44
+
45
+
46
+ def _iso_week_key(dt: datetime) -> str:
47
+ iso = dt.date().isocalendar()
48
+ return f"{iso.year}-W{iso.week:02d}"
49
+
50
+
51
+ def _trailing_iso_weeks(reference: datetime, weeks: int) -> list[str]:
52
+ """The `weeks` ISO week keys ending at reference's own week, oldest first."""
53
+ if weeks <= 0:
54
+ return []
55
+ ref_date = reference.date()
56
+ ref_monday = ref_date - timedelta(days=ref_date.isoweekday() - 1)
57
+ return [
58
+ _iso_week_key(datetime.combine(ref_monday - timedelta(weeks=i), datetime.min.time()))
59
+ for i in range(weeks - 1, -1, -1)
60
+ ]
61
+
62
+
63
+ def weekly_counts(
64
+ runs_with_findings: list[tuple[Run, list[Finding]]],
65
+ weeks: int,
66
+ *,
67
+ now: datetime | None = None,
68
+ ) -> WeeklyCounts:
69
+ """Per detector, per ISO week in the trailing `weeks` weeks ending at
70
+ `now` (default: the current time), the number of runs that fired that
71
+ detector at least once. A run with no started_at is skipped: it cannot
72
+ be placed in a week. Detectors that never fired in the window are
73
+ simply absent from the result. A finding whose detector does not apply
74
+ to its own run's kind (docs/DESIGN.md 4.3 and 4.7) is never counted:
75
+ run_detectors() already keeps this from happening in practice, but a
76
+ caller that hands in findings some other way must not silently inflate
77
+ a count over a kind the detector never runs on.
78
+ """
79
+ reference = now or datetime.now(timezone.utc)
80
+ week_keys = _trailing_iso_weeks(reference, weeks)
81
+ window = set(week_keys)
82
+ kinds_by_detector = _detector_kinds()
83
+
84
+ counts: WeeklyCounts = {}
85
+ for run, findings in runs_with_findings:
86
+ if run.started_at is None:
87
+ continue
88
+ week_key = _iso_week_key(run.started_at)
89
+ if week_key not in window:
90
+ continue
91
+ applicable_detectors = {
92
+ finding.detector for finding in findings
93
+ if run.kind in kinds_by_detector.get(finding.detector, ())
94
+ }
95
+ # Sorted: set iteration order changes with the hash seed, and the
96
+ # insertion order here is the key order `trend --json` prints.
97
+ for detector_id in sorted(applicable_detectors):
98
+ row = counts.setdefault(detector_id, {week: 0 for week in week_keys})
99
+ row[week_key] += 1
100
+
101
+ return counts
102
+
103
+
104
+ def _parse_applied_at(value: str | None) -> datetime | None:
105
+ if not value:
106
+ return None
107
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
108
+ try:
109
+ parsed = datetime.fromisoformat(text)
110
+ except ValueError:
111
+ return None
112
+ if parsed.tzinfo is None:
113
+ parsed = parsed.replace(tzinfo=timezone.utc)
114
+ return parsed
115
+
116
+
117
+ def before_after(
118
+ applied: list[dict],
119
+ runs_with_findings: list[tuple[Run, list[Finding]]],
120
+ *,
121
+ window_weeks: int | None = None,
122
+ ) -> list[dict]:
123
+ """For each applied-rule record, the fire rate before and after applied_at.
124
+
125
+ "Fired" means at least one of the rule's detectors produced a finding in
126
+ that run. Runs with no started_at, or whose kind is not one the rule's
127
+ backing detectors apply to (docs/DESIGN.md 4.7), are excluded from both
128
+ windows: a session run must never dilute or inflate a workflow-only
129
+ rule's rate just because it has its own started_at. A rule with no
130
+ detectors backing it in v0 (for example R-MODEL-ALLOCATION, which no
131
+ detector fires yet) or an unparsable applied_at yields a 0/0, rate None
132
+ on both sides: there is nothing to count. Each row also carries "kinds":
133
+ the sorted run kinds the rule's detectors apply to, empty when nothing
134
+ backs it -- the denominator's own label (docs/DESIGN.md 4.7).
135
+
136
+ `window_weeks` (docs/DESIGN.md 9, `rules review`): when given, a run
137
+ only counts toward "before" if it started within that many weeks before
138
+ applied_at, and toward "after" only within that many weeks after --
139
+ unbounded (the default, what plain `trend` uses) when None, so every
140
+ existing caller is unaffected.
141
+ """
142
+ kinds_by_detector = _detector_kinds()
143
+ window = timedelta(weeks=window_weeks) if window_weeks is not None else None
144
+ rows: list[dict] = []
145
+ for entry in applied:
146
+ rule_id = entry.get("rule_id")
147
+ applied_at = _parse_applied_at(entry.get("applied_at"))
148
+ rule = RULES.get(rule_id)
149
+ detector_ids = set(rule.detectors) if rule else set()
150
+ applicable_kinds = _rule_kinds(rule_id, kinds_by_detector)
151
+
152
+ before_runs = before_fired = after_runs = after_fired = 0
153
+ if applied_at is not None and detector_ids:
154
+ for run, findings in runs_with_findings:
155
+ if run.started_at is None or run.kind not in applicable_kinds:
156
+ continue
157
+ fired = any(finding.detector in detector_ids for finding in findings)
158
+ if run.started_at < applied_at:
159
+ if window is not None and run.started_at < applied_at - window:
160
+ continue
161
+ before_runs += 1
162
+ before_fired += int(fired)
163
+ else:
164
+ if window is not None and run.started_at >= applied_at + window:
165
+ continue
166
+ after_runs += 1
167
+ after_fired += int(fired)
168
+
169
+ rows.append(
170
+ {
171
+ "rule_id": rule_id,
172
+ "target": entry.get("target"),
173
+ "applied_at": entry.get("applied_at"),
174
+ "kinds": sorted(applicable_kinds),
175
+ "before": {
176
+ "runs": before_runs,
177
+ "fired": before_fired,
178
+ "rate": (before_fired / before_runs) if before_runs else None,
179
+ },
180
+ "after": {
181
+ "runs": after_runs,
182
+ "fired": after_fired,
183
+ "rate": (after_fired / after_runs) if after_runs else None,
184
+ },
185
+ }
186
+ )
187
+ return rows
188
+
189
+
190
+ # --- rules review (docs/DESIGN.md 9) ----------------------------------------
191
+
192
+
193
+ def review_rows(
194
+ adopted: list[dict], runs_with_findings: list[tuple[Run, list[Finding]]], weeks: int
195
+ ) -> list[dict]:
196
+ """before_after()'s own rows, windowed to `weeks` on each side of
197
+ applied_at, plus each rule's own target_kind and backing detectors --
198
+ everything `rules review`'s renderer needs, without it re-deriving any
199
+ of before_after()'s own counting."""
200
+ rows = before_after(adopted, runs_with_findings, window_weeks=weeks)
201
+ for row in rows:
202
+ rule = RULES.get(row["rule_id"])
203
+ row["detectors"] = list(rule.detectors) if rule else []
204
+ row["target_kind"] = rule.target_kind if rule else None
205
+ return rows
206
+
207
+
208
+ def _rule_not_improved(before: dict, after: dict) -> bool:
209
+ """docs/DESIGN.md 9: a rule is worth "consider removing" when both
210
+ windows actually have data and the after-rate is not lower than the
211
+ before-rate -- adopting it did not measurably help. Insufficient data on
212
+ either side (0 runs, or a rate that could not be computed) is never
213
+ flagged: there is nothing yet to conclude either way."""
214
+ if before["runs"] == 0 or after["runs"] == 0:
215
+ return False
216
+ if before["rate"] is None or after["rate"] is None:
217
+ return False
218
+ return after["rate"] >= before["rate"]
219
+
220
+
221
+ def _adopted_date(applied_at: str | None) -> str:
222
+ return (applied_at or "")[:10] or "unknown"
223
+
224
+
225
+ def render_review_text(rows: list[dict]) -> str:
226
+ """docs/DESIGN.md 9: one line per adopted rule (its backing detectors'
227
+ combined before/after counts), then a closing "consider removing" line."""
228
+ if not rows:
229
+ return "no adopted rule to review\n"
230
+
231
+ lines = []
232
+ removing = []
233
+ for row in rows:
234
+ before, after = row["before"], row["after"]
235
+ label = ", ".join(row["detectors"]) if row["detectors"] else "(no detector)"
236
+ lines.append(
237
+ f"{row['rule_id']} adopted {_adopted_date(row['applied_at'])} "
238
+ f"{label}: {before['fired']} of {before['runs']} runs before, "
239
+ f"{after['fired']} of {after['runs']} runs after"
240
+ )
241
+ if _rule_not_improved(before, after):
242
+ removing.append(row["rule_id"])
243
+
244
+ lines.append("")
245
+ lines.append(f"consider removing: {', '.join(removing) if removing else 'none'}")
246
+ return "\n".join(lines) + "\n"
247
+
248
+
249
+ def _display_review_target(target: str | None, redactor: Redactor) -> str:
250
+ """docs/DESIGN.md 9/5.1 (F13 hardening): `rules review`'s own target
251
+ field, target-N alone by default, the raw target string reserved for
252
+ --no-redact. Deliberately a separate function from _display_target
253
+ above: trend's own before/after rendering keeps 'target-N (basename)'
254
+ together by design (tests/test_trend.py pins it), but a basename is a
255
+ real filename that can carry a client or project name, and review must
256
+ never let it survive by default the way trend's richer, human-facing
257
+ rendering still does."""
258
+ target = target or ""
259
+ if not redactor.enabled or not target:
260
+ return target
261
+ return redactor.names.target(target)
262
+
263
+
264
+ def review_to_json(rows: list[dict], redactor: Redactor | None = None) -> dict:
265
+ redactor = redactor or Redactor(enabled=True)
266
+ redactor.names = Pseudonyms(enabled=redactor.enabled)
267
+ removing = [row["rule_id"] for row in rows if _rule_not_improved(row["before"], row["after"])]
268
+ return {
269
+ "schema": SCHEMA_VERSION,
270
+ "disclosure": redactor.level,
271
+ "reviews": [
272
+ {
273
+ "rule_id": row["rule_id"],
274
+ "target_kind": row["target_kind"],
275
+ "detectors": row["detectors"],
276
+ "adopted_at": row["applied_at"],
277
+ "target": _display_review_target(row["target"], redactor),
278
+ "before": row["before"],
279
+ "after": row["after"],
280
+ }
281
+ for row in rows
282
+ ],
283
+ "consider_removing": removing,
284
+ }
285
+
286
+
287
+ def _format_rate(rate: float | None) -> str:
288
+ if rate is None:
289
+ return "n/a"
290
+ return f"{rate * 100:.0f}%"
291
+
292
+
293
+ def _display_target(target: str | None, redactor: Redactor) -> str:
294
+ """docs/DESIGN.md 5.1: a rule target (the free-text 'target' an owner
295
+ recorded for an adopted rule) renders as 'target-N (basename)', a
296
+ per-render pseudonym plus the file's own basename -- enough to tell two
297
+ different targets apart in one report without the full path, which
298
+ routinely embeds a client or project name (docs/DESIGN.md 5.1's own
299
+ example: an instruction file under a client's directory). The raw path
300
+ prints only with --no-redact."""
301
+ target = target or ""
302
+ if not redactor.enabled or not target:
303
+ return target
304
+ if redactor.level == "local":
305
+ # N3b P19: the one path function. trend makes no observation, so
306
+ # there is no repository root (D-n): a path inside home prints as
307
+ # ~/.../<basename>, any other (and any relative one, which has no
308
+ # base to be placed against) as path-N. Never absolute.
309
+ return redactor.names.path(target)
310
+ pseudonym = redactor.names.target(target)
311
+ basename = PurePosixPath(target).name or target
312
+ return f"{pseudonym} ({basename})"
313
+
314
+
315
+ def render_trend_text(
316
+ weekly: WeeklyCounts,
317
+ before_after_rows: list[dict],
318
+ weeks: int,
319
+ redactor: Redactor | None = None,
320
+ ) -> str:
321
+ # docs/DESIGN.md 5.2/5.3: default to redaction on; raw output is an
322
+ # explicit opt-out (Redactor(enabled=False)), never the fallback.
323
+ redactor = redactor or Redactor(enabled=True)
324
+ # docs/DESIGN.md 5.1: a fresh Pseudonyms map for this render (at local,
325
+ # the one with the P19 path function attached).
326
+ redactor.fresh_names(detailed=True)
327
+ lines = [f"WEEKLY DETECTOR COUNTS (last {weeks} weeks)"]
328
+ if not weekly:
329
+ lines.append("(no findings in this window)")
330
+ else:
331
+ week_keys = sorted({week for row in weekly.values() for week in row})
332
+ header = "DETECTOR".ljust(26) + "".join(week.ljust(11) for week in week_keys)
333
+ lines.append(header)
334
+ for detector_id in sorted(weekly):
335
+ row = weekly[detector_id]
336
+ counts_str = "".join(str(row.get(week, 0)).ljust(11) for week in week_keys)
337
+ lines.append(f"{detector_id.ljust(26)}{counts_str}")
338
+
339
+ lines.append("")
340
+ lines.append("BEFORE / AFTER APPLIED RULES")
341
+ if not before_after_rows:
342
+ lines.append("(no rule adopted yet; see 'agent-postmortem rules review')")
343
+ else:
344
+ for row in before_after_rows:
345
+ before = row["before"]
346
+ after = row["after"]
347
+ target = _display_target(row["target"], redactor)
348
+ # docs/DESIGN.md 4.7: the denominator printed beside every rate
349
+ # names the kind(s) the rule's backing detector(s) apply to.
350
+ kind_label = "/".join(row.get("kinds") or []) or None
351
+ unit = f"{kind_label} runs" if kind_label else "runs"
352
+ lines.append(f"{row['rule_id']} applied {row['applied_at']} target {target}")
353
+ before_text = (
354
+ f"{_format_rate(before['rate'])} ({before['fired']}/{before['runs']} {unit})"
355
+ )
356
+ after_text = (
357
+ f"{_format_rate(after['rate'])} ({after['fired']}/{after['runs']} {unit})"
358
+ )
359
+ lines.append(f" before {before_text} / after {after_text}")
360
+ return "\n".join(lines) + "\n"
361
+
362
+
363
+ def to_json(
364
+ weekly: WeeklyCounts,
365
+ before_after_rows: list[dict],
366
+ weeks: int,
367
+ redactor: Redactor | None = None,
368
+ ) -> dict:
369
+ # docs/DESIGN.md 5.2/5.3: default to redaction on, same as report.to_json;
370
+ # docs/DESIGN.md 5.1: a fresh Pseudonyms map for this render, and 5.1's
371
+ # 'target-N (basename)' representation matches the text renderer's.
372
+ redactor = redactor or Redactor(enabled=True)
373
+ # At local the same path function trend's text uses (N3b P19), so a target
374
+ # prints as ~/.../name or path-N, never absolute.
375
+ redactor.fresh_names(detailed=True)
376
+ before_after_rows = [
377
+ {**row, "target": _display_target(row["target"], redactor)} for row in before_after_rows
378
+ ]
379
+ # docs/DESIGN.md 5.3b: schema 3 (`weeks` stays the requested count, an int).
380
+ # trend never filters a silenced detector
381
+ # out of weekly_counts or before_after -- docs/DESIGN.md 4.10: "a
382
+ # silenced detector still runs ... and still counts in trend" -- so this
383
+ # is the one JSON command whose payload shape and population are both
384
+ # completely unchanged by this unit apart from the new top-level key.
385
+ return {
386
+ "schema": SCHEMA_VERSION,
387
+ "disclosure": redactor.level,
388
+ "weeks": weeks,
389
+ "weekly_counts": weekly,
390
+ "before_after": before_after_rows,
391
+ }