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,792 @@
1
+ """Read-only repo observation of paths a killed or failed attempt wrote.
2
+
3
+ docs/DESIGN.md 4.9. observe_run(run) looks at the attempts of one Run whose
4
+ outcome is 'killed', 'failed', or 'superseded' and, for every path one of
5
+ them wrote or left in flight, asks git (and only git, and only five exact
6
+ argument lists) whether that path is dirty, clean, or missing right now.
7
+
8
+ It never writes anything: no index update, no stash, no object, no config
9
+ change, no repo-controlled process. Standard library only: subprocess, os,
10
+ re, shutil, dataclasses, datetime.
11
+
12
+ This module is not called by the reader, a detector, or a hook in this
13
+ unit. It is wired into `postmortem run` only behind the `--observe` flag
14
+ (cli.py), so it is testable end to end; a guided-rescue command that calls
15
+ it directly is a later unit.
16
+
17
+ Every guard below exists because the repository being observed is not
18
+ trusted: it can configure a clean/process filter that runs an arbitrary
19
+ program on `git status`, or simply not exist, or not even be a git
20
+ repository at all. Rule of thumb for anyone touching this file: if a change
21
+ would make git run something the repo's own configuration chose, it is
22
+ wrong -- the five argument lists below are the only ones this module is
23
+ ever allowed to run, and no path derived from a repository (a filter name,
24
+ a config value) may become part of an eventual command.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ from dataclasses import dataclass, field
34
+ from datetime import datetime, timedelta, timezone
35
+
36
+ from . import ledger
37
+ from .models import Attempt, Run
38
+
39
+ # --- public states (docs/DESIGN.md 4.9) -------------------------------------
40
+
41
+ DIRTY_NOW = "DIRTY_NOW"
42
+ CLEAN_NOW = "CLEAN_NOW"
43
+ MISSING_NOW = "MISSING_NOW"
44
+ UNKNOWN = "UNKNOWN"
45
+
46
+ # Outcomes whose paths_written/in_flight are candidates for observation
47
+ # (docs/DESIGN.md 4.9 rule 2).
48
+ _CANDIDATE_OUTCOMES = frozenset({"killed", "failed", "superseded"})
49
+
50
+ # The exact regex argument to the filter-driver guard (rule 4/5): matches any
51
+ # git config key of the form filter.<name>.clean or filter.<name>.process,
52
+ # the two hooks `git status`/`git add` can invoke.
53
+ _FILTER_CONFIG_PATTERN = r"^filter\..*\.(clean|process)$"
54
+
55
+ # The `--show-scope` scopes whose filter drivers the person installed for
56
+ # themselves (rule 5): `git lfs install` writes Git LFS's drivers to the global
57
+ # config, a system package to the system config. Such a driver is switched off
58
+ # for the one `status` call (`_neutralize_filters`), never run. Every other
59
+ # scope (`local`, `worktree`, a file either of those includes, `command`,
60
+ # `unknown`) is one the observed repository can steer, so a driver there still
61
+ # refuses `status`.
62
+ _PERSON_FILTER_SCOPES = frozenset({"system", "global"})
63
+
64
+ # A key `_filter_drivers` accepts, capturing the driver name. A name outside
65
+ # this character set refuses `status` instead of being switched off: git splits
66
+ # a `-c` argument at its first `=`, and a subsection name may hold spaces and
67
+ # other characters this module would have to quote.
68
+ _FILTER_KEY_RE = re.compile(r"^filter\.([A-Za-z0-9][A-Za-z0-9_.-]*)\.(clean|process)$")
69
+
70
+ _GIT_VERSION_RE = re.compile(r"git version (\d+)\.(\d+)(?:\.(\d+))?")
71
+ _MIN_GIT_VERSION = (2, 36, 0)
72
+
73
+ _STALE_AFTER = timedelta(hours=72)
74
+
75
+ _SUBPROCESS_TIMEOUT = 10
76
+
77
+ # The closed list of group-level reasons (docs/DESIGN.md 4.9, N5c-1; plan S6).
78
+ REASON_REFUSED = "refused"
79
+ REASON_HISTORICAL = "historical"
80
+ REASON_NOT_GIT = "not a git repository"
81
+ REASON_GIT_UNAVAILABLE = "git unavailable"
82
+ REASON_GUARD = "guard refused"
83
+ REASON_OUTSIDE = "outside the work tree"
84
+ GROUP_REASONS = (
85
+ REASON_REFUSED, REASON_HISTORICAL, REASON_NOT_GIT, REASON_GIT_UNAVAILABLE, REASON_GUARD,
86
+ REASON_OUTSIDE,
87
+ )
88
+
89
+
90
+ @dataclass
91
+ class PathObservation:
92
+ """One candidate path's observed git state (docs/DESIGN.md 4.9).
93
+
94
+ state is one of DIRTY_NOW / CLEAN_NOW / MISSING_NOW / UNKNOWN. reason is
95
+ set only for a path this module refused to ask git about at all (outside
96
+ the work tree) -- never for one it actually asked git and got an answer
97
+ for. attempts holds, for every attempt (of the run this observation came
98
+ from) that wrote or left this path in flight, {'agent_id', 'ordinal'},
99
+ in the order those attempts were found.
100
+ """
101
+
102
+ state: str
103
+ later_write_observed: bool
104
+ attempts: list[dict] = field(default_factory=list)
105
+ reason: str | None = None
106
+
107
+
108
+ @dataclass
109
+ class RepoObservation:
110
+ """The result of observe_run() (docs/DESIGN.md 4.9).
111
+
112
+ checked is False whenever no git process was ever started (the run is
113
+ stale, there were no candidate paths, the cwd could not be resolved, or
114
+ a guard refused); reason then names why. paths is keyed by the path
115
+ relative to repo_root for a path git was actually asked about, or by the
116
+ original candidate path string for one refused as outside the work tree
117
+ -- the same string a renderer shows under --no-redact and pseudonymizes
118
+ as path-N otherwise.
119
+ """
120
+
121
+ checked: bool
122
+ reason: str | None = None
123
+ repo_root: str | None = None
124
+ head: str | None = None
125
+ git_version: str | None = None
126
+ paths: dict[str, PathObservation] = field(default_factory=dict)
127
+
128
+
129
+ def _not_checked(
130
+ reason: str, *, git_version: str | None = None, repo_root: str | None = None,
131
+ head: str | None = None,
132
+ ) -> RepoObservation:
133
+ return RepoObservation(
134
+ checked=False, reason=reason, repo_root=repo_root, head=head,
135
+ git_version=git_version, paths={},
136
+ )
137
+
138
+
139
+ # --- facts plumbing (defensive: a fact can be the literal string 'UNKNOWN') --
140
+
141
+
142
+ def _facts_lists(facts) -> tuple[list, list]:
143
+ """(paths_written, in_flight) as real lists, or ([], []) when this
144
+ attempt has no facts or the reader could not read its transcript
145
+ (models.FACTS_UNKNOWN is a string, never iterate it as if it were a
146
+ list -- that would yield one candidate per character)."""
147
+ if facts is None:
148
+ return [], []
149
+ written = facts.paths_written if isinstance(facts.paths_written, list) else []
150
+ in_flight = facts.in_flight if isinstance(facts.in_flight, list) else []
151
+ return written, in_flight
152
+
153
+
154
+ def _in_flight_paths(in_flight: list) -> list[str]:
155
+ paths = []
156
+ for entry in in_flight:
157
+ if isinstance(entry, dict):
158
+ path = entry.get("path")
159
+ if isinstance(path, str) and path:
160
+ paths.append(path)
161
+ return paths
162
+
163
+
164
+ def _attempt_has_path(attempt, raw_path: str) -> bool:
165
+ written, in_flight = _facts_lists(attempt.facts)
166
+ if raw_path in written:
167
+ return True
168
+ return raw_path in _in_flight_paths(in_flight)
169
+
170
+
171
+ def _candidate_paths(run: Run) -> tuple[list[str], dict[str, list]]:
172
+ """The union of paths_written and in_flight[].path over every attempt
173
+ whose outcome is killed/failed/superseded (rule 2), in first-seen
174
+ order, plus which attempt objects wrote each one (also first-seen
175
+ order, and each attempt listed at most once per path even when it
176
+ appears in both paths_written and in_flight)."""
177
+ order: list[str] = []
178
+ writers: dict[str, list] = {}
179
+ for attempt in run.attempts:
180
+ if attempt.outcome not in _CANDIDATE_OUTCOMES:
181
+ continue
182
+ written, in_flight = _facts_lists(attempt.facts)
183
+ seen_this_attempt: set[str] = set()
184
+ for raw_path in list(written) + _in_flight_paths(in_flight):
185
+ if raw_path in seen_this_attempt:
186
+ continue
187
+ seen_this_attempt.add(raw_path)
188
+ if raw_path not in writers:
189
+ writers[raw_path] = []
190
+ order.append(raw_path)
191
+ writers[raw_path].append(attempt)
192
+ return order, writers
193
+
194
+
195
+ def _discover_cwd(run: Run) -> str | None:
196
+ """The most common cwd among the candidate-outcome attempts' own
197
+ facts.cwds, ties broken by first-seen order (rule 3). max() over a list
198
+ built in first-seen order already breaks ties this way: it only
199
+ replaces its current pick on a strictly greater count, so the first cwd
200
+ to reach the eventual maximum keeps it."""
201
+ counts: dict[str, int] = {}
202
+ order: list[str] = []
203
+ for attempt in run.attempts:
204
+ if attempt.outcome not in _CANDIDATE_OUTCOMES:
205
+ continue
206
+ facts = attempt.facts
207
+ cwds = facts.cwds if facts is not None and isinstance(facts.cwds, list) else []
208
+ for cwd in cwds:
209
+ if not isinstance(cwd, str) or not cwd:
210
+ continue
211
+ if cwd not in counts:
212
+ counts[cwd] = 0
213
+ order.append(cwd)
214
+ counts[cwd] += 1
215
+ if not order:
216
+ return None
217
+ return max(order, key=lambda cwd: counts[cwd])
218
+
219
+
220
+ def _later_write_observed(run: Run, writer_attempts: list, raw_path: str) -> bool:
221
+ """True when an attempt of the same key with a higher ordinal than one
222
+ of writer_attempts also wrote raw_path (rule 8)."""
223
+ for writer in writer_attempts:
224
+ if writer.key is None:
225
+ continue
226
+ for other in run.attempts:
227
+ if other.key == writer.key and other.ordinal > writer.ordinal:
228
+ if _attempt_has_path(other, raw_path):
229
+ return True
230
+ return False
231
+
232
+
233
+ # --- the exact, from-scratch environment (rule 4) ----------------------------
234
+
235
+
236
+ def _build_env() -> dict[str, str]:
237
+ """An environment built from scratch (rule 4): PATH and HOME copied from
238
+ the current process, a fixed set of git-behavior variables, and nothing
239
+ else -- no GIT_DIR, no GIT_WORK_TREE, no GIT_CONFIG_*, no
240
+ GIT_EXTERNAL_DIFF, none of which this process happens to have set, but
241
+ none of which git should ever be handed even if it did.
242
+ `GIT_ALLOW_PROTOCOL=` (no transport protocol allowed, overriding any
243
+ `protocol.*.allow` in the repository's config) and `GIT_NO_LAZY_FETCH=1`
244
+ stop `status` in a partial clone from fetching a missing object through a
245
+ transport the repository configured, such as `ext::<command>`."""
246
+ env: dict[str, str] = {}
247
+ path = os.environ.get("PATH")
248
+ if path is not None:
249
+ env["PATH"] = path
250
+ home = os.environ.get("HOME")
251
+ if home is not None:
252
+ env["HOME"] = home
253
+ env["GIT_OPTIONAL_LOCKS"] = "0"
254
+ env["GIT_PAGER"] = "cat"
255
+ env["PAGER"] = "cat"
256
+ env["GIT_TERMINAL_PROMPT"] = "0"
257
+ env["LC_ALL"] = "C"
258
+ env["GIT_ALLOW_PROTOCOL"] = ""
259
+ env["GIT_NO_LAZY_FETCH"] = "1"
260
+ return env
261
+
262
+
263
+ def _run_git(
264
+ git_path: str, args: list[str], env: dict[str, str],
265
+ ) -> subprocess.CompletedProcess | None:
266
+ """Run exactly `[git_path, *args]` with the restricted environment,
267
+ never a shell, never inheriting a cwd of its own (callers pass `-C
268
+ <dir>` instead). `git_path` is always the one absolute path
269
+ `observe_run` resolved once with `shutil.which("git")` -- never the
270
+ bare string "git" -- so every one of this module's five calls runs the
271
+ exact same binary rather than each doing its own separate PATH
272
+ resolution (docs/DESIGN.md 4.9 rule 4). Any failure to even start the
273
+ process, or a timeout, comes back as None so the caller turns it into
274
+ `checked=False` with its own reason rather than letting an exception
275
+ escape observe_run."""
276
+ try:
277
+ return subprocess.run(
278
+ [git_path, *args],
279
+ env=env,
280
+ stdin=subprocess.DEVNULL,
281
+ capture_output=True,
282
+ timeout=_SUBPROCESS_TIMEOUT,
283
+ encoding="utf-8",
284
+ errors="replace",
285
+ )
286
+ except (OSError, subprocess.TimeoutExpired, ValueError):
287
+ return None
288
+
289
+
290
+ def _filter_drivers(
291
+ git_path: str, cwd: str, env: dict[str, str],
292
+ ) -> tuple[bool | None, tuple[str, ...]]:
293
+ """Rule 5, guard 4. Returns (repo_driver, person_drivers). repo_driver is
294
+ None when the check itself failed; True when `status` must not run: a clean
295
+ or process filter driver comes from a scope the repository can steer, or a
296
+ line is not `<scope>\\t<key> <value>` with a key `_FILTER_KEY_RE` accepts;
297
+ False otherwise (exit 1, git's "no such key" for --get-regexp, means no
298
+ driver at all). person_drivers, given only with False, names each driver
299
+ from the person's own system or global config, sorted; `status` runs with
300
+ every one of them switched off, so no driver ever runs. A multi-line
301
+ value's continuation line never parses, so it can only add a refusal,
302
+ never hide the line its own scope opens with."""
303
+ result = _run_git(
304
+ git_path,
305
+ ["-C", cwd, "config", "--show-scope", "--get-regexp", _FILTER_CONFIG_PATTERN],
306
+ env,
307
+ )
308
+ if result is None:
309
+ return None, ()
310
+ if result.returncode == 1:
311
+ return False, ()
312
+ if result.returncode != 0:
313
+ return None, ()
314
+ # git ends each record with "\n" only, so split on it alone (str.splitlines
315
+ # would also split a value at U+2028 and other Unicode breaks) and drop only
316
+ # the final empty piece: an empty line inside the output is a multi-line
317
+ # value's continuation, which refuses like any other line that does not
318
+ # parse.
319
+ lines = result.stdout.split("\n")
320
+ if lines[-1] == "":
321
+ lines.pop()
322
+ if not lines:
323
+ return None, ()
324
+ names: set[str] = set()
325
+ for line in lines:
326
+ scope, tab, rest = line.partition("\t")
327
+ match = _FILTER_KEY_RE.match(rest.split(" ", 1)[0])
328
+ if not tab or scope not in _PERSON_FILTER_SCOPES or match is None:
329
+ return True, ()
330
+ names.add(match.group(1))
331
+ return False, tuple(sorted(names))
332
+
333
+
334
+ def _neutralize_filters(names: tuple[str, ...]) -> list[str]:
335
+ """The `-c` arguments that switch off each named driver for one `status`
336
+ call: an empty clean and process command, and `required=false` so git does
337
+ not stop on the missing driver. A file routed through one is then compared
338
+ as stored, without running anything (for Git LFS, a file whose timestamp
339
+ changed reads dirty even when its content did not)."""
340
+ args: list[str] = []
341
+ for name in names:
342
+ args += [
343
+ "-c", f"filter.{name}.clean=",
344
+ "-c", f"filter.{name}.process=",
345
+ "-c", f"filter.{name}.required=false",
346
+ ]
347
+ return args
348
+
349
+
350
+ def _parse_git_version(version_text: str | None) -> tuple[int, int, int] | None:
351
+ if not version_text:
352
+ return None
353
+ match = _GIT_VERSION_RE.search(version_text)
354
+ if not match:
355
+ return None
356
+ major, minor, patch = match.group(1), match.group(2), match.group(3)
357
+ return (int(major), int(minor), int(patch) if patch is not None else 0)
358
+
359
+
360
+ def _parse_porcelain_v2(raw: str) -> set[str]:
361
+ """The set of repo-relative paths git reports a status entry for, from
362
+ `status --porcelain=v2 -z` output (rule 7).
363
+
364
+ Every record is one or more NUL-separated chunks. Type '1' (ordinary,
365
+ staged or not) and 'u' (unmerged) are one chunk each; '2'
366
+ (rename/copy -- never produced here, --no-renames is always passed, but
367
+ parsed correctly anyway) is two chunks, path then origPath, and only the
368
+ new path is dirty; '?' (untracked) and '!' (ignored) are one chunk with
369
+ no numeric fields at all. The path is taken as the remainder after the
370
+ fixed number of space-separated fields so an embedded space in a real
371
+ path is never split apart.
372
+ """
373
+ chunks = [chunk for chunk in raw.split("\0") if chunk]
374
+ dirty: set[str] = set()
375
+ i = 0
376
+ n = len(chunks)
377
+ while i < n:
378
+ chunk = chunks[i]
379
+ type_char = chunk[0]
380
+ if type_char == "1":
381
+ parts = chunk.split(" ", 8)
382
+ dirty.add(parts[-1])
383
+ i += 1
384
+ elif type_char == "u":
385
+ parts = chunk.split(" ", 10)
386
+ dirty.add(parts[-1])
387
+ i += 1
388
+ elif type_char == "2":
389
+ parts = chunk.split(" ", 9)
390
+ dirty.add(parts[-1])
391
+ i += 2 # skip the origPath chunk that follows
392
+ elif type_char in ("?", "!"):
393
+ dirty.add(chunk[2:])
394
+ i += 1
395
+ else:
396
+ i += 1
397
+ return dirty
398
+
399
+
400
+ def observe_run(run: Run, now: datetime | None = None) -> RepoObservation:
401
+ """docs/DESIGN.md 4.9. Read-only. Starts a git process only after every
402
+ prior guard has passed, and only ever the five argument lists rule 4
403
+ lists -- never a subcommand a repository could steer. No exception ever
404
+ escapes this function: every failure mode becomes checked=False with a
405
+ reason naming the step."""
406
+ now = now if now is not None else datetime.now(timezone.utc)
407
+
408
+ # Rule 1: staleness. No process started at all for a run that ended
409
+ # more than 72 hours ago -- the working tree has long since moved on.
410
+ if run.ended_at is not None and (now - run.ended_at) > _STALE_AFTER:
411
+ return _not_checked("historical: run older than 72 hours")
412
+
413
+ # Rule 2: candidate paths.
414
+ order, writers = _candidate_paths(run)
415
+ if not order:
416
+ return _not_checked("no paths written by a killed or failed attempt")
417
+
418
+ # Rule 3: repo discovery.
419
+ cwd = _discover_cwd(run)
420
+ if cwd is None or not os.path.isdir(cwd):
421
+ return _not_checked("cwd not found")
422
+
423
+ # Rule 5, guard 1: git on PATH. Resolved to one absolute path here and
424
+ # reused for every call below (docs/DESIGN.md 4.9 rule 4) -- never a
425
+ # bare "git" that would let each of the five calls do its own,
426
+ # independently timed PATH search.
427
+ git_path = shutil.which("git")
428
+ if git_path is None:
429
+ return _not_checked("git not found on PATH")
430
+
431
+ env = _build_env()
432
+
433
+ # Rule 5, guard 2: git 2.36 or newer. An unparsable --version output (a
434
+ # failed process, a timeout, unexpected text) counts as too old -- there
435
+ # is no version we can trust it to be.
436
+ version_result = _run_git(git_path, ["--version"], env)
437
+ git_version = (
438
+ version_result.stdout.strip()
439
+ if version_result is not None and version_result.returncode == 0
440
+ else None
441
+ )
442
+ parsed_version = _parse_git_version(git_version)
443
+ if parsed_version is None or parsed_version < _MIN_GIT_VERSION:
444
+ return _not_checked("git is older than 2.36, or its version could not be read",
445
+ git_version=git_version)
446
+
447
+ # Rule 5, guard 3: this is actually a git work tree.
448
+ toplevel_result = _run_git(git_path, ["-C", cwd, "rev-parse", "--show-toplevel"], env)
449
+ if toplevel_result is None or toplevel_result.returncode != 0:
450
+ return _not_checked("cwd is not inside a git repository", git_version=git_version)
451
+ toplevel = toplevel_result.stdout.strip()
452
+ if not toplevel:
453
+ return _not_checked("cwd is not inside a git repository", git_version=git_version)
454
+
455
+ head_result = _run_git(git_path, ["-C", cwd, "rev-parse", "HEAD"], env)
456
+ if head_result is None or head_result.returncode != 0:
457
+ return _not_checked(
458
+ "git rev-parse HEAD failed", git_version=git_version, repo_root=toplevel,
459
+ )
460
+ head = head_result.stdout.strip()
461
+
462
+ # Rule 6: containment. A path outside the work tree (after being made
463
+ # absolute against cwd and normalized) is UNKNOWN and never reaches git.
464
+ paths: dict[str, PathObservation] = {}
465
+ contained: list[tuple[str, str]] = [] # (raw_path, repo-relative path)
466
+ prefix = toplevel.rstrip(os.sep) + os.sep
467
+ for raw_path in order:
468
+ writer_attempts = writers[raw_path]
469
+ later = _later_write_observed(run, writer_attempts, raw_path)
470
+ attempts = [
471
+ {"agent_id": attempt.agent_id, "ordinal": attempt.ordinal}
472
+ for attempt in writer_attempts
473
+ ]
474
+
475
+ abs_path = raw_path if os.path.isabs(raw_path) else os.path.join(cwd, raw_path)
476
+ norm = os.path.normpath(abs_path)
477
+ if not norm.startswith(prefix):
478
+ paths[raw_path] = PathObservation(
479
+ state=UNKNOWN, later_write_observed=later, attempts=attempts,
480
+ reason="outside the work tree",
481
+ )
482
+ continue
483
+ rel_path = norm[len(prefix):]
484
+ contained.append((raw_path, rel_path))
485
+ paths[rel_path] = PathObservation(
486
+ state=UNKNOWN, later_write_observed=later, attempts=attempts,
487
+ )
488
+
489
+ if contained:
490
+ # Rule 5, guard 4: no clean/process filter driver configured at a
491
+ # scope the repository can steer; a driver from the person's own
492
+ # system or global config, such as Git LFS's, is switched off for
493
+ # `status` instead of run. Run only when `status` is actually about
494
+ # to run, and as the call immediately before it -- no other
495
+ # subprocess call in between -- so the window in which a
496
+ # repository-controlled process could install a filter driver
497
+ # between this guard and `status` is as small as this module can
498
+ # make it without a stronger isolation primitive than the standard
499
+ # library offers (the remaining race is documented in
500
+ # docs/DESIGN.md 4.9 as a known limit).
501
+ repo_driver, person_drivers = _filter_drivers(git_path, cwd, env)
502
+ if repo_driver is None:
503
+ return _not_checked(
504
+ "git config --show-scope --get-regexp failed", git_version=git_version,
505
+ repo_root=toplevel, head=head,
506
+ )
507
+ if repo_driver:
508
+ return _not_checked(
509
+ "a clean or process filter driver is configured for this repository",
510
+ git_version=git_version, repo_root=toplevel, head=head,
511
+ )
512
+
513
+ rel_paths = [rel for _, rel in contained]
514
+ status_result = _run_git(
515
+ git_path,
516
+ [
517
+ "-C", cwd, "--no-optional-locks", "-c", "core.fsmonitor=false",
518
+ *_neutralize_filters(person_drivers),
519
+ "status", "--porcelain=v2", "-z", "--no-renames",
520
+ "--ignore-submodules=all", "--", *rel_paths,
521
+ ],
522
+ env,
523
+ )
524
+ if status_result is None or status_result.returncode != 0:
525
+ return _not_checked(
526
+ "git status failed", git_version=git_version, repo_root=toplevel, head=head,
527
+ )
528
+ dirty = _parse_porcelain_v2(status_result.stdout)
529
+ for _raw_path, rel_path in contained:
530
+ observation = paths[rel_path]
531
+ if rel_path in dirty:
532
+ observation.state = DIRTY_NOW
533
+ else:
534
+ exists = os.path.exists(os.path.join(toplevel, rel_path))
535
+ observation.state = CLEAN_NOW if exists else MISSING_NOW
536
+
537
+ return RepoObservation(
538
+ checked=True, reason=None, repo_root=toplevel, head=head,
539
+ git_version=git_version, paths=paths,
540
+ )
541
+
542
+
543
+ # =============================================================================
544
+ # Per-repository observation (docs/DESIGN.md 4.9, N5c-1; plan S5 and S6)
545
+ # =============================================================================
546
+
547
+ _PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
548
+
549
+
550
+ @dataclass
551
+ class GroupObservation:
552
+ """One repository group of a run (docs/DESIGN.md 4.9, N5c-1). `repo` is the
553
+ candidate repository (None for the unknown group); `refused` is true for a
554
+ group a partial ledger contributes to and for the unknown group; `checked`
555
+ is true only when `git status` actually ran. `reason` is one of GROUP_REASONS
556
+ for a group that was not checked, else None. `repo_root`, `head`, and
557
+ `git_version` are internal (never exported). `paths` holds a PathObservation
558
+ per observed path (the absolute ledger path), only for a checked group;
559
+ `unobserved_paths` is every ledger path no process looked at. `attempts` is
560
+ the internal list of attempt references (`main`, `attempts[<i>]`)."""
561
+
562
+ repo: str | None
563
+ refused: bool
564
+ checked: bool
565
+ reason: str | None
566
+ repo_root: str | None = None
567
+ head: str | None = None
568
+ git_version: str | None = None
569
+ paths: dict[str, PathObservation] = field(default_factory=dict)
570
+ unobserved_paths: list[str] = field(default_factory=list)
571
+ attempts: list[str] = field(default_factory=list)
572
+
573
+
574
+ @dataclass
575
+ class RunObservation:
576
+ """The result of observe_groups(): the ordered group list, named
577
+ repositories in sorted candidate order, then the unknown group."""
578
+
579
+ groups: list[GroupObservation] = field(default_factory=list)
580
+
581
+ @property
582
+ def checked(self) -> bool:
583
+ return any(group.checked for group in self.groups)
584
+
585
+ @property
586
+ def repo_root(self) -> str | None:
587
+ """The first checked group's toplevel: the one repository root the
588
+ disclosure context knows at `local` (docs/DESIGN.md 4.13.5, D-n)."""
589
+ for group in self.groups:
590
+ if group.checked and group.repo_root:
591
+ return group.repo_root
592
+ return None
593
+
594
+
595
+ def _entry_paths(attempt: Attempt) -> set[str]:
596
+ built = attempt.ledger
597
+ if built is None:
598
+ return set()
599
+ return {
600
+ entry.path for entry in built.entries
601
+ if entry.path is not None and entry.effect in _PATH_EFFECTS
602
+ }
603
+
604
+
605
+ def _later_write(run: Run, writers: list[Attempt], path: str) -> bool:
606
+ """True when an attempt of the same key with a higher ordinal than one of the
607
+ path's writers also recorded the path (rule 8, on the ledger)."""
608
+ for writer in writers:
609
+ if writer.key is None:
610
+ continue
611
+ for other in run.attempts:
612
+ if (
613
+ other.key == writer.key and other.ordinal > writer.ordinal
614
+ and path in _entry_paths(other)
615
+ ):
616
+ return True
617
+ return False
618
+
619
+
620
+ def _path_observation(
621
+ run: Run, members: list[Attempt], path: str, state: str,
622
+ ) -> PathObservation:
623
+ writers = [attempt for attempt in members if path in _entry_paths(attempt)]
624
+ attempts = [
625
+ {
626
+ "agent_id": "main" if attempt is run.main else attempt.agent_id,
627
+ "ordinal": attempt.ordinal,
628
+ }
629
+ for attempt in writers
630
+ ]
631
+ return PathObservation(
632
+ state=state, later_write_observed=_later_write(run, writers, path), attempts=attempts,
633
+ )
634
+
635
+
636
+ class _GitSession:
637
+ """The one absolute git path, the scrubbed environment, and the version
638
+ guard, resolved lazily and once for a whole run (docs/DESIGN.md 4.9 rules 4
639
+ and 5). `ready()` is None when git can be used, else the closed reason."""
640
+
641
+ def __init__(self) -> None:
642
+ self._resolved = False
643
+ self._reason: str | None = None
644
+ self.git_path: str | None = None
645
+ self.env: dict[str, str] = {}
646
+ self.version: str | None = None
647
+
648
+ def ready(self) -> str | None:
649
+ if self._resolved:
650
+ return self._reason
651
+ self._resolved = True
652
+ git_path = shutil.which("git")
653
+ if git_path is None:
654
+ self._reason = REASON_GIT_UNAVAILABLE
655
+ return self._reason
656
+ self.git_path = git_path
657
+ self.env = _build_env()
658
+ result = _run_git(git_path, ["--version"], self.env)
659
+ self.version = (
660
+ result.stdout.strip() if result is not None and result.returncode == 0 else None
661
+ )
662
+ parsed = _parse_git_version(self.version)
663
+ if parsed is None or parsed < _MIN_GIT_VERSION:
664
+ self._reason = REASON_GIT_UNAVAILABLE
665
+ return self._reason
666
+
667
+
668
+ def _unchecked(
669
+ repo: str | None, group, reason: str, *, refused: bool, session: "_GitSession | None" = None,
670
+ repo_root: str | None = None, head: str | None = None,
671
+ ) -> GroupObservation:
672
+ return GroupObservation(
673
+ repo=repo, refused=refused, checked=False, reason=reason, repo_root=repo_root,
674
+ head=head, git_version=session.version if session is not None else None,
675
+ paths={}, unobserved_paths=sorted(group.paths), attempts=list(group.attempts),
676
+ )
677
+
678
+
679
+ def _observe_named_group(
680
+ run: Run, repo: str, group, members: list[Attempt], session: _GitSession, stale: bool,
681
+ ) -> GroupObservation:
682
+ """The guarded 4.9 sequence for one named repository group that is not
683
+ refused. Every failure is an unchecked group with a closed reason; nothing
684
+ raises."""
685
+ if stale:
686
+ return _unchecked(repo, group, REASON_HISTORICAL, refused=False)
687
+ if not os.path.isdir(repo):
688
+ return _unchecked(repo, group, REASON_NOT_GIT, refused=False)
689
+ if session.ready() is not None:
690
+ return _unchecked(repo, group, REASON_GIT_UNAVAILABLE, refused=False)
691
+ git_path, env = session.git_path, session.env
692
+ assert git_path is not None
693
+ toplevel_result = _run_git(git_path, ["-C", repo, "rev-parse", "--show-toplevel"], env)
694
+ if toplevel_result is None or toplevel_result.returncode != 0:
695
+ return _unchecked(repo, group, REASON_NOT_GIT, refused=False, session=session)
696
+ toplevel = toplevel_result.stdout.strip()
697
+ if not toplevel:
698
+ return _unchecked(repo, group, REASON_NOT_GIT, refused=False, session=session)
699
+ head_result = _run_git(git_path, ["-C", repo, "rev-parse", "HEAD"], env)
700
+ if head_result is None or head_result.returncode != 0:
701
+ return _unchecked(
702
+ repo, group, REASON_GUARD, refused=False, session=session, repo_root=toplevel,
703
+ )
704
+ head = head_result.stdout.strip()
705
+
706
+ # Rule 6, containment: only a path inside the toplevel ever reaches git.
707
+ prefix = toplevel.rstrip(os.sep) + os.sep
708
+ contained: list[tuple[str, str]] = []
709
+ outside: list[str] = []
710
+ for path in sorted(group.paths):
711
+ norm = os.path.normpath(path)
712
+ if norm.startswith(prefix):
713
+ contained.append((path, norm[len(prefix):]))
714
+ else:
715
+ outside.append(path)
716
+ if not contained:
717
+ return _unchecked(
718
+ repo, group, REASON_OUTSIDE, refused=False, session=session,
719
+ repo_root=toplevel, head=head,
720
+ )
721
+
722
+ # The filter-driver guard is the call immediately before `status`; a
723
+ # driver from the person's own system or global config is switched off
724
+ # for `status`, never run.
725
+ repo_driver, person_drivers = _filter_drivers(git_path, repo, env)
726
+ if repo_driver is not False:
727
+ return _unchecked(
728
+ repo, group, REASON_GUARD, refused=False, session=session,
729
+ repo_root=toplevel, head=head,
730
+ )
731
+ status_result = _run_git(
732
+ git_path,
733
+ [
734
+ "-C", repo, "--no-optional-locks", "-c", "core.fsmonitor=false",
735
+ *_neutralize_filters(person_drivers), "status",
736
+ "--porcelain=v2", "-z", "--no-renames", "--ignore-submodules=all", "--",
737
+ *[rel for _path, rel in contained],
738
+ ],
739
+ env,
740
+ )
741
+ if status_result is None or status_result.returncode != 0:
742
+ return _unchecked(
743
+ repo, group, REASON_GUARD, refused=False, session=session,
744
+ repo_root=toplevel, head=head,
745
+ )
746
+ dirty = _parse_porcelain_v2(status_result.stdout)
747
+ paths: dict[str, PathObservation] = {}
748
+ for path, rel in contained:
749
+ if rel in dirty:
750
+ state = DIRTY_NOW
751
+ else:
752
+ state = CLEAN_NOW if os.path.exists(os.path.join(toplevel, rel)) else MISSING_NOW
753
+ paths[path] = _path_observation(run, members, path, state)
754
+ return GroupObservation(
755
+ repo=repo, refused=False, checked=True, reason=None, repo_root=toplevel, head=head,
756
+ git_version=session.version, paths=paths, unobserved_paths=outside,
757
+ attempts=list(group.attempts),
758
+ )
759
+
760
+
761
+ def observe_groups(
762
+ run: Run, now: datetime | None = None, groups: "ledger.RepoGroups | None" = None,
763
+ ) -> RunObservation:
764
+ """docs/DESIGN.md 4.9 (N5c-1), plan S5 and S6. Read-only. The repository
765
+ groups are partitioned before any process starts (`ledger.group_by_repo`, a
766
+ pure function); a refused group and the unknown group start no process at
767
+ all; a group that is not refused runs the guarded sequence with only its own
768
+ ledger paths. No exception escapes: every failure is an unchecked group with
769
+ a reason from GROUP_REASONS."""
770
+ now = now if now is not None else datetime.now(timezone.utc)
771
+ grouped = groups if groups is not None else ledger.group_by_repo(run)
772
+ ended = run.ended_at
773
+ if ended is not None and ended.tzinfo is None:
774
+ ended = ended.replace(tzinfo=timezone.utc)
775
+ stale = ended is not None and (now - ended) > _STALE_AFTER
776
+
777
+ by_ref: dict[str, Attempt] = {}
778
+ for attempt in ([run.main] if run.main is not None else []) + list(run.attempts):
779
+ by_ref[ledger.attempt_refs(run)[id(attempt)]] = attempt
780
+ session = _GitSession()
781
+
782
+ result: list[GroupObservation] = []
783
+ for repo, group in grouped.repos.items():
784
+ if group.refused:
785
+ result.append(_unchecked(repo, group, REASON_REFUSED, refused=True))
786
+ continue
787
+ members = [by_ref[ref] for ref in group.attempts if ref in by_ref]
788
+ result.append(_observe_named_group(run, repo, group, members, session, stale))
789
+ unknown = grouped.unknown
790
+ if unknown.attempts or unknown.paths:
791
+ result.append(_unchecked(None, unknown, REASON_REFUSED, refused=True))
792
+ return RunObservation(result)