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,627 @@
1
+ """The rules library: what a finding suggests doing about it.
2
+
3
+ RULES maps a stable id (R-RETRY-SAFE, ...) to the exact directive text a
4
+ maintainer can paste into a prompt, a hook, or their project's own
5
+ instructions. rules_for() turns a list of Findings into the rules they
6
+ back, ordered by how many findings back each one. propose_rules() turns a
7
+ whole fleet's worth of (Run, [Finding]) pairs into the rules that are
8
+ backed by enough evidence to suggest adopting (docs/DESIGN.md section 9).
9
+ apply_rule() computes the unified diff of a rule's managed block against a
10
+ target file -- it never writes the target itself (R3: the tool has no code
11
+ path that writes an instruction file; a human applies the diff by hand).
12
+ See docs/DESIGN.md section 4.3/9 for the contract this module implements,
13
+ including the exact rule texts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import difflib
19
+ import json
20
+ import textwrap
21
+ from collections.abc import Callable, Iterable
22
+ from dataclasses import dataclass
23
+ from datetime import date, datetime
24
+ from pathlib import Path
25
+ from typing import NamedTuple
26
+
27
+ from .models import Finding, Run
28
+
29
+ BEGIN_MARKER = (
30
+ "<!-- agent-postmortem:rules:begin (do not edit inside; managed by postmortem apply) -->"
31
+ )
32
+ END_MARKER = "<!-- agent-postmortem:rules:end -->"
33
+
34
+ _HOOKS_DIR = Path(__file__).parent / "hooks"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Rule:
39
+ id: str
40
+ title: str # one line, plain English
41
+ text: str # the exact directive text, verbatim into a managed block
42
+ enforcement: str # 'prompt' or 'hook'
43
+ detectors: tuple[str, ...] # detector ids that back this rule; () if none in v0
44
+ hook_template: str | None # filename under hooks/, or None
45
+ # docs/DESIGN.md 9: where a human should put this rule -- "workflow
46
+ # prompt" (the agent prompt preamble in a workflow script, where most
47
+ # rules belong), "agent prompt" (the prompt any agent receives, a
48
+ # workflow preamble or an Agent-tool subagent's prompt: R-NO-LONG-BASH),
49
+ # "hook" (a Claude Code hook, like R-MODEL-ALLOCATION), or "project
50
+ # instructions" (a repo-wide file like R-GIT-CWD, since it is about how
51
+ # every agent in every workflow is launched, not one workflow's own
52
+ # prompt).
53
+ target_kind: str
54
+
55
+
56
+ RULES: dict[str, Rule] = {
57
+ "R-RETRY-SAFE": Rule(
58
+ id="R-RETRY-SAFE",
59
+ title="Recover from a killed attempt without losing work",
60
+ text=(
61
+ "Every implementation prompt begins: You may be a retry after a killed "
62
+ "attempt. First run git status and git diff --stat; if uncommitted work "
63
+ "exists, verify it and commit it before continuing. Never git checkout, "
64
+ "restore, or stash a file holding uncommitted work."
65
+ ),
66
+ enforcement="prompt",
67
+ detectors=("silent_retry",),
68
+ hook_template=None,
69
+ target_kind="workflow prompt",
70
+ ),
71
+ "R-CAP-CONCURRENCY": Rule(
72
+ id="R-CAP-CONCURRENCY",
73
+ title="Cap concurrent heavy agents per parallel() call",
74
+ text=(
75
+ "Never start more than two heavy agents in one parallel() call; batch "
76
+ "them. Simultaneous requests from one account are the observed trigger "
77
+ "for paired stalls and kills."
78
+ ),
79
+ enforcement="prompt",
80
+ detectors=("silent_retry",),
81
+ hook_template=None,
82
+ target_kind="workflow prompt",
83
+ ),
84
+ "R-NO-LONG-BASH": Rule(
85
+ id="R-NO-LONG-BASH",
86
+ title="Never run a Bash command that can outlive its timeout",
87
+ text=(
88
+ "Inside a workflow agent never run a shell command that can outlive its "
89
+ "timeout: scope test runs to touched files, pass an explicit timeout the "
90
+ "run will meet, no sleep or poll loops. In a subagent, run long jobs from "
91
+ "the main session; never background and wait. macOS has no timeout(1); "
92
+ "use a python3 -c 'import subprocess; subprocess.run([...], timeout=N)' "
93
+ "or perl -e 'alarm N; exec @ARGV' form instead."
94
+ ),
95
+ enforcement="prompt",
96
+ detectors=("bash_backgrounded", "bash_timeout"),
97
+ hook_template=None,
98
+ target_kind="agent prompt",
99
+ ),
100
+ "R-SMALL-QUANTUM": Rule(
101
+ id="R-SMALL-QUANTUM",
102
+ title="Keep each agent() call to one milestone",
103
+ text="One milestone per agent() call, about 30 to 60 minutes; never a multi-hour stage.",
104
+ enforcement="prompt",
105
+ detectors=(),
106
+ hook_template=None,
107
+ target_kind="workflow prompt",
108
+ ),
109
+ "R-ASSERT-ARTIFACT": Rule(
110
+ id="R-ASSERT-ARTIFACT",
111
+ title="Assert a required artifact exists before consuming it",
112
+ text=(
113
+ "When an agent was told to produce a file, assert the file exists and is "
114
+ "non-trivial before any later stage consumes it. A truthy return means "
115
+ "the agent replied, not that the work landed. A missing artifact fails "
116
+ "loudly."
117
+ ),
118
+ enforcement="prompt",
119
+ detectors=("missing_artifact", "codex_write_denied"),
120
+ hook_template=None,
121
+ target_kind="workflow prompt",
122
+ ),
123
+ "R-SCALAR-SCHEMA": Rule(
124
+ id="R-SCALAR-SCHEMA",
125
+ title="Keep structured-output schemas to scalars and paths",
126
+ text=(
127
+ "Structured-output schemas carry scalars and paths only. Prose findings "
128
+ "go to a file the schema points at."
129
+ ),
130
+ enforcement="prompt",
131
+ detectors=("structured_output_failure",),
132
+ hook_template=None,
133
+ target_kind="workflow prompt",
134
+ ),
135
+ "R-CODEX-CONTRACT": Rule(
136
+ id="R-CODEX-CONTRACT",
137
+ title="Give a Codex reviewer an unambiguous read or write contract",
138
+ text=(
139
+ "A Codex reviewer inside a workflow either writes to a path inside the "
140
+ "project cwd with no read-only wording in its prompt, or stays read-only "
141
+ "and returns findings as text for a writable stage to persist. Keep each "
142
+ "Codex task under eight minutes. Before a workflow relies on a Codex "
143
+ "agent, check from the same environment that the companion starts (for "
144
+ "example codex --version) and fail the run when it does not, so a review "
145
+ "that never ran cannot read as done."
146
+ ),
147
+ enforcement="prompt",
148
+ detectors=("codex_write_denied", "codex_unavailable"),
149
+ hook_template=None,
150
+ target_kind="workflow prompt",
151
+ ),
152
+ "R-MONITOR-RETRIES": Rule(
153
+ id="R-MONITOR-RETRIES",
154
+ title="Monitor a workflow for silent retries",
155
+ text=(
156
+ "After launching a workflow, watch for a second agent meta file "
157
+ "appearing for the same logical agent; at the end compare journal "
158
+ "starts to agent count and report retries and the wall-clock they "
159
+ "burned."
160
+ ),
161
+ enforcement="prompt",
162
+ detectors=("mislabeled_kill",),
163
+ hook_template=None,
164
+ target_kind="workflow prompt",
165
+ ),
166
+ "R-GIT-CWD": Rule(
167
+ id="R-GIT-CWD",
168
+ title="Give every agent an explicit repository path",
169
+ text=(
170
+ "Give every agent the absolute repository path and have it cd there "
171
+ "first; never rely on the inherited working directory."
172
+ ),
173
+ enforcement="prompt",
174
+ detectors=("git_cwd_mismatch",),
175
+ hook_template=None,
176
+ target_kind="project instructions",
177
+ ),
178
+ "R-USAGE-LIMIT": Rule(
179
+ id="R-USAGE-LIMIT",
180
+ title="Do not relaunch before a usage limit resets",
181
+ text=(
182
+ "When a run reports a usage-limit failure, do not relaunch the same key "
183
+ "before the reset time the failure named."
184
+ ),
185
+ enforcement="prompt",
186
+ detectors=("attempt_failed",),
187
+ hook_template=None,
188
+ target_kind="workflow prompt",
189
+ ),
190
+ "R-MODEL-ALLOCATION": Rule(
191
+ id="R-MODEL-ALLOCATION",
192
+ title="Set an explicit model or agentType on every agent() call",
193
+ text="Every agent() call sets an explicit model or agentType.",
194
+ enforcement="hook",
195
+ detectors=(),
196
+ hook_template="model_allocation_guard.mjs",
197
+ target_kind="hook",
198
+ ),
199
+ }
200
+
201
+
202
+ def rules_for(findings: list[Finding]) -> list[tuple[Rule, int]]:
203
+ """Which rules a list of findings back, and by how many findings each.
204
+
205
+ Ordered by backing count descending, then by rule id, so the most
206
+ impactful rule is first. A finding counts once per rule in its
207
+ rule_ids (unit M1d) -- a finding backing two rules (silent_retry's
208
+ R-RETRY-SAFE plus R-CAP-CONCURRENCY on a burst) adds one to each,
209
+ exactly as if it had been two separate findings, without actually being
210
+ two. Findings with no rule_ids, or a rule id this library does not
211
+ know, are ignored.
212
+ """
213
+ counts: dict[str, int] = {}
214
+ for finding in findings:
215
+ for rule_id in finding.rule_ids:
216
+ if rule_id in RULES:
217
+ counts[rule_id] = counts.get(rule_id, 0) + 1
218
+ rows = [(RULES[rule_id], count) for rule_id, count in counts.items()]
219
+ rows.sort(key=lambda row: (-row[1], row[0].id))
220
+ return rows
221
+
222
+
223
+ def render_rules_text(rows: list[tuple[Rule, int]]) -> str:
224
+ """One line per (rule, backing_count) row, in the order given."""
225
+ if not rows:
226
+ return "(no rules backed by a finding in this scope)\n"
227
+ lines = []
228
+ for rule, count in rows:
229
+ plural = "" if count == 1 else "s"
230
+ lines.append(f"{rule.id} ({count} finding{plural}) {rule.title}")
231
+ return "\n".join(lines) + "\n"
232
+
233
+
234
+ AGENT_PROMPT_ACTION = (
235
+ "add this directive to the workflow's agent preamble or to the Agent-tool "
236
+ "prompt that launches the subagent"
237
+ )
238
+
239
+
240
+ def action_line(rule: Rule) -> str | None:
241
+ """docs/DESIGN.md 9 (N8): what to do with a rule that has no file to
242
+ apply to. An `agent prompt` rule names the two places its directive goes;
243
+ every other kind returns None and keeps the `apply ... --target <file>`
244
+ line of `rules propose`."""
245
+ return AGENT_PROMPT_ACTION if rule.target_kind == "agent prompt" else None
246
+
247
+
248
+ def render_rule_show(rule: Rule, path_display: Callable[[str], str] = str) -> str:
249
+ """The full text of one rule: id, title, enforcement, detectors,
250
+ target_kind, text. `path_display` shows the template path (N3b C4: the
251
+ command redacts it as one value before it joins the sentence)."""
252
+ detectors = ", ".join(rule.detectors) if rule.detectors else "(none in v0)"
253
+ lines = [
254
+ f"{rule.id}: {rule.title}",
255
+ f"enforcement: {rule.enforcement}",
256
+ f"detectors: {detectors}",
257
+ f"belongs in: {rule.target_kind}",
258
+ "",
259
+ rule.text,
260
+ ]
261
+ action = action_line(rule)
262
+ if action is not None:
263
+ lines.append("")
264
+ lines.append(action)
265
+ if rule.hook_template:
266
+ lines.append("")
267
+ lines.append(f"hook template: {path_display(str(_HOOKS_DIR / rule.hook_template))}")
268
+ return "\n".join(lines) + "\n"
269
+
270
+
271
+ def rule_to_json(rule: Rule) -> dict:
272
+ """The JSON shape for `rules --show --json`: the same fields
273
+ render_rule_show renders as text, structured instead of prose."""
274
+ return {
275
+ "rule_id": rule.id,
276
+ "title": rule.title,
277
+ "enforcement": rule.enforcement,
278
+ "detectors": list(rule.detectors),
279
+ "target_kind": rule.target_kind,
280
+ "text": rule.text,
281
+ }
282
+
283
+
284
+ def render_hook_install_note(rule: Rule, path_display: Callable[[str], str] = str) -> str:
285
+ """Template path and a settings.json snippet to register the rule's hook.
286
+
287
+ Returns "" for a rule with no hook_template. v0 never edits settings.json
288
+ itself (docs/DESIGN.md section 7); this only tells the user what to add.
289
+ """
290
+ if not rule.hook_template:
291
+ return ""
292
+ template_path = path_display(str(_HOOKS_DIR / rule.hook_template))
293
+ snippet = json.dumps(
294
+ {
295
+ "hooks": {
296
+ "PreToolUse": [
297
+ {
298
+ "matcher": "Workflow",
299
+ "hooks": [{"type": "command", "command": f"node {template_path}"}],
300
+ }
301
+ ]
302
+ }
303
+ },
304
+ indent=2,
305
+ )
306
+ return (
307
+ f"Hook template: {template_path}\n"
308
+ "Register it by merging this into your settings.json "
309
+ "(v0 does not edit settings.json for you):\n"
310
+ f"{snippet}\n"
311
+ )
312
+
313
+
314
+ def directive_line(rule_id: str) -> str:
315
+ """The canonical directive of one rule: the single line `apply` puts in
316
+ the managed block (N3b P18 prints it at share, where no byte of the target
317
+ file is shown)."""
318
+ rule = RULES.get(rule_id)
319
+ text = rule.text if rule is not None else "(unknown rule)"
320
+ return f"- {rule_id}: {text}"
321
+
322
+
323
+ def _render_block(rule_ids: set[str]) -> str:
324
+ lines = [BEGIN_MARKER]
325
+ for rule_id in sorted(rule_ids):
326
+ lines.append(directive_line(rule_id))
327
+ lines.append(END_MARKER)
328
+ return "\n".join(lines)
329
+
330
+
331
+ def _parse_block_ids(block_text: str) -> set[str]:
332
+ ids: set[str] = set()
333
+ for line in block_text.splitlines():
334
+ line = line.strip()
335
+ if line.startswith("- "):
336
+ rule_id = line[2:].split(":", 1)[0].strip()
337
+ if rule_id:
338
+ ids.add(rule_id)
339
+ return ids
340
+
341
+
342
+ def _separator_before_a_new_block(original: str) -> str:
343
+ """Just enough newlines to put one blank line between original's
344
+ existing trailing content and a newly created managed block, without
345
+ stripping any trailing blank lines original already has: everything
346
+ outside the block is preserved, not just everything before it."""
347
+ trailing_newlines = len(original) - len(original.rstrip("\n"))
348
+ if trailing_newlines >= 2:
349
+ return ""
350
+ if trailing_newlines == 1:
351
+ return "\n"
352
+ return "\n\n"
353
+
354
+
355
+ def _with_rule_applied(original: str, rule_id: str) -> str:
356
+ """original with rule_id added to its managed block (creating one if absent)."""
357
+ begin_idx = original.find(BEGIN_MARKER)
358
+ if begin_idx == -1:
359
+ block_text = _render_block({rule_id})
360
+ if original == "":
361
+ return block_text + "\n"
362
+ return original + _separator_before_a_new_block(original) + block_text + "\n"
363
+
364
+ end_idx = original.find(END_MARKER, begin_idx)
365
+ if end_idx == -1:
366
+ raise ValueError("target has an unterminated agent-postmortem rules block")
367
+ end_idx_full = end_idx + len(END_MARKER)
368
+
369
+ rule_ids = _parse_block_ids(original[begin_idx:end_idx_full])
370
+ rule_ids.add(rule_id)
371
+ block_text = _render_block(rule_ids)
372
+ return original[:begin_idx] + block_text + original[end_idx_full:]
373
+
374
+
375
+ def _diff_text(original: str, new_text: str, target: Path, label: str | None = None) -> str:
376
+ shown = str(target) if label is None else label
377
+ diff_lines = difflib.unified_diff(
378
+ original.splitlines(keepends=True),
379
+ new_text.splitlines(keepends=True),
380
+ fromfile=shown,
381
+ tofile=shown,
382
+ )
383
+ return "".join(diff_lines)
384
+
385
+
386
+ def apply_rule(rule_id: str, target: Path, label: str | None = None) -> str:
387
+ """The unified diff of adding rule_id to target's managed rules block
388
+ against target's current content ('' if target does not exist); '' when
389
+ the rule is already present (nothing to add).
390
+
391
+ docs/DESIGN.md 9/R3: this never writes target, and there is no `write`
392
+ argument to ask it to -- the tool has no code path that writes an
393
+ instruction file. A human reads the diff and applies it themselves,
394
+ with their own editor or `git apply`.
395
+
396
+ `label` (N3b P19) replaces the target path in the diff's two header lines;
397
+ None keeps the real path, which is what `git apply` needs.
398
+
399
+ Raises ValueError for an unknown rule id or a target whose rules block
400
+ has a begin marker but no end marker.
401
+ """
402
+ if rule_id not in RULES:
403
+ raise ValueError(f"unknown rule id: {rule_id}")
404
+
405
+ target = Path(target)
406
+ original = target.read_text(encoding="utf-8") if target.exists() else ""
407
+ new_text = _with_rule_applied(original, rule_id)
408
+ return _diff_text(original, new_text, target, label)
409
+
410
+
411
+ # --- rules propose (docs/DESIGN.md 9) ---------------------------------------
412
+
413
+ # A rule is proposed only once its backing detector(s) fired in at least
414
+ # this many distinct runs, on at least this many distinct days (by run
415
+ # start date): one bad week should not read as a pattern.
416
+ PROPOSE_MIN_RUNS = 3
417
+ PROPOSE_MIN_DAYS = 2
418
+
419
+
420
+ class RuleCount(NamedTuple):
421
+ """What the one shared counter returns (docs/DESIGN.md 9, N8): findings
422
+ whose own rule_ids name the rule, the distinct runs holding at least one,
423
+ and the earliest and latest start date among those runs (None when no
424
+ dated run backs the count)."""
425
+
426
+ findings: int
427
+ runs: int
428
+ first: str | None
429
+ last: str | None
430
+
431
+
432
+ def _run_date(run: Run) -> date | None:
433
+ return run.started_at.date() if run.started_at is not None else None
434
+
435
+
436
+ def count_rule(
437
+ rule_id: str, runs_with_findings: list[tuple[Run, list[Finding]]], since: date | None = None,
438
+ ) -> RuleCount:
439
+ """The one counter for a rule over a run set. A finding counts once per
440
+ rule named in its own rule_ids (the same way rules_for() counts it), never
441
+ because a backing detector merely fired. `since` keeps only runs started
442
+ on or after that date; a run with no start date cannot be placed and is
443
+ left out then."""
444
+ findings = 0
445
+ run_ids: set[str] = set()
446
+ dates: set[date] = set()
447
+ for run, run_findings in runs_with_findings:
448
+ run_date = _run_date(run)
449
+ if since is not None and (run_date is None or run_date < since):
450
+ continue
451
+ backing = sum(1 for finding in run_findings if rule_id in finding.rule_ids)
452
+ if backing == 0:
453
+ continue
454
+ findings += backing
455
+ run_ids.add(run.run_id)
456
+ if run_date is not None:
457
+ dates.add(run_date)
458
+ return RuleCount(
459
+ findings,
460
+ len(run_ids),
461
+ min(dates).isoformat() if dates else None,
462
+ max(dates).isoformat() if dates else None,
463
+ )
464
+
465
+
466
+ def _adoptions(adopted: Iterable) -> dict[str, tuple[date, int]]:
467
+ """rule id -> (earliest adoption date, number of records) over every
468
+ `[[adopted]]` record (anything with `.rule` and a `.date` of YYYY-MM-DD)."""
469
+ found: dict[str, tuple[date, int]] = {}
470
+ for item in adopted:
471
+ try:
472
+ when = datetime.strptime(item.date, "%Y-%m-%d").date()
473
+ except (TypeError, ValueError):
474
+ continue
475
+ earliest, records = found.get(item.rule, (when, 0))
476
+ found[item.rule] = (min(earliest, when), records + 1)
477
+ return found
478
+
479
+
480
+ def propose_rules(
481
+ runs_with_findings: list[tuple[Run, list[Finding]]],
482
+ adopted: Iterable = (),
483
+ *,
484
+ single_run: bool = False,
485
+ ) -> list[dict]:
486
+ """Which rules a fleet's worth of runs backs strongly enough to propose
487
+ adopting (docs/DESIGN.md 9), sorted by run count descending then rule id,
488
+ followed by one status row per adopted rule (by rule id).
489
+
490
+ A finding backs a rule the same way rules_for() counts it: once per rule
491
+ named in its own rule_ids. Each returned dict is {'rule': Rule,
492
+ 'findings': int, 'runs': int, 'first': 'YYYY-MM-DD'|None, 'last':
493
+ 'YYYY-MM-DD'|None, 'adopted': 'YYYY-MM-DD'|None, 'adopted_records': int,
494
+ 'since': {'findings', 'runs'}|None}. For a proposal 'findings' is the
495
+ total finding count, 'runs' the number of distinct runs that backed it at
496
+ least once, 'first'/'last' the earliest/latest start date among those
497
+ runs, and 'adopted'/'since' are None. A run with no started_at still
498
+ counts toward 'findings'/'runs' (its own finding did happen) but can
499
+ never be one of the >= PROPOSE_MIN_DAYS distinct days a rule needs to
500
+ qualify, since it cannot be placed on a calendar day at all.
501
+
502
+ A rule listed in `adopted` is never proposed again (N8, 4.13.8): its row
503
+ carries the earliest adoption date and counts everything since it, and is
504
+ kept whether or not it clears the threshold. `single_run` (rules propose
505
+ --run) lists every rule backed by at least one finding, since one run can
506
+ never meet the threshold; an adopted rule then appears only when the run
507
+ backs it.
508
+ """
509
+ adoptions = _adoptions(adopted)
510
+ backed: dict[str, dict] = {}
511
+ for run, findings in runs_with_findings:
512
+ run_date = _run_date(run)
513
+ for finding in findings:
514
+ for rule_id in finding.rule_ids:
515
+ if rule_id not in RULES:
516
+ continue
517
+ entry = backed.setdefault(rule_id, {"runs": set(), "dates": set()})
518
+ entry["runs"].add(run.run_id)
519
+ if run_date is not None:
520
+ entry["dates"].add(run_date)
521
+
522
+ rows: list[dict] = []
523
+ for rule_id, entry in backed.items():
524
+ if rule_id in adoptions:
525
+ continue
526
+ if not single_run and (
527
+ len(entry["runs"]) < PROPOSE_MIN_RUNS or len(entry["dates"]) < PROPOSE_MIN_DAYS
528
+ ):
529
+ continue
530
+ counted = count_rule(rule_id, runs_with_findings)
531
+ rows.append({
532
+ "rule": RULES[rule_id],
533
+ "findings": counted.findings,
534
+ "runs": counted.runs,
535
+ "first": counted.first,
536
+ "last": counted.last,
537
+ "adopted": None,
538
+ "adopted_records": 0,
539
+ "since": None,
540
+ })
541
+ rows.sort(key=lambda row: (-row["runs"], row["rule"].id))
542
+
543
+ for rule_id in sorted(adoptions):
544
+ if rule_id not in RULES or (single_run and rule_id not in backed):
545
+ continue
546
+ when, records = adoptions[rule_id]
547
+ counted = count_rule(rule_id, runs_with_findings, since=when)
548
+ rows.append({
549
+ "rule": RULES[rule_id],
550
+ "findings": counted.findings,
551
+ "runs": counted.runs,
552
+ "first": counted.first,
553
+ "last": counted.last,
554
+ "adopted": when.isoformat(),
555
+ "adopted_records": records,
556
+ "since": {"findings": counted.findings, "runs": counted.runs},
557
+ })
558
+ return rows
559
+
560
+
561
+ def _count_word(count: int, singular: str) -> str:
562
+ return f"{count} {singular}" + ("" if count == 1 else "s")
563
+
564
+
565
+ def render_propose_text(rows: list[dict], *, run_id: str | None = None) -> str:
566
+ """docs/DESIGN.md 9: one block per proposed rule, blank-line separated,
567
+ sorted the same way propose_rules() already sorted `rows`; an adopted
568
+ rule is one status line instead. With `run_id` (rules propose --run) the
569
+ text opens with the single-run heading."""
570
+ if not rows:
571
+ if run_id is not None:
572
+ return f"no rule is backed by a finding in run {run_id}\n"
573
+ return "no rule is backed by 3 runs on 2 days yet\n"
574
+
575
+ blocks = []
576
+ for row in rows:
577
+ rule = row["rule"]
578
+ if row.get("adopted") is not None:
579
+ records = row.get("adopted_records", 1)
580
+ kept = f" ({records} records)" if records > 1 else ""
581
+ blocks.append(
582
+ f"{rule.id} adopted {row['adopted']}{kept}; still violated "
583
+ f"{_count_word(row['findings'], 'time')} in "
584
+ f"{_count_word(row['runs'], 'run')} since"
585
+ )
586
+ continue
587
+ header = (
588
+ f"{rule.id} backed by {_count_word(row['findings'], 'finding')} in "
589
+ f"{_count_word(row['runs'], 'run')}"
590
+ )
591
+ if row["first"] is not None:
592
+ header += f", first {row['first']}, last {row['last']}"
593
+ lines = [header, f" belongs in: {rule.target_kind}"]
594
+ lines.extend(
595
+ textwrap.fill(
596
+ rule.text, width=76, initial_indent=" ", subsequent_indent=" "
597
+ ).splitlines()
598
+ )
599
+ action = action_line(rule)
600
+ if action is not None:
601
+ lines.append(f" {action}")
602
+ else:
603
+ lines.append(
604
+ f" show the diff for a file: agent-postmortem apply {rule.id} --target <file>"
605
+ )
606
+ blocks.append("\n".join(lines))
607
+ body = "\n\n".join(blocks) + "\n"
608
+ if run_id is not None:
609
+ return f"rules backed by findings in run {run_id}\n\n{body}"
610
+ return body
611
+
612
+
613
+ def propose_row_to_json(row: dict) -> dict:
614
+ """The JSON shape for one `rules propose --json` entry (docs/DESIGN.md
615
+ 5.3a schema 2)."""
616
+ rule = row["rule"]
617
+ return {
618
+ "rule_id": rule.id,
619
+ "target_kind": rule.target_kind,
620
+ "findings": row["findings"],
621
+ "runs": row["runs"],
622
+ "first": row["first"],
623
+ "last": row["last"],
624
+ "adopted": row.get("adopted"),
625
+ "since": row.get("since"),
626
+ "text": rule.text,
627
+ }