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,3010 @@
|
|
|
1
|
+
"""Render a Run and its Findings as text or JSON.
|
|
2
|
+
|
|
3
|
+
Verdict-first, small functions. render_bug_report drafts an upstream bug
|
|
4
|
+
report from the same Run and Findings render_run_text uses, in the section
|
|
5
|
+
order docs/DESIGN.md section 4.4 specifies: Title, Environment, What
|
|
6
|
+
happened, Reproduction, Evidence, Suggested fixes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import textwrap
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
|
|
19
|
+
from . import commands, disclosure, ledger, reader, severity
|
|
20
|
+
from .detectors.base import attempt_seconds, format_duration, wasted_attempts
|
|
21
|
+
from .models import Attempt, Finding, Run, TitleParts, effective_end, raw_diagnostic
|
|
22
|
+
from .redact import Pseudonyms, Redactor, RenderedCommit
|
|
23
|
+
from .rules import RULES, rules_for
|
|
24
|
+
|
|
25
|
+
_SEVERITIES = ("critical", "major", "minor", "info")
|
|
26
|
+
|
|
27
|
+
# docs/DESIGN.md 4.7 (unit M3a): the age computed for the header's "ended ...
|
|
28
|
+
# ago" clause, and every "within 72 hours" question WORK AT RISK/WHAT TO DO
|
|
29
|
+
# ask, use this instead of datetime.now(...) directly, the same pattern
|
|
30
|
+
# detectors/claim_contradiction.py already uses -- a test monkeypatches
|
|
31
|
+
# report._now to pin the answer instead of racing the real clock.
|
|
32
|
+
def _now() -> datetime:
|
|
33
|
+
return datetime.now(timezone.utc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _aware(value: datetime) -> datetime:
|
|
37
|
+
"""value, given a UTC tzinfo if it somehow has none (defensive: every
|
|
38
|
+
real reader-produced timestamp is already aware, but a hand-built test
|
|
39
|
+
Run is not required to bother)."""
|
|
40
|
+
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plural(count: int, word: str) -> str:
|
|
44
|
+
return word if count == 1 else f"{word}s"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_STALE_AFTER = timedelta(hours=72)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ended_recently(run: Run, now: datetime) -> bool:
|
|
51
|
+
"""docs/DESIGN.md 4.7 (unit M3a): the same 72-hour rule observe.py's own
|
|
52
|
+
staleness guard uses (4.9) -- a run with no known end time is treated as
|
|
53
|
+
recent, since nothing says it is old."""
|
|
54
|
+
if run.ended_at is None:
|
|
55
|
+
return True
|
|
56
|
+
return (now - _aware(run.ended_at)) <= _STALE_AFTER
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ended_within_72h_strict(run: Run, now: datetime) -> bool:
|
|
60
|
+
"""unit M3b's NEEDS ATTENTION candidate rules 1(b)/1(c): unlike
|
|
61
|
+
_ended_recently's "an unknown end time counts as recent" convention
|
|
62
|
+
(used everywhere else a run's own report reasons about its own age), a
|
|
63
|
+
candidate rule keyed off a real elapsed age must not fire just because
|
|
64
|
+
the age happens to be unknown -- both rules spell out "ended_at not
|
|
65
|
+
None" explicitly."""
|
|
66
|
+
if run.ended_at is None:
|
|
67
|
+
return False
|
|
68
|
+
return (now - _aware(run.ended_at)) <= _STALE_AFTER
|
|
69
|
+
|
|
70
|
+
# A fixed sentence for a detector whose findings carry no rule_id, because
|
|
71
|
+
# the failure is a runtime defect rather than something a prompt can route
|
|
72
|
+
# around (docs/DESIGN.md section 4.3). Used by _bug_suggested_fixes_lines.
|
|
73
|
+
_DETECTOR_BUG_SENTENCES = {
|
|
74
|
+
"tool_json_error": (
|
|
75
|
+
"This is a runtime defect, not something a prompt can route around: the "
|
|
76
|
+
"tool's own input failed to parse as JSON. File it upstream with the "
|
|
77
|
+
"evidence below."
|
|
78
|
+
),
|
|
79
|
+
"classifier_unavailable": (
|
|
80
|
+
"The safety classifier the runtime relies on for Bash was temporarily "
|
|
81
|
+
"unavailable. This is a transient runtime issue, not something the "
|
|
82
|
+
"agent's prompt controls."
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verdict_word(
|
|
88
|
+
findings: list[Finding], status: str = "COMPLETE", *, terminal_failed: bool = False,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""FAILED (the run's own lifecycle terminal is `failed`, or any finding is
|
|
91
|
+
critical), DEGRADED (any major, not FAILED), CLEAN (otherwise), always
|
|
92
|
+
suffixed with ' (INCOMPLETE)' when status is INCOMPLETE (docs/DESIGN.md
|
|
93
|
+
4.6, 4.13.4). A caller that has a Run passes `verdict_of` the run instead,
|
|
94
|
+
so the lifecycle terminal can never be forgotten."""
|
|
95
|
+
severities = {finding.severity for finding in findings}
|
|
96
|
+
if terminal_failed or "critical" in severities:
|
|
97
|
+
word = "FAILED"
|
|
98
|
+
elif "major" in severities:
|
|
99
|
+
word = "DEGRADED"
|
|
100
|
+
else:
|
|
101
|
+
word = "CLEAN"
|
|
102
|
+
if status == "INCOMPLETE":
|
|
103
|
+
word = f"{word} (INCOMPLETE)"
|
|
104
|
+
return word
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def lifecycle_failed(run: Run) -> bool:
|
|
108
|
+
"""The run's own lifecycle terminal (4.13.3) is `failed`: the harness itself
|
|
109
|
+
reported the run failed. A session has no lifecycle, so never."""
|
|
110
|
+
return run.lifecycle is not None and run.lifecycle.terminal == "failed"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verdict_of(run: Run, findings: list[Finding]) -> str:
|
|
114
|
+
"""The verdict word of one run over the findings its caller shows."""
|
|
115
|
+
return verdict_word(findings, run.status, terminal_failed=lifecycle_failed(run))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_failed(run: Run, findings: list[Finding]) -> bool:
|
|
119
|
+
"""Whether the verdict is FAILED: the condition behind exit code 3 (4.6)."""
|
|
120
|
+
return verdict_of(run, findings).split(" ", 1)[0] == "FAILED"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _attempt_cwd(attempt: Attempt) -> str | None:
|
|
124
|
+
facts = attempt.facts
|
|
125
|
+
if facts is not None and isinstance(facts.cwds, list):
|
|
126
|
+
for cwd in facts.cwds:
|
|
127
|
+
if isinstance(cwd, str) and cwd:
|
|
128
|
+
return cwd
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _register_observation_paths(observation, redactor: Redactor, run: Run | None = None) -> None:
|
|
133
|
+
"""docs/DESIGN.md 4.9/5.1 (N5c-1): every repository, observed path, and
|
|
134
|
+
unobserved path a RunObservation carries gets its own spelling in the one
|
|
135
|
+
"path" namespace (path-N at share, the P19 answer at local), the same one a
|
|
136
|
+
ledger entry's `path` and `repo` use, so one path has one display in one
|
|
137
|
+
render. Called before either the REPO STATE block or the JSON export is
|
|
138
|
+
built."""
|
|
139
|
+
if observation is None:
|
|
140
|
+
return
|
|
141
|
+
for group in observation.groups:
|
|
142
|
+
if group.repo is not None:
|
|
143
|
+
redactor.names.path(group.repo)
|
|
144
|
+
for path in group.paths:
|
|
145
|
+
redactor.names.path(path)
|
|
146
|
+
for path in group.unobserved_paths:
|
|
147
|
+
redactor.names.path(path)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _register_names(run: Run, redactor: Redactor, *, detailed: bool = False) -> None:
|
|
151
|
+
"""docs/DESIGN.md 5.1: every public renderer entry point builds a fresh
|
|
152
|
+
Pseudonyms map for its own render (so numbering restarts at agent-1
|
|
153
|
+
every time and two reports can never be linked by a repeated pseudonym),
|
|
154
|
+
then registers every child attempt's label, meta description, phase,
|
|
155
|
+
and agent_type before rendering anything. Doing the full pass up front
|
|
156
|
+
-- not lazily, the first time each renderer happens to touch a
|
|
157
|
+
particular attempt -- is what lets Finding.title (redactor.title())
|
|
158
|
+
catch a name that only ever appears embedded in a title sentence, not
|
|
159
|
+
as a structured field of its own. run.main is deliberately excluded:
|
|
160
|
+
its label is the fixed literal 'main', never user text, and 5.3 already
|
|
161
|
+
excludes it from export.
|
|
162
|
+
|
|
163
|
+
Also registers every attempt's own report_path claim, if it has one
|
|
164
|
+
(docs/DESIGN.md 5.1, M1 review finding 1): unlike the four name fields
|
|
165
|
+
above, a registered path is substituted everywhere the plain Redactor
|
|
166
|
+
renders text (Redactor.__call__ itself, not only title()), so this pass
|
|
167
|
+
is what lets a report_path embedded in a title or evidence line -- not
|
|
168
|
+
only the structured reported/metrics fields that call
|
|
169
|
+
redactor.names.path() directly at export time -- still get the same
|
|
170
|
+
path-N pseudonym in a text or bug report render, which never calls
|
|
171
|
+
export."""
|
|
172
|
+
redactor.fresh_names(detailed=detailed)
|
|
173
|
+
for attempt in run.attempts:
|
|
174
|
+
redactor.names.agent(attempt.label)
|
|
175
|
+
meta = attempt.meta if isinstance(attempt.meta, dict) else {}
|
|
176
|
+
description = meta.get("description")
|
|
177
|
+
if isinstance(description, str):
|
|
178
|
+
redactor.names.description(description, label=attempt.label)
|
|
179
|
+
redactor.names.phase(attempt.phase)
|
|
180
|
+
redactor.names.type(attempt.agent_type)
|
|
181
|
+
reported = attempt.reported
|
|
182
|
+
if isinstance(reported, dict):
|
|
183
|
+
claims = reported.get("claims")
|
|
184
|
+
if isinstance(claims, dict):
|
|
185
|
+
report_path = claims.get("report_path")
|
|
186
|
+
if isinstance(report_path, str) and report_path:
|
|
187
|
+
redactor.names.path(report_path, _attempt_cwd(attempt))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _register_facts_paths(run: Run, redactor: Redactor) -> None:
|
|
191
|
+
"""docs/DESIGN.md 5.1/5.3 (F4 hardening): register every attempt's own
|
|
192
|
+
facts.paths_written, facts.in_flight[].path, and facts.cwds through the
|
|
193
|
+
same "path" namespace report_path and observation paths already share,
|
|
194
|
+
so `_export_attempt`'s plain-Redactor pass (redact_value, same treatment
|
|
195
|
+
as `reported`) substitutes a path-N pseudonym instead of leaving a
|
|
196
|
+
repo-relative path, or an absolute path outside home/tmp, unchanged.
|
|
197
|
+
|
|
198
|
+
Deliberately a separate function from `_register_names` above, called
|
|
199
|
+
only by `export_run` (the one place these raw values are actually
|
|
200
|
+
printed, in `run --json`'s facts export): `_register_names` is also
|
|
201
|
+
called directly by rescue.py, which has its own, repo-relative
|
|
202
|
+
"display_key" registration for the very same underlying files
|
|
203
|
+
(`_register_plan_paths`/`_register_status_paths`) -- folding this in
|
|
204
|
+
there too would burn path-N ordinals on the raw absolute form before
|
|
205
|
+
rescue.py ever gets to register its own, preferred relative one, so the
|
|
206
|
+
same file could end up with two different pseudonyms in one rescue
|
|
207
|
+
render."""
|
|
208
|
+
for attempt in run.attempts:
|
|
209
|
+
facts = attempt.facts
|
|
210
|
+
if facts is None:
|
|
211
|
+
continue
|
|
212
|
+
if isinstance(facts.paths_written, list):
|
|
213
|
+
for facts_path in facts.paths_written:
|
|
214
|
+
if isinstance(facts_path, str) and facts_path:
|
|
215
|
+
redactor.names.path(facts_path)
|
|
216
|
+
if isinstance(facts.in_flight, list):
|
|
217
|
+
for entry in facts.in_flight:
|
|
218
|
+
if isinstance(entry, dict):
|
|
219
|
+
in_flight_path = entry.get("path")
|
|
220
|
+
if isinstance(in_flight_path, str) and in_flight_path:
|
|
221
|
+
redactor.names.path(in_flight_path)
|
|
222
|
+
if isinstance(facts.cwds, list):
|
|
223
|
+
for cwd in facts.cwds:
|
|
224
|
+
if isinstance(cwd, str) and cwd:
|
|
225
|
+
redactor.names.path(cwd)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _register_ledger_paths(run: Run, redactor: Redactor) -> None:
|
|
229
|
+
"""docs/DESIGN.md 5.3b: every ledger entry's own `path` and `repo` goes
|
|
230
|
+
through the same "path" namespace as facts and observation paths, so one
|
|
231
|
+
path has one display in one render. Called after `_register_facts_paths`,
|
|
232
|
+
which leaves the facts numbering unchanged."""
|
|
233
|
+
for attempt in run.attempts:
|
|
234
|
+
ledger = attempt.ledger
|
|
235
|
+
if ledger is None:
|
|
236
|
+
continue
|
|
237
|
+
for entry in ledger.entries:
|
|
238
|
+
for value in (entry.path, entry.repo):
|
|
239
|
+
if isinstance(value, str) and value:
|
|
240
|
+
redactor.names.path(value)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def redact_value(value, redactor: Redactor):
|
|
244
|
+
"""Recursively apply redactor to every string in a JSON-shaped value."""
|
|
245
|
+
if isinstance(value, RenderedCommit):
|
|
246
|
+
return value
|
|
247
|
+
if isinstance(value, str):
|
|
248
|
+
return redactor(value)
|
|
249
|
+
if isinstance(value, list):
|
|
250
|
+
return [redact_value(item, redactor) for item in value]
|
|
251
|
+
if isinstance(value, dict):
|
|
252
|
+
return {key: redact_value(item, redactor) for key, item in value.items()}
|
|
253
|
+
return value
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def number_claim_commits(claims, redactor: Redactor):
|
|
257
|
+
"""docs/DESIGN.md 4.13.5, 5.1 (M9): a result's own `claims.commits` list holds reported
|
|
258
|
+
commit ids; each goes through the per-render commit mapping (`commit-N` at share, as written
|
|
259
|
+
at local and raw). Every other key is returned unchanged; the keys are never renamed."""
|
|
260
|
+
if isinstance(claims, dict) and isinstance(claims.get("commits"), list):
|
|
261
|
+
return {
|
|
262
|
+
**claims,
|
|
263
|
+
"commits": [
|
|
264
|
+
redactor.names.commit(c) if isinstance(c, str) else c for c in claims["commits"]
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
return claims
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- N3b: identity collection and evidence pointers (P9, P10, P16) -----------
|
|
271
|
+
#
|
|
272
|
+
# A pointer is "<file>:<line>" or "<file>:<line>: <excerpt>", the format every
|
|
273
|
+
# detector writes (detectors/base.py evidence_line, plus the two pointer-only
|
|
274
|
+
# forms). It is parsed with one strict pattern: a bare file name (letters,
|
|
275
|
+
# digits, dot, underscore, hyphen; no directory part), a line number, an
|
|
276
|
+
# optional excerpt. At share the file name must name a canonical identity
|
|
277
|
+
# (agent-<17 hex>.jsonl or .meta.json, <session uuid>.jsonl or .meta.json) or
|
|
278
|
+
# be journal.jsonl; anything else renders a fixed placeholder, never its text.
|
|
279
|
+
|
|
280
|
+
_POINTER_RE = re.compile(
|
|
281
|
+
r"(?P<file>[A-Za-z0-9._-]+):(?P<line>[0-9]+)(?:: (?P<excerpt>.*))?", re.DOTALL
|
|
282
|
+
)
|
|
283
|
+
_AGENT_FILE_RE = re.compile(r"agent-(?P<id>[0-9a-f]{17})(?P<ext>\.jsonl|\.meta\.json)")
|
|
284
|
+
_SESSION_FILE_RE = re.compile(
|
|
285
|
+
r"(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
|
|
286
|
+
r"(?P<ext>\.jsonl|\.meta\.json)"
|
|
287
|
+
)
|
|
288
|
+
_JOURNAL_FILE = "journal.jsonl"
|
|
289
|
+
|
|
290
|
+
EXCERPT_NOTE = (
|
|
291
|
+
"note: excerpts need --local or --raw; the default level prints pointers and "
|
|
292
|
+
"marker codes only"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_pointer(text: object) -> tuple[str, int, str | None] | None:
|
|
297
|
+
"""(file name, line, excerpt or None) of a well-formed evidence string,
|
|
298
|
+
else None."""
|
|
299
|
+
if not isinstance(text, str):
|
|
300
|
+
return None
|
|
301
|
+
match = _POINTER_RE.fullmatch(text)
|
|
302
|
+
if match is None:
|
|
303
|
+
return None
|
|
304
|
+
return match.group("file"), int(match.group("line")), match.group("excerpt")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _file_identity(name: str) -> tuple[str, str, str] | None:
|
|
308
|
+
"""(kind, real id, extension) when `name` is a canonical transcript or
|
|
309
|
+
meta file name: kind "agent" or "session"."""
|
|
310
|
+
match = _AGENT_FILE_RE.fullmatch(name)
|
|
311
|
+
if match is not None and disclosure.is_canonical_id("agent", match.group("id")):
|
|
312
|
+
return "agent", match.group("id"), match.group("ext")
|
|
313
|
+
match = _SESSION_FILE_RE.fullmatch(name)
|
|
314
|
+
if match is not None and disclosure.is_canonical_id("session", match.group("id")):
|
|
315
|
+
return "session", match.group("id"), match.group("ext")
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _all_attempts(run: Run) -> list[Attempt]:
|
|
320
|
+
attempts = [run.main] if run.main is not None else []
|
|
321
|
+
attempts.extend(run.attempts)
|
|
322
|
+
return attempts
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def collect_identities(run: Run, findings: list[Finding] | None = None) -> list[tuple[str, str]]:
|
|
326
|
+
"""P9: every (kind, real id) a text render of this run may show, in one
|
|
327
|
+
deterministic order: the project directory name, then session ids (the
|
|
328
|
+
run's own, then those named by transcript or evidence file names), then
|
|
329
|
+
agent ids (attempts in run order, then finding and evidence ids). Only
|
|
330
|
+
canonical ids are returned (disclosure.register rejects the rest; an
|
|
331
|
+
attempt without a canonical agent id is named positionally at render)."""
|
|
332
|
+
seen: set[tuple[str, str]] = set()
|
|
333
|
+
ordered: list[tuple[str, str]] = []
|
|
334
|
+
|
|
335
|
+
def add(kind: str, value: object) -> None:
|
|
336
|
+
if isinstance(value, str) and disclosure.is_canonical_id(kind, value):
|
|
337
|
+
key = (kind, value)
|
|
338
|
+
if key not in seen:
|
|
339
|
+
seen.add(key)
|
|
340
|
+
ordered.append(key)
|
|
341
|
+
|
|
342
|
+
add("project", run.project_slug)
|
|
343
|
+
add("session", run.session_id)
|
|
344
|
+
attempts = _all_attempts(run)
|
|
345
|
+
names: list[str] = []
|
|
346
|
+
for attempt in attempts:
|
|
347
|
+
if attempt.transcript_path is not None:
|
|
348
|
+
names.append(attempt.transcript_path.name)
|
|
349
|
+
for finding in findings or []:
|
|
350
|
+
for evidence in finding.evidence:
|
|
351
|
+
parsed = parse_pointer(evidence)
|
|
352
|
+
if parsed is not None:
|
|
353
|
+
names.append(parsed[0])
|
|
354
|
+
for name in names:
|
|
355
|
+
identity = _file_identity(name)
|
|
356
|
+
if identity is not None and identity[0] == "session":
|
|
357
|
+
add("session", identity[1])
|
|
358
|
+
for attempt in attempts:
|
|
359
|
+
add("agent", attempt.agent_id)
|
|
360
|
+
for finding in findings or []:
|
|
361
|
+
add("agent", finding.agent_id)
|
|
362
|
+
for name in names:
|
|
363
|
+
identity = _file_identity(name)
|
|
364
|
+
if identity is not None and identity[0] == "agent":
|
|
365
|
+
add("agent", identity[1])
|
|
366
|
+
return ordered
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def collect_project_identities(slugs) -> list[tuple[str, str]]:
|
|
370
|
+
"""P9 for a sink that renders only project names (`scan --all`): each
|
|
371
|
+
distinct canonical project directory name, in the order given."""
|
|
372
|
+
seen: set[str] = set()
|
|
373
|
+
ordered: list[tuple[str, str]] = []
|
|
374
|
+
for slug in slugs:
|
|
375
|
+
if slug in seen or not disclosure.is_canonical_id("project", slug):
|
|
376
|
+
continue
|
|
377
|
+
if isinstance(slug, str):
|
|
378
|
+
seen.add(slug)
|
|
379
|
+
ordered.append(("project", slug))
|
|
380
|
+
return ordered
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def project_text(slug: str, redactor: Redactor) -> str:
|
|
384
|
+
"""P10/P11, the project in any text sink: at share `project:<alias>`
|
|
385
|
+
(positional on fallback); at local and raw the project directory name as
|
|
386
|
+
it is on disk (the string `local` prints, 4.13.5)."""
|
|
387
|
+
if not slug:
|
|
388
|
+
return ""
|
|
389
|
+
if redactor.level == "share":
|
|
390
|
+
return redactor.identity_text("project", slug)
|
|
391
|
+
if redactor.level == "local":
|
|
392
|
+
return redactor.names.local_text(slug)
|
|
393
|
+
return slug
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def identity_value(kind: str, value: str | None, redactor: Redactor) -> str | None:
|
|
397
|
+
"""5.3b's `agent_id` (and the like) in JSON: at share the registry alias in
|
|
398
|
+
its rendered form (`a:<alias>`; positional `agent-N` on fallback and for an
|
|
399
|
+
id that is not canonical, never the real value), at local the real value
|
|
400
|
+
with control characters and paths neutralized, at raw the value as is."""
|
|
401
|
+
if value is None:
|
|
402
|
+
return None
|
|
403
|
+
if redactor.level == "share":
|
|
404
|
+
return redactor.identity_text(kind, value)
|
|
405
|
+
if redactor.level == "local":
|
|
406
|
+
return redactor.names.local_text(value)
|
|
407
|
+
return value
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class EvidenceView:
|
|
411
|
+
"""How one render prints an evidence string (P10, P16).
|
|
412
|
+
|
|
413
|
+
share: the pointer with an aliased file name plus the marker code when
|
|
414
|
+
the line is a MarkerHit's, never an excerpt; a string that is not a
|
|
415
|
+
pointer with a canonical file name renders evidence-N; a pointer into an
|
|
416
|
+
attempt without a canonical agent id is omitted (None).
|
|
417
|
+
local, raw: today's `file:line: excerpt` only when `show_excerpts`
|
|
418
|
+
(--evidence); otherwise the pointer (real file name) plus the code.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def __init__(self, run: Run, redactor: Redactor, *, show_excerpts: bool) -> None:
|
|
422
|
+
self.redactor = redactor
|
|
423
|
+
self.level = redactor.level
|
|
424
|
+
self.show_excerpts = show_excerpts and self.level != "share"
|
|
425
|
+
self._placeholders: dict[str, str] = {}
|
|
426
|
+
self._codes: dict[tuple[str, int], list[str]] = {}
|
|
427
|
+
self._no_identity: set[str] = set()
|
|
428
|
+
for attempt in _all_attempts(run):
|
|
429
|
+
if attempt.transcript_path is None:
|
|
430
|
+
continue
|
|
431
|
+
name = attempt.transcript_path.name
|
|
432
|
+
if attempt is not run.main and not disclosure.is_canonical_id(
|
|
433
|
+
"agent", attempt.agent_id
|
|
434
|
+
):
|
|
435
|
+
self._no_identity.add(name)
|
|
436
|
+
for hit in attempt.marker_hits:
|
|
437
|
+
if not hit.anchored or hit.in_payload:
|
|
438
|
+
continue
|
|
439
|
+
for line in {hit.event_line, hit.result_line}:
|
|
440
|
+
if line is not None:
|
|
441
|
+
codes = self._codes.setdefault((name, line), [])
|
|
442
|
+
if hit.marker not in codes:
|
|
443
|
+
codes.append(hit.marker)
|
|
444
|
+
|
|
445
|
+
def _placeholder(self, text: str) -> str:
|
|
446
|
+
if text not in self._placeholders:
|
|
447
|
+
self._placeholders[text] = f"evidence-{len(self._placeholders) + 1}"
|
|
448
|
+
return self._placeholders[text]
|
|
449
|
+
|
|
450
|
+
def _share_file(self, name: str) -> str | None:
|
|
451
|
+
if name == _JOURNAL_FILE:
|
|
452
|
+
return name
|
|
453
|
+
identity = _file_identity(name)
|
|
454
|
+
if identity is None:
|
|
455
|
+
return None
|
|
456
|
+
kind, real_id, extension = identity
|
|
457
|
+
alias = self.redactor.book.alias(kind, real_id)
|
|
458
|
+
return disclosure.file_stem(kind, alias) + extension
|
|
459
|
+
|
|
460
|
+
def pointer(self, name: str, line: int) -> str | None:
|
|
461
|
+
"""A bare `file:line` pointer with no marker code (rescue's TASK
|
|
462
|
+
line): the aliased file name at share, the real one at local and raw;
|
|
463
|
+
None for an attempt without a canonical agent id at share."""
|
|
464
|
+
if self.level != "share":
|
|
465
|
+
return f"{name}:{line}"
|
|
466
|
+
if name in self._no_identity:
|
|
467
|
+
return None
|
|
468
|
+
shown = self._share_file(name)
|
|
469
|
+
if shown is None:
|
|
470
|
+
return self._placeholder(f"{name}:{line}")
|
|
471
|
+
return f"{shown}:{line}"
|
|
472
|
+
|
|
473
|
+
def ledger_line(self, line: str) -> str:
|
|
474
|
+
"""A ledger entry's own `<transcript file name>:<line>` (docs/DESIGN.md
|
|
475
|
+
5.3b's `line` convention): at share the file half is its aliased
|
|
476
|
+
spelling, an `evidence-N` placeholder when the name is no canonical
|
|
477
|
+
identity or the attempt has no canonical agent id (never a real name);
|
|
478
|
+
at local and raw the real file name, with no marker code."""
|
|
479
|
+
parsed = parse_pointer(line)
|
|
480
|
+
if self.level != "share":
|
|
481
|
+
if parsed is None:
|
|
482
|
+
return self.redactor.names.local_text(line) if self.level == "local" else line
|
|
483
|
+
return f"{parsed[0]}:{parsed[1]}"
|
|
484
|
+
if parsed is None:
|
|
485
|
+
return self._placeholder(line)
|
|
486
|
+
name, number, _excerpt = parsed
|
|
487
|
+
if name in self._no_identity:
|
|
488
|
+
return self._placeholder(line)
|
|
489
|
+
shown = self._share_file(name)
|
|
490
|
+
if shown is None:
|
|
491
|
+
return self._placeholder(line)
|
|
492
|
+
return f"{shown}:{number}"
|
|
493
|
+
|
|
494
|
+
def _code_suffix(self, name: str, line: int) -> str:
|
|
495
|
+
codes = self._codes.get((name, line))
|
|
496
|
+
return f" [{','.join(sorted(codes))}]" if codes else ""
|
|
497
|
+
|
|
498
|
+
def line(self, raw: str) -> str | None:
|
|
499
|
+
parsed = parse_pointer(raw)
|
|
500
|
+
if self.level == "share":
|
|
501
|
+
if parsed is None:
|
|
502
|
+
return self._placeholder(raw)
|
|
503
|
+
name, line, _excerpt = parsed
|
|
504
|
+
if name in self._no_identity:
|
|
505
|
+
return None
|
|
506
|
+
shown = self._share_file(name)
|
|
507
|
+
if shown is None:
|
|
508
|
+
return self._placeholder(raw)
|
|
509
|
+
return f"{shown}:{line}{self._code_suffix(name, line)}"
|
|
510
|
+
if self.show_excerpts:
|
|
511
|
+
if parsed is None:
|
|
512
|
+
return self.redactor(raw)
|
|
513
|
+
name, line, excerpt = parsed
|
|
514
|
+
pointer = f"{name}:{line}"
|
|
515
|
+
return pointer if excerpt is None else f"{pointer}: {self.redactor(excerpt)}"
|
|
516
|
+
if parsed is None:
|
|
517
|
+
return self._placeholder(raw)
|
|
518
|
+
name, line, _excerpt = parsed
|
|
519
|
+
return f"{name}:{line}{self._code_suffix(name, line)}"
|
|
520
|
+
|
|
521
|
+
def note(self) -> str | None:
|
|
522
|
+
"""The one share-level note a --evidence request earns."""
|
|
523
|
+
return EXCERPT_NOTE if self.level == "share" else None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _shown_evidence(
|
|
527
|
+
evidence: list[str], view: "EvidenceView | None", redactor: Redactor,
|
|
528
|
+
) -> list[str]:
|
|
529
|
+
"""The rendered evidence lines of one finding or story, in first order,
|
|
530
|
+
each distinct line once at share and local (N3b S13): two entries that
|
|
531
|
+
differed only by an excerpt the level does not print render identically
|
|
532
|
+
and show once. raw keeps v0.2's count and order (R2-3). A line the view
|
|
533
|
+
omits (None) is skipped."""
|
|
534
|
+
dedupe = redactor.level != "raw"
|
|
535
|
+
shown: list[str] = []
|
|
536
|
+
for line in evidence:
|
|
537
|
+
text = view.line(line) if view is not None else redactor(line)
|
|
538
|
+
if text is not None and (not dedupe or text not in shown):
|
|
539
|
+
shown.append(text)
|
|
540
|
+
return shown
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _format_range(start: datetime | None, end: datetime | None) -> str:
|
|
544
|
+
if start is None or end is None:
|
|
545
|
+
return "no timestamps recorded"
|
|
546
|
+
# Convert to UTC first: a timestamp that kept a non-UTC offset would
|
|
547
|
+
# otherwise print its local wall-clock value with a "Z" suffix that
|
|
548
|
+
# falsely claims it is UTC.
|
|
549
|
+
start = start.astimezone(timezone.utc)
|
|
550
|
+
end = end.astimezone(timezone.utc)
|
|
551
|
+
start_str = start.strftime("%Y-%m-%d %H:%MZ")
|
|
552
|
+
end_str = (
|
|
553
|
+
end.strftime("%H:%MZ") if start.date() == end.date() else end.strftime("%Y-%m-%d %H:%MZ")
|
|
554
|
+
)
|
|
555
|
+
return f"{start_str} to {end_str}"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def silent_retry_count(run: Run) -> int:
|
|
559
|
+
"""How many attempts were thrown away by a restart: for every logical
|
|
560
|
+
agent, every attempt but the last one. Attempts with no key (an unkeyed
|
|
561
|
+
'started' journal event) are not part of any LogicalAgent and must never
|
|
562
|
+
be counted here; subtracting len(run.attempts) - len(run.agents) would
|
|
563
|
+
silently attribute one to an agent that was never actually retried."""
|
|
564
|
+
return sum(len(agent.attempts) - 1 for agent in run.agents if agent.attempts)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _summary_line(run: Run) -> str:
|
|
568
|
+
agent_count = len(run.agents)
|
|
569
|
+
attempt_count = len(run.attempts)
|
|
570
|
+
retries = silent_retry_count(run)
|
|
571
|
+
wasted_seconds = sum(
|
|
572
|
+
attempt_seconds(attempt)
|
|
573
|
+
for agent in run.agents
|
|
574
|
+
for attempt in sorted(agent.attempts, key=lambda a: a.ordinal)[:-1]
|
|
575
|
+
)
|
|
576
|
+
killed = sum(1 for attempt in run.attempts if attempt.outcome == "killed")
|
|
577
|
+
return (
|
|
578
|
+
f"{agent_count} agents became {attempt_count} attempts; "
|
|
579
|
+
f"{retries} silent retries burned {format_duration(wasted_seconds)}; "
|
|
580
|
+
f"{killed} kills recorded as user interrupts"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _session_summary_line(run: Run) -> str:
|
|
585
|
+
"""docs/DESIGN.md 4.7: a session's summary reports subagents, not
|
|
586
|
+
logical agents becoming attempts -- a session's subagents are never
|
|
587
|
+
retried in a way v0.1 can observe, so it says so plainly instead of
|
|
588
|
+
printing a 'silent retries' count that would always read zero."""
|
|
589
|
+
run_seconds = 0.0
|
|
590
|
+
if run.started_at is not None and run.ended_at is not None:
|
|
591
|
+
run_seconds = max((run.ended_at - run.started_at).total_seconds(), 0.0)
|
|
592
|
+
subagents = len(run.attempts)
|
|
593
|
+
interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
|
|
594
|
+
unattributed = sum(
|
|
595
|
+
1 for attempt in run.attempts
|
|
596
|
+
if attempt.interrupt is not None and attempt.interrupt.cause == "unattributed"
|
|
597
|
+
)
|
|
598
|
+
return (
|
|
599
|
+
f"session ran {format_duration(run_seconds)}; {subagents} subagents, "
|
|
600
|
+
f"{interrupted} interrupted ({unattributed} unattributed); retries not observable"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _summary_line_for(run: Run) -> str:
|
|
605
|
+
return _session_summary_line(run) if run.kind == "session" else _summary_line(run)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def summary_line(run: Run) -> str:
|
|
609
|
+
"""The one-line run summary docs/DESIGN.md 4.7 renders first inside
|
|
610
|
+
render_run_text/render_bug_report: 'N agents became M attempts; ...' for
|
|
611
|
+
a workflow run, 'session ran T; N subagents, ...' for a session. Public
|
|
612
|
+
so a caller outside this module (hooks_cli.py's notification lines,
|
|
613
|
+
docs/DESIGN.md 7.2) reuses the exact same sentence instead of
|
|
614
|
+
re-deriving it."""
|
|
615
|
+
return _summary_line_for(run)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _project_line(run: Run, redactor: Redactor) -> str:
|
|
619
|
+
project = redactor(run.project_slug)
|
|
620
|
+
version = run.claude_version or "unknown"
|
|
621
|
+
time_range = _format_range(run.started_at, run.ended_at)
|
|
622
|
+
return f"project {project} claude {version} {time_range}"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# --- unit M3a: the redesigned `run` text report -----------------------------
|
|
626
|
+
#
|
|
627
|
+
# docs/DESIGN.md 4.7 (rewritten by this unit): header, account, WORK AT RISK,
|
|
628
|
+
# WHAT TO DO, AGENT table, STORIES. The old v0.1 header/summary-line/
|
|
629
|
+
# project-line/FINDINGS/SUGGESTED RULES/NEXT ACTION shape is gone from
|
|
630
|
+
# render_run_text; render_bug_report keeps the v0.1 section order untouched
|
|
631
|
+
# (it has its own _bug_* helpers and never calls anything below).
|
|
632
|
+
|
|
633
|
+
_AGENT_END_KILLED_CAUSE = {"parent_stop": "stopped by parent", "human": "interrupted"}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _age_string(ended_at: datetime, now: datetime, short: bool = False) -> str:
|
|
637
|
+
"""'<N> days|hours|minutes ago', floor-rounded, for the header's 'ended
|
|
638
|
+
... ago' clause (docs/DESIGN.md 4.7). short=True (unit M3b's scan triage
|
|
639
|
+
age column) abbreviates the unit ('<N>d/h/m ago') instead of spelling it
|
|
640
|
+
out; the rounding and threshold logic is exactly the same either way, so
|
|
641
|
+
a triage row's age and the run report's header age can never disagree
|
|
642
|
+
about which unit a given duration falls into."""
|
|
643
|
+
seconds = max((now - _aware(ended_at)).total_seconds(), 0.0)
|
|
644
|
+
days = int(seconds // 86400)
|
|
645
|
+
if days >= 1:
|
|
646
|
+
return f"{days}d ago" if short else f"{days} {_plural(days, 'day')} ago"
|
|
647
|
+
hours = int(seconds // 3600)
|
|
648
|
+
if hours >= 1:
|
|
649
|
+
return f"{hours}h ago" if short else f"{hours} {_plural(hours, 'hour')} ago"
|
|
650
|
+
minutes = int(seconds // 60)
|
|
651
|
+
return f"{minutes}m ago" if short else f"{minutes} {_plural(minutes, 'minute')} ago"
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _uncertain_terminal(run: Run) -> bool:
|
|
655
|
+
"""docs/DESIGN.md 4.13.3 (v0.3 N2): true when nothing says this run has
|
|
656
|
+
actually stopped -- a session (no lifecycle model at all, D11) or a
|
|
657
|
+
workflow whose lifecycle terminal is 'none' (no generation evidence, or
|
|
658
|
+
a pending one, ever reached a terminal value). Every other workflow
|
|
659
|
+
terminal (completed/failed/stopped/journal_resolved/unknown) is a real,
|
|
660
|
+
if sometimes unclear, word from the harness, so 'ended' stays honest."""
|
|
661
|
+
if run.kind == "session":
|
|
662
|
+
return True
|
|
663
|
+
return run.lifecycle is not None and run.lifecycle.terminal == "none"
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _activity_recent(run: Run, now: datetime) -> bool:
|
|
667
|
+
"""docs/DESIGN.md 4.13.3's activity rule, for the header's own 'possibly
|
|
668
|
+
running' suffix: a workflow's own Lifecycle.activity (mtime-based, set
|
|
669
|
+
once at load time) for a workflow; the same 10-minute freshness rule
|
|
670
|
+
applied to the session's own last recorded event for a session (no
|
|
671
|
+
Lifecycle to carry a precomputed activity field, D11)."""
|
|
672
|
+
if run.kind == "workflow":
|
|
673
|
+
return run.lifecycle is not None and run.lifecycle.activity == "recent"
|
|
674
|
+
if run.ended_at is None:
|
|
675
|
+
return False
|
|
676
|
+
return reader.activity_word(_aware(run.ended_at).timestamp(), now.timestamp()) == "recent"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _run_header_line(run: Run, findings: list[Finding], show_age: bool = True) -> str:
|
|
680
|
+
"""'RUN <id> <VERDICT>[ (INCOMPLETE)][ (unfinished)] ended <ts> (<age>)'
|
|
681
|
+
-- 'SESSION' instead of 'RUN' for a session run (docs/DESIGN.md 4.7 rule
|
|
682
|
+
1). ended_at is never redacted (it carries no user text, same as a run
|
|
683
|
+
id). (v0.3, N2, 4.13.3) When nothing certifies this run has actually
|
|
684
|
+
stopped (a session, or a workflow whose lifecycle terminal is 'none'),
|
|
685
|
+
the clause reads 'last activity <ts> (<age>)' instead of 'ended ... ',
|
|
686
|
+
plus a trailing 'possibly running' when activity is recent -- 'ended'
|
|
687
|
+
would otherwise claim a certainty nothing here actually has. The
|
|
688
|
+
'(unfinished)' tag is driven by Run.finished being exactly False (never
|
|
689
|
+
None: a session's own null finished never earns the tag, D11)."""
|
|
690
|
+
kind_word = "SESSION" if run.kind == "session" else "RUN"
|
|
691
|
+
verdict = verdict_of(run, findings)
|
|
692
|
+
unfinished_tag = " (unfinished)" if run.finished is False else ""
|
|
693
|
+
uncertain = _uncertain_terminal(run)
|
|
694
|
+
label = "last activity" if uncertain else "ended"
|
|
695
|
+
if run.ended_at is None:
|
|
696
|
+
clause = f"{label}: unknown"
|
|
697
|
+
else:
|
|
698
|
+
ended_utc = _aware(run.ended_at).astimezone(timezone.utc)
|
|
699
|
+
clause = f"{label} {ended_utc.strftime('%Y-%m-%d %H:%MZ')}"
|
|
700
|
+
if show_age:
|
|
701
|
+
clause += f" ({_age_string(run.ended_at, _now())})"
|
|
702
|
+
if uncertain and _activity_recent(run, _now()):
|
|
703
|
+
clause += " possibly running"
|
|
704
|
+
return f"{kind_word} {run.run_id} {verdict}{unfinished_tag} {clause}"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# docs/DESIGN.md 4.2/4.7: the phrase each attempt_failed cause renders as,
|
|
708
|
+
# shared by the account's failed-sentence, WHAT TO DO's relaunch guidance,
|
|
709
|
+
# and a story's own attempt_failed clause -- one table instead of three
|
|
710
|
+
# copies of the same wording.
|
|
711
|
+
_CAUSE_PHRASES = {
|
|
712
|
+
"usage_limit_session": "session usage limit reached",
|
|
713
|
+
"usage_limit_weekly": "weekly usage limit reached",
|
|
714
|
+
"machine_sleep": "the machine went to sleep",
|
|
715
|
+
"api_error": "a server error occurred",
|
|
716
|
+
"interrupted": "interrupted before finishing",
|
|
717
|
+
"unattributed": "cause not recorded in the transcript",
|
|
718
|
+
# docs/DESIGN.md 4.3 (G1c, v0.3.1): a session subagent that reported failure
|
|
719
|
+
"launch_result_failed": "its launch result reported failure",
|
|
720
|
+
"task_notification_failed": "its task notification reported failure",
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _cause_phrase(cause: str) -> str:
|
|
725
|
+
return _CAUSE_PHRASES.get(cause, cause)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _workflow_account_lines(run: Run, findings: list[Finding]) -> list[str]:
|
|
729
|
+
"""docs/DESIGN.md 4.7 rule 2: 1 to 3(+) plain sentences, verdict already
|
|
730
|
+
said in the header -- this is the 'what happened' account. Any sentence
|
|
731
|
+
whose count is 0 is omitted outright, never printed as '0 ...'."""
|
|
732
|
+
agent_count = len(run.agents)
|
|
733
|
+
attempt_count = len(run.attempts)
|
|
734
|
+
sentences = [
|
|
735
|
+
f"{agent_count} {_plural(agent_count, 'agent')} took "
|
|
736
|
+
f"{attempt_count} {_plural(attempt_count, 'attempt')}."
|
|
737
|
+
]
|
|
738
|
+
|
|
739
|
+
runtime = 0
|
|
740
|
+
parent_stop = 0
|
|
741
|
+
human = 0
|
|
742
|
+
unattributed = 0
|
|
743
|
+
for attempt in run.attempts:
|
|
744
|
+
interrupt = attempt.interrupt
|
|
745
|
+
if interrupt is None:
|
|
746
|
+
continue
|
|
747
|
+
if interrupt.cause == "runtime":
|
|
748
|
+
runtime += 1
|
|
749
|
+
elif interrupt.cause == "parent_stop":
|
|
750
|
+
parent_stop += 1
|
|
751
|
+
elif interrupt.cause == "human":
|
|
752
|
+
human += 1
|
|
753
|
+
elif interrupt.cause == "unattributed":
|
|
754
|
+
unattributed += 1
|
|
755
|
+
|
|
756
|
+
if runtime:
|
|
757
|
+
was_were = "was" if runtime == 1 else "were"
|
|
758
|
+
sentences.append(
|
|
759
|
+
f"{runtime} {_plural(runtime, 'attempt')} {was_were} killed by "
|
|
760
|
+
"the runtime and restarted."
|
|
761
|
+
)
|
|
762
|
+
if parent_stop:
|
|
763
|
+
was_were = "was" if parent_stop == 1 else "were"
|
|
764
|
+
sentences.append(f"{parent_stop} {was_were} stopped by the parent session.")
|
|
765
|
+
if human:
|
|
766
|
+
was_were = "was" if human == 1 else "were"
|
|
767
|
+
sentences.append(f"{human} {was_were} interrupted with the parent session.")
|
|
768
|
+
if unattributed:
|
|
769
|
+
was_were = "was" if unattributed == 1 else "were"
|
|
770
|
+
sentences.append(f"{unattributed} {was_were} interrupted, cause unattributed.")
|
|
771
|
+
|
|
772
|
+
failed_sentence = _failed_clause(findings)
|
|
773
|
+
if failed_sentence:
|
|
774
|
+
sentences.append(failed_sentence)
|
|
775
|
+
|
|
776
|
+
wasted_sentence = _wasted_clause(findings)
|
|
777
|
+
if wasted_sentence:
|
|
778
|
+
sentences.append(wasted_sentence)
|
|
779
|
+
|
|
780
|
+
return sentences
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _wasted_clause(findings: list[Finding]) -> str | None:
|
|
784
|
+
"""docs/DESIGN.md 4.7 (FA5): '<W> was spent on retries and attempts that ended
|
|
785
|
+
without a result.' from `wasted_time`'s own `wasted_seconds` (workflow and session
|
|
786
|
+
alike), or None when it is 0 or there is no such finding."""
|
|
787
|
+
wasted_seconds = 0.0
|
|
788
|
+
for finding in findings:
|
|
789
|
+
if finding.detector == "wasted_time":
|
|
790
|
+
wasted_seconds = finding.metrics.get("wasted_seconds", 0.0) or 0.0
|
|
791
|
+
if not wasted_seconds:
|
|
792
|
+
return None
|
|
793
|
+
return (
|
|
794
|
+
f"{format_duration(wasted_seconds)} was spent on retries and attempts that ended "
|
|
795
|
+
"without a result."
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _failed_clause(findings: list[Finding]) -> str | None:
|
|
800
|
+
"""'<F> failed (<causes, comma separated, with counts>).' (docs/
|
|
801
|
+
DESIGN.md 4.7 rule 2), or None when there are no attempt_failed findings
|
|
802
|
+
at all -- true of every session run today (attempt_failed is workflow-
|
|
803
|
+
only), so the session account line simply never grows this clause."""
|
|
804
|
+
failed = [f for f in findings if f.detector == "attempt_failed"]
|
|
805
|
+
if not failed:
|
|
806
|
+
return None
|
|
807
|
+
causes: list[list] = []
|
|
808
|
+
index_by_cause: dict[str, int] = {}
|
|
809
|
+
for finding in failed:
|
|
810
|
+
cause = finding.metrics.get("cause", "unattributed")
|
|
811
|
+
if cause not in index_by_cause:
|
|
812
|
+
index_by_cause[cause] = len(causes)
|
|
813
|
+
causes.append([cause, 0])
|
|
814
|
+
causes[index_by_cause[cause]][1] += 1
|
|
815
|
+
cause_text = ", ".join(f"{_cause_phrase(cause)} ({count})" for cause, count in causes)
|
|
816
|
+
return f"{len(failed)} failed ({cause_text})."
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _session_account_lines(run: Run, findings: list[Finding]) -> list[str]:
|
|
820
|
+
"""docs/DESIGN.md 4.7 rule 2: the existing session summary sentence, plus
|
|
821
|
+
the failed sentence when one applies (attempt_failed is workflow-only
|
|
822
|
+
today, so this never actually fires yet -- kept so the two account
|
|
823
|
+
builders share the same failed-clause code path)."""
|
|
824
|
+
sentences = [f"{_session_summary_line(run)}."]
|
|
825
|
+
failed_sentence = _failed_clause(findings)
|
|
826
|
+
if failed_sentence:
|
|
827
|
+
sentences.append(failed_sentence)
|
|
828
|
+
wasted_sentence = _wasted_clause(findings)
|
|
829
|
+
if wasted_sentence:
|
|
830
|
+
sentences.append(wasted_sentence)
|
|
831
|
+
return sentences
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _account_lines(run: Run, findings: list[Finding]) -> list[str]:
|
|
835
|
+
if run.kind == "session":
|
|
836
|
+
return _session_account_lines(run, findings)
|
|
837
|
+
return _workflow_account_lines(run, findings)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# docs/DESIGN.md 4.9/4.7: the outcomes whose facts feed WORK AT RISK, the
|
|
841
|
+
# AGENT table's PATHS/COMMITS columns, and a story's own facts clause --
|
|
842
|
+
# the same set observe.py's own _CANDIDATE_OUTCOMES already uses.
|
|
843
|
+
_RISK_OUTCOMES = frozenset({"killed", "failed", "superseded"})
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _risk_paths_and_commits(run: Run) -> tuple[set[str], set[str]]:
|
|
847
|
+
"""The union of paths_written/commits over every killed, failed, or
|
|
848
|
+
superseded attempt of the run (the same population _work_at_risk_lines
|
|
849
|
+
and _what_to_do_items already walk) -- shared by unit M3b's scan triage
|
|
850
|
+
(the NEEDS ATTENTION candidate rules, the paths_at_risk JSON field, and
|
|
851
|
+
the --observe next-command decision) so there is exactly one definition
|
|
852
|
+
of "paths at risk" in this module."""
|
|
853
|
+
qualifying = [a for a in run.attempts if a.outcome in _RISK_OUTCOMES]
|
|
854
|
+
paths: set[str] = set()
|
|
855
|
+
commits: set[str] = set()
|
|
856
|
+
for attempt in qualifying:
|
|
857
|
+
facts = attempt.facts
|
|
858
|
+
if facts is None:
|
|
859
|
+
continue
|
|
860
|
+
if isinstance(facts.paths_written, list):
|
|
861
|
+
paths.update(facts.paths_written)
|
|
862
|
+
if isinstance(facts.commits, list):
|
|
863
|
+
commits.update(facts.commits)
|
|
864
|
+
return paths, commits
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
_RUN_REASON_WORDS = {
|
|
868
|
+
"discovery_incomplete": "some run files could not be read or matched",
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
# docs/DESIGN.md 4.7 (field milestone F, P6): why an attempt could not be placed in a launch.
|
|
872
|
+
_UNASSIGNABLE_REASON_WORDS = {
|
|
873
|
+
"before_launch": "it started before the run was launched",
|
|
874
|
+
"no_usable_timestamp": "an early line has no usable timestamp",
|
|
875
|
+
"launch_times_unusable": "the launch times are missing or out of order",
|
|
876
|
+
"no_launch_record": "no launch of this run was found in the parent transcript",
|
|
877
|
+
"launch_unusable": "a launch of this run was found but could not be used",
|
|
878
|
+
}
|
|
879
|
+
_MAX_UNASSIGNABLE_NAMED = 3
|
|
880
|
+
_NOT_PLACED = "could not be placed in a launch of this run"
|
|
881
|
+
|
|
882
|
+
# Ledger reasons the per-attempt clauses explain by their own counts, and the
|
|
883
|
+
# run-level reasons the population line reports once (docs/DESIGN.md 4.7).
|
|
884
|
+
_EXPLAINED_REASONS = frozenset({
|
|
885
|
+
"shell_context_unverified", "possible_opaque_command", "possible_delegation",
|
|
886
|
+
"possible_background_process", "possible_unknown_tool", "unassignable_attempt",
|
|
887
|
+
"discovery_incomplete",
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
_MAX_HINTS = 3
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _id_list(ids: list[str], more: int) -> str:
|
|
894
|
+
"""`a`, `a and b`, `a, b and c`, `a, b, c and 2 more`."""
|
|
895
|
+
items = list(ids)
|
|
896
|
+
if more > 0:
|
|
897
|
+
items.append(f"{more} more")
|
|
898
|
+
if len(items) <= 1:
|
|
899
|
+
return "".join(items)
|
|
900
|
+
return f"{', '.join(items[:-1])} and {items[-1]}"
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _unassignable_words(run: Run, redactor: Redactor) -> str:
|
|
904
|
+
"""P6: `<id> could not be placed in a launch of this run (<reason>)`. Attempts with the same
|
|
905
|
+
reason share one phrase (`<id>, <id> and <id> could not ...`, at most three named per reason,
|
|
906
|
+
in `Run.attempts` order, then `and <k> more`); phrases of different reasons join with "; "."""
|
|
907
|
+
groups: dict[str, list[Attempt]] = {}
|
|
908
|
+
for attempt in run.attempts:
|
|
909
|
+
if ledger.is_unassignable(run, attempt):
|
|
910
|
+
reason = ledger.unassignable_reason(run, attempt)
|
|
911
|
+
groups.setdefault(reason or "before_launch", []).append(attempt)
|
|
912
|
+
phrases = []
|
|
913
|
+
for reason, attempts in groups.items():
|
|
914
|
+
named = [
|
|
915
|
+
_attempt_ident(attempt, run, redactor, with_label=False)
|
|
916
|
+
for attempt in attempts[:_MAX_UNASSIGNABLE_NAMED]
|
|
917
|
+
]
|
|
918
|
+
words = _UNASSIGNABLE_REASON_WORDS.get(reason, _UNASSIGNABLE_REASON_WORDS["before_launch"])
|
|
919
|
+
phrases.append(
|
|
920
|
+
f"{_id_list(named, len(attempts) - len(named))} {_NOT_PLACED} ({words})"
|
|
921
|
+
)
|
|
922
|
+
return "; ".join(phrases)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _run_reason_words(run: Run, redactor: Redactor) -> str:
|
|
926
|
+
reasons = list(getattr(run, "risk_population_completeness_reasons", []) or [])
|
|
927
|
+
words = []
|
|
928
|
+
for reason in reasons:
|
|
929
|
+
if reason == "unassignable_attempt":
|
|
930
|
+
text = _unassignable_words(run, redactor)
|
|
931
|
+
if text:
|
|
932
|
+
words.append(text)
|
|
933
|
+
elif reason in _RUN_REASON_WORDS:
|
|
934
|
+
words.append(_RUN_REASON_WORDS[reason])
|
|
935
|
+
if not words:
|
|
936
|
+
words = [_RUN_REASON_WORDS["discovery_incomplete"]]
|
|
937
|
+
return "; ".join(words)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _attempt_ident(
|
|
941
|
+
attempt: Attempt, run: Run, redactor: Redactor, *, with_label: bool = True,
|
|
942
|
+
) -> str:
|
|
943
|
+
"""The attempt's id in WORK AT RISK: `main` for a session main thread, else
|
|
944
|
+
the agent id as the level shows it (a:<alias> at share)."""
|
|
945
|
+
if attempt is run.main:
|
|
946
|
+
return "main"
|
|
947
|
+
ident = identity_value("agent", attempt.agent_id, redactor) or "agent"
|
|
948
|
+
# docs/DESIGN.md 4.7 (G6, v0.3.1): one name per agent, as the AGENT table names it
|
|
949
|
+
label = redactor.names.agent(attempt.label) if with_label and attempt.label else ""
|
|
950
|
+
return f"{ident} ({label})" if label else ident
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _attempt_clauses(attempt: Attempt) -> list[str]:
|
|
954
|
+
"""docs/DESIGN.md 4.7: what one population attempt wrote and what it may have
|
|
955
|
+
written, from its ledger and its calls."""
|
|
956
|
+
built = attempt.ledger
|
|
957
|
+
entries = built.entries if built is not None else []
|
|
958
|
+
|
|
959
|
+
def distinct(effect: str) -> int:
|
|
960
|
+
return len({entry.path for entry in entries if entry.effect == effect and entry.path})
|
|
961
|
+
|
|
962
|
+
clauses: list[str] = []
|
|
963
|
+
wrote = distinct("observed")
|
|
964
|
+
if wrote:
|
|
965
|
+
clauses.append(f"wrote {wrote} {_plural(wrote, 'path')} (file tools)")
|
|
966
|
+
named = distinct("claimed_target")
|
|
967
|
+
if named:
|
|
968
|
+
clauses.append(f"named {named} {_plural(named, 'path')} in write commands")
|
|
969
|
+
tried = distinct("attempted")
|
|
970
|
+
if tried:
|
|
971
|
+
clauses.append(f"tried {tried} {_plural(tried, 'path')} with no confirmed result")
|
|
972
|
+
shell = sum(1 for call in attempt.tool_calls if call.tool_name in ledger.SHELL_TOOLS)
|
|
973
|
+
if shell:
|
|
974
|
+
clauses.append(
|
|
975
|
+
f"ran {shell} shell {_plural(shell, 'command')} that may have written more"
|
|
976
|
+
)
|
|
977
|
+
unknown_tools = sum(
|
|
978
|
+
1 for entry in entries if entry.effect == "possible" and entry.basis == "unknown_tool"
|
|
979
|
+
)
|
|
980
|
+
if unknown_tools:
|
|
981
|
+
clauses.append(
|
|
982
|
+
f"called {unknown_tools} unrecognized {_plural(unknown_tools, 'tool')} "
|
|
983
|
+
"that may have written"
|
|
984
|
+
)
|
|
985
|
+
delegated = sum(1 for call in attempt.tool_calls if call.tool_name in commands.DELEGATION_TOOLS)
|
|
986
|
+
if delegated:
|
|
987
|
+
clauses.append(f"delegated {delegated} {_plural(delegated, 'call')} that may have written")
|
|
988
|
+
reasons = built.reasons if built is not None else ["transcript_missing"]
|
|
989
|
+
attempt_level = [reason for reason in reasons if reason not in _EXPLAINED_REASONS]
|
|
990
|
+
if attempt_level:
|
|
991
|
+
clauses.append(f"ledger incomplete ({', '.join(attempt_level)})")
|
|
992
|
+
if not clauses:
|
|
993
|
+
clauses.append("wrote nothing the ledger names")
|
|
994
|
+
return clauses
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _unreturned_subagents(run: Run, population: list[Attempt]) -> list[Attempt]:
|
|
998
|
+
"""docs/DESIGN.md 4.13.1 (S11): a session's subagents outside the risk
|
|
999
|
+
population that did not return a result to their parent (the guard behind a
|
|
1000
|
+
session's `none`)."""
|
|
1001
|
+
if run.kind != "session":
|
|
1002
|
+
return []
|
|
1003
|
+
inside = {id(attempt) for attempt in population}
|
|
1004
|
+
return [
|
|
1005
|
+
attempt for attempt in run.attempts
|
|
1006
|
+
if id(attempt) not in inside and attempt.return_state != "returned"
|
|
1007
|
+
]
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# docs/DESIGN.md 4.7 (W1, v0.3.1): the classes of a session subagent with no established return,
|
|
1011
|
+
# in breakdown order: (key, phrase)
|
|
1012
|
+
RETURN_CLASSES = (
|
|
1013
|
+
("reported_failed", "reported failed"),
|
|
1014
|
+
("reported_killed", "reported killed"),
|
|
1015
|
+
("possibly_running", "possibly running"),
|
|
1016
|
+
("no_return_evidence", "no return evidence found"),
|
|
1017
|
+
("not_established", "return could not be established"),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def transcript_recent(attempt: Attempt) -> bool:
|
|
1022
|
+
"""The child's transcript was modified within UNKNOWN_QUIET_SECONDS (600 s) of now. A file
|
|
1023
|
+
that cannot be statted is not recent."""
|
|
1024
|
+
path = attempt.transcript_path
|
|
1025
|
+
if path is None:
|
|
1026
|
+
return False
|
|
1027
|
+
try:
|
|
1028
|
+
mtime = path.stat().st_mtime
|
|
1029
|
+
except OSError:
|
|
1030
|
+
return False
|
|
1031
|
+
return reader.activity_word(mtime) == "recent"
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def return_class(attempt: Attempt) -> str:
|
|
1035
|
+
"""The W1 class of a session subagent whose return_state is not `returned`."""
|
|
1036
|
+
state = attempt.return_state
|
|
1037
|
+
if state == "reported_failed":
|
|
1038
|
+
return "reported_failed"
|
|
1039
|
+
if state == "reported_killed":
|
|
1040
|
+
return "reported_killed"
|
|
1041
|
+
if state in ("pending", "unknown") and transcript_recent(attempt):
|
|
1042
|
+
return "possibly_running"
|
|
1043
|
+
if state == "pending" or (state == "unknown" and attempt.return_reason == "no_evidence"):
|
|
1044
|
+
return "no_return_evidence"
|
|
1045
|
+
return "not_established"
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _is_nested(attempt: Attempt) -> bool:
|
|
1049
|
+
return isinstance(attempt.meta, dict) and isinstance(attempt.meta.get("parentAgentId"), str)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def return_reason_phrase(attempt: Attempt) -> str:
|
|
1053
|
+
klass = return_class(attempt)
|
|
1054
|
+
phrase = dict(RETURN_CLASSES)[klass]
|
|
1055
|
+
if klass == "no_return_evidence" and _is_nested(attempt):
|
|
1056
|
+
phrase += " in its parent agent"
|
|
1057
|
+
return phrase
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def unestablished_returns(run: Run) -> list[Attempt]:
|
|
1061
|
+
"""Every session subagent whose return_state is not `returned` (empty for a workflow)."""
|
|
1062
|
+
if run.kind != "session":
|
|
1063
|
+
return []
|
|
1064
|
+
return [attempt for attempt in run.attempts if attempt.return_state != "returned"]
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def return_breakdown(attempts: list[Attempt]) -> dict[str, int]:
|
|
1068
|
+
counts = {key: 0 for key, _phrase in RETURN_CLASSES}
|
|
1069
|
+
for attempt in attempts:
|
|
1070
|
+
counts[return_class(attempt)] += 1
|
|
1071
|
+
return counts
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def breakdown_text(counts: dict[str, int]) -> str:
|
|
1075
|
+
return ", ".join(f"{counts[key]} {phrase}" for key, phrase in RETURN_CLASSES if counts[key])
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _have(k: int) -> str:
|
|
1079
|
+
return "has" if k == 1 else "have"
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _shell_arg(text: str, level: str) -> str:
|
|
1083
|
+
"""A repository spelling as a command argument: quoted at raw (a real path),
|
|
1084
|
+
quoted at the other levels only when it holds characters a shell would split."""
|
|
1085
|
+
if level == "raw":
|
|
1086
|
+
return shlex.quote(text)
|
|
1087
|
+
return text if re.fullmatch(r"[\w@%+=:,./~-]+", text) else shlex.quote(text)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _unknown_group_hints(run: Run, group, redactor: Redactor) -> list[str]:
|
|
1091
|
+
refs = ledger.attempt_refs(run)
|
|
1092
|
+
by_ref = {ref: attempt for attempt in _all_attempts(run) for ref in [refs[id(attempt)]]}
|
|
1093
|
+
seen: list[str] = []
|
|
1094
|
+
for ref in group.attempts:
|
|
1095
|
+
attempt = by_ref.get(ref)
|
|
1096
|
+
if attempt is None:
|
|
1097
|
+
continue
|
|
1098
|
+
for call in attempt.tool_calls:
|
|
1099
|
+
cwd = call.cwd
|
|
1100
|
+
if isinstance(cwd, str) and cwd.startswith("/") and cwd not in seen:
|
|
1101
|
+
seen.append(cwd)
|
|
1102
|
+
return [
|
|
1103
|
+
f" hint: an attempt ran in {redactor.names.path(cwd)}" for cwd in seen[:_MAX_HINTS]
|
|
1104
|
+
]
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _observation_state_lines(run: Run, grouped, observation, redactor: Redactor) -> list[str]:
|
|
1108
|
+
"""docs/DESIGN.md 4.7 item 3 of the WORK AT RISK block."""
|
|
1109
|
+
if observation is not None:
|
|
1110
|
+
lines: list[str] = []
|
|
1111
|
+
checked = [group for group in observation.groups if group.checked]
|
|
1112
|
+
if checked:
|
|
1113
|
+
states = [obs.state for group in checked for obs in group.paths.values()]
|
|
1114
|
+
dirty = states.count("DIRTY_NOW")
|
|
1115
|
+
lines.append(
|
|
1116
|
+
f" {dirty} {_plural(dirty, 'path')} dirty now, "
|
|
1117
|
+
f"{states.count('MISSING_NOW')} missing, {states.count('CLEAN_NOW')} clean"
|
|
1118
|
+
)
|
|
1119
|
+
reasons: list[str] = []
|
|
1120
|
+
for group in observation.groups:
|
|
1121
|
+
if not group.checked and not group.refused and group.reason not in reasons:
|
|
1122
|
+
reasons.append(group.reason or "not checked")
|
|
1123
|
+
for reason in reasons:
|
|
1124
|
+
lines.append(f" repo state not checked ({redactor(reason)})")
|
|
1125
|
+
return lines
|
|
1126
|
+
if not any(not group.refused and group.paths for group in grouped.repos.values()):
|
|
1127
|
+
return []
|
|
1128
|
+
if _ended_recently(run, _now()):
|
|
1129
|
+
return [" repo state: run with --observe"]
|
|
1130
|
+
return [" historical: repo state not checked (run older than 72h)"]
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _manual_command_text(repo: str, redactor: Redactor) -> str:
|
|
1134
|
+
"""The manual `run yourself: git ... status --short` command for one repository, in the
|
|
1135
|
+
spelling the level allows (docs/DESIGN.md 4.7 item 4; REPO STATE reuses it, 4.9)."""
|
|
1136
|
+
shown = redactor.names.path(repo)
|
|
1137
|
+
if redactor.level == "share":
|
|
1138
|
+
return f"run yourself: git status --short in {shown}; --raw prints the exact command"
|
|
1139
|
+
return f"run yourself: git -C {_shell_arg(shown, redactor.level)} status --short"
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _manual_command_lines(run: Run, grouped, redactor: Redactor, observation=None) -> list[str]:
|
|
1143
|
+
"""docs/DESIGN.md 4.7 item 4: a refused group runs no git, so the block ends
|
|
1144
|
+
with the manual command; so does (N5 fix 1, m3) every observed group that was not
|
|
1145
|
+
checked or holds unobserved paths, whatever the reason, so the person is always
|
|
1146
|
+
told to look."""
|
|
1147
|
+
needs: set[str] = {repo for repo, group in grouped.repos.items() if group.refused}
|
|
1148
|
+
if observation is not None:
|
|
1149
|
+
needs.update(
|
|
1150
|
+
group.repo for group in observation.groups
|
|
1151
|
+
if group.repo is not None and (not group.checked or group.unobserved_paths)
|
|
1152
|
+
)
|
|
1153
|
+
lines: list[str] = []
|
|
1154
|
+
for repo in grouped.repos:
|
|
1155
|
+
if repo not in needs:
|
|
1156
|
+
continue
|
|
1157
|
+
lines.append(" " + _manual_command_text(repo, redactor))
|
|
1158
|
+
unknown = grouped.unknown
|
|
1159
|
+
if unknown.attempts or unknown.paths:
|
|
1160
|
+
lines.append(" run yourself: git status --short in the repository the agent worked in")
|
|
1161
|
+
if redactor.level != "share":
|
|
1162
|
+
lines.extend(_unknown_group_hints(run, unknown, redactor))
|
|
1163
|
+
return lines
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
@dataclass(frozen=True)
|
|
1167
|
+
class _RiskCounts:
|
|
1168
|
+
attempts: int
|
|
1169
|
+
paths: frozenset[str]
|
|
1170
|
+
partial: bool
|
|
1171
|
+
commit_ids: tuple[str, ...]
|
|
1172
|
+
commit_commands_without_id: int
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _work_at_risk_counts(run: Run, population) -> _RiskCounts:
|
|
1176
|
+
"""docs/DESIGN.md 4.7: the one definition of the WORK AT RISK count line's numbers (attempt
|
|
1177
|
+
count, ledger paths of effect observed, claimed_target or attempted, the partial flag, commit
|
|
1178
|
+
ids, commit commands without an id). The run report and both scan reasons (runtime kills and
|
|
1179
|
+
rule (c)) read it, so the three can never disagree."""
|
|
1180
|
+
paths: set[str] = set()
|
|
1181
|
+
commit_ids: list[str] = []
|
|
1182
|
+
for attempt in population:
|
|
1183
|
+
if attempt.ledger is not None:
|
|
1184
|
+
paths.update(
|
|
1185
|
+
entry.path for entry in attempt.ledger.entries
|
|
1186
|
+
if entry.path and entry.effect in ("observed", "claimed_target", "attempted")
|
|
1187
|
+
)
|
|
1188
|
+
facts = attempt.facts
|
|
1189
|
+
if facts is not None and isinstance(facts.commits, list):
|
|
1190
|
+
commit_ids.extend(c for c in facts.commits if c not in commit_ids)
|
|
1191
|
+
partial = run.risk_population_completeness != "complete" or any(
|
|
1192
|
+
attempt.ledger is None or attempt.ledger.completeness != "complete"
|
|
1193
|
+
for attempt in population
|
|
1194
|
+
)
|
|
1195
|
+
return _RiskCounts(
|
|
1196
|
+
len(population), frozenset(paths), partial, tuple(commit_ids),
|
|
1197
|
+
_commit_commands_without_id(population),
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _work_at_risk_lines(run: Run, observation, redactor: Redactor) -> list[str]:
|
|
1202
|
+
"""docs/DESIGN.md 4.7 (N5c-1; plan S4 and S11): WORK AT RISK from the write
|
|
1203
|
+
ledger and the repository groups."""
|
|
1204
|
+
population = ledger.risk_population(run)
|
|
1205
|
+
unreturned = _unreturned_subagents(run, population)
|
|
1206
|
+
risk = severity.work_risk(run)
|
|
1207
|
+
if risk == "none" and not unreturned:
|
|
1208
|
+
# P5: an empty population never claims a kill or a failure.
|
|
1209
|
+
if not population:
|
|
1210
|
+
return ["WORK AT RISK none: no attempt was killed, failed, or left unfinished."]
|
|
1211
|
+
return ["WORK AT RISK none: the attempts at risk wrote no paths."]
|
|
1212
|
+
|
|
1213
|
+
def subagent_lines() -> list[str]:
|
|
1214
|
+
return [
|
|
1215
|
+
f" {_attempt_ident(attempt, run, redactor)} {return_reason_phrase(attempt)}"
|
|
1216
|
+
for attempt in unreturned
|
|
1217
|
+
]
|
|
1218
|
+
|
|
1219
|
+
if not population and run.risk_population_completeness == "complete":
|
|
1220
|
+
k = len(unreturned)
|
|
1221
|
+
verb = "it" if k == 1 else "they"
|
|
1222
|
+
breakdown = breakdown_text(return_breakdown(unreturned))
|
|
1223
|
+
return [
|
|
1224
|
+
f"WORK AT RISK unknown: {k} {_plural(k, 'subagent')} {_have(k)} no established "
|
|
1225
|
+
f"return ({breakdown}); the tool cannot say what {verb} wrote.",
|
|
1226
|
+
*subagent_lines(),
|
|
1227
|
+
]
|
|
1228
|
+
if not population:
|
|
1229
|
+
return [
|
|
1230
|
+
"WORK AT RISK unknown: the tool could not establish which attempts were at risk: "
|
|
1231
|
+
f"{_run_reason_words(run, redactor)}."
|
|
1232
|
+
]
|
|
1233
|
+
|
|
1234
|
+
grouped = ledger.group_by_repo(run)
|
|
1235
|
+
counts = _work_at_risk_counts(run, population)
|
|
1236
|
+
n, p, c = counts.attempts, len(counts.paths), len(counts.commit_ids)
|
|
1237
|
+
partial, k = counts.partial, counts.commit_commands_without_id
|
|
1238
|
+
first = (
|
|
1239
|
+
f"WORK AT RISK {n} {_plural(n, 'attempt')} at risk: {p} {_plural(p, 'path')} named, "
|
|
1240
|
+
f"{c} {_plural(c, 'commit')} reported"
|
|
1241
|
+
)
|
|
1242
|
+
if k:
|
|
1243
|
+
first += f" ({k} {_plural(k, 'call')} with a commit command reported no id)"
|
|
1244
|
+
first += "; the tool cannot see all of what was written." if partial else "."
|
|
1245
|
+
lines = [first]
|
|
1246
|
+
for attempt in population:
|
|
1247
|
+
lines.append(
|
|
1248
|
+
f" {_attempt_ident(attempt, run, redactor)} {'; '.join(_attempt_clauses(attempt))}"
|
|
1249
|
+
)
|
|
1250
|
+
if run.risk_population_completeness != "complete":
|
|
1251
|
+
lines.append(
|
|
1252
|
+
" the list of attempts at risk may be incomplete: "
|
|
1253
|
+
f"{_run_reason_words(run, redactor)}."
|
|
1254
|
+
)
|
|
1255
|
+
lines.extend(_observation_state_lines(run, grouped, observation, redactor))
|
|
1256
|
+
lines.extend(_manual_command_lines(run, grouped, redactor, observation))
|
|
1257
|
+
if unreturned:
|
|
1258
|
+
k = len(unreturned)
|
|
1259
|
+
ids = ", ".join(_attempt_ident(attempt, run, redactor) for attempt in unreturned)
|
|
1260
|
+
lines.append(
|
|
1261
|
+
f" {k} {_plural(k, 'subagent')} outside this list {_have(k)} no established "
|
|
1262
|
+
f"return: {ids}"
|
|
1263
|
+
)
|
|
1264
|
+
return lines
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _finding_ids_by_detector(findings: list[Finding], detector: str) -> list[Finding]:
|
|
1268
|
+
return [f for f in findings if f.detector == detector]
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
_ABSENT_FILE_DETECTORS = frozenset({"missing_artifact", "claim_contradiction"})
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _absent_files_item(run: Run, findings: list[Finding]) -> dict | None:
|
|
1275
|
+
"""docs/DESIGN.md 4.13.4, 4.7 item 3: one `[look]` item for however many
|
|
1276
|
+
shown info-severity `missing_artifact` and `claim_contradiction` findings
|
|
1277
|
+
exist, only while the run's own last activity is known and within 72
|
|
1278
|
+
hours. A rendering hint, never a severity change; no path in its text."""
|
|
1279
|
+
count = sum(
|
|
1280
|
+
1 for finding in findings
|
|
1281
|
+
if finding.detector in _ABSENT_FILE_DETECTORS and finding.severity == "info"
|
|
1282
|
+
)
|
|
1283
|
+
if count == 0 or not _ended_within_72h_strict(run, _now()):
|
|
1284
|
+
return None
|
|
1285
|
+
if count == 1:
|
|
1286
|
+
text = "1 expected file absent now: check whether the run wrote it"
|
|
1287
|
+
else:
|
|
1288
|
+
text = f"{count} expected files absent now: check whether the run wrote them"
|
|
1289
|
+
return {
|
|
1290
|
+
"grade": "look",
|
|
1291
|
+
"text": text,
|
|
1292
|
+
"command": f"agent-postmortem run {run.run_id} --verbose",
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _what_to_do_items(
|
|
1297
|
+
run: Run, findings: list[Finding], observation, observe_given: bool,
|
|
1298
|
+
) -> list[dict]:
|
|
1299
|
+
"""docs/DESIGN.md 4.7 rule 4: deterministic, at most 3 items, each
|
|
1300
|
+
{'grade': 'look'|'decide', 'text': str, 'command': str|None}."""
|
|
1301
|
+
items: list[dict] = []
|
|
1302
|
+
|
|
1303
|
+
# docs/DESIGN.md 4.7 item 1 (N5c-1): only a named repository group that is
|
|
1304
|
+
# not refused is ever observed, so only one earns the suggestion.
|
|
1305
|
+
observable = any(
|
|
1306
|
+
not group.refused and group.paths for group in ledger.group_by_repo(run).repos.values()
|
|
1307
|
+
)
|
|
1308
|
+
if observable and not observe_given and _ended_recently(run, _now()):
|
|
1309
|
+
items.append({
|
|
1310
|
+
"grade": "look",
|
|
1311
|
+
"text": "see which paths are dirty",
|
|
1312
|
+
"command": f"agent-postmortem run {run.run_id} --observe",
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
failed = _finding_ids_by_detector(findings, "attempt_failed")
|
|
1316
|
+
wait_item = None
|
|
1317
|
+
safe_item = None
|
|
1318
|
+
for finding in failed:
|
|
1319
|
+
if finding.severity != "major":
|
|
1320
|
+
continue # the key was already retried to completion; nothing to decide
|
|
1321
|
+
retry_now = finding.metrics.get("retry_now")
|
|
1322
|
+
reset_time = finding.metrics.get("reset_time")
|
|
1323
|
+
if wait_item is None and retry_now == "no" and reset_time:
|
|
1324
|
+
wait_item = {
|
|
1325
|
+
"grade": "decide",
|
|
1326
|
+
"text": f"wait until {reset_time} before relaunching",
|
|
1327
|
+
"command": None,
|
|
1328
|
+
}
|
|
1329
|
+
if safe_item is None and retry_now == "yes":
|
|
1330
|
+
cause = finding.metrics.get("cause", "unattributed")
|
|
1331
|
+
safe_item = {
|
|
1332
|
+
"grade": "decide",
|
|
1333
|
+
"text": f"relaunch is safe now: {_cause_phrase(cause)}",
|
|
1334
|
+
"command": None,
|
|
1335
|
+
}
|
|
1336
|
+
if len(items) < 3:
|
|
1337
|
+
if wait_item is not None:
|
|
1338
|
+
items.append(wait_item)
|
|
1339
|
+
elif safe_item is not None:
|
|
1340
|
+
items.append(safe_item)
|
|
1341
|
+
|
|
1342
|
+
absent_item = _absent_files_item(run, findings)
|
|
1343
|
+
if absent_item is not None and len(items) < 3:
|
|
1344
|
+
items.append(absent_item)
|
|
1345
|
+
|
|
1346
|
+
if len(items) < 3:
|
|
1347
|
+
# docs/DESIGN.md 4.7 rule 4c / 9 (unit M6): `run` never scans other
|
|
1348
|
+
# runs to learn a real r-of-R rate, so this always points at the
|
|
1349
|
+
# fleet-wide proposal command instead of naming this one run --
|
|
1350
|
+
# `rules --run <id>` still exists, just no longer what `run` prints.
|
|
1351
|
+
backing_rule_ids = {
|
|
1352
|
+
rule_id
|
|
1353
|
+
for finding in findings
|
|
1354
|
+
if finding.severity in ("major", "critical")
|
|
1355
|
+
for rule_id in finding.rule_ids
|
|
1356
|
+
}
|
|
1357
|
+
if backing_rule_ids:
|
|
1358
|
+
items.append({
|
|
1359
|
+
"grade": "decide",
|
|
1360
|
+
"text": "rules that would have prevented this",
|
|
1361
|
+
"command": "agent-postmortem rules propose",
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
# docs/DESIGN.md 4.7, 4.13.8: no generic "see the findings hidden here"
|
|
1365
|
+
# item. The default view hides only info findings, and the trailing
|
|
1366
|
+
# "N info findings not shown (--verbose)" line is already the pointer.
|
|
1367
|
+
return items[:3]
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _what_to_do_content(item: dict) -> str:
|
|
1371
|
+
grade, text, command = item["grade"], item["text"], item["command"]
|
|
1372
|
+
if grade == "look":
|
|
1373
|
+
return f"{command} {text}"
|
|
1374
|
+
if command:
|
|
1375
|
+
return f"{text}: {command}"
|
|
1376
|
+
return text
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _what_to_do_lines(items: list[dict]) -> list[str]:
|
|
1380
|
+
lines = ["WHAT TO DO"]
|
|
1381
|
+
for index, item in enumerate(items, start=1):
|
|
1382
|
+
tag_field = f"[{item['grade']}]"
|
|
1383
|
+
lines.append(f" {index}. {tag_field:<9}{_what_to_do_content(item)}")
|
|
1384
|
+
return lines
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _agent_end(final: Attempt | None, run: Run) -> str:
|
|
1388
|
+
"""docs/DESIGN.md 4.7 rule 5: 'completed', 'killed', 'failed', 'stopped
|
|
1389
|
+
by parent', 'interrupted', 'superseded', 'no result', or 'unknown',
|
|
1390
|
+
derived from the final attempt's own outcome and interrupt cause.
|
|
1391
|
+
(v0.3, N2, 4.13.3) An attempt still open in a workflow journal (no
|
|
1392
|
+
'result'/'failed' event ever closed it) shows 'no result' rather than
|
|
1393
|
+
the stale 'completed' _compute_outcome falls back to for a transcript
|
|
1394
|
+
that simply has not (yet) ended in the kill sentinel."""
|
|
1395
|
+
if final is None:
|
|
1396
|
+
return "unknown"
|
|
1397
|
+
if final.return_state is not None:
|
|
1398
|
+
# G1 (v0.3.1): a session subagent ends as its effective end says; a proven state is
|
|
1399
|
+
# never changed by the modification time
|
|
1400
|
+
end = effective_end(final)
|
|
1401
|
+
if end in ("open", "unknown"):
|
|
1402
|
+
return "possibly running" if transcript_recent(final) else "unknown"
|
|
1403
|
+
if end == "killed" and final.outcome == "killed":
|
|
1404
|
+
cause = final.interrupt.cause if final.interrupt is not None else None
|
|
1405
|
+
return _AGENT_END_KILLED_CAUSE.get(cause, "killed")
|
|
1406
|
+
return end
|
|
1407
|
+
if (
|
|
1408
|
+
final.outcome == "completed"
|
|
1409
|
+
and run.kind == "workflow"
|
|
1410
|
+
and reader.attempt_unresolved_in_journal(run.journal, final.key, final.ordinal)
|
|
1411
|
+
):
|
|
1412
|
+
return "no result"
|
|
1413
|
+
if final.outcome != "killed":
|
|
1414
|
+
return final.outcome
|
|
1415
|
+
cause = final.interrupt.cause if final.interrupt is not None else None
|
|
1416
|
+
return _AGENT_END_KILLED_CAUSE.get(cause, "killed")
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
_ALL_PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _agent_paths(agent) -> tuple[int | None, bool]:
|
|
1423
|
+
"""(count, partial) for the AGENT table's PATHS column (docs/DESIGN.md 4.7 rule 5, 4.13.8): the
|
|
1424
|
+
distinct ledger paths of effect observed, claimed_target, or attempted over every attempt of
|
|
1425
|
+
this logical agent, plus `facts.paths_written`, each normalized path once. `partial` is true
|
|
1426
|
+
when any attempt's ledger is partial, a missing transcript included (the count is then a
|
|
1427
|
+
lower bound); the count is None when no attempt has a ledger and no attempt's facts could be
|
|
1428
|
+
established at all."""
|
|
1429
|
+
known = False
|
|
1430
|
+
partial = False
|
|
1431
|
+
paths: set[str] = set()
|
|
1432
|
+
for attempt in agent.attempts:
|
|
1433
|
+
built = attempt.ledger
|
|
1434
|
+
if built is not None:
|
|
1435
|
+
# a ledger with no transcript (N8 fix 1, S1) names nothing, but it makes the count a
|
|
1436
|
+
# lower bound all the same; only a ledger built from a transcript establishes it
|
|
1437
|
+
if "transcript_missing" not in built.reasons:
|
|
1438
|
+
known = True
|
|
1439
|
+
if built.completeness == "partial":
|
|
1440
|
+
partial = True
|
|
1441
|
+
if attempt.facts is not None and isinstance(attempt.facts.paths_written, list):
|
|
1442
|
+
known = True
|
|
1443
|
+
paths.update(ledger.attempt_ledger_paths(
|
|
1444
|
+
attempt, _ALL_PATH_EFFECTS, include_facts_written=True,
|
|
1445
|
+
))
|
|
1446
|
+
return (len(paths) if known else None), partial
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _agent_facts_totals(agent) -> tuple[str, int]:
|
|
1450
|
+
"""(paths display, commit count) for the AGENT table's PATHS/COMMITS
|
|
1451
|
+
columns: PATHS is `_agent_paths` (`N`, `N+` for a partial ledger, `?` when
|
|
1452
|
+
nothing could be established, docs/DESIGN.md 4.7 rule 5); COMMITS is the
|
|
1453
|
+
union of `commits` over every attempt of this logical agent and has no
|
|
1454
|
+
such marker, it simply counts what is known."""
|
|
1455
|
+
count, partial = _agent_paths(agent)
|
|
1456
|
+
commits: set[str] = set()
|
|
1457
|
+
for attempt in agent.attempts:
|
|
1458
|
+
facts = attempt.facts
|
|
1459
|
+
if facts is not None and isinstance(facts.commits, list):
|
|
1460
|
+
commits.update(facts.commits)
|
|
1461
|
+
if count is None:
|
|
1462
|
+
return "?", len(commits)
|
|
1463
|
+
return f"{count}{'+' if partial else ''}", len(commits)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _commit_commands_without_id(attempts) -> int:
|
|
1467
|
+
"""FA6: the sum of `commit_commands_without_id` over attempts whose facts say a
|
|
1468
|
+
number (an unknown or missing fact adds nothing: the lower-bound mark is for a
|
|
1469
|
+
call the tool SAW contain a commit command and report no id)."""
|
|
1470
|
+
total = 0
|
|
1471
|
+
for attempt in attempts:
|
|
1472
|
+
facts = attempt.facts
|
|
1473
|
+
value = getattr(facts, "commit_commands_without_id", 0) if facts is not None else 0
|
|
1474
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
1475
|
+
total += value
|
|
1476
|
+
return total
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _commits_words(known: int, without_id: int) -> str:
|
|
1480
|
+
"""docs/DESIGN.md 4.7 (G4b): a commit count in a short clause, by the COMMITS cell's rule:
|
|
1481
|
+
`<c>+ commits` when some id is known and a call with a commit command reported none,
|
|
1482
|
+
`commit count unknown` when no id is known at all."""
|
|
1483
|
+
if without_id > 0:
|
|
1484
|
+
return f"{known}+ commits" if known else "commit count unknown"
|
|
1485
|
+
return f"{known} {_plural(known, 'commit')}"
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _reported_commits(known: int, without_id: int) -> str:
|
|
1489
|
+
"""docs/DESIGN.md 4.7 (G4b): the commit part of the STORIES facts clause."""
|
|
1490
|
+
if without_id > 0 and not known:
|
|
1491
|
+
return "commit count unknown (a commit command reported no id)"
|
|
1492
|
+
return f"reported {_commits_words(known, without_id)}"
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _commits_display(agent, commits_count: int) -> str:
|
|
1496
|
+
"""The AGENT table's COMMITS cell: the count of known commit ids; when any attempt of the
|
|
1497
|
+
agent has a completed call that contains a commit command and reported no id (a commit may
|
|
1498
|
+
have happened, docs/DESIGN.md 4.7, FA6, G4): `?` when no id is known, `N+` otherwise."""
|
|
1499
|
+
if _commit_commands_without_id(agent.attempts) > 0:
|
|
1500
|
+
return "?" if commits_count == 0 else f"{commits_count}+"
|
|
1501
|
+
return str(commits_count)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _runtime_owned_kill(attempt) -> bool:
|
|
1505
|
+
"""docs/DESIGN.md 4.13.8: an attempt's interrupt is a kill the runtime
|
|
1506
|
+
may own only when its cause is neither `parent_stop` nor `human` (that is,
|
|
1507
|
+
`runtime` or `unattributed`). The AGENT table's KILLED column is the only
|
|
1508
|
+
per-agent kill count; the account sentences split causes themselves."""
|
|
1509
|
+
interrupt = attempt.interrupt
|
|
1510
|
+
return interrupt is not None and interrupt.cause not in ("parent_stop", "human")
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
_MAX_LABEL = 40
|
|
1514
|
+
_MAX_PHASE = 22
|
|
1515
|
+
_LOWER_BOUND_LEGEND = "+ is a lower bound: incomplete evidence may hide more paths or commits."
|
|
1516
|
+
_COMMIT_UNKNOWN_LEGEND = (
|
|
1517
|
+
"COMMITS ? a completed shell call contained a commit command but reported no commit id"
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _truncate_shown(text: str, limit: int) -> str:
|
|
1522
|
+
"""docs/DESIGN.md 4.7 (P3): a shown label longer than `limit` keeps its first
|
|
1523
|
+
`limit - 3` characters and ends `...`."""
|
|
1524
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _agent_table(run: Run, redactor: Redactor) -> list[str]:
|
|
1528
|
+
"""docs/DESIGN.md 4.7 rule 5: AGENT, [PHASE], ATTEMPTS, END, KILLED,
|
|
1529
|
+
PATHS, COMMITS, WASTED. PHASE is kept only when at least one logical
|
|
1530
|
+
agent of this run actually carries one (a session's subagents never
|
|
1531
|
+
do)."""
|
|
1532
|
+
show_phase = any(agent.phase for agent in run.agents)
|
|
1533
|
+
# docs/DESIGN.md 4.7 (field milestone F, P3): the shown label and phase are truncated, and
|
|
1534
|
+
# each column is as wide as its longest shown value plus two separator spaces (floors 10
|
|
1535
|
+
# and 11, today's widths, which the share labels never exceed).
|
|
1536
|
+
# The names layer has already replaced any path in a name (and stripped control characters),
|
|
1537
|
+
# so truncation can never cut a path in half; the assembled row is still scrubbed whole
|
|
1538
|
+
# below. Agent by agent, label then phase: the order the paths were numbered in before.
|
|
1539
|
+
labels: list[str] = []
|
|
1540
|
+
phases: list[str] = []
|
|
1541
|
+
for agent in run.agents:
|
|
1542
|
+
labels.append(_truncate_shown(redactor.names.agent(agent.label), _MAX_LABEL))
|
|
1543
|
+
phases.append(_truncate_shown(redactor.names.phase(agent.phase), _MAX_PHASE))
|
|
1544
|
+
label_width = max([10, *(len(label) + 2 for label in labels)])
|
|
1545
|
+
phase_width = max([11, *(len(phase) + 2 for phase in phases)])
|
|
1546
|
+
header = f"{'AGENT':<{label_width}}"
|
|
1547
|
+
if show_phase:
|
|
1548
|
+
header += f"{'PHASE':<{phase_width}}"
|
|
1549
|
+
# docs/DESIGN.md 4.7 (G9, v0.3.1): a RESUMES column only when some subagent was resumed
|
|
1550
|
+
show_resumes = run.kind == "session" and any(a.resumes > 0 for a in run.attempts)
|
|
1551
|
+
header += f"{'ATTEMPTS':<10}"
|
|
1552
|
+
if show_resumes:
|
|
1553
|
+
header += f"{'RESUMES':<9}"
|
|
1554
|
+
header += f"{'END':<19}{'KILLED':<8}{'PATHS':<7}{'COMMITS':<9}WASTED"
|
|
1555
|
+
lines = [header]
|
|
1556
|
+
lower_bound = False
|
|
1557
|
+
commit_unknown = False
|
|
1558
|
+
|
|
1559
|
+
for agent, label, phase in zip(run.agents, labels, phases):
|
|
1560
|
+
attempts = sorted(agent.attempts, key=lambda a: a.ordinal)
|
|
1561
|
+
final = attempts[-1] if attempts else None
|
|
1562
|
+
wasted_seconds = sum(attempt_seconds(a) for a in wasted_attempts(attempts))
|
|
1563
|
+
killed = sum(1 for a in attempts if _runtime_owned_kill(a))
|
|
1564
|
+
paths_display, commits_count = _agent_facts_totals(agent)
|
|
1565
|
+
commits_display = _commits_display(agent, commits_count)
|
|
1566
|
+
lower_bound = lower_bound or paths_display.endswith("+") or commits_display.endswith("+")
|
|
1567
|
+
commit_unknown = commit_unknown or commits_display == "?"
|
|
1568
|
+
|
|
1569
|
+
row = f"{label:<{label_width}}"
|
|
1570
|
+
if show_phase:
|
|
1571
|
+
row += f"{phase:<{phase_width}}"
|
|
1572
|
+
row += f"{len(attempts):<10}"
|
|
1573
|
+
if show_resumes:
|
|
1574
|
+
resumes = sum(a.resumes for a in attempts)
|
|
1575
|
+
row += f"{resumes if resumes else '':<9}"
|
|
1576
|
+
row += (
|
|
1577
|
+
f"{_agent_end(final, run):<19}{killed:<8}"
|
|
1578
|
+
f"{paths_display:<7}{commits_display:<9}{format_duration(wasted_seconds)}"
|
|
1579
|
+
)
|
|
1580
|
+
lines.append(redactor.scrub_local_line(row))
|
|
1581
|
+
if lower_bound: # P4
|
|
1582
|
+
lines.append(_LOWER_BOUND_LEGEND)
|
|
1583
|
+
if commit_unknown: # G4
|
|
1584
|
+
lines.append(_COMMIT_UNKNOWN_LEGEND)
|
|
1585
|
+
return lines
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
_REFUSED_WORDS = (
|
|
1589
|
+
"its ledger is partial: incomplete evidence may hide more writes, so the tool does not "
|
|
1590
|
+
"run status for it"
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _repo_state_section(observation, redactor: Redactor) -> list[str]:
|
|
1595
|
+
"""docs/DESIGN.md 4.9 (N5c-1): per group `repository <repo> checked: ...`,
|
|
1596
|
+
then per observed path its state, later write yes/no, and the ordinals of the
|
|
1597
|
+
attempts that wrote it, and per unobserved path `not observed`. Only ever
|
|
1598
|
+
called when the caller actually ran --observe (observation is not None)."""
|
|
1599
|
+
if not observation.groups: # P8: the scope, on one line
|
|
1600
|
+
return ["REPO STATE (no repository holds a path at risk)"]
|
|
1601
|
+
lines = ["REPO STATE"]
|
|
1602
|
+
for group in observation.groups:
|
|
1603
|
+
repo = redactor.names.path(group.repo) if group.repo is not None else "unknown"
|
|
1604
|
+
if group.checked:
|
|
1605
|
+
lines.append(f" repository {repo} checked: yes")
|
|
1606
|
+
elif group.refused and group.repo is not None:
|
|
1607
|
+
# P7: say why, then the manual command (the JSON reason code stays `refused`)
|
|
1608
|
+
lines.append(f" repository {repo} checked: no ({_REFUSED_WORDS})")
|
|
1609
|
+
lines.append(" " + _manual_command_text(group.repo, redactor))
|
|
1610
|
+
else:
|
|
1611
|
+
reason = redactor(group.reason) if group.reason else "not checked"
|
|
1612
|
+
lines.append(f" repository {repo} checked: no ({reason})")
|
|
1613
|
+
for path, path_observation in group.paths.items():
|
|
1614
|
+
later = "yes" if path_observation.later_write_observed else "no"
|
|
1615
|
+
ordinals = ", ".join(str(entry["ordinal"]) for entry in path_observation.attempts)
|
|
1616
|
+
lines.append(
|
|
1617
|
+
f" {redactor.names.path(path)} {path_observation.state} "
|
|
1618
|
+
f"later write: {later} attempts: {ordinals}"
|
|
1619
|
+
)
|
|
1620
|
+
for path in group.unobserved_paths:
|
|
1621
|
+
lines.append(f" {redactor.names.path(path)} not observed")
|
|
1622
|
+
return lines
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _finding_title(finding: Finding, redactor: Redactor) -> str:
|
|
1626
|
+
"""The title of a finding as a sink prints it (docs/DESIGN.md 4.13.4, 5.1; F fix 7, W7-2): a
|
|
1627
|
+
title that names a path renders from its structured parts (the path rendered once, the
|
|
1628
|
+
tool's own words never scanned); any other title goes through `Redactor.title`."""
|
|
1629
|
+
if finding.title_parts is not None:
|
|
1630
|
+
return redactor.title_parts(finding.title_parts)
|
|
1631
|
+
return redactor.title(finding.title)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _findings_section(
|
|
1635
|
+
findings: list[Finding], redactor: Redactor, view: "EvidenceView | None" = None,
|
|
1636
|
+
) -> list[str]:
|
|
1637
|
+
counts = {severity: 0 for severity in _SEVERITIES}
|
|
1638
|
+
for finding in findings:
|
|
1639
|
+
counts[finding.severity] = counts.get(finding.severity, 0) + 1
|
|
1640
|
+
|
|
1641
|
+
lines = [
|
|
1642
|
+
f"FINDINGS (critical {counts['critical']}, major {counts['major']}, "
|
|
1643
|
+
f"minor {counts['minor']})"
|
|
1644
|
+
]
|
|
1645
|
+
for finding in findings:
|
|
1646
|
+
title = _finding_title(finding, redactor)
|
|
1647
|
+
lines.append(f"[{finding.severity}] {finding.detector} {title}")
|
|
1648
|
+
for shown in _shown_evidence(finding.evidence, view, redactor):
|
|
1649
|
+
lines.append(f" {shown}")
|
|
1650
|
+
return lines
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
_TOOLING_STORY_PHRASES = {
|
|
1654
|
+
"bash_backgrounded": "Bash commands backgrounded",
|
|
1655
|
+
"bash_timeout": "Bash commands timed out",
|
|
1656
|
+
"tool_json_error": "tool input JSON errors",
|
|
1657
|
+
"structured_output_failure": "structured output failures",
|
|
1658
|
+
"codex_write_denied": "sandbox write denials",
|
|
1659
|
+
"codex_unavailable": "Codex call(s) could not start",
|
|
1660
|
+
"classifier_unavailable": "classifier outages",
|
|
1661
|
+
"git_cwd_mismatch": "git cwd mismatches",
|
|
1662
|
+
"permission_denied": "tool calls denied",
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
_CLAIM_CLAUSE_PHRASES = {
|
|
1666
|
+
"ok_false": "ok is false",
|
|
1667
|
+
"ready_false": "ready is false",
|
|
1668
|
+
"report_path_missing": "a report_path that does not exist",
|
|
1669
|
+
"self_reported_blocked": "being blocked",
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _claim_contradiction_phrases(metrics: dict) -> list[str]:
|
|
1674
|
+
"""The human phrases backing a claim_contradiction finding's clauses
|
|
1675
|
+
(docs/DESIGN.md 4.7 rule 6), rebuilt from Finding.metrics (clause ids
|
|
1676
|
+
plus the claims dict) rather than re-parsing the finding's own title --
|
|
1677
|
+
the same information the detector used to build its title in the first
|
|
1678
|
+
place (detectors/claim_contradiction.py's _clauses)."""
|
|
1679
|
+
claims = metrics.get("claims") if isinstance(metrics.get("claims"), dict) else {}
|
|
1680
|
+
clause_ids = metrics.get("clauses") or []
|
|
1681
|
+
phrases = []
|
|
1682
|
+
for clause_id in clause_ids:
|
|
1683
|
+
if clause_id == "tests_failed":
|
|
1684
|
+
n = claims.get("tests_failed", 0)
|
|
1685
|
+
phrases.append(f"{n} failed {_plural(n, 'test')}")
|
|
1686
|
+
elif clause_id == "suite_failed":
|
|
1687
|
+
n = claims.get("suite_failed", 0)
|
|
1688
|
+
phrases.append(f"{n} failed suite {_plural(n, 'check')}")
|
|
1689
|
+
elif clause_id == "blockers":
|
|
1690
|
+
n = claims.get("blockers_count", 0)
|
|
1691
|
+
phrases.append(f"{n} {_plural(n, 'blocker')}")
|
|
1692
|
+
elif clause_id == "self_reported_failure":
|
|
1693
|
+
n = metrics.get("self_reported_failure_counts", 0)
|
|
1694
|
+
phrases.append(f"{n} {_plural(n, 'failure count')}")
|
|
1695
|
+
else:
|
|
1696
|
+
phrases.append(_CLAIM_CLAUSE_PHRASES.get(clause_id, clause_id))
|
|
1697
|
+
return phrases or ["a contradiction"]
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _finding_ordinal(finding: Finding, attempts_sorted: list[Attempt]) -> int:
|
|
1701
|
+
for attempt in attempts_sorted:
|
|
1702
|
+
if attempt.agent_id == finding.agent_id:
|
|
1703
|
+
return attempt.ordinal
|
|
1704
|
+
return 1
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _agent_findings_pairs(
|
|
1708
|
+
run: Run, findings: list[Finding],
|
|
1709
|
+
) -> tuple[list[tuple], list[Finding]]:
|
|
1710
|
+
"""[(logical agent, its findings)] for every agent of the run, in
|
|
1711
|
+
run.agents order, plus the findings with no agent_id at all (docs/
|
|
1712
|
+
DESIGN.md 4.7 rule 6: these form the one 'run' story). A finding whose
|
|
1713
|
+
agent_id names no attempt of this run (should not happen in practice) is
|
|
1714
|
+
folded into the run-level list too, rather than silently dropped."""
|
|
1715
|
+
owner_by_agent_id: dict[str, object] = {}
|
|
1716
|
+
for agent in run.agents:
|
|
1717
|
+
for attempt in agent.attempts:
|
|
1718
|
+
owner_by_agent_id[attempt.agent_id] = agent
|
|
1719
|
+
findings_by_agent: dict[int, list[Finding]] = {id(agent): [] for agent in run.agents}
|
|
1720
|
+
run_level: list[Finding] = []
|
|
1721
|
+
for finding in findings:
|
|
1722
|
+
owner = owner_by_agent_id.get(finding.agent_id) if finding.agent_id else None
|
|
1723
|
+
if owner is None:
|
|
1724
|
+
run_level.append(finding)
|
|
1725
|
+
else:
|
|
1726
|
+
findings_by_agent[id(owner)].append(finding)
|
|
1727
|
+
pairs = [(agent, findings_by_agent[id(agent)]) for agent in run.agents]
|
|
1728
|
+
return pairs, run_level
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def _agent_story_pieces(agent, agent_findings: list[Finding]) -> list:
|
|
1732
|
+
"""docs/DESIGN.md 4.7 rule 6: one deterministic sentence per clause type
|
|
1733
|
+
present, in a fixed order, joined into a paragraph, as pieces (`_sentences_pieces`).
|
|
1734
|
+
Not yet redacted or wrapped -- the caller does both, once, the same way for text and JSON."""
|
|
1735
|
+
by_detector: dict[str, list[Finding]] = {}
|
|
1736
|
+
for finding in agent_findings:
|
|
1737
|
+
by_detector.setdefault(finding.detector, []).append(finding)
|
|
1738
|
+
|
|
1739
|
+
attempts_sorted = sorted(agent.attempts, key=lambda a: a.ordinal)
|
|
1740
|
+
non_final = attempts_sorted[:-1]
|
|
1741
|
+
final = attempts_sorted[-1] if attempts_sorted else None
|
|
1742
|
+
mislabeled_by_agent_id = {f.agent_id: f for f in by_detector.get("mislabeled_kill", [])}
|
|
1743
|
+
|
|
1744
|
+
clauses: list = []
|
|
1745
|
+
|
|
1746
|
+
lead = None
|
|
1747
|
+
silent_retry_findings = by_detector.get("silent_retry", [])
|
|
1748
|
+
if silent_retry_findings:
|
|
1749
|
+
k = silent_retry_findings[0].metrics.get("attempts", len(attempts_sorted)) - 1
|
|
1750
|
+
k = max(k, 0)
|
|
1751
|
+
silences = []
|
|
1752
|
+
all_runtime = bool(non_final)
|
|
1753
|
+
for attempt in non_final:
|
|
1754
|
+
mf = mislabeled_by_agent_id.get(attempt.agent_id)
|
|
1755
|
+
if mf is not None and mf.metrics.get("cause") == "runtime":
|
|
1756
|
+
silence = mf.metrics.get("silence_seconds")
|
|
1757
|
+
silences.append(f"{int(round(silence))}s" if silence is not None else "unknown")
|
|
1758
|
+
else:
|
|
1759
|
+
all_runtime = False
|
|
1760
|
+
if all_runtime and silences:
|
|
1761
|
+
silence_text = ", ".join(silences)
|
|
1762
|
+
lead = f"killed {k} {_plural(k, 'time')} by the runtime ({silence_text} of silence)"
|
|
1763
|
+
else:
|
|
1764
|
+
lead = f"restarted {k} {_plural(k, 'time')}"
|
|
1765
|
+
|
|
1766
|
+
parent_stop = False
|
|
1767
|
+
if final is not None:
|
|
1768
|
+
mf = mislabeled_by_agent_id.get(final.agent_id)
|
|
1769
|
+
parent_stop = mf is not None and mf.metrics.get("cause") == "parent_stop"
|
|
1770
|
+
|
|
1771
|
+
if lead is not None and parent_stop:
|
|
1772
|
+
clauses.append(f"{lead}, then stopped by the parent")
|
|
1773
|
+
elif lead is not None:
|
|
1774
|
+
clauses.append(lead)
|
|
1775
|
+
elif parent_stop:
|
|
1776
|
+
clauses.append("stopped by the parent")
|
|
1777
|
+
|
|
1778
|
+
for finding in by_detector.get("attempt_failed", []):
|
|
1779
|
+
ordinal = _finding_ordinal(finding, attempts_sorted)
|
|
1780
|
+
phrase = _cause_phrase(finding.metrics.get("cause", "unattributed"))
|
|
1781
|
+
reset_time = finding.metrics.get("reset_time")
|
|
1782
|
+
text = f"attempt {ordinal} failed: {phrase}"
|
|
1783
|
+
if reset_time:
|
|
1784
|
+
text += f", resets {reset_time}"
|
|
1785
|
+
clauses.append(text)
|
|
1786
|
+
|
|
1787
|
+
for finding in by_detector.get("claim_contradiction", []):
|
|
1788
|
+
ordinal = _finding_ordinal(finding, attempts_sorted)
|
|
1789
|
+
phrases = _claim_contradiction_phrases(finding.metrics)
|
|
1790
|
+
clauses.append(f"attempt {ordinal} completed but reported {'; '.join(phrases)}")
|
|
1791
|
+
|
|
1792
|
+
_paths_display, commits_count = _agent_facts_totals(agent)
|
|
1793
|
+
path_count, paths_partial = _agent_paths(agent)
|
|
1794
|
+
facts_known = path_count is not None
|
|
1795
|
+
paths_count = path_count or 0
|
|
1796
|
+
was_killed = final is not None and final.outcome == "killed"
|
|
1797
|
+
without_id = _commit_commands_without_id(agent.attempts)
|
|
1798
|
+
if paths_count or commits_count or without_id:
|
|
1799
|
+
# a partial ledger makes the count a lower bound (docs/DESIGN.md 4.13.8)
|
|
1800
|
+
paths_word = (
|
|
1801
|
+
f"{paths_count}+ paths" if paths_partial
|
|
1802
|
+
else f"{paths_count} {_plural(paths_count, 'path')}"
|
|
1803
|
+
)
|
|
1804
|
+
clauses.append(f"Wrote {paths_word}, {_reported_commits(commits_count, without_id)}.")
|
|
1805
|
+
elif facts_known and was_killed and not paths_partial:
|
|
1806
|
+
clauses.append("Wrote no paths.")
|
|
1807
|
+
|
|
1808
|
+
tooling_bits = []
|
|
1809
|
+
for detector_id, phrase in _TOOLING_STORY_PHRASES.items():
|
|
1810
|
+
for finding in by_detector.get(detector_id, []):
|
|
1811
|
+
count = finding.metrics.get("count", 1)
|
|
1812
|
+
if detector_id == "permission_denied":
|
|
1813
|
+
phrase = f"tool {_plural(count, 'call')} denied"
|
|
1814
|
+
elif detector_id == "codex_unavailable":
|
|
1815
|
+
phrase = f"Codex {_plural(count, 'call')} could not start"
|
|
1816
|
+
tooling_bits.append(f"{count} {phrase}")
|
|
1817
|
+
if tooling_bits:
|
|
1818
|
+
clauses.append("; ".join(tooling_bits))
|
|
1819
|
+
|
|
1820
|
+
if not clauses and agent_findings:
|
|
1821
|
+
clauses.append(agent_findings[0].title_parts or agent_findings[0].title)
|
|
1822
|
+
|
|
1823
|
+
return _sentences_pieces(clauses)
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
def _sentences(clauses: list[str]) -> str:
|
|
1827
|
+
"""Join clauses as sentences: each starts with a capital and the whole
|
|
1828
|
+
ends with one period. Finding titles start lowercase by convention
|
|
1829
|
+
(docs/DESIGN.md 4.3), so this is where a story gets its capitals."""
|
|
1830
|
+
parts = [clause.rstrip(".") for clause in clauses if clause]
|
|
1831
|
+
text = ". ".join(c[:1].upper() + c[1:] for c in parts)
|
|
1832
|
+
return f"{text}." if text else ""
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _sentences_pieces(clauses: list) -> list:
|
|
1836
|
+
"""`_sentences` as pieces (F fix 7, W7-2): a clause is a string or the `TitleParts` of a
|
|
1837
|
+
finding title that names a path. With no `TitleParts` the result is the one string
|
|
1838
|
+
`_sentences` returns (or no piece for no text). With one, the template is capitalized and
|
|
1839
|
+
stripped of a final period like any clause, and the pieces are the strings around it (merged)
|
|
1840
|
+
and the `TitleParts` themselves, so `_render_pieces` can render the path once and never scan
|
|
1841
|
+
the tool's own words."""
|
|
1842
|
+
if not any(isinstance(clause, TitleParts) for clause in clauses):
|
|
1843
|
+
text = _sentences(clauses)
|
|
1844
|
+
return [text] if text else []
|
|
1845
|
+
parts: list = []
|
|
1846
|
+
for clause in clauses:
|
|
1847
|
+
if not clause:
|
|
1848
|
+
continue
|
|
1849
|
+
if isinstance(clause, TitleParts):
|
|
1850
|
+
head, placeholder, tail = clause.template.partition("{path}")
|
|
1851
|
+
template = head + placeholder + tail.rstrip(".")
|
|
1852
|
+
parts.append(TitleParts(template[:1].upper() + template[1:], clause.path))
|
|
1853
|
+
else:
|
|
1854
|
+
text = clause.rstrip(".")
|
|
1855
|
+
parts.append(text[:1].upper() + text[1:])
|
|
1856
|
+
pieces: list = []
|
|
1857
|
+
for index, part in enumerate(parts):
|
|
1858
|
+
for piece in ((". ",) if index else ()) + (part,):
|
|
1859
|
+
if isinstance(piece, str) and pieces and isinstance(pieces[-1], str):
|
|
1860
|
+
pieces[-1] += piece
|
|
1861
|
+
else:
|
|
1862
|
+
pieces.append(piece)
|
|
1863
|
+
if pieces and isinstance(pieces[-1], str):
|
|
1864
|
+
pieces[-1] += "."
|
|
1865
|
+
else:
|
|
1866
|
+
pieces.append(".")
|
|
1867
|
+
return pieces
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _pieces_raw(pieces: list) -> str:
|
|
1871
|
+
"""The pieces with the real path (the raw text of a story)."""
|
|
1872
|
+
return "".join(piece.raw() if isinstance(piece, TitleParts) else piece for piece in pieces)
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def _render_pieces(pieces: list, redactor: Redactor) -> str:
|
|
1876
|
+
"""The pieces as a sink prints them: a string through `Redactor.title` (labels, paths and the
|
|
1877
|
+
rest of the scanner), a `TitleParts` through `Redactor.title_parts` (its path rendered once,
|
|
1878
|
+
its words never scanned)."""
|
|
1879
|
+
return "".join(
|
|
1880
|
+
redactor.title_parts(piece) if isinstance(piece, TitleParts) else redactor.title(piece)
|
|
1881
|
+
for piece in pieces
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def _story_records(run: Run, findings: list[Finding], verbose: bool) -> list[dict]:
|
|
1886
|
+
"""docs/DESIGN.md 4.7 rule 6: one record per logical agent that earns a
|
|
1887
|
+
story (at least one non-info finding, or --verbose with any finding at
|
|
1888
|
+
all), worst severity first then by (pre-pseudonym) label, plus one
|
|
1889
|
+
trailing 'run' record for findings with no agent_id. Each record is
|
|
1890
|
+
{'agent': label_or_"run", 'severity': str, 'text': str,
|
|
1891
|
+
'finding_ids': [...], 'findings': [...]} -- 'findings' is
|
|
1892
|
+
report-internal (kept for evidence lookups) and is stripped before this
|
|
1893
|
+
goes anywhere public (text rendering, JSON export)."""
|
|
1894
|
+
pairs, run_level = _agent_findings_pairs(run, findings)
|
|
1895
|
+
|
|
1896
|
+
entries = []
|
|
1897
|
+
for agent, agent_findings in pairs:
|
|
1898
|
+
if not agent_findings:
|
|
1899
|
+
continue
|
|
1900
|
+
non_info = [f for f in agent_findings if f.severity != "info"]
|
|
1901
|
+
if not non_info and not verbose:
|
|
1902
|
+
continue
|
|
1903
|
+
relevant = agent_findings if verbose else non_info
|
|
1904
|
+
rank = min(_SEVERITIES.index(f.severity) for f in relevant)
|
|
1905
|
+
pieces = _agent_story_pieces(agent, relevant)
|
|
1906
|
+
entries.append({
|
|
1907
|
+
"agent": agent.label,
|
|
1908
|
+
"severity": _SEVERITIES[rank],
|
|
1909
|
+
"text": _pieces_raw(pieces),
|
|
1910
|
+
"pieces": pieces,
|
|
1911
|
+
"finding_ids": [f.finding_id for f in relevant],
|
|
1912
|
+
"findings": relevant,
|
|
1913
|
+
"_rank": rank,
|
|
1914
|
+
})
|
|
1915
|
+
# Worst severity first; ties broken by wasted time (biggest loss first),
|
|
1916
|
+
# then by the agent's position in the run, which is the order the AGENT
|
|
1917
|
+
# table and the agent-N pseudonyms follow. Sorting by the raw label would
|
|
1918
|
+
# look random once the redactor has replaced it.
|
|
1919
|
+
position = {agent.label: index for index, agent in enumerate(run.agents)}
|
|
1920
|
+
|
|
1921
|
+
def wasted(entry: dict) -> float:
|
|
1922
|
+
return sum(
|
|
1923
|
+
(f.metrics.get("wasted_seconds", 0) or 0)
|
|
1924
|
+
for f in entry["findings"] if f.detector == "silent_retry"
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
entries.sort(
|
|
1928
|
+
key=lambda entry: (entry["_rank"], -wasted(entry), position.get(entry["agent"], 1 << 30))
|
|
1929
|
+
)
|
|
1930
|
+
for entry in entries:
|
|
1931
|
+
del entry["_rank"]
|
|
1932
|
+
|
|
1933
|
+
run_relevant = [f for f in run_level if verbose or f.severity != "info"]
|
|
1934
|
+
if run_relevant:
|
|
1935
|
+
rank = min(_SEVERITIES.index(f.severity) for f in run_relevant)
|
|
1936
|
+
pieces = _sentences_pieces([f.title_parts or f.title for f in run_relevant])
|
|
1937
|
+
entries.append({
|
|
1938
|
+
"agent": "run",
|
|
1939
|
+
"severity": _SEVERITIES[rank],
|
|
1940
|
+
"text": _pieces_raw(pieces),
|
|
1941
|
+
"pieces": pieces,
|
|
1942
|
+
"finding_ids": [f.finding_id for f in run_relevant],
|
|
1943
|
+
"findings": run_relevant,
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
return entries
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def _wrap_story(label: str, text: str) -> list[str]:
|
|
1950
|
+
"""Story text wrapped at 78 columns, at most 2 lines (docs/DESIGN.md 4.7
|
|
1951
|
+
rule 8), the first line prefixed by the (already pseudonymized/redacted)
|
|
1952
|
+
label, continuation lines indented to the same column."""
|
|
1953
|
+
indent = " " * (len(label) + 2)
|
|
1954
|
+
width = max(78 - len(indent), 20)
|
|
1955
|
+
wrapped = textwrap.wrap(text, width=width) or [text]
|
|
1956
|
+
wrapped = wrapped[:2]
|
|
1957
|
+
lines = [f" {label} {wrapped[0]}"]
|
|
1958
|
+
for extra in wrapped[1:]:
|
|
1959
|
+
lines.append(f"{indent}{extra}")
|
|
1960
|
+
return lines
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _stories_section(
|
|
1964
|
+
run: Run, findings: list[Finding], redactor: Redactor, verbose: bool, evidence: bool,
|
|
1965
|
+
view: "EvidenceView | None" = None,
|
|
1966
|
+
) -> list[str]:
|
|
1967
|
+
entries = _story_records(run, findings, verbose)
|
|
1968
|
+
lines = [f"STORIES ({len(entries)}) details: --verbose evidence: --evidence"]
|
|
1969
|
+
if evidence and view is not None and view.note() is not None:
|
|
1970
|
+
lines.append(f" {view.note()}")
|
|
1971
|
+
for entry in entries:
|
|
1972
|
+
label = "run" if entry["agent"] == "run" else redactor.names.agent(entry["agent"])
|
|
1973
|
+
text = _render_pieces(entry["pieces"], redactor)
|
|
1974
|
+
lines.extend(_wrap_story(label, text))
|
|
1975
|
+
if evidence:
|
|
1976
|
+
story_lines: list[str] = []
|
|
1977
|
+
for finding in entry["findings"]:
|
|
1978
|
+
for line in finding.evidence:
|
|
1979
|
+
if len(story_lines) >= 2:
|
|
1980
|
+
break
|
|
1981
|
+
text = view.line(line) if view is not None else redactor(line)
|
|
1982
|
+
if text is not None and (redactor.level == "raw" or text not in story_lines):
|
|
1983
|
+
story_lines.append(text)
|
|
1984
|
+
if len(story_lines) >= 2:
|
|
1985
|
+
break
|
|
1986
|
+
lines.extend(f" {text}" for text in story_lines)
|
|
1987
|
+
return lines, entries
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def _silenced_detectors_line(silenced_detectors: frozenset) -> str | None:
|
|
1991
|
+
"""docs/DESIGN.md 4.7/4.10: the last line of `run`'s and `scan`'s text
|
|
1992
|
+
output, printed whenever [detectors].off silences at least one detector
|
|
1993
|
+
-- a config-level fact, independent of whether that detector actually
|
|
1994
|
+
fired on this particular run or scan."""
|
|
1995
|
+
if not silenced_detectors:
|
|
1996
|
+
return None
|
|
1997
|
+
ids = ", ".join(sorted(silenced_detectors))
|
|
1998
|
+
n = len(silenced_detectors)
|
|
1999
|
+
return f"{n} {_plural(n, 'detector')} silenced by config: {ids}"
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def render_run_text(
|
|
2003
|
+
run: Run,
|
|
2004
|
+
findings: list[Finding],
|
|
2005
|
+
redactor: Redactor,
|
|
2006
|
+
observation=None,
|
|
2007
|
+
verbose: bool = False,
|
|
2008
|
+
evidence: bool = False,
|
|
2009
|
+
show_age: bool = True,
|
|
2010
|
+
silenced_detectors: frozenset = frozenset(),
|
|
2011
|
+
) -> str:
|
|
2012
|
+
"""docs/DESIGN.md 4.7 (rewritten by unit M3a): header, account, WORK AT
|
|
2013
|
+
RISK, WHAT TO DO, AGENT table, [REPO STATE], STORIES, then either the
|
|
2014
|
+
hidden-info-count line or (--verbose) the full v0.1 FINDINGS list, then
|
|
2015
|
+
(unit M3c, 4.10) a last line naming every detector [detectors].off
|
|
2016
|
+
silenced, when there is at least one.
|
|
2017
|
+
show_age=False drops the header's "(N days ago)", which depends on the
|
|
2018
|
+
wall clock; `demo` uses it so README's byte-compared demo block stays
|
|
2019
|
+
stable from one day to the next.
|
|
2020
|
+
|
|
2021
|
+
`silenced_detectors` (docs/DESIGN.md 4.6/4.7): findings from these
|
|
2022
|
+
detector ids are dropped before anything else in this function looks at
|
|
2023
|
+
`findings` -- the header's verdict, the account sentences, WORK AT RISK,
|
|
2024
|
+
WHAT TO DO, the AGENT table's story-adjacent totals, STORIES, and the
|
|
2025
|
+
--verbose FINDINGS list all see only the shown (non-silenced) findings,
|
|
2026
|
+
exactly like a caller that filtered before calling ever existed. The
|
|
2027
|
+
caller (config.partition_silenced) still has the full list for --json."""
|
|
2028
|
+
findings = [f for f in findings if f.detector not in silenced_detectors]
|
|
2029
|
+
|
|
2030
|
+
_register_names(run, redactor, detailed=True)
|
|
2031
|
+
_register_observation_paths(observation, redactor, run)
|
|
2032
|
+
view = EvidenceView(run, redactor, show_excerpts=evidence)
|
|
2033
|
+
|
|
2034
|
+
info_count = sum(1 for finding in findings if finding.severity == "info")
|
|
2035
|
+
hidden_count = 0 if verbose else info_count
|
|
2036
|
+
|
|
2037
|
+
lines = [_run_header_line(run, findings, show_age)]
|
|
2038
|
+
lines.extend(redactor(sentence) for sentence in _account_lines(run, findings))
|
|
2039
|
+
lines.append("")
|
|
2040
|
+
lines.extend(redactor(line) for line in _work_at_risk_lines(run, observation, redactor))
|
|
2041
|
+
lines.append("")
|
|
2042
|
+
items = _what_to_do_items(run, findings, observation, observation is not None)
|
|
2043
|
+
if items: # docs/DESIGN.md 4.7: the heading is never printed empty
|
|
2044
|
+
lines.extend(redactor(line) for line in _what_to_do_lines(items))
|
|
2045
|
+
lines.append("")
|
|
2046
|
+
lines.extend(_agent_table(run, redactor))
|
|
2047
|
+
lines.append("")
|
|
2048
|
+
# docs/DESIGN.md 4.9 rule 10: the REPO STATE block appears only when the
|
|
2049
|
+
# caller actually ran --observe; without it, text output carries no
|
|
2050
|
+
# trace of observation at all.
|
|
2051
|
+
if observation is not None:
|
|
2052
|
+
lines.extend(_repo_state_section(observation, redactor))
|
|
2053
|
+
lines.append("")
|
|
2054
|
+
stories_lines, _entries = _stories_section(run, findings, redactor, verbose, evidence, view)
|
|
2055
|
+
lines.extend(stories_lines)
|
|
2056
|
+
|
|
2057
|
+
if verbose:
|
|
2058
|
+
lines.append("")
|
|
2059
|
+
lines.extend(_findings_section(findings, redactor, view))
|
|
2060
|
+
elif hidden_count:
|
|
2061
|
+
lines.append("")
|
|
2062
|
+
hidden_word = _plural(hidden_count, "finding")
|
|
2063
|
+
lines.append(f"{hidden_count} info {hidden_word} not shown (--verbose)")
|
|
2064
|
+
|
|
2065
|
+
silenced_line = _silenced_detectors_line(silenced_detectors)
|
|
2066
|
+
if silenced_line is not None:
|
|
2067
|
+
lines.append(silenced_line)
|
|
2068
|
+
|
|
2069
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
# --- unit M3b: scan triage (NEEDS ATTENTION is the default; --all keeps ---
|
|
2073
|
+
# --- the row-per-run list above/below unchanged) ---------------------------
|
|
2074
|
+
#
|
|
2075
|
+
# A run needing attention is ranked, not merely listed: possibly-live runs
|
|
2076
|
+
# first, then runs whose killed/failed attempts left the most paths behind,
|
|
2077
|
+
# then by verdict, then by wasted time, then by recency. Every function here
|
|
2078
|
+
# is pure (no filesystem access): the CLI stats a run's own files once and
|
|
2079
|
+
# hands the result in as `newest_mtime`, the same "inject the clock" pattern
|
|
2080
|
+
# _now()/_age_string already use for the wall clock.
|
|
2081
|
+
|
|
2082
|
+
_VERDICT_RANK = {"FAILED": 0, "DEGRADED": 1, "CLEAN": 2}
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def _verdict_bare(run: Run, findings: list[Finding]) -> str:
|
|
2086
|
+
"""verdict_of() without its trailing ' (INCOMPLETE)' suffix, for a
|
|
2087
|
+
ranking or a candidate rule that only cares about FAILED/DEGRADED/CLEAN
|
|
2088
|
+
itself, never whether the run's own inspection was complete."""
|
|
2089
|
+
return verdict_of(run, findings).split(" ", 1)[0]
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def is_possibly_live(
|
|
2093
|
+
run: Run, newest_mtime: float | None, now: datetime, quiet_seconds: float,
|
|
2094
|
+
) -> bool:
|
|
2095
|
+
"""unit M3b / docs/DESIGN.md 7.2's UNKNOWN_QUIET_SECONDS: the best a cold
|
|
2096
|
+
`scan` can say about liveness without hook state or a live task list --
|
|
2097
|
+
a run that has not finished and was touched inside the quiet window
|
|
2098
|
+
might still be running. **(fixed after cross-model review, N1N2 review
|
|
2099
|
+
finding 3)** This also applies to a session: a session's own
|
|
2100
|
+
Run.finished is always None (never anything the `run.finished` check
|
|
2101
|
+
below rejects, since v0.3 N2, D11), and its own newest mtime is already
|
|
2102
|
+
computed correctly by the caller (cli._run_files_for_liveness handles
|
|
2103
|
+
both kinds); a workflow scan already known to have finished is the only
|
|
2104
|
+
thing this function still rules out by `run.finished` alone. `newest_
|
|
2105
|
+
mtime` is None when no file could be statted at all (already gone by the
|
|
2106
|
+
time this runs), which is never "recently touched"."""
|
|
2107
|
+
if run.finished:
|
|
2108
|
+
return False
|
|
2109
|
+
if newest_mtime is None:
|
|
2110
|
+
return False
|
|
2111
|
+
return (now.timestamp() - newest_mtime) < quiet_seconds
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def scan_age_string(run: Run, now: datetime, live: bool) -> str:
|
|
2115
|
+
"""The triage age column and the JSON `age` field: 'possibly live' when
|
|
2116
|
+
live, else the abbreviated '<N>d/h/m ago' built from ended_at, or
|
|
2117
|
+
'unknown' when ended_at itself is unknown (never None -- age is always a
|
|
2118
|
+
str)."""
|
|
2119
|
+
if live:
|
|
2120
|
+
return "possibly live"
|
|
2121
|
+
if run.ended_at is None:
|
|
2122
|
+
return "unknown"
|
|
2123
|
+
return _age_string(run.ended_at, now, short=True)
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def scan_paths_at_risk(run: Run) -> int:
|
|
2127
|
+
"""The JSON `paths_at_risk` field and the ranking key's own path count
|
|
2128
|
+
(docs/DESIGN.md 4.13.8): the distinct ledger paths over the run's 4.13.1
|
|
2129
|
+
risk population (a completed attempt in a failed generation counts)."""
|
|
2130
|
+
paths, _complete = ledger.risk_paths(run)
|
|
2131
|
+
return len(paths)
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
def scan_paths_at_risk_complete(run: Run) -> bool:
|
|
2135
|
+
"""The JSON `paths_at_risk_complete` field: true only when the risk
|
|
2136
|
+
population and every ledger in it are complete; `paths_at_risk` is a lower
|
|
2137
|
+
bound otherwise."""
|
|
2138
|
+
_paths, complete = ledger.risk_paths(run)
|
|
2139
|
+
return complete
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def unfinished_state(run: Run, live: bool) -> str | None:
|
|
2143
|
+
"""docs/DESIGN.md 5.3b (v0.3, N2): 'possibly running' | 'no terminal
|
|
2144
|
+
record' | 'failed' | 'stopped' | 'unknown' | None for a workflow row;
|
|
2145
|
+
'possibly running' | None for a session row. 'abandoned' is removed:
|
|
2146
|
+
derived only from the lifecycle, never from per-key journal state, so a
|
|
2147
|
+
run whose journal happens to record a per-key 'failed' event no longer
|
|
2148
|
+
shows up here at all once the run's own lifecycle resolved cleanly
|
|
2149
|
+
(journal_resolved) -- that per-key story belongs to attempt_failed, not
|
|
2150
|
+
this field.
|
|
2151
|
+
|
|
2152
|
+
`live` is unit M3b's own hook-free 'possibly live' heuristic (a run not
|
|
2153
|
+
yet Run.finished whose files were touched inside the quiet window); it
|
|
2154
|
+
still takes priority when true, for both kinds, since it is strictly
|
|
2155
|
+
fresher evidence than a workflow's own (necessarily somewhat stale,
|
|
2156
|
+
lifecycle is built once at load time) activity field."""
|
|
2157
|
+
if live:
|
|
2158
|
+
return "possibly running"
|
|
2159
|
+
if run.kind != "workflow":
|
|
2160
|
+
return None
|
|
2161
|
+
lifecycle = run.lifecycle
|
|
2162
|
+
if lifecycle is None:
|
|
2163
|
+
return None
|
|
2164
|
+
if lifecycle.terminal in ("completed", "journal_resolved"):
|
|
2165
|
+
return None
|
|
2166
|
+
if lifecycle.terminal in ("failed", "stopped", "unknown"):
|
|
2167
|
+
return lifecycle.terminal
|
|
2168
|
+
# lifecycle.terminal == "none"
|
|
2169
|
+
return "possibly running" if lifecycle.activity == "recent" else "no terminal record"
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def is_attention_candidate(run: Run, findings: list[Finding], now: datetime, live: bool) -> bool:
|
|
2173
|
+
"""unit M3b rule 1, rules (b) to (d) (docs/DESIGN.md 4.7; field milestone F, FB2): rule
|
|
2174
|
+
(a), "possibly live", alone no longer makes a candidate; such a run is listed under LIVE
|
|
2175
|
+
NOW instead, and is a candidate only through (b) to (d). `live` stays in the signature
|
|
2176
|
+
because the caller computes it once for the row, the ranking key, and the LIVE NOW group."""
|
|
2177
|
+
del live
|
|
2178
|
+
verdict = _verdict_bare(run, findings)
|
|
2179
|
+
if verdict == "FAILED": # (d) verdict FAILED regardless of age
|
|
2180
|
+
return True
|
|
2181
|
+
if _ended_within_72h_strict(run, now):
|
|
2182
|
+
if verdict == "DEGRADED": # (b) ended recently, FAILED already handled above
|
|
2183
|
+
return True
|
|
2184
|
+
paths, _commits = _risk_paths_and_commits(run)
|
|
2185
|
+
if paths: # (c) ended recently, a killed/failed/superseded attempt wrote a path
|
|
2186
|
+
return True
|
|
2187
|
+
return False
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
def _scan_top_detector(findings: list[Finding]) -> str | None:
|
|
2191
|
+
"""The same rule cli._top_detector applies (docs/DESIGN.md 4.7's unit
|
|
2192
|
+
M1d: an info-only finding, present on every run, is never the "top"
|
|
2193
|
+
detector), duplicated here rather than imported so this module never
|
|
2194
|
+
depends on cli.py."""
|
|
2195
|
+
counts: dict[str, int] = {}
|
|
2196
|
+
for finding in findings:
|
|
2197
|
+
if finding.severity not in ("major", "critical"):
|
|
2198
|
+
continue
|
|
2199
|
+
counts[finding.detector] = counts.get(finding.detector, 0) + 1
|
|
2200
|
+
if not counts:
|
|
2201
|
+
return None
|
|
2202
|
+
return max(counts, key=lambda detector_id: counts[detector_id])
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def attention_reason(run: Run, findings: list[Finding]) -> str:
|
|
2206
|
+
"""unit M3b rule 3: the one-line NEEDS ATTENTION reason, deterministic,
|
|
2207
|
+
first that applies."""
|
|
2208
|
+
failed = [f for f in findings if f.detector == "attempt_failed"]
|
|
2209
|
+
if failed:
|
|
2210
|
+
k = len(failed)
|
|
2211
|
+
causes: list[str] = []
|
|
2212
|
+
for finding in failed:
|
|
2213
|
+
phrase = _cause_phrase(finding.metrics.get("cause", "unattributed"))
|
|
2214
|
+
if phrase not in causes:
|
|
2215
|
+
causes.append(phrase)
|
|
2216
|
+
text = f"{k} {_plural(k, 'attempt')} failed: {', '.join(causes)}"
|
|
2217
|
+
reset_time = next(
|
|
2218
|
+
(f.metrics.get("reset_time") for f in failed if f.metrics.get("reset_time")), None,
|
|
2219
|
+
)
|
|
2220
|
+
if reset_time:
|
|
2221
|
+
text += f", resets {reset_time}"
|
|
2222
|
+
return text
|
|
2223
|
+
|
|
2224
|
+
runtime_kills = [
|
|
2225
|
+
f for f in findings
|
|
2226
|
+
if f.detector == "mislabeled_kill" and f.metrics.get("cause") == "runtime"
|
|
2227
|
+
]
|
|
2228
|
+
if runtime_kills:
|
|
2229
|
+
k = len(runtime_kills)
|
|
2230
|
+
counts = _work_at_risk_counts(run, ledger.risk_population(run))
|
|
2231
|
+
n_paths = len(counts.paths)
|
|
2232
|
+
# docs/DESIGN.md 4.7: the WORK AT RISK count line's numbers, `N+` when it is partial.
|
|
2233
|
+
paths_word = (
|
|
2234
|
+
f"{n_paths}+ paths" if counts.partial else f"{n_paths} {_plural(n_paths, 'path')}"
|
|
2235
|
+
)
|
|
2236
|
+
return (
|
|
2237
|
+
f"{k} {_plural(k, 'attempt')} killed by the runtime, "
|
|
2238
|
+
f"{paths_word} named, "
|
|
2239
|
+
f"{_commits_words(len(counts.commit_ids), counts.commit_commands_without_id)}"
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
top = _scan_top_detector(findings)
|
|
2243
|
+
if top is not None:
|
|
2244
|
+
count = sum(
|
|
2245
|
+
1 for f in findings if f.detector == top and f.severity in ("major", "critical")
|
|
2246
|
+
)
|
|
2247
|
+
return f"{top} x{count}"
|
|
2248
|
+
|
|
2249
|
+
if run.kind == "session":
|
|
2250
|
+
interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
|
|
2251
|
+
if interrupted:
|
|
2252
|
+
return f"{interrupted} {_plural(interrupted, 'subagent')} interrupted"
|
|
2253
|
+
|
|
2254
|
+
# docs/DESIGN.md 4.7 (M3b rule 3): a rule (c) candidate, same counts and wording as the
|
|
2255
|
+
# WORK AT RISK line of the run report.
|
|
2256
|
+
population = ledger.risk_population(run)
|
|
2257
|
+
counts = _work_at_risk_counts(run, population)
|
|
2258
|
+
if population and (counts.paths or ledger.risk_paths(run)[0]):
|
|
2259
|
+
n, n_paths = counts.attempts, len(counts.paths)
|
|
2260
|
+
paths_word = (
|
|
2261
|
+
f"{n_paths}+ paths" if counts.partial else f"{n_paths} {_plural(n_paths, 'path')}"
|
|
2262
|
+
)
|
|
2263
|
+
return (
|
|
2264
|
+
f"{n} {_plural(n, 'attempt')} at risk: {paths_word} named, "
|
|
2265
|
+
f"{_commits_words(len(counts.commit_ids), counts.commit_commands_without_id)}"
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
return "clean" # defensive: a candidate has a finding, an interrupt, or a path at risk
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
def attention_next_command(run: Run, now: datetime) -> str:
|
|
2272
|
+
"""unit M3b rule 3's second line: --observe only when a killed or
|
|
2273
|
+
failed attempt is known to have written a path and the run is recent
|
|
2274
|
+
enough that a repo might still show it -- the same "unknown end counts
|
|
2275
|
+
as recent" convention _what_to_do_items already uses for this exact
|
|
2276
|
+
decision on the `run` report."""
|
|
2277
|
+
paths, _commits = _risk_paths_and_commits(run)
|
|
2278
|
+
if paths and _ended_recently(run, now):
|
|
2279
|
+
return f"agent-postmortem run {run.run_id} --observe"
|
|
2280
|
+
return f"agent-postmortem run {run.run_id}"
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
def attention_sort_key(
|
|
2284
|
+
run: Run, findings: list[Finding], live: bool, paths_at_risk: int, now: datetime,
|
|
2285
|
+
) -> tuple:
|
|
2286
|
+
"""unit M3b rule 1's ranking key, ascending: possibly live first, then
|
|
2287
|
+
runs that ended within 72 hours (a rescue is only possible while the
|
|
2288
|
+
working tree still holds the work), then paths at risk descending, then
|
|
2289
|
+
verdict (FAILED, DEGRADED, CLEAN), then wasted seconds descending, then
|
|
2290
|
+
ended_at descending (unknown last)."""
|
|
2291
|
+
recent = _ended_within_72h_strict(run, now)
|
|
2292
|
+
verdict_rank = _VERDICT_RANK.get(_verdict_bare(run, findings), 3)
|
|
2293
|
+
wasted_seconds = 0.0
|
|
2294
|
+
for finding in findings:
|
|
2295
|
+
if finding.detector == "wasted_time":
|
|
2296
|
+
wasted_seconds = finding.metrics.get("wasted_seconds", 0.0) or 0.0
|
|
2297
|
+
ended_ts = _aware(run.ended_at).timestamp() if run.ended_at is not None else float("-inf")
|
|
2298
|
+
return (
|
|
2299
|
+
0 if live else 1, 0 if recent else 1, -paths_at_risk, verdict_rank,
|
|
2300
|
+
-wasted_seconds, -ended_ts,
|
|
2301
|
+
)
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _scan_triage_totals_line(label: str, totals: dict) -> str:
|
|
2305
|
+
"""One LAST 7 DAYS / ALL TIME line (unit M3b rule 5): label padded to
|
|
2306
|
+
14 columns, then the same workflow/session numbers _scan_totals_lines
|
|
2307
|
+
prints, condensed onto one line with format_duration instead of a
|
|
2308
|
+
'%.1fh' float -- wasted_hours is recovered back to seconds rather than
|
|
2309
|
+
threading a parallel seconds accumulator through _cmd_scan, since
|
|
2310
|
+
totals['workflow'] is also the JSON payload and test_cli_smoke.py's
|
|
2311
|
+
test_scan_totals_are_stratified_by_kind pins its exact key set."""
|
|
2312
|
+
workflow = totals.get("workflow", {})
|
|
2313
|
+
session = totals.get("session", {})
|
|
2314
|
+
wasted_seconds = workflow.get("wasted_hours", 0.0) * 3600.0
|
|
2315
|
+
return (
|
|
2316
|
+
f"{label:<14}{workflow.get('runs', 0)} workflow runs, "
|
|
2317
|
+
f"{workflow.get('runs_with_retries', 0)} with retries, "
|
|
2318
|
+
f"{format_duration(wasted_seconds)} wasted; "
|
|
2319
|
+
f"{session.get('runs', 0)} session runs, "
|
|
2320
|
+
f"{session.get('interrupted', 0)} subagents interrupted"
|
|
2321
|
+
)
|
|
2322
|
+
|
|
2323
|
+
|
|
2324
|
+
def render_scan_triage_text(
|
|
2325
|
+
shown_candidates: list[dict],
|
|
2326
|
+
total_candidates: int,
|
|
2327
|
+
total_runs: int,
|
|
2328
|
+
totals_7d: dict,
|
|
2329
|
+
totals_all: dict,
|
|
2330
|
+
redactor: Redactor,
|
|
2331
|
+
silenced_detectors: frozenset = frozenset(),
|
|
2332
|
+
live_now: list[dict] | None = None,
|
|
2333
|
+
total_live_now: int = 0,
|
|
2334
|
+
both_kinds: bool = True,
|
|
2335
|
+
) -> str:
|
|
2336
|
+
"""unit M3b: the new default `scan` text output. `shown_candidates` is
|
|
2337
|
+
already sorted and already truncated to --limit by the caller;
|
|
2338
|
+
`total_candidates` is the true count before truncation, so the header
|
|
2339
|
+
can say "(3 of 172 runs)" even when only 10 are printed. Each candidate
|
|
2340
|
+
dict carries 'run', 'age', 'verdict', 'reason', 'command' (attention_*
|
|
2341
|
+
above build these; the caller assembles the dict once per row so this
|
|
2342
|
+
renderer stays a pure formatter, the same division of labor
|
|
2343
|
+
render_run_text keeps with its own _work_at_risk_lines/_what_to_do_items
|
|
2344
|
+
callers)."""
|
|
2345
|
+
redactor.names = Pseudonyms(enabled=redactor.enabled)
|
|
2346
|
+
# docs/DESIGN.md 4.7 (FB2): a truncated list says so in its own header.
|
|
2347
|
+
truncated = len(shown_candidates) < total_candidates
|
|
2348
|
+
note = (
|
|
2349
|
+
f"; showing {len(shown_candidates)}, use --limit N or --all for more" if truncated else ""
|
|
2350
|
+
)
|
|
2351
|
+
lines = [f"NEEDS ATTENTION ({total_candidates} of {total_runs} runs{note})"]
|
|
2352
|
+
if not shown_candidates and not total_candidates: # P12: never a bare header
|
|
2353
|
+
lines.append(" nothing needs attention")
|
|
2354
|
+
workflow_runs = totals_all.get("workflow", {}).get("runs", 0)
|
|
2355
|
+
session_children = totals_all.get("session", {}).get("subagents", 0)
|
|
2356
|
+
if both_kinds and not workflow_runs and not session_children:
|
|
2357
|
+
# docs/DESIGN.md 4.8 (0.3.4): sessions with no subagent transcript and no Workflow
|
|
2358
|
+
# run give the tool nothing to judge; say what the scan observed (a launch whose
|
|
2359
|
+
# transcript is missing is not "no launch"), never more.
|
|
2360
|
+
lines.append(
|
|
2361
|
+
" this scan found no subagent transcript and no Workflow run, which is what "
|
|
2362
|
+
"agent-postmortem checks; agent-postmortem demo shows a sample report"
|
|
2363
|
+
)
|
|
2364
|
+
for candidate in shown_candidates:
|
|
2365
|
+
run = candidate["run"]
|
|
2366
|
+
lines.append(
|
|
2367
|
+
f" {run.run_id} {candidate['age']} {candidate['verdict']} "
|
|
2368
|
+
f"{redactor(candidate['reason'])}"
|
|
2369
|
+
)
|
|
2370
|
+
lines.append(f" -> {redactor(candidate['command'])}")
|
|
2371
|
+
if total_live_now:
|
|
2372
|
+
shown_live = live_now or []
|
|
2373
|
+
live_note = (
|
|
2374
|
+
f"; showing {len(shown_live)} of {total_live_now}, use --limit N or --all for more"
|
|
2375
|
+
if len(shown_live) < total_live_now else ""
|
|
2376
|
+
)
|
|
2377
|
+
lines.append(f"LIVE NOW ({total_live_now} of {total_runs} runs{live_note})")
|
|
2378
|
+
for entry in shown_live:
|
|
2379
|
+
lines.append(
|
|
2380
|
+
f" {entry['run'].run_id} possibly live {entry['verdict']} "
|
|
2381
|
+
f"{redactor(entry['command'])}"
|
|
2382
|
+
)
|
|
2383
|
+
lines.append(_scan_triage_totals_line("LAST 7 DAYS", totals_7d))
|
|
2384
|
+
lines.append(_scan_triage_totals_line("ALL TIME", totals_all))
|
|
2385
|
+
lines.append(" full list: agent-postmortem scan --all")
|
|
2386
|
+
silenced_line = _silenced_detectors_line(silenced_detectors)
|
|
2387
|
+
if silenced_line is not None:
|
|
2388
|
+
lines.append(silenced_line)
|
|
2389
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
2390
|
+
|
|
2391
|
+
|
|
2392
|
+
def _scan_row_line(row: dict, redactor: Redactor) -> str:
|
|
2393
|
+
# docs/DESIGN.md 4.7: id, date, project, kind, agents, attempts,
|
|
2394
|
+
# verdict, top detector, in that order.
|
|
2395
|
+
project = project_text(row.get("project", ""), redactor)
|
|
2396
|
+
kind = row.get("kind", "workflow")
|
|
2397
|
+
# docs/DESIGN.md 12's D-C: the verdict is left exactly as computed (a
|
|
2398
|
+
# real critical finding stays visible) with "unfinished" appended
|
|
2399
|
+
# after it, not substituted for it, when this run has not finished --
|
|
2400
|
+
# so `latest` can skip it, but a run can be both genuinely broken and
|
|
2401
|
+
# still in progress and the row says both.
|
|
2402
|
+
verdict = row.get("verdict", "")
|
|
2403
|
+
if row.get("finished") is False:
|
|
2404
|
+
verdict = f"{verdict} unfinished"
|
|
2405
|
+
elif row.get("finished") is None and row.get("unfinished_state") == "possibly running":
|
|
2406
|
+
# a session has no lifecycle; recent activity marks it as maybe still going, so `latest`
|
|
2407
|
+
# can skip it too (release review of 0.3.0)
|
|
2408
|
+
verdict = f"{verdict} possibly running"
|
|
2409
|
+
line = (
|
|
2410
|
+
f"{row.get('run_id', '')} {row.get('date', '')} {project} {kind} "
|
|
2411
|
+
f"{row.get('agents', 0)} agents {row.get('attempts', 0)} attempts "
|
|
2412
|
+
f"{verdict} {row.get('top_detector') or '-'}"
|
|
2413
|
+
)
|
|
2414
|
+
return line
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def _scan_totals_lines(totals: dict) -> list[str]:
|
|
2418
|
+
"""docs/DESIGN.md 4.7: totals stratified by kind -- workflow runs, runs
|
|
2419
|
+
with retries, attempts, wasted hours; session runs, subagents,
|
|
2420
|
+
interrupted -- then findings per detector (each already counted only
|
|
2421
|
+
over the runs of the kinds that detector applies to, since a detector
|
|
2422
|
+
never fires on a run outside its own kinds)."""
|
|
2423
|
+
workflow = totals.get("workflow", {})
|
|
2424
|
+
session = totals.get("session", {})
|
|
2425
|
+
lines = [
|
|
2426
|
+
f"{workflow.get('runs', 0)} workflow runs, "
|
|
2427
|
+
f"{workflow.get('runs_with_retries', 0)} with retries, "
|
|
2428
|
+
f"{workflow.get('attempts', 0)} attempts, {workflow.get('wasted_hours', 0.0):.1f}h wasted",
|
|
2429
|
+
f"{session.get('runs', 0)} session runs, {session.get('subagents', 0)} subagents, "
|
|
2430
|
+
f"{session.get('interrupted', 0)} interrupted",
|
|
2431
|
+
]
|
|
2432
|
+
findings_per_detector = totals.get("findings_per_detector", {})
|
|
2433
|
+
if findings_per_detector: # P9: a finding is not an event
|
|
2434
|
+
lines.append("findings per detector (one finding can cover several events):")
|
|
2435
|
+
for detector_id in sorted(findings_per_detector):
|
|
2436
|
+
lines.append(f" {detector_id}: {findings_per_detector[detector_id]}")
|
|
2437
|
+
return lines
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
def render_scan_text(
|
|
2441
|
+
rows: list[dict], totals: dict, redactor: Redactor, silenced_detectors: frozenset = frozenset(),
|
|
2442
|
+
) -> str:
|
|
2443
|
+
# docs/DESIGN.md 5.1: every public renderer entry point gets a fresh
|
|
2444
|
+
# Pseudonyms map, even one like this that never prints a per-agent
|
|
2445
|
+
# name (scan lists runs, not agents) -- a stale mapping left over from
|
|
2446
|
+
# an earlier render must never leak into this one.
|
|
2447
|
+
redactor.names = Pseudonyms(enabled=redactor.enabled)
|
|
2448
|
+
lines = [_scan_row_line(row, redactor) for row in rows]
|
|
2449
|
+
lines.append("")
|
|
2450
|
+
lines.extend(_scan_totals_lines(totals))
|
|
2451
|
+
silenced_line = _silenced_detectors_line(silenced_detectors)
|
|
2452
|
+
if silenced_line is not None:
|
|
2453
|
+
lines.append(silenced_line)
|
|
2454
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def _iso(value: datetime | None) -> str | None:
|
|
2458
|
+
return value.isoformat() if value is not None else None
|
|
2459
|
+
|
|
2460
|
+
|
|
2461
|
+
def _key_digest(key: str | None) -> str | None:
|
|
2462
|
+
"""sha256(key)[:8]: an opaque, one-way stand-in for a journal key
|
|
2463
|
+
(docs/DESIGN.md 5.3). Unconditional on the redactor: a workflow key
|
|
2464
|
+
often embeds the attempt's own label verbatim (e.g. "v2:silent-alpha"),
|
|
2465
|
+
so the export never carries the raw key, --no-redact or not."""
|
|
2466
|
+
if key is None:
|
|
2467
|
+
return None
|
|
2468
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
def _export_ledger(attempt: Attempt, redactor: Redactor, view: "EvidenceView") -> dict:
|
|
2472
|
+
"""docs/DESIGN.md 5.3b: `{completeness, reasons, entries}` on every
|
|
2473
|
+
attempt. `path` and `repo` are null when the entry never named one
|
|
2474
|
+
(never the string "UNKNOWN"), else `path-N` at share and the P19 answer
|
|
2475
|
+
at local; `line` per the `line` convention. An attempt the reader never
|
|
2476
|
+
gave a ledger (a hand-built one) is partial for the one reason that says
|
|
2477
|
+
no ledger could be built."""
|
|
2478
|
+
ledger = attempt.ledger
|
|
2479
|
+
if ledger is None:
|
|
2480
|
+
return {"completeness": "partial", "reasons": ["transcript_missing"], "entries": []}
|
|
2481
|
+
|
|
2482
|
+
def shown(value: str | None) -> str | None:
|
|
2483
|
+
return redactor.names.path(value) if isinstance(value, str) and value else None
|
|
2484
|
+
|
|
2485
|
+
return {
|
|
2486
|
+
"completeness": ledger.completeness,
|
|
2487
|
+
"reasons": list(ledger.reasons),
|
|
2488
|
+
"entries": [
|
|
2489
|
+
{
|
|
2490
|
+
"effect": entry.effect,
|
|
2491
|
+
"basis": entry.basis,
|
|
2492
|
+
"completion": entry.completion,
|
|
2493
|
+
"path": shown(entry.path),
|
|
2494
|
+
"repo": shown(entry.repo),
|
|
2495
|
+
"line": view.ledger_line(entry.line),
|
|
2496
|
+
}
|
|
2497
|
+
for entry in ledger.entries
|
|
2498
|
+
],
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
def _export_attempt(attempt: Attempt, redactor: Redactor, view: "EvidenceView") -> dict:
|
|
2503
|
+
"""docs/DESIGN.md 5.3's Attempt allowlist. Calling redactor.names here
|
|
2504
|
+
(rather than the plain redactor()) both returns the pseudonym and
|
|
2505
|
+
registers it, so a finding title or evidence line rendered afterward
|
|
2506
|
+
with the same redactor picks up the same substitution via apply()."""
|
|
2507
|
+
interrupt = None
|
|
2508
|
+
if attempt.interrupt is not None:
|
|
2509
|
+
interrupt = {
|
|
2510
|
+
"cause": attempt.interrupt.cause,
|
|
2511
|
+
"silence_seconds": attempt.interrupt.silence_seconds,
|
|
2512
|
+
}
|
|
2513
|
+
agent_type = redactor.names.type(attempt.agent_type) if attempt.agent_type else None
|
|
2514
|
+
# docs/DESIGN.md 5.3: reported is not a structured name field (it never
|
|
2515
|
+
# embeds a label the way a title does), so it goes through the plain
|
|
2516
|
+
# Redactor via redact_value -- structural redaction only, same as an
|
|
2517
|
+
# evidence line or a metrics value. This is what redacts a report_path
|
|
2518
|
+
# claim (a path) without a pseudonym ever touching it.
|
|
2519
|
+
shown_reported = attempt.reported
|
|
2520
|
+
if isinstance(shown_reported, dict) and isinstance(shown_reported.get("keys"), list):
|
|
2521
|
+
# docs/DESIGN.md 5.3 (G-B1, GB-8): result keys are user-authored identifiers; at share
|
|
2522
|
+
# they print as key-N, in order of first appearance within this render.
|
|
2523
|
+
shown_reported = {
|
|
2524
|
+
**shown_reported, "keys": [redactor.names.key(key) for key in shown_reported["keys"]],
|
|
2525
|
+
}
|
|
2526
|
+
if isinstance(shown_reported, dict) and isinstance(shown_reported.get("claims"), dict):
|
|
2527
|
+
# DESIGN 4.13.5 (M9): the result's own commit list prints as commit-N at share
|
|
2528
|
+
shown_reported = {
|
|
2529
|
+
**shown_reported, "claims": number_claim_commits(shown_reported["claims"], redactor),
|
|
2530
|
+
}
|
|
2531
|
+
reported = redact_value(shown_reported, redactor) if shown_reported is not None else None
|
|
2532
|
+
# docs/DESIGN.md 5.3's v0.2 reader changes: facts is exported, tool_calls
|
|
2533
|
+
# is not (a command is private, never leaves the tool). facts holds
|
|
2534
|
+
# paths (paths_written, in_flight[].path, cwds) that go through the same
|
|
2535
|
+
# plain Redactor as any other path, exactly like `reported` above -- not
|
|
2536
|
+
# a structured name field, so never through Pseudonyms. Built from
|
|
2537
|
+
# AttemptFacts' own fields directly, the same way `interrupt` above is
|
|
2538
|
+
# built from Interrupt's fields, never through its private complete-dict
|
|
2539
|
+
# method (that form is for tests only -- report.py never calls it).
|
|
2540
|
+
facts = None
|
|
2541
|
+
if attempt.facts is not None:
|
|
2542
|
+
facts = redact_value(
|
|
2543
|
+
{
|
|
2544
|
+
"paths_written": attempt.facts.paths_written,
|
|
2545
|
+
"in_flight": attempt.facts.in_flight,
|
|
2546
|
+
"commits": (
|
|
2547
|
+
[redactor.names.commit(c) if isinstance(c, str) else c
|
|
2548
|
+
for c in attempt.facts.commits]
|
|
2549
|
+
if isinstance(attempt.facts.commits, list) else attempt.facts.commits
|
|
2550
|
+
),
|
|
2551
|
+
"commit_commands_without_id": attempt.facts.commit_commands_without_id,
|
|
2552
|
+
"cwds": attempt.facts.cwds,
|
|
2553
|
+
"background_unfinished": attempt.facts.background_unfinished,
|
|
2554
|
+
"last_tool": attempt.facts.last_tool,
|
|
2555
|
+
"seconds_since_last_tool_at_end": attempt.facts.seconds_since_last_tool_at_end,
|
|
2556
|
+
},
|
|
2557
|
+
redactor,
|
|
2558
|
+
)
|
|
2559
|
+
return {
|
|
2560
|
+
"agent_id": identity_value("agent", attempt.agent_id, redactor),
|
|
2561
|
+
"key": _key_digest(attempt.key),
|
|
2562
|
+
"label": redactor.names.agent(attempt.label),
|
|
2563
|
+
"phase": redactor.names.phase(attempt.phase),
|
|
2564
|
+
"agent_type": agent_type,
|
|
2565
|
+
"ordinal": attempt.ordinal,
|
|
2566
|
+
# docs/DESIGN.md 5.3b (E1): a session subagent's exported outcome is its effective end
|
|
2567
|
+
"outcome": effective_end(attempt),
|
|
2568
|
+
"return_state": attempt.return_state,
|
|
2569
|
+
"return_reason": attempt.return_reason,
|
|
2570
|
+
"resumes": attempt.resumes,
|
|
2571
|
+
"first_ts": _iso(attempt.first_ts),
|
|
2572
|
+
"last_ts": _iso(attempt.last_ts),
|
|
2573
|
+
"usage": attempt.usage,
|
|
2574
|
+
"event_count": len(attempt.events),
|
|
2575
|
+
"interrupt": interrupt,
|
|
2576
|
+
"reported": reported,
|
|
2577
|
+
"facts": facts,
|
|
2578
|
+
"ledger": _export_ledger(attempt, redactor, view),
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _export_finding(
|
|
2583
|
+
finding: Finding, redactor: Redactor, silenced_detectors: frozenset,
|
|
2584
|
+
view: "EvidenceView | None" = None,
|
|
2585
|
+
) -> dict:
|
|
2586
|
+
"""docs/DESIGN.md 5.3: 'all fields, evidence redacted'. title goes
|
|
2587
|
+
through redactor.title() (structural redact() plus pseudonym
|
|
2588
|
+
substitution: a title routinely embeds a label verbatim, since a
|
|
2589
|
+
detector formats it straight into a sentence). evidence and metrics go
|
|
2590
|
+
through the plain redactor only -- structural redact(), never a
|
|
2591
|
+
pseudonym substitution: docs/DESIGN.md 5.2 keeps evidence (and a
|
|
2592
|
+
metrics value that can carry raw text of its own, e.g.
|
|
2593
|
+
bash_backgrounded's commands) a faithful quote of transcript text.
|
|
2594
|
+
rule_ids (unit M1d) carries no user text, so it needs no redaction, the
|
|
2595
|
+
same as rule_id.
|
|
2596
|
+
|
|
2597
|
+
`silenced` (unit M3c, docs/DESIGN.md 4.10): true when this finding's
|
|
2598
|
+
detector is named in [detectors].off in the user's config file. A
|
|
2599
|
+
silenced finding is never dropped from --json -- only from text
|
|
2600
|
+
rendering and hook notification -- so this bool is what lets a JSON
|
|
2601
|
+
consumer tell the two apart.
|
|
2602
|
+
|
|
2603
|
+
Schema 3 (N5a-1, docs/DESIGN.md 5.3b): `agent_id` is the alias, and
|
|
2604
|
+
`evidence` is the text renderer's evidence view (pointers with aliased
|
|
2605
|
+
file names at share, excerpts only at local and raw with --evidence)."""
|
|
2606
|
+
return {
|
|
2607
|
+
"detector": finding.detector,
|
|
2608
|
+
"severity": finding.severity,
|
|
2609
|
+
"title": _finding_title(finding, redactor),
|
|
2610
|
+
"run_id": finding.run_id,
|
|
2611
|
+
"agent_id": identity_value("agent", finding.agent_id, redactor),
|
|
2612
|
+
"evidence": _shown_evidence(finding.evidence, view, redactor),
|
|
2613
|
+
"metrics": redact_value(
|
|
2614
|
+
_metrics_with_numbered_commits(finding.metrics, redactor), redactor,
|
|
2615
|
+
),
|
|
2616
|
+
"rule_id": finding.rule_id,
|
|
2617
|
+
"rule_ids": list(finding.rule_ids),
|
|
2618
|
+
"finding_id": finding.finding_id,
|
|
2619
|
+
"silenced": finding.detector in silenced_detectors,
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def _metrics_with_numbered_commits(metrics, redactor: Redactor):
|
|
2624
|
+
"""DESIGN 4.13.5 (M9): a finding's `metrics.claims` is a copy of a result's claims, so its
|
|
2625
|
+
`commits` list is numbered like the attempt's own `reported.claims.commits`."""
|
|
2626
|
+
if isinstance(metrics, dict) and isinstance(metrics.get("claims"), dict):
|
|
2627
|
+
return {**metrics, "claims": number_claim_commits(metrics["claims"], redactor)}
|
|
2628
|
+
return metrics
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
def export_observation_groups(
|
|
2632
|
+
observation, redactor: Redactor, *, omit_empty_unknown: bool = False,
|
|
2633
|
+
) -> list[dict]:
|
|
2634
|
+
"""docs/DESIGN.md 5.3b (N5c-1, plan S6): the group objects, exactly `group`
|
|
2635
|
+
(the index in this list), `repo`, `refused`, `checked`, `reason`, `paths`, and
|
|
2636
|
+
`unobserved_paths`. `repo`, every `paths` key, and every `unobserved_paths`
|
|
2637
|
+
element use the path namespace of the level; `repo_root`, `head`, and
|
|
2638
|
+
`git_version` are internal and never exported. `omit_empty_unknown` leaves the
|
|
2639
|
+
unknown group out when it names no path (rescue plan, S7)."""
|
|
2640
|
+
exported: list[dict] = []
|
|
2641
|
+
for group in observation.groups:
|
|
2642
|
+
if omit_empty_unknown and group.repo is None and not group.unobserved_paths:
|
|
2643
|
+
continue
|
|
2644
|
+
paths = {}
|
|
2645
|
+
for path, path_observation in group.paths.items():
|
|
2646
|
+
paths[redactor.names.path(path)] = {
|
|
2647
|
+
"state": path_observation.state,
|
|
2648
|
+
"reason": (
|
|
2649
|
+
redactor(path_observation.reason)
|
|
2650
|
+
if path_observation.reason is not None else None
|
|
2651
|
+
),
|
|
2652
|
+
"later_write_observed": path_observation.later_write_observed,
|
|
2653
|
+
"attempts": [
|
|
2654
|
+
{
|
|
2655
|
+
**attempt,
|
|
2656
|
+
"agent_id": (
|
|
2657
|
+
attempt["agent_id"] if attempt.get("agent_id") == "main"
|
|
2658
|
+
else identity_value("agent", attempt.get("agent_id"), redactor)
|
|
2659
|
+
),
|
|
2660
|
+
}
|
|
2661
|
+
for attempt in path_observation.attempts
|
|
2662
|
+
],
|
|
2663
|
+
}
|
|
2664
|
+
exported.append({
|
|
2665
|
+
"group": len(exported),
|
|
2666
|
+
"repo": redactor.names.path(group.repo) if group.repo is not None else None,
|
|
2667
|
+
"refused": group.refused,
|
|
2668
|
+
"checked": group.checked,
|
|
2669
|
+
"reason": redactor(group.reason) if group.reason is not None else None,
|
|
2670
|
+
"paths": paths,
|
|
2671
|
+
"unobserved_paths": [redactor.names.path(path) for path in group.unobserved_paths],
|
|
2672
|
+
})
|
|
2673
|
+
return exported
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
def _export_observation(observation, redactor: Redactor) -> dict:
|
|
2677
|
+
"""docs/DESIGN.md 5.3b: `observation` is `{groups: [...]}`."""
|
|
2678
|
+
return {"groups": export_observation_groups(observation, redactor)}
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
def _diagnostic_for_export(diagnostic: str, redactor: Redactor) -> str:
|
|
2682
|
+
"""One Run.diagnostics entry for the JSON export (N3b P10b, D-h). Only a
|
|
2683
|
+
raw sink (the redactor is off) asks for the raw form, and it does so
|
|
2684
|
+
before the redactor sees the text; every other level gets the diagnostic's
|
|
2685
|
+
own plain string, which never carries a journal key."""
|
|
2686
|
+
if not redactor.enabled:
|
|
2687
|
+
return redactor(raw_diagnostic(diagnostic))
|
|
2688
|
+
return redactor(str(diagnostic))
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _export_lifecycle(run: Run) -> dict | None:
|
|
2692
|
+
"""docs/DESIGN.md 5.3b: the four public Lifecycle fields; null for a
|
|
2693
|
+
session (no lifecycle model to report, D11)."""
|
|
2694
|
+
lifecycle = run.lifecycle
|
|
2695
|
+
if lifecycle is None:
|
|
2696
|
+
return None
|
|
2697
|
+
return {
|
|
2698
|
+
"terminal": lifecycle.terminal,
|
|
2699
|
+
"source": lifecycle.source,
|
|
2700
|
+
"activity": lifecycle.activity,
|
|
2701
|
+
"generations": lifecycle.generations,
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def export_run(
|
|
2706
|
+
run: Run,
|
|
2707
|
+
findings: list[Finding],
|
|
2708
|
+
redactor: Redactor,
|
|
2709
|
+
observation=None,
|
|
2710
|
+
silenced_detectors: frozenset = frozenset(),
|
|
2711
|
+
*,
|
|
2712
|
+
evidence: bool = False,
|
|
2713
|
+
) -> dict:
|
|
2714
|
+
"""The JSON export allowlist (docs/DESIGN.md 5.3): {run, findings}, with
|
|
2715
|
+
run holding exactly run_id, kind, status, claude_version, started_at,
|
|
2716
|
+
ended_at, counts, diagnostics, and attempts (each attempt allowlisted by
|
|
2717
|
+
_export_attempt). No meta, no cwd, no branch, no event list, no
|
|
2718
|
+
transcript text -- and this never reaches for a dataclass's own private,
|
|
2719
|
+
complete dict form (that form exists for tests only).
|
|
2720
|
+
|
|
2721
|
+
_register_names runs first (docs/DESIGN.md 5.1): a fresh Pseudonyms map
|
|
2722
|
+
registered with every attempt's label/meta description/phase/agent_type
|
|
2723
|
+
before either attempts or findings are exported, so a finding title
|
|
2724
|
+
that embeds one of those verbatim (an ordinary detector does exactly
|
|
2725
|
+
that) is pseudonymized correctly regardless of which findings happen to
|
|
2726
|
+
reference which attempt.
|
|
2727
|
+
|
|
2728
|
+
`silenced_detectors` (unit M3c, docs/DESIGN.md 4.10) never removes a
|
|
2729
|
+
finding from `findings` here -- every finding a silenced detector fired
|
|
2730
|
+
still exports, with `silenced: true` (5.3) -- callers that must not
|
|
2731
|
+
show a silenced finding (the text renderers, the verdict, stories,
|
|
2732
|
+
next) filter beforehand with config.partition_silenced() instead.
|
|
2733
|
+
"""
|
|
2734
|
+
_register_names(run, redactor, detailed=True)
|
|
2735
|
+
_register_facts_paths(run, redactor)
|
|
2736
|
+
_register_ledger_paths(run, redactor)
|
|
2737
|
+
_register_observation_paths(observation, redactor, run)
|
|
2738
|
+
view = EvidenceView(run, redactor, show_excerpts=evidence)
|
|
2739
|
+
attempts = [_export_attempt(attempt, redactor, view) for attempt in run.attempts]
|
|
2740
|
+
exported_findings = [
|
|
2741
|
+
_export_finding(finding, redactor, silenced_detectors, view) for finding in findings
|
|
2742
|
+
]
|
|
2743
|
+
interrupted = sum(1 for attempt in run.attempts if attempt.interrupt is not None)
|
|
2744
|
+
|
|
2745
|
+
payload = {
|
|
2746
|
+
"run": {
|
|
2747
|
+
"run_id": run.run_id,
|
|
2748
|
+
"kind": run.kind,
|
|
2749
|
+
"status": run.status,
|
|
2750
|
+
"claude_version": run.claude_version,
|
|
2751
|
+
"started_at": _iso(run.started_at),
|
|
2752
|
+
"ended_at": _iso(run.ended_at),
|
|
2753
|
+
"counts": {
|
|
2754
|
+
"agents": len(run.agents),
|
|
2755
|
+
"attempts": len(run.attempts),
|
|
2756
|
+
"interrupted": interrupted,
|
|
2757
|
+
},
|
|
2758
|
+
"diagnostics": [_diagnostic_for_export(d, redactor) for d in run.diagnostics],
|
|
2759
|
+
"attempts": attempts,
|
|
2760
|
+
"lifecycle": _export_lifecycle(run),
|
|
2761
|
+
},
|
|
2762
|
+
"findings": exported_findings,
|
|
2763
|
+
}
|
|
2764
|
+
# docs/DESIGN.md 4.9 rule 10: the key is present at all only when the
|
|
2765
|
+
# caller actually ran --observe; without it, JSON output is unchanged.
|
|
2766
|
+
if observation is not None:
|
|
2767
|
+
payload["observation"] = _export_observation(observation, redactor)
|
|
2768
|
+
return payload
|
|
2769
|
+
|
|
2770
|
+
|
|
2771
|
+
def _export_story(entry: dict, redactor: Redactor) -> dict:
|
|
2772
|
+
"""docs/DESIGN.md 4.7 rule 9/5.3: {agent, severity, text, finding_ids}.
|
|
2773
|
+
agent goes through the same Pseudonyms.agent() the AGENT table and text
|
|
2774
|
+
STORIES section use (the literal 'run' label for a run-level story is
|
|
2775
|
+
never a user-authored name and is left alone); text through
|
|
2776
|
+
redactor.title() (5.1/5.2: a story sentence routinely embeds a label
|
|
2777
|
+
verbatim, the same reason Finding.title does)."""
|
|
2778
|
+
agent = "run" if entry["agent"] == "run" else redactor.names.agent(entry["agent"])
|
|
2779
|
+
return {
|
|
2780
|
+
"agent": agent,
|
|
2781
|
+
"severity": entry["severity"],
|
|
2782
|
+
"text": _render_pieces(entry["pieces"], redactor),
|
|
2783
|
+
"finding_ids": list(entry["finding_ids"]),
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def _export_next(item: dict, redactor: Redactor) -> dict:
|
|
2788
|
+
"""docs/DESIGN.md 4.7 rule 9/5.3: {grade, text, command}. Neither field
|
|
2789
|
+
is a structured name field (a command names no user, only a run id, and
|
|
2790
|
+
'text' is one of this module's own fixed phrases), so both go through
|
|
2791
|
+
the plain Redactor exactly like a metrics value -- structural redact()
|
|
2792
|
+
only."""
|
|
2793
|
+
return {
|
|
2794
|
+
"grade": item["grade"],
|
|
2795
|
+
"text": redactor(item["text"]),
|
|
2796
|
+
"command": redactor(item["command"]) if item["command"] else None,
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
|
|
2800
|
+
SCHEMA_VERSION = 3
|
|
2801
|
+
|
|
2802
|
+
|
|
2803
|
+
def to_json(
|
|
2804
|
+
run: Run,
|
|
2805
|
+
findings: list[Finding],
|
|
2806
|
+
redactor: Redactor | None = None,
|
|
2807
|
+
observation=None,
|
|
2808
|
+
silenced_detectors: frozenset = frozenset(),
|
|
2809
|
+
*,
|
|
2810
|
+
evidence: bool = False,
|
|
2811
|
+
) -> dict:
|
|
2812
|
+
"""docs/DESIGN.md 4.7/5.3b: {schema, disclosure, run, findings, rules,
|
|
2813
|
+
verdict, status, stories, next}, run and findings built by export_run (5.3's
|
|
2814
|
+
allowlist), rules and verdict derived the same way the text report
|
|
2815
|
+
derives them, status duplicated at the top level for a consumer that
|
|
2816
|
+
does not want to dig into run. observation (v0.2, unit M2b) is forwarded
|
|
2817
|
+
to export_run unchanged and is absent from the payload entirely unless
|
|
2818
|
+
the caller passed one.
|
|
2819
|
+
|
|
2820
|
+
stories/next (unit M3a) are the same records render_run_text derives,
|
|
2821
|
+
exported through the allowlist of docs/DESIGN.md 4.7 rule 9/5.3 rather
|
|
2822
|
+
than report.py's internal, wrapped/truncated text-rendering shape:
|
|
2823
|
+
stories always computed with verbose=False, so an info-only agent never
|
|
2824
|
+
backs a JSON story either, same as the default text report.
|
|
2825
|
+
|
|
2826
|
+
`silenced_detectors` (unit M3c, docs/DESIGN.md 4.10): `findings` itself
|
|
2827
|
+
is exported whole -- every finding, silenced or not, each getting its
|
|
2828
|
+
own `silenced` bool (5.3) -- but `verdict`, `rules`, `stories`, and
|
|
2829
|
+
`next` are all derived from the shown (non-silenced) findings only, the
|
|
2830
|
+
same rule the text report follows (docs/DESIGN.md 4.6/4.7): a silenced
|
|
2831
|
+
detector must never flip the verdict, suggest its own rule, or narrate
|
|
2832
|
+
its own story, even though its raw finding is still right there in
|
|
2833
|
+
`findings` for a caller that wants it.
|
|
2834
|
+
|
|
2835
|
+
Defaults to redaction on (docs/DESIGN.md 5.2/5.3): a caller that wants
|
|
2836
|
+
raw output must pass an explicit Redactor(enabled=False), never get it
|
|
2837
|
+
by omission."""
|
|
2838
|
+
redactor = redactor or Redactor(enabled=True)
|
|
2839
|
+
payload = export_run(
|
|
2840
|
+
run, findings, redactor, observation, silenced_detectors, evidence=evidence,
|
|
2841
|
+
)
|
|
2842
|
+
payload["schema"] = SCHEMA_VERSION
|
|
2843
|
+
payload["disclosure"] = redactor.level
|
|
2844
|
+
shown = [finding for finding in findings if finding.detector not in silenced_detectors]
|
|
2845
|
+
payload["rules"] = [{"rule_id": rule.id, "count": count} for rule, count in rules_for(shown)]
|
|
2846
|
+
payload["verdict"] = verdict_of(run, shown)
|
|
2847
|
+
payload["status"] = run.status
|
|
2848
|
+
|
|
2849
|
+
# export_run already ran _register_names/_register_observation_paths, so
|
|
2850
|
+
# every pseudonym _export_story below needs is already registered.
|
|
2851
|
+
story_entries = _story_records(run, shown, verbose=False)
|
|
2852
|
+
payload["stories"] = [_export_story(entry, redactor) for entry in story_entries]
|
|
2853
|
+
|
|
2854
|
+
next_items = _what_to_do_items(run, shown, observation, observation is not None)
|
|
2855
|
+
payload["next"] = [_export_next(item, redactor) for item in next_items]
|
|
2856
|
+
return payload
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def _bug_title(run: Run, findings: list[Finding], redactor: Redactor) -> str:
|
|
2860
|
+
verdict = verdict_of(run, findings)
|
|
2861
|
+
summary = redactor(_summary_line_for(run))
|
|
2862
|
+
return f"# {redactor(run.run_id)} ({verdict}): {summary}"
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
def _bug_environment_lines(run: Run) -> list[str]:
|
|
2866
|
+
return [
|
|
2867
|
+
"## Environment",
|
|
2868
|
+
f"- Claude Code version: {run.claude_version or 'unknown'}",
|
|
2869
|
+
f"- OS: {platform.platform()}",
|
|
2870
|
+
]
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
def _bug_what_happened_lines(run: Run, findings: list[Finding], redactor: Redactor) -> list[str]:
|
|
2874
|
+
counts = {severity: 0 for severity in _SEVERITIES}
|
|
2875
|
+
for finding in findings:
|
|
2876
|
+
counts[finding.severity] = counts.get(finding.severity, 0) + 1
|
|
2877
|
+
return [
|
|
2878
|
+
"## What happened",
|
|
2879
|
+
f"{verdict_of(run, findings)}: {redactor(_summary_line_for(run))}",
|
|
2880
|
+
"",
|
|
2881
|
+
f"- agents: {len(run.agents)}",
|
|
2882
|
+
f"- attempts: {len(run.attempts)}",
|
|
2883
|
+
f"- findings: critical {counts['critical']}, major {counts['major']}, "
|
|
2884
|
+
f"minor {counts['minor']}, info {counts['info']}",
|
|
2885
|
+
]
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
def _bug_project_text(run: Run, redactor: Redactor) -> str:
|
|
2889
|
+
"""P10/P11: the project at share is project:<alias>; at local and raw the
|
|
2890
|
+
project directory name as it is on disk."""
|
|
2891
|
+
return project_text(run.project_slug, redactor)
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
def _bug_reproduction_lines(run: Run, redactor: Redactor) -> list[str]:
|
|
2895
|
+
lines = [
|
|
2896
|
+
"## Reproduction",
|
|
2897
|
+
f"project {_bug_project_text(run, redactor)}, {len(run.agents)} logical agent(s):",
|
|
2898
|
+
]
|
|
2899
|
+
for agent in run.agents:
|
|
2900
|
+
attempts = sorted(agent.attempts, key=lambda a: a.ordinal)
|
|
2901
|
+
models = sorted({attempt.agent_type for attempt in attempts if attempt.agent_type})
|
|
2902
|
+
pseudo_models = sorted({redactor.names.type(model) for model in models})
|
|
2903
|
+
model_str = ", ".join(pseudo_models) if pseudo_models else "unknown model"
|
|
2904
|
+
label = redactor.names.agent(agent.label)
|
|
2905
|
+
phase = redactor.names.phase(agent.phase)
|
|
2906
|
+
lines.append(redactor.scrub_local_line(
|
|
2907
|
+
f"- {label} (phase {phase}, {model_str}): {len(attempts)} attempt(s)"
|
|
2908
|
+
))
|
|
2909
|
+
return lines
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
def _bug_evidence_lines(
|
|
2913
|
+
run: Run, findings: list[Finding], redactor: Redactor, view: "EvidenceView | None" = None,
|
|
2914
|
+
evidence_flag: bool = False,
|
|
2915
|
+
) -> list[str]:
|
|
2916
|
+
lines = ["## Evidence", f"From run {redactor(run.run_id)}:"]
|
|
2917
|
+
if evidence_flag and view is not None and view.note() is not None:
|
|
2918
|
+
lines.append(view.note())
|
|
2919
|
+
if not findings:
|
|
2920
|
+
lines.append("(no findings)")
|
|
2921
|
+
return lines
|
|
2922
|
+
for finding in findings:
|
|
2923
|
+
lines.append(
|
|
2924
|
+
f"- [{finding.severity}] {finding.detector}: {_finding_title(finding, redactor)}"
|
|
2925
|
+
)
|
|
2926
|
+
for shown in _shown_evidence(finding.evidence, view, redactor):
|
|
2927
|
+
lines.append(f" - {shown}")
|
|
2928
|
+
return lines
|
|
2929
|
+
|
|
2930
|
+
|
|
2931
|
+
def _bug_suggested_fixes_lines(findings: list[Finding]) -> list[str]:
|
|
2932
|
+
# See _suggested_rules_section: counts a finding once per rule in
|
|
2933
|
+
# rule_ids, not just its first rule_id, so a finding backing more than
|
|
2934
|
+
# one rule is never silently dropped from every rule but its first.
|
|
2935
|
+
lines = ["## Suggested fixes"]
|
|
2936
|
+
rule_ids = sorted({rule_id for finding in findings for rule_id in finding.rule_ids})
|
|
2937
|
+
detector_ids_without_rule = sorted(
|
|
2938
|
+
{
|
|
2939
|
+
finding.detector
|
|
2940
|
+
for finding in findings
|
|
2941
|
+
if not finding.rule_ids and finding.detector in _DETECTOR_BUG_SENTENCES
|
|
2942
|
+
}
|
|
2943
|
+
)
|
|
2944
|
+
if not rule_ids and not detector_ids_without_rule:
|
|
2945
|
+
lines.append("(no suggested fix in this build)")
|
|
2946
|
+
return lines
|
|
2947
|
+
for rule_id in rule_ids:
|
|
2948
|
+
rule = RULES.get(rule_id)
|
|
2949
|
+
if rule is None:
|
|
2950
|
+
continue
|
|
2951
|
+
count = sum(1 for finding in findings if rule_id in finding.rule_ids)
|
|
2952
|
+
plural = "" if count == 1 else "s"
|
|
2953
|
+
lines.append(f"- {rule_id} (backed by {count} finding{plural}): {rule.text}")
|
|
2954
|
+
for detector_id in detector_ids_without_rule:
|
|
2955
|
+
lines.append(f"- {detector_id}: {_DETECTOR_BUG_SENTENCES[detector_id]}")
|
|
2956
|
+
return lines
|
|
2957
|
+
|
|
2958
|
+
|
|
2959
|
+
# docs/DESIGN.md 5.2's D2, made true per level by N3b P16: the closing note
|
|
2960
|
+
# says what the report above actually carries.
|
|
2961
|
+
_BUG_REPORT_FOOTERS = {
|
|
2962
|
+
"share": (
|
|
2963
|
+
"Redacted: paths, ids, emails, account names, agent names. "
|
|
2964
|
+
"Evidence lines are pointers and marker codes only; no transcript excerpts are included."
|
|
2965
|
+
),
|
|
2966
|
+
"local": (
|
|
2967
|
+
"Local level: real names, ids, and the project directory name are shown; "
|
|
2968
|
+
"paths are repo-relative or numbered. {evidence} Do not paste this report."
|
|
2969
|
+
),
|
|
2970
|
+
"raw": (
|
|
2971
|
+
"Raw level: real names, ids, and paths are shown; nothing is redacted. "
|
|
2972
|
+
"{evidence} Do not paste this report."
|
|
2973
|
+
),
|
|
2974
|
+
}
|
|
2975
|
+
_BUG_EVIDENCE_WITH = "Evidence lines include transcript excerpts (--evidence); read them first."
|
|
2976
|
+
_BUG_EVIDENCE_WITHOUT = (
|
|
2977
|
+
"Evidence lines are pointers and marker codes only; add --evidence for transcript excerpts."
|
|
2978
|
+
)
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
def _bug_footer(level: str, evidence: bool) -> str:
|
|
2982
|
+
template = _BUG_REPORT_FOOTERS[level]
|
|
2983
|
+
if level == "share":
|
|
2984
|
+
return template
|
|
2985
|
+
return template.format(evidence=_BUG_EVIDENCE_WITH if evidence else _BUG_EVIDENCE_WITHOUT)
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def render_bug_report(
|
|
2989
|
+
run: Run, findings: list[Finding], redactor: Redactor, evidence: bool = False,
|
|
2990
|
+
) -> str:
|
|
2991
|
+
"""Markdown bug report: Title, Environment, What happened, Reproduction,
|
|
2992
|
+
Evidence, Suggested fixes, in that order (docs/DESIGN.md section 4.4),
|
|
2993
|
+
then a footer (5.2's D2) spelling out what this level prints. `evidence`
|
|
2994
|
+
is --evidence: at local and raw it adds transcript excerpts to the
|
|
2995
|
+
pointers, at share it earns the one note line (P16)."""
|
|
2996
|
+
_register_names(run, redactor, detailed=True)
|
|
2997
|
+
view = EvidenceView(run, redactor, show_excerpts=evidence)
|
|
2998
|
+
lines = [_bug_title(run, findings, redactor), ""]
|
|
2999
|
+
lines.extend(_bug_environment_lines(run))
|
|
3000
|
+
lines.append("")
|
|
3001
|
+
lines.extend(_bug_what_happened_lines(run, findings, redactor))
|
|
3002
|
+
lines.append("")
|
|
3003
|
+
lines.extend(_bug_reproduction_lines(run, redactor))
|
|
3004
|
+
lines.append("")
|
|
3005
|
+
lines.extend(_bug_evidence_lines(run, findings, redactor, view, evidence))
|
|
3006
|
+
lines.append("")
|
|
3007
|
+
lines.extend(_bug_suggested_fixes_lines(findings))
|
|
3008
|
+
lines.append("")
|
|
3009
|
+
lines.append(_bug_footer(redactor.level, evidence))
|
|
3010
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|