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,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
|
+
}
|