agent-postmortem 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2096 @@
1
+ """Guided rescue: `rescue plan` (a decision packet) and `rescue status`
2
+ (what was observed), docs/DESIGN.md 4.11, unit M4.
3
+
4
+ Both commands are read-only and stateless. Neither writes a file, runs a
5
+ check, or touches git beyond what `observe.py` already does. The main
6
+ Claude session or the human performs every action (`.reviews/proposals/
7
+ 2026-09-v0.2-guided-rescue.md` section 3): this module only reads
8
+ transcripts, observes repo state through `observe.observe_groups`, and prints
9
+ facts. Per R8, it never claims to have confirmed something by running it --
10
+ it reports what it observed and names what it could not.
11
+
12
+ Two phases per command: a builder (`build_plan`/`build_status`) that
13
+ produces a plain dataclass tree of RAW, unredacted values, and a renderer
14
+ (text or JSON) that applies the active Redactor at the very end -- the same
15
+ split `report.py` uses (`_story_records` then both text and JSON consume
16
+ it), so text and JSON output can never drift apart on the facts, only on
17
+ formatting.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ from . import commands, ledger, observe, reader, report
30
+ from . import config as config_module
31
+ from .models import FACTS_UNKNOWN, Attempt, Finding, LedgerEntry, LogicalAgent, Run
32
+ from .redact import Redactor
33
+
34
+ SCHEMA_VERSION = 3
35
+
36
+ # docs/DESIGN.md 4.9: the same outcomes observe.py's own candidate-path rule
37
+ # uses -- a unit of work is one whose attempts include at least one of these.
38
+ _RISK_OUTCOMES = frozenset({"killed", "failed", "superseded"})
39
+
40
+ # docs/DESIGN.md 4.11: the NEVER list -- a rescue must never undo with any of
41
+ # these, and `rescue status` flags one found in the transcript after the run
42
+ # ended.
43
+ _NEVER_SUBCOMMANDS = ("checkout", "restore", "reset", "stash", "clean")
44
+ _NEVER_LIST_RE = re.compile(r"\bgit\s+(" + "|".join(_NEVER_SUBCOMMANDS) + r")\b")
45
+
46
+ # docs/DESIGN.md 4.11 (F3 hardening): whether a Bash command run after the
47
+ # last counted check is PROVEN read-only, for rescue's own "did this session
48
+ # possibly write to the repository" question -- deliberately its own,
49
+ # narrower heuristic from reader._is_read_only_bash_command (docs/DESIGN.md
50
+ # 4.3), which answers a different question (can this tool_result's own text
51
+ # be trusted as a faithful quote of something already on disk) and, for that
52
+ # reason, treats `git status` as NOT read-only (it can trigger an index
53
+ # refresh) -- see tests/test_reader.py::test_bash_git_status_is_not_read_
54
+ # only_output, a deliberately pinned, unrelated distinction this function
55
+ # must not disturb. Here the question is only "did it write": `git status`
56
+ # never does, and neither does a `sed` invocation without an in-place flag;
57
+ # everything else follows the same shape reader._is_read_only_bash_command
58
+ # already uses (a fixed read command list, a redirection check, an
59
+ # interpreter/test-runner denylist).
60
+ _RESCUE_READ_ONLY_RE = re.compile(
61
+ r"\b(?:cat|sed|head|tail|grep|rg|nl|less|more|bat|wc|diff|ls|find)\b"
62
+ r"|\bgit\s+(?:show|diff|log|grep|blame|status)\b"
63
+ )
64
+ _RESCUE_NOT_READ_ONLY_RE = re.compile(
65
+ r"\b(?:node|codex|python|pytest|npm|npx|curl|make|tee)\b|\buv\s+run\b"
66
+ )
67
+ _SED_INPLACE_RE = re.compile(r"\bsed\b[^\n]*(?:-i\b|--in-place\b)")
68
+
69
+
70
+ def _proven_read_only(command: str | None) -> bool:
71
+ if not isinstance(command, str) or not command.strip():
72
+ return False
73
+ if ">" in command:
74
+ return False
75
+ if _SED_INPLACE_RE.search(command):
76
+ return False
77
+ if _RESCUE_NOT_READ_ONLY_RE.search(command):
78
+ return False
79
+ return bool(_RESCUE_READ_ONLY_RE.search(command))
80
+
81
+
82
+ # A timestamp floor for sorting tool calls that carry no `ts` at all (a
83
+ # missing timestamp) alongside ones that do, so a None never crashes the
84
+ # sort and always sorts first (oldest), never last.
85
+ _MIN_TS = datetime.min.replace(tzinfo=timezone.utc)
86
+
87
+
88
+ # --- shared data model -------------------------------------------------------
89
+
90
+
91
+ @dataclass
92
+ class PathRow:
93
+ raw_path: str
94
+ display_key: str # repo-relative when resolvable, else raw_path
95
+ state: str # observe.DIRTY_NOW / CLEAN_NOW / MISSING_NOW / UNKNOWN
96
+ state_reason: str | None
97
+ attempt_ordinals: list[int]
98
+ later_write: bool
99
+ # docs/DESIGN.md 5.3b, 4.13.11: one (attempt ordinal, ledger entry) pair per
100
+ # ledger entry that names this path, in attempt order, nothing collapsed.
101
+ ledger: list[tuple[int, LedgerEntry]] = field(default_factory=list)
102
+ # docs/DESIGN.md 5.3b (N5c-1): the index of the observation group that lists
103
+ # this path (None when none does), the path's own observation (None when its
104
+ # group was refused, unknown, or not checked, or the path lay outside the
105
+ # toplevel), and whether the path still needs a person to look at it because
106
+ # its group was never checked.
107
+ group: int | None = None
108
+ observation: "observe.PathObservation | None" = None
109
+ unchecked: bool = False
110
+ # docs/DESIGN.md 4.11 (N5 fix 1, C3): the normalized absolute target the row is keyed by
111
+ # (with `group`); None for a hand-built row.
112
+ target: str | None = None
113
+
114
+
115
+ @dataclass
116
+ class InFlightRow:
117
+ tool: str | None
118
+ raw_path: str | None
119
+ display_key: str | None
120
+
121
+
122
+ @dataclass
123
+ class TaskInfo:
124
+ transcript_name: str
125
+ line_no: int
126
+ text: str # full, untruncated, harness-noise-stripped
127
+ length: int # true length of `text`, not merely len(excerpt)
128
+
129
+
130
+ @dataclass
131
+ class UnitPlan:
132
+ key: str
133
+ label: str
134
+ phase: str
135
+ attempts_count: int
136
+ last_end_word: str
137
+ task: TaskInfo | None
138
+ reported: dict | None # the LAST attempt's own Attempt.reported
139
+ paths: list[PathRow]
140
+ commits: list[str]
141
+ in_flight: list[InFlightRow]
142
+ agent_ids: list[str] = field(default_factory=list)
143
+ # docs/DESIGN.md 4.11 (F6 hardening): true when at least one of this
144
+ # unit's own killed/failed/superseded attempts has facts is None (no
145
+ # transcript at all) -- distinct from the checked/reason UNKNOWN line
146
+ # below, which is about the repo observation, not the transcript.
147
+ facts_unknown: bool = False
148
+ # docs/DESIGN.md 4.11 (N5c-2, plan S7): the declared `[[repos]]` roots that own
149
+ # this unit's paths (lexically, in order of first appearance) and their declared
150
+ # checks, in that order. Never taken from an observation.
151
+ repos: list[str] = field(default_factory=list)
152
+ declared_checks: list[str] = field(default_factory=list)
153
+ # docs/DESIGN.md 4.11 (N5 fix 1, S1): see `_unit_unknown_group`.
154
+ unknown_group: bool = False
155
+
156
+
157
+ @dataclass
158
+ class RescuePlan:
159
+ run_id: str
160
+ kind: str
161
+ finished: bool
162
+ possibly_live: bool
163
+ nothing_to_rescue: bool
164
+ # docs/DESIGN.md 4.11 (F6 hardening): meaningful only when
165
+ # nothing_to_rescue is True. True is the cheerful "every attempt
166
+ # completed" case (run.finished and every attempt's outcome is
167
+ # 'completed'); False is "NOTHING TO RESCUE YET" -- an unfinished run,
168
+ # or one with an attempt that is neither completed nor killed/failed/
169
+ # superseded (for example 'unknown'), so the claim "every attempt
170
+ # completed" would not be true.
171
+ nothing_to_rescue_ok: bool
172
+ units: list[UnitPlan]
173
+ checked: bool
174
+ observation_reason: str | None
175
+ repo_root: str | None
176
+ config_warnings: list[str]
177
+ killed_brief: list[dict]
178
+ failed_brief: list[dict]
179
+ already_exists_paths: list[str]
180
+ already_exists_commits: list[str]
181
+ has_unknown_fact: bool
182
+ exit_code: int = 0
183
+ # N8 fix 1, S2: with no unit, the size of the risk population when it is not empty or not
184
+ # complete on a finished all-completed run (the sentence names it); None otherwise.
185
+ unchecked_attempts: int | None = None
186
+ # N3b P19: display string -> the raw spelling it came from, so local can
187
+ # place a repo-relative key by its original path.
188
+ already_exists_sources: dict[str, str] = field(default_factory=dict)
189
+ # docs/DESIGN.md 4.9, 5.3b (N5c-1): the run's observation, whose groups the
190
+ # JSON export lists and whose indexes the path rows name.
191
+ observation: "observe.RunObservation | None" = None
192
+ # docs/DESIGN.md 4.11 (N8b, K4.2): true only for a workflow run in which two attempts of
193
+ # different keys overlapped in time; the relaunch brief's fan-out caveat prints only then.
194
+ fan_out_overlap: bool = False
195
+ # docs/DESIGN.md 4.11 (R1, v0.3.1): why a plan with no unit has none, and for a session whose
196
+ # children have no established return the five-class breakdown; None whenever units exist
197
+ empty_state: str | None = None
198
+ return_breakdown: dict | None = None
199
+
200
+
201
+ # --- building the packet ------------------------------------------------------
202
+
203
+
204
+ def _facts_lists(facts) -> tuple[list, list]:
205
+ if facts is None:
206
+ return [], []
207
+ written = facts.paths_written if isinstance(facts.paths_written, list) else []
208
+ in_flight = facts.in_flight if isinstance(facts.in_flight, list) else []
209
+ return written, in_flight
210
+
211
+
212
+ def _in_flight_paths(in_flight: list) -> list[str]:
213
+ paths = []
214
+ for entry in in_flight:
215
+ if isinstance(entry, dict):
216
+ path = entry.get("path")
217
+ if isinstance(path, str) and path:
218
+ paths.append(path)
219
+ return paths
220
+
221
+
222
+ def _unit_risk_attempts(unit: LogicalAgent) -> list[Attempt]:
223
+ return [a for a in unit.attempts if a.outcome in _RISK_OUTCOMES]
224
+
225
+
226
+ def qualifying_units(run: Run) -> list[LogicalAgent]:
227
+ """docs/DESIGN.md 4.11: one unit of work per logical agent whose
228
+ attempts include a killed, failed, or superseded one."""
229
+ return [unit for unit in run.agents if _unit_risk_attempts(unit)]
230
+
231
+
232
+ @dataclass
233
+ class _Contribution:
234
+ """One way a unit's killed, failed, or superseded attempt names a path (N5 fix 1, C3 and
235
+ S1): from its facts (a file tool's `paths_written` or `in_flight`, with the spelling the call
236
+ used) or from a ledger entry (any tool the ledger gives a path). `target` is the normalized
237
+ absolute path (the raw spelling itself when nothing places a relative one); `repo` is the
238
+ call's candidate repository, the group key the ledger gives it."""
239
+
240
+ attempt: Attempt
241
+ target: str
242
+ raw: str | None
243
+ repo: str | None
244
+ entry: LedgerEntry | None = None
245
+
246
+
247
+ _PATH_EFFECTS = ("observed", "claimed_target", "attempted")
248
+
249
+
250
+ def _unit_contributions(unit: LogicalAgent) -> list[_Contribution]:
251
+ """Every path contribution of the unit's risk attempts: the facts paths of all attempts
252
+ first (the same union `observe.py`'s own rule 2 computes, scoped to this one unit), then
253
+ every ledger entry path (so a MultiEdit or a path named in a shell command has a row)."""
254
+ found: list[_Contribution] = []
255
+ attempts = _unit_risk_attempts(unit)
256
+ for attempt in attempts:
257
+ written, in_flight = _facts_lists(attempt.facts)
258
+ seen: set[str] = set()
259
+ for raw in list(written) + _in_flight_paths(in_flight):
260
+ if raw in seen:
261
+ continue
262
+ seen.add(raw)
263
+ placed = False
264
+ for call in attempt.tool_calls:
265
+ if call.path != raw:
266
+ continue
267
+ cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
268
+ base = commands.normalize_path(cwd) if cwd is not None else None
269
+ resolved = commands.resolve_target(base, raw)
270
+ if resolved is not None and resolved[0] is not None:
271
+ found.append(_Contribution(attempt, resolved[0], raw, base))
272
+ placed = True
273
+ if not placed:
274
+ target = commands.normalize_path(raw) if raw.startswith("/") else raw
275
+ found.append(_Contribution(attempt, target, raw, None))
276
+ for attempt in sorted(attempts, key=lambda item: item.ordinal):
277
+ if attempt.ledger is None:
278
+ continue
279
+ for entry in attempt.ledger.entries:
280
+ if entry.path is not None and entry.effect in _PATH_EFFECTS:
281
+ found.append(_Contribution(attempt, entry.path, None, entry.repo, entry))
282
+ return found
283
+
284
+
285
+ def _unit_targets(unit: LogicalAgent) -> dict[str, list[_Contribution]]:
286
+ """target -> its contributions, targets in first-seen order (facts paths first)."""
287
+ targets: dict[str, list[_Contribution]] = {}
288
+ for contribution in _unit_contributions(unit):
289
+ targets.setdefault(contribution.target, []).append(contribution)
290
+ return targets
291
+
292
+
293
+ def _listed_spellings(unit: LogicalAgent) -> list[str]:
294
+ """The spellings the status text may need to pseudonymize, first seen first: the raw
295
+ spelling a facts path used, else the target of a ledger-only path."""
296
+ listed: list[str] = []
297
+ for contribution in _unit_contributions(unit):
298
+ spelling = contribution.raw if contribution.raw is not None else contribution.target
299
+ if spelling not in listed:
300
+ listed.append(spelling)
301
+ return listed
302
+
303
+
304
+ @dataclass
305
+ class _RowSpec:
306
+ """One future path row: a target, the observation group that lists it (None when no group
307
+ does), and the contributions that belong to that group."""
308
+
309
+ target: str
310
+ index: int | None
311
+ group: "observe.GroupObservation | None"
312
+ contributions: list[_Contribution]
313
+
314
+
315
+ def _row_specs(unit: LogicalAgent, observation) -> list[_RowSpec]:
316
+ """Rows keyed by (normalized absolute target, group), never by the raw spelling (C3). A
317
+ contribution goes to the listing group whose candidate repository it names, else to the
318
+ first listing group; a group that lists the target but holds none of this unit's
319
+ contributions gets no row."""
320
+ specs: list[_RowSpec] = []
321
+ for target, contributions in _unit_targets(unit).items():
322
+ listing = [
323
+ (index, group) for index, group in enumerate(observation.groups)
324
+ if target in group.paths or target in group.unobserved_paths
325
+ ]
326
+ if not listing:
327
+ specs.append(_RowSpec(target, None, None, contributions))
328
+ continue
329
+ assigned: dict[int, list[_Contribution]] = {index: [] for index, _group in listing}
330
+ for contribution in contributions:
331
+ home = next(
332
+ (index for index, group in listing if group.repo == contribution.repo),
333
+ listing[0][0],
334
+ )
335
+ assigned[home].append(contribution)
336
+ for index, group in listing:
337
+ if assigned[index]:
338
+ specs.append(_RowSpec(target, index, group, assigned[index]))
339
+ return specs
340
+
341
+
342
+ def _display_key(raw_path: str, cwd: str | None, repo_root: str | None) -> str:
343
+ """The repo-relative form of raw_path, the same normalization
344
+ observe.py's own containment rule (4.9 rule 6) applies -- a pure string
345
+ operation, no filesystem access. Falls back to raw_path itself when
346
+ repo_root or cwd is unknown, or when the path does not resolve inside
347
+ repo_root (observe.py would have recorded it as UNKNOWN too)."""
348
+ if not repo_root:
349
+ return raw_path
350
+ abs_path = raw_path if os.path.isabs(raw_path) else os.path.join(cwd or "", raw_path)
351
+ norm = os.path.normpath(abs_path)
352
+ prefix = repo_root.rstrip(os.sep) + os.sep
353
+ if norm.startswith(prefix):
354
+ return norm[len(prefix):]
355
+ return raw_path
356
+
357
+
358
+ # --- declared repositories (docs/DESIGN.md 4.11, N5c-2, plan S7) -----------------------
359
+
360
+
361
+ def declared_roots(cfg: config_module.Config) -> dict[str, list[str]]:
362
+ """The `[[repos]]` roots of the configuration, lexically normalized, never resolved
363
+ through git, each with its declared checks (the first table wins for a duplicated
364
+ root, which `duplicate_roots` reports as a configuration error)."""
365
+ roots: dict[str, list[str]] = {}
366
+ for repo in cfg.repos:
367
+ roots.setdefault(commands.normalize_path(repo.path), list(repo.checks))
368
+ return roots
369
+
370
+
371
+ def duplicate_roots(cfg: config_module.Config) -> list[str]:
372
+ """Normalized roots that two or more `[[repos]]` tables name, in order of first repeat."""
373
+ seen: set[str] = set()
374
+ repeated: list[str] = []
375
+ for repo in cfg.repos:
376
+ root = commands.normalize_path(repo.path)
377
+ if root in seen and root not in repeated:
378
+ repeated.append(root)
379
+ seen.add(root)
380
+ return repeated
381
+
382
+
383
+ def _owner(path: str | None, roots) -> str | None:
384
+ """The longest declared root that contains `path` component-wise (`/work/repo2` is not
385
+ inside `/work/repo`), or None. Pure string logic; both must be absolute."""
386
+ if not isinstance(path, str) or not path.startswith("/"):
387
+ return None
388
+ best: str | None = None
389
+ for root in roots:
390
+ if not root.startswith("/"):
391
+ continue
392
+ try:
393
+ inside = os.path.commonpath([root, path]) == root
394
+ except ValueError:
395
+ inside = False
396
+ if inside and (best is None or len(root) > len(best)):
397
+ best = root
398
+ return best
399
+
400
+
401
+ def _target_owners(specs: list[_RowSpec], roots) -> dict[str, list[str]]:
402
+ """target -> the declared roots that own it: the owner of the target itself, and when it
403
+ has none, the owner of the candidate repository of each group that lists it (a candidate
404
+ repository belongs to the longest declared root too)."""
405
+ owners: dict[str, list[str]] = {}
406
+ for spec in specs:
407
+ found = owners.setdefault(spec.target, [])
408
+ root = _owner(spec.target, roots)
409
+ if root is None and spec.group is not None and spec.group.repo is not None:
410
+ root = _owner(commands.normalize_path(spec.group.repo), roots)
411
+ if root is not None and root not in found:
412
+ found.append(root)
413
+ return owners
414
+
415
+
416
+ def _unit_repos(owners: dict[str, list[str]]) -> list[str]:
417
+ repos: list[str] = []
418
+ for roots_of_target in owners.values():
419
+ for root in roots_of_target:
420
+ if root not in repos:
421
+ repos.append(root)
422
+ return repos
423
+
424
+
425
+ def _path_ledger(unit: LogicalAgent, raw_path: str) -> list[tuple[int, LedgerEntry]]:
426
+ """The ledger entries of the unit's killed, failed, or superseded attempts
427
+ that name `raw_path`, as (ordinal, entry) in attempt order. The facts keep
428
+ the path as the file tool spelled it; the ledger keeps it normalized
429
+ against the call's working directory (4.13.1), so each call that wrote
430
+ `raw_path` contributes its own normalized spelling to match on."""
431
+ pairs: list[tuple[int, LedgerEntry]] = []
432
+ for attempt in sorted(_unit_risk_attempts(unit), key=lambda item: item.ordinal):
433
+ if attempt.ledger is None:
434
+ continue
435
+ spellings: set[str] = set()
436
+ for call in attempt.tool_calls:
437
+ if call.path != raw_path:
438
+ continue
439
+ cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
440
+ base = commands.normalize_path(cwd) if cwd is not None else None
441
+ resolved = commands.resolve_target(base, raw_path)
442
+ if resolved is not None and resolved[0] is not None:
443
+ spellings.add(resolved[0])
444
+ if not spellings:
445
+ continue
446
+ pairs.extend(
447
+ (attempt.ordinal, entry) for entry in attempt.ledger.entries
448
+ if entry.path in spellings
449
+ )
450
+ return pairs
451
+
452
+
453
+ def _unit_path_rows(
454
+ run: Run, unit: LogicalAgent, observation, global_cwd: str | None,
455
+ ) -> list[PathRow]:
456
+ rows: list[PathRow] = []
457
+ for spec in _row_specs(unit, observation):
458
+ writer_attempts: list[Attempt] = []
459
+ for contribution in spec.contributions:
460
+ if not any(contribution.attempt is seen for seen in writer_attempts):
461
+ writer_attempts.append(contribution.attempt)
462
+ ordinals = sorted({attempt.ordinal for attempt in writer_attempts})
463
+ raws = [c.raw for c in spec.contributions if c.raw is not None]
464
+ raw_path = raws[0] if raws else spec.target
465
+ later = observe._later_write(run, writer_attempts, spec.target) or any(
466
+ observe._later_write_observed(run, writer_attempts, raw) for raw in raws
467
+ )
468
+ path_obs = None
469
+ unchecked = False
470
+ display = raw_path
471
+ if spec.group is None:
472
+ state, reason = observe.UNKNOWN, "not found in the observation"
473
+ else:
474
+ group = spec.group
475
+ path_obs = group.paths.get(spec.target)
476
+ if path_obs is not None:
477
+ state, reason = path_obs.state, path_obs.reason
478
+ if group.repo_root:
479
+ display = spec.target[len(group.repo_root.rstrip(os.sep)) + 1:]
480
+ else:
481
+ state = observe.UNKNOWN
482
+ reason = group.reason if not group.checked else observe.REASON_OUTSIDE
483
+ unchecked = not group.checked
484
+ ledger_pairs = sorted(
485
+ ((c.attempt.ordinal, c.entry) for c in spec.contributions if c.entry is not None),
486
+ key=lambda pair: pair[0],
487
+ )
488
+ rows.append(PathRow(
489
+ raw_path=raw_path, display_key=display, state=state, state_reason=reason,
490
+ attempt_ordinals=ordinals, later_write=later, ledger=ledger_pairs,
491
+ group=spec.index, observation=path_obs, unchecked=unchecked, target=spec.target,
492
+ ))
493
+ return rows
494
+
495
+
496
+ def _unit_in_flight_rows(unit: LogicalAgent, global_cwd, repo_root, checked) -> list[InFlightRow]:
497
+ rows: list[InFlightRow] = []
498
+ for attempt in _unit_risk_attempts(unit):
499
+ _written, in_flight = _facts_lists(attempt.facts)
500
+ for entry in in_flight:
501
+ if not isinstance(entry, dict):
502
+ continue
503
+ tool = entry.get("tool")
504
+ raw_path = entry.get("path")
505
+ raw_path = raw_path if isinstance(raw_path, str) and raw_path else None
506
+ display = None
507
+ if raw_path is not None:
508
+ display = _display_key(raw_path, global_cwd, repo_root) if checked else raw_path
509
+ rows.append(InFlightRow(
510
+ tool=tool if isinstance(tool, str) else None, raw_path=raw_path,
511
+ display_key=display,
512
+ ))
513
+ return rows
514
+
515
+
516
+ def _unit_commits(unit: LogicalAgent) -> list[str]:
517
+ seen: list[str] = []
518
+ for attempt in _unit_risk_attempts(unit):
519
+ facts = attempt.facts
520
+ commits = facts.commits if facts is not None and isinstance(facts.commits, list) else []
521
+ for commit_id in commits:
522
+ if commit_id not in seen:
523
+ seen.append(commit_id)
524
+ return seen
525
+
526
+
527
+ def _unit_has_unknown_facts(unit: LogicalAgent) -> bool:
528
+ """docs/DESIGN.md 4.11 (F6 hardening): a risk attempt (killed/failed/
529
+ superseded) with facts is None -- no transcript at all -- counts as
530
+ UNKNOWN too, not only one whose transcript exists but could not be read
531
+ (FACTS_UNKNOWN). The old `continue` here silently dropped the None case
532
+ from the UNKNOWN accounting, so a unit whose only risk attempt had no
533
+ transcript could pass as fully known."""
534
+ for attempt in _unit_risk_attempts(unit):
535
+ facts = attempt.facts
536
+ if facts is None:
537
+ return True
538
+ for value in (
539
+ facts.paths_written, facts.in_flight, facts.commits, facts.cwds,
540
+ facts.background_unfinished, facts.last_tool,
541
+ facts.seconds_since_last_tool_at_end,
542
+ ):
543
+ if value == FACTS_UNKNOWN:
544
+ return True
545
+ return False
546
+
547
+
548
+ def _first_user_event(attempt: Attempt):
549
+ for event in attempt.events:
550
+ if event.kind == "user":
551
+ return event
552
+ return None
553
+
554
+
555
+ def _read_raw_line_text(path: Path, line_no: int) -> str | None:
556
+ """The full, untruncated user-message text on line_no of path, flattened
557
+ the same way reader._user_events does, then harness-noise-stripped the
558
+ same way reader._excerpt does -- everything but the 400-char bound,
559
+ since this is used only to report a true prompt length and to quote the
560
+ first 300 characters, both under the caller's own control. Read-only;
561
+ returns None on any parse trouble rather than raising."""
562
+ try:
563
+ with reader._transcript_reader(path) as lines:
564
+ for current, line, torn in lines:
565
+ if current != line_no:
566
+ continue
567
+ if torn:
568
+ return None
569
+ line = line.strip()
570
+ if not line:
571
+ return None
572
+ try:
573
+ entry = json.loads(line)
574
+ except (json.JSONDecodeError, ValueError, RecursionError):
575
+ return None
576
+ if not isinstance(entry, dict):
577
+ return None
578
+ message = entry.get("message")
579
+ content = message.get("content") if isinstance(message, dict) else None
580
+ if isinstance(content, str):
581
+ text = content
582
+ else:
583
+ text = reader._flatten_block_text(content)
584
+ return reader._strip_harness_noise(text)
585
+ except (OSError, UnicodeError):
586
+ return None
587
+ return None
588
+
589
+
590
+ def _unit_task(unit: LogicalAgent) -> TaskInfo | None:
591
+ first_attempt = min(unit.attempts, key=lambda attempt: attempt.ordinal)
592
+ if first_attempt.transcript_path is None:
593
+ return None
594
+ event = _first_user_event(first_attempt)
595
+ if event is None:
596
+ return None
597
+ text = _read_raw_line_text(first_attempt.transcript_path, event.line_no)
598
+ if text is None:
599
+ text = event.text # already truncated/stripped fallback
600
+ return TaskInfo(
601
+ transcript_name=first_attempt.transcript_path.name, line_no=event.line_no,
602
+ text=text, length=len(text),
603
+ )
604
+
605
+
606
+ def _unit_unknown_group(run: Run, unit: LogicalAgent, observation) -> bool:
607
+ """docs/DESIGN.md 4.11 (N5 fix 1, S1): one of the unit's risk attempts sits in a refused
608
+ or unknown group, or in a group with unobserved paths, so what the unit wrote is not fully
609
+ seen (a shell write, an attempt with no recorded path)."""
610
+ refs = ledger.attempt_refs(run)
611
+ mine = {refs[id(attempt)] for attempt in _unit_risk_attempts(unit) if id(attempt) in refs}
612
+ return any(
613
+ (group.refused or group.unobserved_paths) and mine.intersection(group.attempts)
614
+ for group in observation.groups
615
+ )
616
+
617
+
618
+ def _unit_plan(
619
+ run: Run, unit: LogicalAgent, observation, global_cwd: str | None,
620
+ roots: dict[str, list[str]],
621
+ ) -> UnitPlan:
622
+ attempts_sorted = sorted(unit.attempts, key=lambda attempt: attempt.ordinal)
623
+ last_attempt = attempts_sorted[-1]
624
+ repos = _unit_repos(_target_owners(_row_specs(unit, observation), roots))
625
+ declared_checks: list[str] = []
626
+ for root in repos:
627
+ for check in roots[root]:
628
+ if check not in declared_checks:
629
+ declared_checks.append(check)
630
+ return UnitPlan(
631
+ key=unit.key,
632
+ label=unit.label,
633
+ phase=unit.phase,
634
+ attempts_count=len(attempts_sorted),
635
+ last_end_word=_end_word(last_attempt),
636
+ task=_unit_task(unit),
637
+ reported=last_attempt.reported,
638
+ paths=_unit_path_rows(run, unit, observation, global_cwd),
639
+ commits=_unit_commits(unit),
640
+ in_flight=_unit_in_flight_rows(
641
+ unit, global_cwd, observation.repo_root, observation.checked,
642
+ ),
643
+ agent_ids=[attempt.agent_id for attempt in unit.attempts],
644
+ facts_unknown=any(a.facts is None for a in _unit_risk_attempts(unit)),
645
+ repos=repos,
646
+ declared_checks=declared_checks,
647
+ unknown_group=_unit_unknown_group(run, unit, observation),
648
+ )
649
+
650
+
651
+ _END_KILLED_CAUSE = {"parent_stop": "stopped by parent", "human": "interrupted"}
652
+
653
+
654
+ def _end_word(attempt: Attempt) -> str:
655
+ if attempt.outcome == "killed":
656
+ cause = attempt.interrupt.cause if attempt.interrupt is not None else None
657
+ return _END_KILLED_CAUSE.get(cause, "killed")
658
+ return attempt.outcome
659
+
660
+
661
+ def _killed_brief_rows(run: Run) -> list[dict]:
662
+ rows = []
663
+ for attempt in run.attempts:
664
+ if attempt.outcome != "killed" or attempt.interrupt is None:
665
+ continue
666
+ kill_ts = attempt.interrupt.kill_ts
667
+ time_str = kill_ts.astimezone(timezone.utc).strftime("%H:%MZ") if kill_ts else None
668
+ rows.append({
669
+ "label": attempt.label, "ordinal": attempt.ordinal, "time": time_str,
670
+ "silence_seconds": attempt.interrupt.silence_seconds,
671
+ })
672
+ return rows
673
+
674
+
675
+ def _failed_brief_rows(run: Run, findings: list[Finding]) -> list[dict]:
676
+ label_by_agent_id = {attempt.agent_id: attempt.label for attempt in run.attempts}
677
+ rows = []
678
+ for finding in findings:
679
+ if finding.detector != "attempt_failed":
680
+ continue
681
+ label = label_by_agent_id.get(finding.agent_id, finding.agent_id or "?")
682
+ rows.append({
683
+ "label": label,
684
+ "cause": finding.metrics.get("cause", "unattributed"),
685
+ "retry_now": finding.metrics.get("retry_now"),
686
+ "reset_time": finding.metrics.get("reset_time"),
687
+ })
688
+ return rows
689
+
690
+
691
+ _CONFIRMED_WRITE_EFFECTS = frozenset({"observed"})
692
+
693
+
694
+ def _already_exists(run: Run) -> tuple[list[str], list[str]]:
695
+ """The brief's `already exists` (docs/DESIGN.md 4.11, 4.13.8, K4.4): confirmed writes only,
696
+ every attempt's ledger paths of effect `observed` plus `facts.paths_written`. A
697
+ `claimed_target` or `attempted` path never enters it, because neither effect proves that a
698
+ write happened. Commit ids are the summary-shaped ones the facts carry."""
699
+ paths: list[str] = []
700
+ commits: list[str] = []
701
+ for attempt in run.attempts:
702
+ for path in ledger.attempt_ledger_paths(
703
+ attempt, _CONFIRMED_WRITE_EFFECTS, include_facts_written=True,
704
+ ):
705
+ if path not in paths:
706
+ paths.append(path)
707
+ facts = attempt.facts
708
+ if facts is None:
709
+ continue
710
+ commit_ids = facts.commits if isinstance(facts.commits, list) else []
711
+ for commit_id in commit_ids:
712
+ if commit_id not in commits:
713
+ commits.append(commit_id)
714
+ return paths, commits
715
+
716
+
717
+ def _fan_out_overlap(run: Run) -> bool:
718
+ """docs/DESIGN.md 4.11 (K4.2): a workflow run in which two attempts of different keys
719
+ overlapped in time. Both attempts need a known first and last timestamp and the closed
720
+ intervals must intersect; an unknown timestamp never triggers it."""
721
+ if run.kind != "workflow":
722
+ return False
723
+ timed = [
724
+ (attempt.key, attempt.first_ts, attempt.last_ts)
725
+ for attempt in run.attempts
726
+ if attempt.key is not None and attempt.first_ts is not None
727
+ and attempt.last_ts is not None
728
+ ]
729
+ for index, (key_a, first_a, last_a) in enumerate(timed):
730
+ for key_b, first_b, last_b in timed[index + 1:]:
731
+ if key_a != key_b and first_a <= last_b and first_b <= last_a:
732
+ return True
733
+ return False
734
+
735
+
736
+ def empty_unit_state(run: Run) -> tuple[str, int]:
737
+ """docs/DESIGN.md 4.11 (F6 hardening, N8b K4.3, N8 fix 1 S2): what a run with no unit may say.
738
+ ('ok', 0): a FINISHED run whose every attempt's own outcome is `completed` AND whose 4.13.1
739
+ risk population is empty and complete, so "every attempt completed" is the whole truth.
740
+ ('risk', n): the same finished all-completed run, but the risk population has `n` attempts
741
+ or is not complete, so WORK AT RISK may still hold unchecked work. ('yet', 0): everything
742
+ else. One rule for the sentence and the exit status of both `rescue plan` and `rescue
743
+ status`."""
744
+ if run.finished is not True or not all(a.outcome == "completed" for a in run.attempts):
745
+ return "yet", 0
746
+ population = ledger.risk_population(run)
747
+ completeness = run.risk_population_completeness
748
+ if completeness is None:
749
+ completeness = ledger.risk_population_completeness(run)[0]
750
+ if population or completeness != "complete":
751
+ return "risk", len(population)
752
+ return "ok", 0
753
+
754
+
755
+ NO_UNIT_UNESTABLISHED = "no_rescue_unit_unestablished_returns"
756
+
757
+
758
+ def no_unit_state(run: Run, *, live: bool = False) -> tuple[str, dict | None]:
759
+ """docs/DESIGN.md 4.11 (R1): (empty_state, return_breakdown) of a run with no unit. A session
760
+ with a subagent whose return is not established first; then the F6/N8 branches."""
761
+ unestablished = report.unestablished_returns(run)
762
+ if unestablished:
763
+ return NO_UNIT_UNESTABLISHED, report.return_breakdown(unestablished)
764
+ state, _count = empty_unit_state(run)
765
+ if state == "risk":
766
+ return "unchecked_risk", None
767
+ if state == "ok" and not live:
768
+ return "nothing_to_rescue", None
769
+ return "nothing_yet", None
770
+
771
+
772
+ def _no_unit_sentence(run_id: str, breakdown: dict) -> str:
773
+ k = sum(breakdown.values())
774
+ return (
775
+ f"NO RESCUE UNIT: {k} {report._plural(k, 'subagent')} {report._have(k)} no established "
776
+ f"return ({report.breakdown_text(breakdown)}); run agent-postmortem run {run_id} to see "
777
+ "WORK AT RISK"
778
+ )
779
+
780
+
781
+ def nothing_to_rescue_ok(run: Run) -> bool:
782
+ """True exactly when `empty_unit_state` is 'ok': the one predicate for the cheerful
783
+ sentence and the exit 0 of both `rescue plan` and `rescue status` (K4.3, S2)."""
784
+ return empty_unit_state(run)[0] == "ok"
785
+
786
+
787
+ def _unchecked_sentence(count: int, run_id: str) -> str:
788
+ if count == 0: # N8 fix 2, R2-1: an empty but incomplete population has no exact count
789
+ return (
790
+ "NO KILLED OR FAILED ATTEMPT: the tool could not establish which attempts were at "
791
+ f"risk; see WORK AT RISK in agent-postmortem run {run_id}."
792
+ )
793
+ return (
794
+ f"NO KILLED OR FAILED ATTEMPT: {count} attempt(s) may still hold unchecked work; "
795
+ f"see WORK AT RISK in agent-postmortem run {run_id}."
796
+ )
797
+
798
+
799
+ def _lifecycle_unknown(run: Run) -> bool:
800
+ """docs/DESIGN.md 4.13.3: the run's lifecycle terminal is `unknown` (a session has no
801
+ lifecycle, so never)."""
802
+ return run.lifecycle is not None and run.lifecycle.terminal == "unknown"
803
+
804
+
805
+ def _observation_reason(observation) -> str | None:
806
+ """Why the repo state was not checked, for the plan's one-line note: the
807
+ first distinct reason of a group that was not checked, or the fixed sentence
808
+ when there is no group at all; None when some group was checked."""
809
+ if observation.checked:
810
+ return None
811
+ for group in observation.groups:
812
+ if group.reason:
813
+ return group.reason
814
+ return "no paths written by a killed or failed attempt"
815
+
816
+
817
+ def build_plan(
818
+ run: Run, observation, findings: list[Finding], cfg: config_module.Config, *,
819
+ possibly_live: bool = False,
820
+ ) -> RescuePlan:
821
+ """The full decision packet's raw facts (docs/DESIGN.md 4.11). `observation`
822
+ is always observe.observe_groups(run) (N5c-1) -- the caller runs it once and
823
+ passes it in so a test can also pass a hand-built one. `possibly_live` is
824
+ computed by the caller (report.is_possibly_live), since it needs a file
825
+ stat this module has no business doing on its own."""
826
+ units_raw = qualifying_units(run)
827
+ global_cwd = observe._discover_cwd(run) if observation.checked else None
828
+ roots = declared_roots(cfg)
829
+ units = [_unit_plan(run, unit, observation, global_cwd, roots) for unit in units_raw]
830
+
831
+ already_raw_paths, already_commits = _already_exists(run)
832
+ already_paths: list[str] = []
833
+ already_sources: dict[str, str] = {}
834
+ for raw_path in already_raw_paths:
835
+ if observation.checked:
836
+ display = _display_key(raw_path, global_cwd, observation.repo_root)
837
+ else:
838
+ display = raw_path
839
+ if display not in already_paths:
840
+ already_paths.append(display)
841
+ already_sources[display] = raw_path
842
+ has_unknown = (not observation.checked) or any(
843
+ row.state == observe.UNKNOWN for unit in units for row in unit.paths
844
+ ) or any(_unit_has_unknown_facts(unit) for unit in units_raw) or any(
845
+ group.refused or group.unobserved_paths for group in observation.groups
846
+ )
847
+
848
+ empty_state, unchecked_count = empty_unit_state(run) if not units else ("yet", 0)
849
+ unchecked = empty_state == "risk"
850
+ plan_state, plan_breakdown = (
851
+ no_unit_state(run, live=possibly_live) if not units else (None, None)
852
+ )
853
+ unestablished = plan_state == NO_UNIT_UNESTABLISHED
854
+ nothing_to_rescue = not units and not unchecked and not unestablished
855
+ # docs/DESIGN.md 4.11 (F6 hardening): "every attempt completed" is a
856
+ # claim about a FINISHED run where every attempt's own outcome is
857
+ # 'completed' -- not merely one with no killed/failed/superseded unit,
858
+ # which is also true of an unfinished run, or one whose only attempt is
859
+ # 'unknown' (no transcript at all, so not proven completed either).
860
+ all_completed = empty_state == "ok"
861
+
862
+ # docs/DESIGN.md 4.11, 4.13.11 (N5c-2): a lifecycle terminal `unknown` is an UNKNOWN
863
+ # fact of the whole run, whatever the units say.
864
+ lifecycle_unknown = _lifecycle_unknown(run)
865
+ if possibly_live or lifecycle_unknown or unchecked or unestablished:
866
+ exit_code = 4
867
+ elif nothing_to_rescue:
868
+ exit_code = 0 if all_completed else 4
869
+ elif has_unknown:
870
+ exit_code = 4
871
+ else:
872
+ exit_code = 0
873
+
874
+ return RescuePlan(
875
+ run_id=run.run_id,
876
+ kind=run.kind,
877
+ finished=run.finished,
878
+ possibly_live=possibly_live,
879
+ nothing_to_rescue=nothing_to_rescue,
880
+ nothing_to_rescue_ok=all_completed,
881
+ units=units,
882
+ checked=observation.checked,
883
+ observation_reason=_observation_reason(observation),
884
+ repo_root=observation.repo_root if observation.checked else None,
885
+ config_warnings=list(cfg.warnings),
886
+ killed_brief=_killed_brief_rows(run),
887
+ failed_brief=_failed_brief_rows(run, findings),
888
+ already_exists_paths=already_paths,
889
+ already_exists_commits=already_commits,
890
+ has_unknown_fact=has_unknown,
891
+ exit_code=exit_code,
892
+ already_exists_sources=already_sources,
893
+ observation=observation,
894
+ fan_out_overlap=_fan_out_overlap(run),
895
+ unchecked_attempts=unchecked_count if unchecked else None,
896
+ empty_state=plan_state,
897
+ return_breakdown=plan_breakdown,
898
+ )
899
+
900
+
901
+ # --- text rendering ------------------------------------------------------
902
+
903
+
904
+ _PATH_COL = 27
905
+ _STATE_COL = 11
906
+ _ATTEMPTS_COL = 10
907
+ _PATH_INDENT = " " * 12
908
+
909
+
910
+ def _register_plan_paths(plan: RescuePlan, redactor: Redactor, run: Run | None = None) -> None:
911
+ """Every path the plan text may print, in one namespace. At local (N3b
912
+ P19) each display key is placed by the path function from the raw
913
+ spelling it came from, against the run's working directory; the
914
+ repository root is the one the plan's own observation resolved (D-n)."""
915
+ cwd = observe._discover_cwd(run) if run is not None else None
916
+ for unit in plan.units:
917
+ for row in unit.paths:
918
+ redactor.names.path(row.display_key, cwd, resolve_from=row.raw_path)
919
+ for entry in unit.in_flight:
920
+ if entry.display_key is not None:
921
+ redactor.names.path(entry.display_key, cwd, resolve_from=entry.raw_path)
922
+ for path in plan.already_exists_paths:
923
+ redactor.names.path(path, cwd, resolve_from=plan.already_exists_sources.get(path))
924
+
925
+
926
+ def _register_plan_names(run: Run, redactor: Redactor, *, detailed: bool = False) -> None:
927
+ report._register_names(run, redactor, detailed=detailed)
928
+
929
+
930
+ def _piece(redactor: Redactor, value):
931
+ """One piece of a rescue line or record, scanned where it enters it (docs/DESIGN.md 5.1; F fix
932
+ 7, W7-4): a string goes through the one free-text scanner, anything else (a number, a bool,
933
+ None) is not text and is returned as it is. An assembled line is never scanned again, so
934
+ every piece that can hold text goes through this (or a pseudonym call) before it is joined."""
935
+ return redactor(value) if isinstance(value, str) else value
936
+
937
+
938
+ def _raw_claim_value(value) -> str:
939
+ if isinstance(value, bool):
940
+ return "true" if value else "false"
941
+ if isinstance(value, list):
942
+ return ",".join(str(item) for item in value)
943
+ return str(value)
944
+
945
+
946
+ def _format_claim_value(key: str, value, redactor: Redactor) -> str:
947
+ # docs/DESIGN.md 4.11 (F7 hardening): reported.status is arbitrary,
948
+ # up-to-32-char free text (docs/DESIGN.md section 3, _REPORTED_STATUS_
949
+ # LIMIT) an agent can put almost anything into -- by default it prints
950
+ # as the fixed word "set", never its own value; --no-redact still shows
951
+ # the real string.
952
+ if key == "status":
953
+ # at local the claim is free text of up to 32 characters: scanned like any other value
954
+ return "set" if redactor.level == "share" else redactor(_raw_claim_value(value))
955
+ if key == "report_path" and isinstance(value, str):
956
+ return redactor.names.path(value)
957
+ if key == "commits" and isinstance(value, list):
958
+ # docs/DESIGN.md 4.13.5 (M9): the result's own commit list, commit-N at share
959
+ return ",".join(
960
+ redactor.names.commit(item) if isinstance(item, str) else str(item) for item in value
961
+ )
962
+ # a claim value is free text: scanned here, as its own piece (the assembled line is not
963
+ # scanned again, docs/DESIGN.md 5.1: the end of a line belongs to the path that starts in it)
964
+ return redactor(_raw_claim_value(value))
965
+
966
+
967
+ def _reported_text(reported: dict | None, redactor: Redactor) -> str:
968
+ if reported is None:
969
+ return "no result event"
970
+ if reported.get("type") == "text":
971
+ return f"text result, {reported.get('text_length', 0)} chars, not kept"
972
+ if reported.get("type") == "dict":
973
+ claims = reported.get("claims") or {}
974
+ if not claims:
975
+ return "dict result, no allowlisted claims"
976
+ parts = [
977
+ f"{key}: {_format_claim_value(key, value, redactor)}" for key, value in claims.items()
978
+ ]
979
+ return ", ".join(parts)
980
+ return "no result event"
981
+
982
+
983
+ def _state_display(row: PathRow, redactor: Redactor) -> str:
984
+ if row.state == observe.UNKNOWN and row.state_reason:
985
+ return f"{observe.UNKNOWN} ({redactor(row.state_reason)})"
986
+ return row.state
987
+
988
+
989
+ def _unit_header_line(unit: UnitPlan, redactor: Redactor) -> str:
990
+ agent = redactor.names.agent(unit.label) or "agent"
991
+ phase_part = ""
992
+ if unit.phase:
993
+ phase_part = f" ({redactor.names.phase(unit.phase)})"
994
+ plural = "attempt" if unit.attempts_count == 1 else "attempts"
995
+ return (
996
+ f"UNIT {agent}{phase_part} {unit.attempts_count} {plural}, "
997
+ f"last one {unit.last_end_word}"
998
+ )
999
+
1000
+
1001
+ def _task_lines(unit: UnitPlan, view: "report.EvidenceView", redactor: Redactor) -> list[str]:
1002
+ """The prompt is quoted only with --evidence at local and raw (N3b D10,
1003
+ D-o), exactly as v0.2 printed it under --no-redact: the first 300
1004
+ characters, then the pointer. Otherwise the pointer to where it is and
1005
+ its length. At share the pointer names an aliased file; at local and raw
1006
+ the real one."""
1007
+ if unit.task is None:
1008
+ return [" TASK no prompt recorded"]
1009
+ pointer = view.pointer(unit.task.transcript_name, unit.task.line_no)
1010
+ if view.show_excerpts and pointer is not None:
1011
+ return [
1012
+ f' TASK "{redactor(unit.task.text[:300])}"', f" full text: {pointer}",
1013
+ ]
1014
+ lines = []
1015
+ if pointer is not None:
1016
+ lines.append(f" TASK full text: {pointer}")
1017
+ lines.append(f" prompt length: {unit.task.length} chars")
1018
+ else:
1019
+ lines.append(f" TASK prompt length: {unit.task.length} chars")
1020
+ return lines
1021
+
1022
+
1023
+ def _paths_lines(unit: UnitPlan, redactor: Redactor) -> list[str]:
1024
+ lines = [" PATHS " + f"{'path':<{_PATH_COL}}{'state':<{_STATE_COL}}"
1025
+ f"{'attempts':<{_ATTEMPTS_COL}}later write"]
1026
+ if not unit.paths:
1027
+ lines.append(_PATH_INDENT + "none")
1028
+ return lines
1029
+ states = [_state_display(row, redactor) for row in unit.paths]
1030
+ # The state column grows to the widest state in this table (an UNKNOWN
1031
+ # with its reason is longer than the fixed width), so the columns after
1032
+ # it never run together.
1033
+ state_col = max(_STATE_COL, max(len(state) for state in states) + 2)
1034
+ for row, state in zip(unit.paths, states):
1035
+ display = redactor(row.display_key)
1036
+ attempts_str = ", ".join(str(o) for o in row.attempt_ordinals)
1037
+ later = "yes" if row.later_write else "no"
1038
+ lines.append(
1039
+ _PATH_INDENT
1040
+ + f"{display:<{_PATH_COL}}{state:<{state_col}}{attempts_str:<{_ATTEMPTS_COL}}{later}"
1041
+ )
1042
+ return lines
1043
+
1044
+
1045
+ def _commits_line(unit: UnitPlan, redactor: Redactor) -> str:
1046
+ ids = (
1047
+ ", ".join(redactor.names.commit(commit) for commit in unit.commits)
1048
+ if unit.commits else "0"
1049
+ )
1050
+ return f" COMMITS reported by these attempts: {ids}"
1051
+
1052
+
1053
+ def _in_flight_lines(unit: UnitPlan, redactor: Redactor) -> list[str]:
1054
+ if not unit.in_flight:
1055
+ return []
1056
+ lines = []
1057
+ for index, entry in enumerate(unit.in_flight):
1058
+ tool = redactor(entry.tool or "tool")
1059
+ path_display = redactor(entry.display_key) if entry.display_key else "(no path)"
1060
+ text = f"{tool} {path_display} (no result recorded)"
1061
+ prefix = " IN FLIGHT at the kill: " if index == 0 else _PATH_INDENT
1062
+ lines.append(prefix + text)
1063
+ return lines
1064
+
1065
+
1066
+ _UNKNOWN_GROUP_SENTENCE = (
1067
+ "(repo state not seen for some of this unit's attempts: a shell command or an "
1068
+ "unobserved path may hide what they wrote)"
1069
+ )
1070
+
1071
+ _UNKNOWN_SENTENCE = (
1072
+ "whose content is in the dirty paths; whether the task's goal was reached; "
1073
+ "what remains of the task"
1074
+ )
1075
+
1076
+
1077
+ def _unknown_lines(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> list[str]:
1078
+ lines = [f" UNKNOWN {_UNKNOWN_SENTENCE}"]
1079
+ if not plan.checked and plan.observation_reason:
1080
+ lines.append(f" (repo state not checked: {redactor(plan.observation_reason)})")
1081
+ # docs/DESIGN.md 4.11 (F6 hardening): a risk attempt with no transcript
1082
+ # at all (facts is None) is its own, distinct UNKNOWN reason.
1083
+ if unit.facts_unknown:
1084
+ lines.append(" UNKNOWN facts: transcript missing")
1085
+ if unit.unknown_group and plan.checked:
1086
+ # (when nothing was checked, the line above already says the state was not seen)
1087
+ lines.append(f" {_UNKNOWN_GROUP_SENTENCE}")
1088
+ return lines
1089
+
1090
+
1091
+ def _checks_line(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> str:
1092
+ if unit.declared_checks:
1093
+ # docs/DESIGN.md 4.11/5.1 (F7 hardening): a declared check's own
1094
+ # text gets its own check-N pseudonym by default -- the whole
1095
+ # command hidden, not merely a path embedded inside it.
1096
+ joined = "; ".join(redactor.check_text(check) for check in unit.declared_checks)
1097
+ if len(unit.repos) > 1:
1098
+ return f" CHECKS declared across {len(unit.repos)} repositories: {joined}"
1099
+ return f" CHECKS declared for this repo: {joined}"
1100
+ if plan.checked and plan.repo_root:
1101
+ repo_display = plan.repo_root if redactor.level == "raw" else "this repository"
1102
+ return (
1103
+ f' CHECKS no checks declared for {repo_display}: add [[repos]] to the '
1104
+ "config, see docs"
1105
+ )
1106
+ return (
1107
+ " CHECKS no checks declared for this repository: add [[repos]] to the "
1108
+ "config, see docs"
1109
+ )
1110
+
1111
+
1112
+ def _step_paths(unit: UnitPlan, plan: RescuePlan) -> tuple[list[PathRow], str]:
1113
+ """The rows the `git status`/`git diff` steps name, and the note that goes
1114
+ with them. Dirty paths always; when the repo state was not checked at all,
1115
+ every path written; a path whose group was refused or not checked is listed
1116
+ too, since nothing looked at it."""
1117
+ dirty = [row for row in unit.paths if row.state == observe.DIRTY_NOW]
1118
+ if not plan.checked and unit.paths:
1119
+ return (dirty or list(unit.paths)), (
1120
+ "" if dirty else " (repo state not checked; every path written is listed)"
1121
+ )
1122
+ unchecked = [row for row in unit.paths if row.unchecked]
1123
+ listed = [
1124
+ row for row in unit.paths if row.state == observe.DIRTY_NOW or row.unchecked
1125
+ ]
1126
+ if unchecked and listed:
1127
+ return listed, " (repo state not checked for some paths; those are listed too)"
1128
+ return dirty, ""
1129
+
1130
+
1131
+ def _run_check_text(unit: UnitPlan, redactor: Redactor) -> str:
1132
+ """The `[decide]` step that runs the unit's declared checks: from the repo root for
1133
+ one repository, from each repository's own root for several."""
1134
+ joined = "; ".join(redactor.check_text(check) for check in unit.declared_checks)
1135
+ if len(unit.repos) > 1:
1136
+ return f"run the declared checks, each from its own repository root: {joined}"
1137
+ return f"run the declared check from the repo root: {joined}"
1138
+
1139
+
1140
+ def _steps_lines(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> list[str]:
1141
+ step_paths, unchecked_note = _step_paths(unit, plan)
1142
+
1143
+ if not step_paths and plan.checked:
1144
+ # Observation ran and nothing the killed attempts wrote is dirty
1145
+ # now: there is nothing to inspect, keep, or commit. Say so instead
1146
+ # of printing commands with no paths. A unit whose attempts sit in a
1147
+ # refused or unobserved group (N5 fix 1, S1) is never told that: what
1148
+ # it wrote was not seen.
1149
+ if unit.unknown_group:
1150
+ first = (
1151
+ " 1. [look] no path was observed for this unit: a shell command or an "
1152
+ "unobserved repository may hide what it wrote; run git status --short in the "
1153
+ "repository the agent worked in"
1154
+ )
1155
+ else:
1156
+ first = (
1157
+ " 1. [look] no path is dirty now: what the killed attempts wrote is "
1158
+ "committed (CLEAN_NOW) or absent (MISSING_NOW); see PATHS"
1159
+ )
1160
+ lines = [" STEPS", first]
1161
+ if plan.possibly_live:
1162
+ return lines
1163
+ if unit.declared_checks:
1164
+ lines.append(
1165
+ f" 2. [decide] {_run_check_text(unit, redactor)}"
1166
+ )
1167
+ else:
1168
+ lines.append(" 2. [decide] declare a check first")
1169
+ lines.append(" 3. [decide] relaunch the remaining work")
1170
+ return lines
1171
+
1172
+ # docs/DESIGN.md 4.11 (K4.1): a unit with no path to name gets both whole-tree commands, no
1173
+ # pathspec; the literal `<none>` is never printed.
1174
+ if step_paths:
1175
+ path_str = " ".join(redactor(row.display_key) for row in step_paths)
1176
+ status_command = f"git status --short -- {path_str}{unchecked_note}"
1177
+ diff_command = f"git diff -- {path_str}"
1178
+ else:
1179
+ status_command = "git status --short"
1180
+ diff_command = "git diff"
1181
+
1182
+ lines = [
1183
+ " STEPS",
1184
+ f" 1. [look] {status_command}",
1185
+ f" 2. [look] {diff_command}",
1186
+ ]
1187
+ # docs/DESIGN.md 4.11 (LIVE rule): a possibly-live run only ever gets the
1188
+ # two read-only look steps -- committing, relaunching, or even deciding
1189
+ # what to keep is unsafe advice while the run might still be writing to
1190
+ # this same repo.
1191
+ if plan.possibly_live:
1192
+ return lines
1193
+
1194
+ if unit.declared_checks:
1195
+ check_text = _run_check_text(unit, redactor)
1196
+ else:
1197
+ check_text = "declare a check first"
1198
+
1199
+ lines.extend([
1200
+ f" 3. [decide] {check_text}",
1201
+ " 4. [decide] keep, fix, or set aside each dirty path. A passing check is "
1202
+ "evidence, not a decision.",
1203
+ " 5. [decide] commit, after reading the diff",
1204
+ " 6. [decide] relaunch the remaining work",
1205
+ ])
1206
+ return lines
1207
+
1208
+
1209
+ def _never_line() -> str:
1210
+ return " NEVER git checkout / restore / reset / stash / clean on these paths"
1211
+
1212
+
1213
+ def _render_unit_text(
1214
+ unit: UnitPlan, plan: RescuePlan, redactor: Redactor, view: "report.EvidenceView",
1215
+ ) -> str:
1216
+ lines = [_unit_header_line(unit, redactor)]
1217
+ lines.extend(_task_lines(unit, view, redactor))
1218
+ lines.append(f" REPORTED {_reported_text(unit.reported, redactor)}")
1219
+ lines.extend(_paths_lines(unit, redactor))
1220
+ lines.append(_commits_line(unit, redactor))
1221
+ lines.extend(_in_flight_lines(unit, redactor))
1222
+ lines.extend(_unknown_lines(unit, plan, redactor))
1223
+ lines.append(_checks_line(unit, plan, redactor))
1224
+ lines.extend(_steps_lines(unit, plan, redactor))
1225
+ lines.append(_never_line())
1226
+ # every piece above was scanned where it entered the line; an assembled line is not scanned
1227
+ # again, because a path hides the rest of its line (docs/DESIGN.md 5.1)
1228
+ return "\n".join(lines)
1229
+
1230
+
1231
+ def _brief_text(plan: RescuePlan, redactor: Redactor) -> str:
1232
+ killed_parts = []
1233
+ for row in plan.killed_brief:
1234
+ label = redactor.names.agent(row["label"])
1235
+ time_str = redactor(row["time"]) if row["time"] else "unknown time"
1236
+ silence = row["silence_seconds"]
1237
+ silence_str = f"{int(silence)}s" if silence is not None else "unknown"
1238
+ killed_parts.append(
1239
+ f"{label} attempt {row['ordinal']} at {time_str}, after {silence_str} of silence"
1240
+ )
1241
+ killed_text = "; ".join(killed_parts) if killed_parts else "none"
1242
+
1243
+ failed_parts = []
1244
+ for row in plan.failed_brief:
1245
+ label = redactor.names.agent(row["label"])
1246
+ cause_phrase = redactor(report._cause_phrase(row["cause"]))
1247
+ piece = f"{label}: {cause_phrase}, retry_now {_piece(redactor, row['retry_now'])}"
1248
+ if row["reset_time"]:
1249
+ piece += f", resets {redactor(row['reset_time'])}"
1250
+ failed_parts.append(piece)
1251
+ failed_text = "; ".join(failed_parts) if failed_parts else "none"
1252
+
1253
+ paths = [redactor(path) for path in plan.already_exists_paths]
1254
+ commits = [redactor.names.commit(commit) for commit in plan.already_exists_commits]
1255
+ # docs/DESIGN.md 4.11, 4.13.1 (N5c-2): a commit id is a summary-shaped output
1256
+ # line the tool has not confirmed, so the brief says commits were reported, never
1257
+ # that they exist.
1258
+ paths_text = f"{len(paths)} paths ({', '.join(paths)})" if paths else "0 paths"
1259
+ commits_text = (
1260
+ f"commits reported: {len(commits)} ({', '.join(commits)})" if commits
1261
+ else "commits reported: 0"
1262
+ )
1263
+
1264
+ lines = [
1265
+ "BRIEF FOR A RELAUNCH",
1266
+ f" killed: {killed_text} failed: {failed_text}",
1267
+ f" already exists: {paths_text}; {commits_text}",
1268
+ " start from what exists; do not redo it",
1269
+ ]
1270
+ # docs/DESIGN.md 4.11 (K4.2): only when two attempts of different keys overlapped in time.
1271
+ if plan.fan_out_overlap:
1272
+ lines.append(
1273
+ " caveat: resume re-runs downstream agents after a parallel fan-out "
1274
+ "(anthropics/claude-code #95076)"
1275
+ )
1276
+ return "\n".join(lines)
1277
+
1278
+
1279
+ _NOTHING_YET = (
1280
+ "NOTHING TO RESCUE YET: the run has not finished and no attempt was killed or failed."
1281
+ )
1282
+
1283
+ # docs/DESIGN.md 4.11 (field milestone F, FB3): the first line of `rescue plan` text on a
1284
+ # possibly-live run, one of three, by who may still be writing.
1285
+ _NOTHING_YET_LIVE = (
1286
+ "NOTHING TO RESCUE YET: the run may still be running; no attempt has been killed or "
1287
+ "failed so far."
1288
+ )
1289
+
1290
+ _LIVE_OTHER = (
1291
+ "POSSIBLY LIVE: the run may still be running, and another agent may be writing the files "
1292
+ "listed below. Do not modify them until it has stopped; confirm with the human."
1293
+ )
1294
+
1295
+ _LIVE_OWN = (
1296
+ "YOUR CURRENT SESSION: this run is the session you are working in. Check the files listed "
1297
+ "below yourself before relaunching the killed work."
1298
+ )
1299
+
1300
+
1301
+ def _is_callers_session(run: Run, caller_session: str | None) -> bool:
1302
+ """FB3: a SESSION run whose transcript stem equals the caller's own session id (a
1303
+ canonical UUID, compared in lowercase). A workflow run is never the caller's session,
1304
+ even one its session launched: its workers may still be writing."""
1305
+ return (
1306
+ caller_session is not None
1307
+ and run.kind == "session"
1308
+ and run.path.stem.lower() == caller_session.lower()
1309
+ )
1310
+
1311
+
1312
+ def render_plan_text(
1313
+ run: Run, plan: RescuePlan, redactor: Redactor, *, evidence: bool = False,
1314
+ caller_session: str | None = None,
1315
+ ) -> str:
1316
+ _register_plan_names(run, redactor, detailed=True)
1317
+ _register_plan_paths(plan, redactor, run)
1318
+ view = report.EvidenceView(run, redactor, show_excerpts=evidence)
1319
+
1320
+ blocks: list[str] = []
1321
+ if evidence and view.note() is not None:
1322
+ blocks.append(view.note())
1323
+ if plan.possibly_live and plan.units:
1324
+ # FB3: who may still be writing the listed files decides the wording.
1325
+ blocks.append(_LIVE_OWN if _is_callers_session(run, caller_session) else _LIVE_OTHER)
1326
+ if plan.empty_state == NO_UNIT_UNESTABLISHED:
1327
+ blocks.append(redactor(_no_unit_sentence(plan.run_id, plan.return_breakdown)))
1328
+ return "\n".join(blocks) + "\n"
1329
+ if plan.nothing_to_rescue:
1330
+ # docs/DESIGN.md 4.11: "every attempt completed" is a claim about a
1331
+ # finished run. A possibly-live run with no qualifying unit yet is a
1332
+ # different, honest claim -- nothing has needed rescuing SO FAR, not
1333
+ # that everything is done -- so it gets its own sentence (FB3), never
1334
+ # the cheerful one and never a do-not-modify warning with no file to protect.
1335
+ if plan.possibly_live:
1336
+ blocks.append(_NOTHING_YET_LIVE)
1337
+ else:
1338
+ if plan.nothing_to_rescue_ok:
1339
+ blocks.append("NOTHING TO RESCUE: every attempt completed.")
1340
+ else:
1341
+ # F6 hardening: an unfinished run (or one with an attempt
1342
+ # that is neither completed nor killed/failed/superseded)
1343
+ # has no qualifying unit YET, not because it is done.
1344
+ blocks.append(_NOTHING_YET)
1345
+ return "\n".join(blocks) + "\n"
1346
+
1347
+ if plan.unchecked_attempts is not None:
1348
+ # N8 fix 1, S2: no unit, but the risk population is not empty or not complete
1349
+ if plan.possibly_live:
1350
+ # unreachable from the CLI (an unchecked population needs a finished run); a
1351
+ # hand-built plan keeps the warning rather than printing nothing
1352
+ blocks.append(_LIVE_OTHER)
1353
+ else:
1354
+ blocks.append(redactor(_unchecked_sentence(plan.unchecked_attempts, plan.run_id)))
1355
+ return "\n".join(blocks) + "\n"
1356
+
1357
+ for unit in plan.units:
1358
+ blocks.append(_render_unit_text(unit, plan, redactor, view))
1359
+
1360
+ if not plan.possibly_live:
1361
+ blocks.append(_brief_text(plan, redactor))
1362
+
1363
+ return "\n".join(blocks) + "\n"
1364
+
1365
+
1366
+ # --- JSON rendering -----------------------------------------------------
1367
+
1368
+
1369
+ def _export_path_row(row: PathRow, redactor: Redactor, view: "report.EvidenceView") -> dict:
1370
+ """docs/DESIGN.md 5.3b: observation state and ledger facts in separate
1371
+ fields. `group` is the index of the path's group in the plan's `groups`
1372
+ array (None when no group lists the path); `observation` is the path's own
1373
+ observation, null when its group was refused, unknown, or not checked (N5c-1);
1374
+ `ledger` is the list of this path's ledger entries in attempt order, `[]`
1375
+ when none names it."""
1376
+ return {
1377
+ "path": redactor(row.display_key),
1378
+ "group": row.group,
1379
+ "observation": (
1380
+ {
1381
+ "state": row.state,
1382
+ "reason": redactor(row.state_reason) if row.state_reason else None,
1383
+ }
1384
+ if row.observation is not None else None
1385
+ ),
1386
+ "ledger": [
1387
+ {
1388
+ "attempt": ordinal,
1389
+ "effect": entry.effect,
1390
+ "basis": entry.basis,
1391
+ "completion": entry.completion,
1392
+ "line": view.ledger_line(entry.line),
1393
+ }
1394
+ for ordinal, entry in row.ledger
1395
+ ],
1396
+ "attempts": row.attempt_ordinals,
1397
+ "later_write_observed": row.later_write,
1398
+ }
1399
+
1400
+
1401
+ def _export_reported(reported: dict | None, redactor: Redactor) -> dict | None:
1402
+ if reported is None:
1403
+ return None
1404
+ if reported.get("type") == "text":
1405
+ return {"type": "text", "text_length": reported.get("text_length")}
1406
+ if reported.get("type") == "dict":
1407
+ claims = reported.get("claims") or {}
1408
+ exported_claims = {}
1409
+ for key, value in claims.items():
1410
+ if key == "report_path" and isinstance(value, str):
1411
+ exported_claims[key] = redactor.names.path(value)
1412
+ elif key == "status":
1413
+ # F7 hardening: same "status: set" treatment as the text
1414
+ # renderer's _format_claim_value.
1415
+ exported_claims[key] = value if not redactor.enabled else "set"
1416
+ else:
1417
+ # a number, a bool, or a list of commit ids: every string in it is scanned
1418
+ exported_claims[key] = report.redact_value(
1419
+ report.number_claim_commits({key: value}, redactor)[key], redactor,
1420
+ )
1421
+ return {"type": "dict", "claims": exported_claims}
1422
+ return None
1423
+
1424
+
1425
+ def _export_unit(
1426
+ unit: UnitPlan, plan: RescuePlan, redactor: Redactor, view: "report.EvidenceView",
1427
+ ) -> dict:
1428
+ task = None
1429
+ if unit.task is not None:
1430
+ # docs/DESIGN.md 5.3b: the pointer follows the `line` convention (null
1431
+ # for an attempt with no canonical agent id at share); `text` exists
1432
+ # only at local and raw with --evidence, structurally redacted there.
1433
+ task = {
1434
+ "pointer": view.pointer(unit.task.transcript_name, unit.task.line_no),
1435
+ "length": unit.task.length,
1436
+ }
1437
+ if view.show_excerpts:
1438
+ task["text"] = redactor(unit.task.text[:300])
1439
+ unknown = [_UNKNOWN_SENTENCE]
1440
+ if not plan.checked and plan.observation_reason:
1441
+ unknown.append(redactor(plan.observation_reason))
1442
+ if unit.facts_unknown:
1443
+ unknown.append("UNKNOWN facts: transcript missing")
1444
+ if unit.unknown_group and plan.checked:
1445
+ unknown.append(_UNKNOWN_GROUP_SENTENCE)
1446
+
1447
+ step_paths, _note = _step_paths(unit, plan)
1448
+ path_str = " ".join(redactor(row.display_key) for row in step_paths) if step_paths else None
1449
+ # docs/DESIGN.md 4.11 (K4.1): both look commands are always non-null strings, the
1450
+ # whole-tree forms when no path is named.
1451
+ steps = [
1452
+ {"grade": "look", "text": "git status", "command":
1453
+ f"git status --short -- {path_str}" if path_str else "git status --short"},
1454
+ {"grade": "look", "text": "git diff", "command":
1455
+ f"git diff -- {path_str}" if path_str else "git diff"},
1456
+ ]
1457
+ # docs/DESIGN.md 4.11 (LIVE rule): steps 3-6 (decide/commit/relaunch) are
1458
+ # never offered for a possibly-live run, in JSON any more than in text.
1459
+ if not plan.possibly_live:
1460
+ if unit.declared_checks:
1461
+ check_command = "; ".join(
1462
+ redactor.check_text(check) for check in unit.declared_checks
1463
+ )
1464
+ step3_text = _run_check_text(unit, redactor)
1465
+ else:
1466
+ check_command = None
1467
+ step3_text = "declare a check first"
1468
+ step3_command = check_command if check_command else None
1469
+ steps.extend([
1470
+ {"grade": "decide", "text": step3_text, "command": step3_command},
1471
+ {"grade": "decide", "text": "keep, fix, or set aside each dirty path. A passing "
1472
+ "check is evidence, not a decision.", "command": None},
1473
+ {"grade": "decide", "text": "commit, after reading the diff", "command": None},
1474
+ {"grade": "decide", "text": "relaunch the remaining work", "command": None},
1475
+ ])
1476
+
1477
+ return {
1478
+ "agent": redactor.names.agent(unit.label),
1479
+ "phase": redactor.names.phase(unit.phase) if unit.phase else None,
1480
+ "attempts": unit.attempts_count,
1481
+ "end": unit.last_end_word,
1482
+ "task": task,
1483
+ "reported": _export_reported(unit.reported, redactor),
1484
+ "paths": [_export_path_row(row, redactor, view) for row in unit.paths],
1485
+ "commits": [redactor.names.commit(commit) for commit in unit.commits],
1486
+ "in_flight": [
1487
+ {
1488
+ "tool": _piece(redactor, entry.tool),
1489
+ "path": redactor(entry.display_key) if entry.display_key else None,
1490
+ }
1491
+ for entry in unit.in_flight
1492
+ ],
1493
+ "unknown": unknown,
1494
+ "checks": [redactor.check_text(check) for check in unit.declared_checks],
1495
+ "steps": steps,
1496
+ }
1497
+
1498
+
1499
+ def plan_to_json(
1500
+ run: Run, plan: RescuePlan, redactor: Redactor, *, evidence: bool = False,
1501
+ ) -> dict:
1502
+ _register_plan_names(run, redactor, detailed=True)
1503
+ _register_plan_paths(plan, redactor, run)
1504
+ view = report.EvidenceView(run, redactor, show_excerpts=evidence)
1505
+
1506
+ killed = [
1507
+ {
1508
+ "agent": redactor.names.agent(row["label"]),
1509
+ "ordinal": row["ordinal"],
1510
+ "time": _piece(redactor, row["time"]),
1511
+ "silence_seconds": row["silence_seconds"],
1512
+ }
1513
+ for row in plan.killed_brief
1514
+ ]
1515
+ failed = [
1516
+ {
1517
+ "agent": redactor.names.agent(row["label"]),
1518
+ "cause": _piece(redactor, row["cause"]),
1519
+ "retry_now": _piece(redactor, row["retry_now"]),
1520
+ "reset_time": _piece(redactor, row["reset_time"]),
1521
+ }
1522
+ for row in plan.failed_brief
1523
+ ]
1524
+ # numbers go in order of first appearance in the output: units come first, then the brief
1525
+ units = [_export_unit(unit, plan, redactor, view) for unit in plan.units]
1526
+ brief = {
1527
+ "killed": killed,
1528
+ "failed": failed,
1529
+ "already_exists": {
1530
+ "paths": [redactor(path) for path in plan.already_exists_paths],
1531
+ "commits": [redactor.names.commit(commit) for commit in plan.already_exists_commits],
1532
+ },
1533
+ }
1534
+
1535
+ return {
1536
+ "schema": SCHEMA_VERSION,
1537
+ "disclosure": redactor.level,
1538
+ "run": {
1539
+ "run_id": plan.run_id, "kind": plan.kind, "finished": plan.finished,
1540
+ "possibly_live": plan.possibly_live,
1541
+ },
1542
+ "nothing_to_rescue": plan.nothing_to_rescue,
1543
+ "empty_state": plan.empty_state,
1544
+ "return_breakdown": plan.return_breakdown,
1545
+ "groups": (
1546
+ report.export_observation_groups(plan.observation, redactor, omit_empty_unknown=True)
1547
+ if plan.observation is not None else []
1548
+ ),
1549
+ "units": units,
1550
+ "brief": (
1551
+ brief if (not plan.possibly_live and plan.units) else None
1552
+ ),
1553
+ "exit_code": plan.exit_code,
1554
+ }
1555
+
1556
+
1557
+ # =============================================================================
1558
+ # rescue status: what was observed (docs/DESIGN.md 4.11, proposal 8.3)
1559
+ # =============================================================================
1560
+
1561
+
1562
+ @dataclass
1563
+ class NotCountedRow:
1564
+ line_no: int
1565
+ reason: str
1566
+ excerpt: str
1567
+ category: str = "command" # fixed text from code; never transcript bytes
1568
+
1569
+
1570
+ @dataclass
1571
+ class CountedRun:
1572
+ line_no: int
1573
+ ts: datetime | None
1574
+ command: str
1575
+ completion: str # 'completed_ok' | 'completed_error'
1576
+
1577
+
1578
+ @dataclass
1579
+ class NeverRow:
1580
+ line_no: int
1581
+ excerpt: str
1582
+ category: str = "command" # the matched rule's own spelling, e.g. "git checkout"
1583
+
1584
+
1585
+ @dataclass
1586
+ class UnitStatus:
1587
+ key: str
1588
+ label: str
1589
+ checks_declared: int
1590
+ last_counted: CountedRun | None
1591
+ word: str # EXIT_0_OBSERVED | NONZERO_OBSERVED | NOT_OBSERVED
1592
+ writes_after: int
1593
+ cycles: int
1594
+ # docs/DESIGN.md 4.11 (F3 hardening): a NEVER-list command was observed
1595
+ # anywhere in the relevant transcript window. Kept separate from `word`
1596
+ # itself (never_matches is transcript-wide, not attributable to one
1597
+ # unit) so equality checks against the plain word ("NONZERO_OBSERVED",
1598
+ # etc.) elsewhere are unaffected; a renderer appends "NEVER_COMMAND_
1599
+ # OBSERVED" to the displayed word when this is true.
1600
+ never_command_observed: bool = False
1601
+ # docs/DESIGN.md 4.11 (N5c-2): the declared repository this row is about (None for a
1602
+ # unit no declared repository contains).
1603
+ repo: str | None = None
1604
+
1605
+
1606
+ @dataclass
1607
+ class RescueStatus:
1608
+ run_id: str
1609
+ units: list[UnitStatus]
1610
+ not_counted: list[NotCountedRow]
1611
+ never_matches: list[NeverRow]
1612
+ transcript_unknown: bool
1613
+ checked: bool
1614
+ repo_root: str | None
1615
+ exit_code: int = 0
1616
+ listed_paths: tuple[str, ...] = () # display keys of every unit's candidate paths
1617
+ # docs/DESIGN.md 4.11 (K4.3): with no unit, true only for a finished run whose every attempt
1618
+ # completed (the one predicate `rescue plan` uses).
1619
+ nothing_to_rescue_ok: bool = False
1620
+ # N8 fix 1, S2: see RescuePlan.unchecked_attempts.
1621
+ unchecked_attempts: int | None = None
1622
+ # docs/DESIGN.md 4.11 (R1): see RescuePlan.empty_state
1623
+ empty_state: str | None = None
1624
+ return_breakdown: dict | None = None
1625
+
1626
+
1627
+ def _never_list_match(command: str) -> bool:
1628
+ return bool(_NEVER_LIST_RE.search(command))
1629
+
1630
+
1631
+ def _never_category(command: str) -> str:
1632
+ """The matched NEVER rule's own canonical spelling (`git checkout`),
1633
+ built from the rule's fixed vocabulary, never from the transcript's
1634
+ bytes (N3b C1). A command that matches no rule gets the fixed word
1635
+ `command`."""
1636
+ match = _NEVER_LIST_RE.search(command)
1637
+ if match is None:
1638
+ return "command"
1639
+ return f"git {match.group(1)}"
1640
+
1641
+
1642
+ def _matched_check(command: str, declared_checks: list[str]) -> str | None:
1643
+ """The declared check this command is, or merely contains: an exact (trailing
1644
+ whitespace stripped) match first, else the first one it contains."""
1645
+ stripped = command.rstrip()
1646
+ if stripped in declared_checks:
1647
+ return stripped
1648
+ for check in declared_checks:
1649
+ if check in command:
1650
+ return check
1651
+ return None
1652
+
1653
+
1654
+ def _classify_bash_call(call, declared_checks: list[str], repo_root: str | None):
1655
+ """(matched_check, reason) for one Bash call already known to be after
1656
+ run.ended_at: reason is None when it counts, else one of 'other cwd',
1657
+ 'compound command', 'backgrounded', 'no result' -- docs/DESIGN.md 4.11's
1658
+ exact vocabulary. matched_check is the declared check string this call's
1659
+ command contains, or None when it contains none of them at all (the call
1660
+ is then irrelevant and never listed). `repo_root` is the declared repository
1661
+ the checks belong to: a call counts only at its exact working directory."""
1662
+ command = call.command
1663
+ if not command:
1664
+ return None, None
1665
+ matched = _matched_check(command, declared_checks)
1666
+ if matched is None:
1667
+ return None, None
1668
+
1669
+ # docs/DESIGN.md 4.2/4.11 (F5 hardening): a call whose tool_use_id was
1670
+ # duplicated in the transcript can never be trusted as a genuine check
1671
+ # run -- neither its exit state nor which result truly belongs to it is
1672
+ # known -- so it is never counted, regardless of what its own command
1673
+ # text says.
1674
+ if call.completion == "ambiguous":
1675
+ return matched, "ambiguous tool id"
1676
+ if call.completion == "no_result":
1677
+ return matched, "no result"
1678
+ if call.completion == "backgrounded":
1679
+ return matched, "backgrounded"
1680
+ normalized_cwd = os.path.normpath(call.cwd) if call.cwd else None
1681
+ normalized_root = os.path.normpath(repo_root) if repo_root else None
1682
+ if normalized_cwd != normalized_root:
1683
+ return matched, "other cwd"
1684
+ if command.rstrip() != matched:
1685
+ return matched, "compound command"
1686
+ return matched, None
1687
+
1688
+
1689
+ _WRITE_SHELL_TOOLS = ("Bash", "Monitor")
1690
+ _WRITE_FILE_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
1691
+
1692
+
1693
+ def _file_tool_path(call) -> str | None:
1694
+ """The target a file-tool call named: MultiEdit keeps it in `multiedit_path`
1695
+ (4.13.1), the other file tools in `path`."""
1696
+ path = call.multiedit_path if call.tool_name == "MultiEdit" else call.path
1697
+ return path if isinstance(path, str) and path else None
1698
+
1699
+
1700
+ def _shell_cwd(call) -> str | None:
1701
+ """The working directory a shell call's later segments run in (4.13.1, L9): the call's
1702
+ own absolute cwd, replaced by a literal absolute leading `cd DIR &&`; None when it is
1703
+ missing, relative, or changed by any other shape (an opaque, unrecorded, or later cd)."""
1704
+ if not call.command:
1705
+ return None
1706
+ lexed = commands.lex(call.command)
1707
+ if not lexed.placed or not lexed.segments:
1708
+ return None
1709
+ bases = commands.segment_bases(lexed, call.cwd)
1710
+ start = 1 if commands.cd_operand(lexed.segments[0]) is not None else 0
1711
+ rest = bases[start:] or bases[:1]
1712
+ if any(base is None for base in rest):
1713
+ return None
1714
+ return rest[0]
1715
+
1716
+
1717
+ def _write_order(call) -> tuple:
1718
+ return (call.ts or _MIN_TS, call.line_no)
1719
+
1720
+
1721
+ def _file_write_key(call) -> str:
1722
+ """The absolute, normalized spelling of a file-tool call's target, or its own text when
1723
+ nothing places it."""
1724
+ path = _file_tool_path(call) or ""
1725
+ if path.startswith("/"):
1726
+ return commands.normalize_path(path)
1727
+ if isinstance(call.cwd, str) and call.cwd.startswith("/"):
1728
+ return commands.normalize_path(os.path.join(call.cwd, path))
1729
+ return path
1730
+
1731
+
1732
+ def build_status(run: Run, cfg: config_module.Config, observation) -> RescueStatus:
1733
+ parent_path = reader._parent_transcript_path(run)
1734
+ transcript_unknown = False
1735
+ tool_calls: list = []
1736
+ if parent_path is not None and parent_path.is_file():
1737
+ main_attempt, _version, _sid, diagnostics, incomplete, _ts = reader._parse_main_attempt(
1738
+ parent_path
1739
+ )
1740
+ tool_calls = list(main_attempt.tool_calls)
1741
+ transcript_unknown = bool(diagnostics) or incomplete
1742
+ else:
1743
+ transcript_unknown = True
1744
+
1745
+ ended_at = run.ended_at
1746
+ relevant_calls = [
1747
+ call for call in tool_calls
1748
+ if ended_at is None or (call.ts is not None and call.ts > ended_at)
1749
+ ]
1750
+
1751
+ # docs/DESIGN.md 4.11 (N5c-2, plan S7): everything below is per (unit, declared
1752
+ # repository). The roots are the configuration's own, lexical, never from git.
1753
+ roots = declared_roots(cfg)
1754
+ units = qualifying_units(run)
1755
+ unit_rows = [] # (unit, listed spellings, target -> owning roots, repos)
1756
+ relevant_roots: list[str] = []
1757
+ for unit in units:
1758
+ specs = _row_specs(unit, observation)
1759
+ owners = _target_owners(specs, roots)
1760
+ repos = _unit_repos(owners)
1761
+ for root in repos:
1762
+ if root not in relevant_roots:
1763
+ relevant_roots.append(root)
1764
+ unit_rows.append((unit, _listed_spellings(unit), owners, repos))
1765
+
1766
+ counted: dict[str, list[CountedRun]] = {root: [] for root in relevant_roots}
1767
+ counted_call_ids: set[int] = set()
1768
+ not_counted: list[NotCountedRow] = []
1769
+ never_matches: list[NeverRow] = []
1770
+
1771
+ for call in relevant_calls:
1772
+ if call.tool_name != "Bash" or not call.command:
1773
+ continue
1774
+ if _never_list_match(call.command):
1775
+ never_matches.append(NeverRow(
1776
+ line_no=call.line_no, excerpt=call.command[:400],
1777
+ category=_never_category(call.command),
1778
+ ))
1779
+ counted_somewhere = False
1780
+ first_reason = None
1781
+ for root in relevant_roots:
1782
+ matched, reason = _classify_bash_call(call, roots[root], root)
1783
+ if matched is None:
1784
+ continue
1785
+ if reason is None:
1786
+ counted[root].append(CountedRun(
1787
+ line_no=call.line_no, ts=call.ts, command=matched,
1788
+ completion=call.completion,
1789
+ ))
1790
+ counted_somewhere = True
1791
+ counted_call_ids.add(id(call))
1792
+ elif first_reason is None:
1793
+ first_reason = reason
1794
+ if not counted_somewhere and first_reason is not None:
1795
+ not_counted.append(NotCountedRow(
1796
+ line_no=call.line_no, reason=first_reason, excerpt=call.command[:400],
1797
+ category=_never_category(call.command),
1798
+ ))
1799
+
1800
+ for root in relevant_roots:
1801
+ counted[root].sort(key=lambda c: (c.ts or _MIN_TS, c.line_no))
1802
+
1803
+ # docs/DESIGN.md 4.11 (F3 hardening, N5c-2): a shell call that is not proven read-only
1804
+ # is a possible write. It belongs to the one declared repository that owns its working
1805
+ # directory (after a literal absolute leading cd); a call whose directory is missing,
1806
+ # unknown, or owned by no declared root may have written in every repository of the run
1807
+ # (fail closed). Only a call counted as a declared check for some repository is skipped
1808
+ # (N5 fix 1, C1): a call that merely contains a check string but was rejected (other
1809
+ # cwd, compound, backgrounded, no result, ambiguous id) is a possible write like any other.
1810
+ shell_writes: list[tuple[tuple, object, frozenset[str]]] = []
1811
+ for call in relevant_calls:
1812
+ if call.tool_name not in _WRITE_SHELL_TOOLS:
1813
+ continue
1814
+ if id(call) in counted_call_ids:
1815
+ continue
1816
+ if call.command and _proven_read_only(call.command):
1817
+ continue
1818
+ cwd = _shell_cwd(call)
1819
+ owner = _owner(cwd, roots) if cwd is not None else None
1820
+ affected = frozenset({owner}) if owner is not None else frozenset(relevant_roots)
1821
+ shell_writes.append((_write_order(call), call, affected))
1822
+
1823
+ file_writes = [
1824
+ call for call in relevant_calls
1825
+ if call.tool_name in _WRITE_FILE_TOOLS and call.completion == "completed_ok"
1826
+ and _file_tool_path(call)
1827
+ ]
1828
+
1829
+ flagged_shell_calls: set[int] = set()
1830
+ unit_statuses: list[UnitStatus] = []
1831
+ all_listed: list[str] = []
1832
+ global_cwd = observe._discover_cwd(run) if observation.checked else None
1833
+ for unit, spellings, owners, repos in unit_rows:
1834
+ listed_keys = {
1835
+ _display_key(raw, global_cwd, observation.repo_root) if observation.checked else raw
1836
+ for raw in spellings
1837
+ }
1838
+ all_listed.extend(key for key in listed_keys if key not in all_listed)
1839
+
1840
+ for repo in (repos or [None]):
1841
+ counted_here = counted.get(repo, []) if repo is not None else []
1842
+ last_counted = counted_here[-1] if counted_here else None
1843
+ if last_counted is None:
1844
+ word = "NOT_OBSERVED"
1845
+ elif last_counted.completion == "completed_ok":
1846
+ word = "EXIT_0_OBSERVED"
1847
+ else:
1848
+ word = "NONZERO_OBSERVED"
1849
+
1850
+ writes: list[tuple] = []
1851
+ shell_after: list[object] = []
1852
+ if repo is not None:
1853
+ for call in file_writes:
1854
+ if repo in owners.get(_file_write_key(call), ()):
1855
+ writes.append(_write_order(call))
1856
+ for order_key, call, affected in shell_writes:
1857
+ if repo in affected:
1858
+ writes.append(order_key)
1859
+ shell_after.append((order_key, call))
1860
+
1861
+ writes_after = 0
1862
+ if last_counted is not None:
1863
+ last_order = (last_counted.ts or _MIN_TS, last_counted.line_no)
1864
+ writes_after = sum(1 for key in writes if key > last_order)
1865
+ for order_key, call in shell_after:
1866
+ if order_key > last_order:
1867
+ flagged_shell_calls.add(id(call))
1868
+
1869
+ # cycles: a counted run preceded, since the previous counted run, by at least one
1870
+ # write this repository owns.
1871
+ timeline = [((c.ts or _MIN_TS, c.line_no), "check") for c in counted_here]
1872
+ timeline.extend((key, "write") for key in writes)
1873
+ timeline.sort(key=lambda item: (item[0], item[1] == "check"))
1874
+ cycles = 0
1875
+ write_since_last_check = False
1876
+ for _key, kind in timeline:
1877
+ if kind == "write":
1878
+ write_since_last_check = True
1879
+ else:
1880
+ if write_since_last_check:
1881
+ cycles += 1
1882
+ write_since_last_check = False
1883
+
1884
+ unit_statuses.append(UnitStatus(
1885
+ key=unit.key, label=unit.label,
1886
+ checks_declared=len(roots[repo]) if repo is not None else 0,
1887
+ last_counted=last_counted, word=word, writes_after=writes_after,
1888
+ cycles=cycles, never_command_observed=bool(never_matches), repo=repo,
1889
+ ))
1890
+
1891
+ listed_lines = {row.line_no for row in not_counted}
1892
+ for _order_key, call, _affected in shell_writes:
1893
+ if id(call) in flagged_shell_calls and call.line_no not in listed_lines:
1894
+ command = call.command or ""
1895
+ not_counted.append(NotCountedRow(
1896
+ line_no=call.line_no,
1897
+ reason="a Bash command that may write ran after the check",
1898
+ excerpt=command[:400],
1899
+ category=_never_category(command),
1900
+ ))
1901
+
1902
+ # docs/DESIGN.md 4.11, 4.13.11 (N5c-2), the first condition that applies wins: 3 for
1903
+ # any failed check, ahead of every 4 condition (a NEVER-list match, an unknown
1904
+ # lifecycle, a transcript that could not be read, a check not observed, a write after
1905
+ # a check); else 0.
1906
+ # K4.3: no unit at all exits 0 only for a finished run whose every attempt completed.
1907
+ empty_state, empty_count = empty_unit_state(run) if not unit_statuses else ("yet", 0)
1908
+ status_state, status_breakdown = no_unit_state(run) if not unit_statuses else (None, None)
1909
+ empty_not_ok = not unit_statuses and (
1910
+ empty_state != "ok" or status_state == NO_UNIT_UNESTABLISHED
1911
+ )
1912
+ if any(u.word == "NONZERO_OBSERVED" for u in unit_statuses):
1913
+ exit_code = 3
1914
+ elif (
1915
+ transcript_unknown or never_matches or _lifecycle_unknown(run) or empty_not_ok
1916
+ or any(u.word == "NOT_OBSERVED" or u.writes_after > 0 for u in unit_statuses)
1917
+ ):
1918
+ exit_code = 4
1919
+ else:
1920
+ exit_code = 0
1921
+
1922
+ return RescueStatus(
1923
+ run_id=run.run_id, units=unit_statuses, not_counted=not_counted,
1924
+ never_matches=never_matches, transcript_unknown=transcript_unknown,
1925
+ checked=observation.checked, repo_root=observation.repo_root,
1926
+ exit_code=exit_code, listed_paths=tuple(all_listed),
1927
+ nothing_to_rescue_ok=empty_state == "ok",
1928
+ unchecked_attempts=empty_count if empty_state == "risk" else None,
1929
+ empty_state=status_state,
1930
+ return_breakdown=status_breakdown,
1931
+ )
1932
+
1933
+
1934
+ _FRESHNESS_SENTENCE = (
1935
+ "Writes made outside this session after the check cannot be seen; "
1936
+ "freshness beyond the transcript is UNKNOWN."
1937
+ )
1938
+
1939
+
1940
+ def _display_word(unit: UnitStatus) -> str:
1941
+ """docs/DESIGN.md 4.11 (F3 hardening): the plain word (EXIT_0_OBSERVED /
1942
+ NONZERO_OBSERVED / NOT_OBSERVED) with NEVER_COMMAND_OBSERVED appended
1943
+ when a NEVER-list command was observed -- appended, never substituted,
1944
+ so both signals stay visible on the same line."""
1945
+ if unit.never_command_observed:
1946
+ return f"{unit.word} NEVER_COMMAND_OBSERVED"
1947
+ return unit.word
1948
+
1949
+
1950
+ def _unit_status_lines(
1951
+ unit: UnitStatus, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
1952
+ ) -> list[str]:
1953
+ agent = redactor.names.agent(unit.label)
1954
+ repo_part = f" repository {redactor.names.path(unit.repo)}" if unit.repo is not None else ""
1955
+ lines = [f"UNIT {agent}{repo_part} checks declared: {unit.checks_declared}"]
1956
+ display_word = _display_word(unit)
1957
+ if unit.last_counted is not None:
1958
+ exit_word = "0" if unit.last_counted.completion == "completed_ok" else "non-zero"
1959
+ time_str = (
1960
+ unit.last_counted.ts.astimezone(timezone.utc).strftime("%H:%MZ")
1961
+ if unit.last_counted.ts else "unknown"
1962
+ )
1963
+ lines.append(
1964
+ f" last counted run: {redactor.check_text(unit.last_counted.command)} "
1965
+ f"exit {exit_word} at {time_str} -> {display_word}"
1966
+ )
1967
+ else:
1968
+ lines.append(f" last counted run: none -> {display_word}")
1969
+ write_suffix = " -> WRITE_AFTER_CHECK" if unit.writes_after > 0 else ""
1970
+ lines.append(f" writes to listed paths after that run: {unit.writes_after}{write_suffix}")
1971
+ lines.append(f" check cycles observed: {unit.cycles} of 2")
1972
+ for row in status.not_counted:
1973
+ lines.append(f" not counted: line {row.line_no}: {row.reason}")
1974
+ for row in status.never_matches:
1975
+ if evidence and redactor.level != "share":
1976
+ # D-o: v0.2's --no-redact line: the paths replaced the way the
1977
+ # PATHS table spells them at this level, the first 60 characters.
1978
+ excerpt = _pseudonymize_excerpt(row.excerpt, status, redactor)[:60]
1979
+ else:
1980
+ excerpt = row.category
1981
+ lines.append(f" commands matching the NEVER list: line {row.line_no}: {excerpt}")
1982
+ return lines
1983
+
1984
+
1985
+ def _pseudonymize_excerpt(excerpt: str, status: RescueStatus, redactor: Redactor) -> str:
1986
+ """A command excerpt as `rescue status --evidence` prints it: every listed path, in its
1987
+ display (repo-relative) and absolute spellings, registered in the active path namespace so it
1988
+ prints as the path-N the PATHS table uses (at local the P19 display), and the excerpt then
1989
+ goes through the one free-text scanner (docs/DESIGN.md 5.1). No raw substring replacement:
1990
+ the scanner renders the complete shell word that holds a listed path, so a continuation
1991
+ after an escaped space or a quote is never left behind, and a prefix-related or a second
1992
+ listed path is found independently."""
1993
+ if not redactor.enabled:
1994
+ return excerpt
1995
+ for display in status.listed_paths:
1996
+ redactor.names.path(display)
1997
+ if status.repo_root:
1998
+ redactor.names.alias_path(os.path.join(status.repo_root, display), display)
1999
+ return redactor(excerpt)
2000
+
2001
+
2002
+ def _register_status_paths(run: Run, status: RescueStatus, redactor: Redactor) -> None:
2003
+ """The same path-N namespace `_register_plan_paths` fills for `rescue
2004
+ plan` (docs/DESIGN.md 5.1), so a declared check's own text -- shown
2005
+ verbatim on the 'last counted run' line -- is redacted consistently with
2006
+ the PATHS table `rescue plan` already prints for the same paths."""
2007
+ global_cwd = observe._discover_cwd(run) if status.checked else None
2008
+ cwd = observe._discover_cwd(run)
2009
+ for row in status.units:
2010
+ if row.repo is not None:
2011
+ redactor.names.path(row.repo)
2012
+ for unit in qualifying_units(run):
2013
+ for raw_path in _listed_spellings(unit):
2014
+ if status.checked:
2015
+ display = _display_key(raw_path, global_cwd, status.repo_root)
2016
+ else:
2017
+ display = raw_path
2018
+ redactor.names.path(display, cwd, resolve_from=raw_path)
2019
+
2020
+
2021
+ def render_status_text(
2022
+ run: Run, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
2023
+ ) -> str:
2024
+ report._register_names(run, redactor, detailed=True)
2025
+ _register_status_paths(run, status, redactor)
2026
+ blocks: list[str] = []
2027
+ if evidence and redactor.level == "share":
2028
+ blocks.append(report.EXCERPT_NOTE)
2029
+ if not status.units:
2030
+ if status.empty_state == NO_UNIT_UNESTABLISHED:
2031
+ blocks.append(redactor(_no_unit_sentence(status.run_id, status.return_breakdown)))
2032
+ elif status.unchecked_attempts is not None:
2033
+ blocks.append(_unchecked_sentence(status.unchecked_attempts, status.run_id))
2034
+ elif status.nothing_to_rescue_ok:
2035
+ blocks.append("NOTHING TO RESCUE: every attempt completed.")
2036
+ else:
2037
+ blocks.append(_NOTHING_YET)
2038
+ else:
2039
+ for unit in status.units:
2040
+ blocks.append("\n".join(_unit_status_lines(unit, status, redactor, evidence=evidence)))
2041
+ blocks.append(_FRESHNESS_SENTENCE)
2042
+ return "\n".join(blocks) + "\n"
2043
+
2044
+
2045
+ def status_to_json(
2046
+ run: Run, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
2047
+ ) -> dict:
2048
+ report._register_names(run, redactor, detailed=True)
2049
+ _register_status_paths(run, status, redactor)
2050
+ units = []
2051
+ for unit in status.units:
2052
+ last = unit.last_counted
2053
+ units.append({
2054
+ "agent": redactor.names.agent(unit.label),
2055
+ "repo": redactor.names.path(unit.repo) if unit.repo is not None else None,
2056
+ "checks_declared": unit.checks_declared,
2057
+ "last_counted_run": None if last is None else {
2058
+ "command": redactor.check_text(last.command),
2059
+ "completion": last.completion,
2060
+ "at": last.ts.isoformat() if last.ts else None,
2061
+ },
2062
+ "word": _display_word(unit),
2063
+ "writes_after": unit.writes_after,
2064
+ "write_after_check": unit.writes_after > 0,
2065
+ "cycles": unit.cycles,
2066
+ })
2067
+ # docs/DESIGN.md 5.3b: `category` is the matched rule's fixed spelling (or
2068
+ # the word `command`), never transcript text; the quoted excerpt exists
2069
+ # only at local and raw with --evidence, the same 60 characters the text
2070
+ # shows there.
2071
+ quote = evidence and redactor.level != "share"
2072
+
2073
+ def excerpt_part(row) -> dict:
2074
+ if not quote:
2075
+ return {}
2076
+ return {"excerpt": _pseudonymize_excerpt(row.excerpt, status, redactor)[:60]}
2077
+
2078
+ return {
2079
+ "schema": SCHEMA_VERSION,
2080
+ "disclosure": redactor.level,
2081
+ "run": {"run_id": status.run_id},
2082
+ "units": units,
2083
+ "not_counted": [
2084
+ {"line": row.line_no, "reason": row.reason, "category": row.category,
2085
+ **excerpt_part(row)}
2086
+ for row in status.not_counted
2087
+ ],
2088
+ "never_matches": [
2089
+ {"line": row.line_no, "category": row.category, **excerpt_part(row)}
2090
+ for row in status.never_matches
2091
+ ],
2092
+ "transcript_unknown": status.transcript_unknown,
2093
+ "empty_state": status.empty_state,
2094
+ "return_breakdown": status.return_breakdown,
2095
+ "exit_code": status.exit_code,
2096
+ }