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,1543 @@
1
+ """Pure command-text classifier for Bash and Monitor calls (docs/DESIGN.md
2
+ 4.13.1, N4a). Standard library only: no filesystem access, no subprocess.
3
+
4
+ The transcript records a command's text, never the shell it ran in, so
5
+ nothing here can prove a command did not write. The grammars decide one
6
+ syntactic claim only: "no write is recognized in the recorded command".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Word:
19
+ text: str
20
+ raw: str
21
+ quoted: bool
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Redirect:
26
+ fd: str | None
27
+ op: str
28
+ target: str | None
29
+ target_quoted: bool
30
+ glued_before: bool
31
+ after_words: int
32
+ body: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Segment:
37
+ words: tuple[Word, ...]
38
+ redirects: tuple[Redirect, ...]
39
+ sep_after: str | None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Lexed:
44
+ segments: tuple[Segment, ...]
45
+ reasons: tuple[str, ...]
46
+ trailing_op: str | None
47
+ # FA6: every (reason, segment index) the lexer raised, the index being the
48
+ # segment under construction when it fired (a reason about the end of the
49
+ # command names the last segment). `reasons` stays the call-global answer;
50
+ # this lets one candidate segment be judged on its own (commit_command_ran).
51
+ reason_at: tuple[tuple[str, int], ...] = field(default=(), compare=False, repr=False)
52
+
53
+ @property
54
+ def placed(self) -> bool:
55
+ return not self.reasons
56
+
57
+
58
+ _FIRST_WORD_OPAQUE = frozenset(
59
+ {"{", "!", "function", "alias", "eval", "exec", "source", ".", "command",
60
+ "builtin", "time", "xargs", "env", "nohup"}
61
+ )
62
+ _ASSIGN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=")
63
+ _BRACE_RE = re.compile(r"\{[^{}]*(?:,|\.\.)[^{}]*\}")
64
+ _MESSAGE_RE = re.compile(
65
+ r"\$\([ \t]*cat[ \t]+<<(-?)(?:'([A-Za-z_][A-Za-z0-9_]*)'|\"([A-Za-z_][A-Za-z0-9_]*)\")[ \t]*\n"
66
+ )
67
+ _CLOSE_PAREN_RE = re.compile(r"[ \t\n]*\)")
68
+ _EXTGLOB_PREV = "+@!?*"
69
+ # Characters scan() treats specially in any word state; everything else is a
70
+ # plain word character once a word is active.
71
+ _PLAIN_RUN_RE = re.compile(r"[^ \t\n\\'\"$`*?\[()&;|<>]+")
72
+ _SPECIAL_CHARS = frozenset(" \t\n\\'\"$`*?[()&;|<>")
73
+ # Inside double quotes: a run with no quote, backslash, or substitution start.
74
+ _DQUOTE_RUN_RE = re.compile(r'[^"\\$`]+')
75
+
76
+
77
+ class _Lexer:
78
+ """One pass over the command. Opaque triggers are recorded as reasons and
79
+ scanning continues, so the words of an opaque command are still available
80
+ to the background and delegation rows."""
81
+
82
+ def __init__(self, command: str, commit_message_form: bool) -> None:
83
+ self.s = command
84
+ self.n = len(command)
85
+ self.i = 0
86
+ self.message_form = commit_message_form
87
+ self.reasons: list[str] = []
88
+ self.reason_at: list[tuple[str, int]] = []
89
+ self.segs: list[dict] = []
90
+ self.words: list[Word] = []
91
+ self.redirs: list[list] = []
92
+ self.heredocs: list[tuple[list, bool, bool]] = []
93
+ self.pending: list | None = None
94
+ self.trailing_op: str | None = None
95
+ self._reset_word()
96
+
97
+ # -- helpers ------------------------------------------------------------
98
+ def reason(self, name: str, index: int | None = None) -> None:
99
+ if name not in self.reasons:
100
+ self.reasons.append(name)
101
+ self.reason_at.append((name, len(self.segs) if index is None else index))
102
+
103
+ def _reset_word(self) -> None:
104
+ self.w_active = False
105
+ self.w_start = 0
106
+ # Pieces of the word: the text, and the same with every quoted or
107
+ # escaped character replaced by "Q" (what brace and extglob detection
108
+ # look at); w_last is the final character of the second.
109
+ self.w_text: list[str] = []
110
+ self.w_unq: list[str] = []
111
+ self.w_last = ""
112
+ self.w_quoted = False
113
+
114
+ def _begin_word(self) -> None:
115
+ if not self.w_active:
116
+ self.w_active = True
117
+ self.w_start = self.i
118
+
119
+ def _add(self, ch: str, quoted: bool) -> None:
120
+ self._begin_word()
121
+ self.w_text.append(ch)
122
+ last = "Q" if quoted else ch
123
+ self.w_unq.append(last)
124
+ self.w_last = last
125
+ if quoted:
126
+ self.w_quoted = True
127
+
128
+ def _add_quoted(self, text: str) -> None:
129
+ """Every character of text, quoted (the word is already active)."""
130
+ if text:
131
+ self.w_text.append(text)
132
+ self.w_unq.append("Q" * len(text))
133
+ self.w_last = "Q"
134
+
135
+ def flush_word(self) -> None:
136
+ if not self.w_active:
137
+ return
138
+ pieces = self.w_text
139
+ text = pieces[0] if len(pieces) == 1 else "".join(pieces)
140
+ word = Word(text, self.s[self.w_start:self.i], self.w_quoted)
141
+ if "{" in text and _BRACE_RE.search("".join(self.w_unq)):
142
+ self.reason("brace")
143
+ if self.pending is not None:
144
+ pending = self.pending
145
+ self.pending = None
146
+ pending[2] = word.text
147
+ pending[3] = word.quoted
148
+ op = pending[1]
149
+ if op in (">&", "<&"):
150
+ if not (op == ">&" and pending[0] == "2" and word.text == "1"):
151
+ self.reason("fd_redirect")
152
+ if op in ("<<", "<<-"):
153
+ self.heredocs.append((pending, op == "<<-", word.quoted))
154
+ else:
155
+ self.words.append(word)
156
+ self.w_active = False
157
+ self.w_text = []
158
+ self.w_unq = []
159
+ self.w_last = ""
160
+ self.w_quoted = False
161
+
162
+ def end_segment(self, op: str) -> None:
163
+ self.flush_word()
164
+ if self.pending is not None:
165
+ self.reason("missing_redirect_target")
166
+ self.pending = None
167
+ if self.words or self.redirs:
168
+ self.segs.append({"words": self.words, "redirs": self.redirs, "sep": op})
169
+ self.words = []
170
+ self.redirs = []
171
+ elif op != "\n":
172
+ self.reason("empty_segment")
173
+
174
+ # -- scanning -------------------------------------------------------------
175
+ def scan(self) -> None:
176
+ s, n = self.s, self.n
177
+ plain_run = _PLAIN_RUN_RE.match
178
+ special = _SPECIAL_CHARS
179
+ while self.i < n:
180
+ c = s[self.i]
181
+ if c in " \t":
182
+ self.flush_word()
183
+ self.i += 1
184
+ continue
185
+ if c not in special and (self.w_active or not (c == "~" or c == "#" or c.isdigit())):
186
+ # The common case first: a run of plain characters (every
187
+ # character not in _SPECIAL_CHARS; ~, # and a digit are plain
188
+ # inside a word and special only where a word starts).
189
+ chunk = plain_run(s, self.i).group()
190
+ if not self.w_active:
191
+ self.w_active = True
192
+ self.w_start = self.i
193
+ self.w_text.append(chunk)
194
+ self.w_unq.append(chunk)
195
+ self.w_last = chunk[-1]
196
+ self.i += len(chunk)
197
+ if self.i < n and s[self.i] in " \t":
198
+ # The blank after a word ends it: what the next pass does.
199
+ self.flush_word()
200
+ self.i += 1
201
+ continue
202
+ if c == "\n":
203
+ self.flush_word()
204
+ self.i += 1
205
+ if self.heredocs:
206
+ self._read_heredocs()
207
+ self.end_segment("\n")
208
+ elif c == "\\":
209
+ self._backslash()
210
+ elif c == "'":
211
+ self._squote()
212
+ elif c == '"':
213
+ self._dquote()
214
+ elif c == "$":
215
+ self.reason("expansion")
216
+ self._add(c, False)
217
+ self.i += 1
218
+ elif c == "`":
219
+ self.reason("command_substitution")
220
+ self._add(c, False)
221
+ self.i += 1
222
+ elif c in "*?[":
223
+ self.reason("glob")
224
+ self._add(c, False)
225
+ self.i += 1
226
+ elif c == "~" and not self.w_active:
227
+ self.reason("tilde")
228
+ self._add(c, False)
229
+ self.i += 1
230
+ elif c in "()":
231
+ if c == "(" and self.w_last and self.w_last in _EXTGLOB_PREV:
232
+ self.reason("extglob")
233
+ else:
234
+ self.reason("paren")
235
+ self._add(c, False)
236
+ self.i += 1
237
+ elif c == ";":
238
+ if s.startswith(";;", self.i):
239
+ self.reason("double_semicolon")
240
+ self.i += 1
241
+ self.i += 1
242
+ self.end_segment(";")
243
+ elif c == "&":
244
+ if s.startswith("&&", self.i):
245
+ self.i += 2
246
+ self.end_segment("&&")
247
+ elif s.startswith("&>", self.i):
248
+ self._redirect(None, self.w_active)
249
+ else:
250
+ self.i += 1
251
+ self.end_segment("&")
252
+ elif c == "|":
253
+ if s.startswith("||", self.i):
254
+ self.i += 2
255
+ self.end_segment("||")
256
+ elif s.startswith("|&", self.i):
257
+ self.i += 2
258
+ self.end_segment("|&")
259
+ else:
260
+ self.i += 1
261
+ self.end_segment("|")
262
+ elif c in "<>":
263
+ self._redirect(None, self.w_active)
264
+ elif c == "#" and not self.w_active:
265
+ nl = s.find("\n", self.i)
266
+ self.i = n if nl < 0 else nl
267
+ elif c.isdigit() and not self.w_active and self._fd_prefix():
268
+ pass
269
+ else:
270
+ # ~, # or a digit that did not start a special form: plain.
271
+ chunk = plain_run(s, self.i).group()
272
+ self._begin_word()
273
+ self.w_text.append(chunk)
274
+ self.w_unq.append(chunk)
275
+ self.w_last = chunk[-1]
276
+ self.i += len(chunk)
277
+ self._finish()
278
+
279
+ def _fd_prefix(self) -> bool:
280
+ j = self.i
281
+ while j < self.n and self.s[j].isdigit():
282
+ j += 1
283
+ if j < self.n and self.s[j] in "<>":
284
+ fd = self.s[self.i:j]
285
+ self.i = j
286
+ self._redirect(fd, False)
287
+ return True
288
+ return False
289
+
290
+ def _backslash(self) -> None:
291
+ s = self.s
292
+ if self.i + 1 >= self.n:
293
+ self._add("\\", False)
294
+ self.i += 1
295
+ return
296
+ nxt = s[self.i + 1]
297
+ if nxt == "\n":
298
+ self.i += 2
299
+ return
300
+ self._begin_word()
301
+ self._add(nxt, True)
302
+ if nxt in "*?[":
303
+ self.reason("glob")
304
+ elif nxt == "$":
305
+ self.reason("expansion")
306
+ elif nxt == "`":
307
+ self.reason("command_substitution")
308
+ self.i += 2
309
+
310
+ def _squote(self) -> None:
311
+ end = self.s.find("'", self.i + 1)
312
+ self._begin_word()
313
+ self.w_quoted = True
314
+ if end < 0:
315
+ self.reason("unbalanced_quote")
316
+ self.i = self.n
317
+ return
318
+ self._add_quoted(self.s[self.i + 1:end])
319
+ self.i = end + 1
320
+
321
+ def _dquote(self) -> None:
322
+ s, n = self.s, self.n
323
+ self._begin_word()
324
+ self.w_quoted = True
325
+ self.i += 1
326
+ run = _DQUOTE_RUN_RE.match
327
+ while self.i < n:
328
+ found = run(s, self.i)
329
+ if found is not None:
330
+ self._add_quoted(found.group())
331
+ self.i = found.end()
332
+ if self.i >= n:
333
+ break
334
+ c = s[self.i]
335
+ if c == '"':
336
+ self.i += 1
337
+ return
338
+ if c == "\\" and self.i + 1 < n and s[self.i + 1] in '$`"\\\n':
339
+ nxt = s[self.i + 1]
340
+ if nxt == "\n":
341
+ self.i += 2
342
+ continue
343
+ if nxt == "$":
344
+ self.reason("expansion")
345
+ elif nxt == "`":
346
+ self.reason("command_substitution")
347
+ self._add(nxt, True)
348
+ self.i += 2
349
+ elif c == "$":
350
+ if self.message_form and self._message_literal():
351
+ continue
352
+ self.reason("expansion")
353
+ self._add(c, True)
354
+ self.i += 1
355
+ elif c == "`":
356
+ self.reason("command_substitution")
357
+ self._add(c, True)
358
+ self.i += 1
359
+ else:
360
+ self._add(c, True)
361
+ self.i += 1
362
+ self.reason("unbalanced_quote")
363
+
364
+ def _message_literal(self) -> bool:
365
+ """The one literal substitution: $(cat <<'DELIM' ... DELIM )."""
366
+ s = self.s
367
+ m = _MESSAGE_RE.match(s, self.i)
368
+ if m is None:
369
+ return False
370
+ strip = m.group(1) == "-"
371
+ delim = m.group(2) or m.group(3)
372
+ pos = m.end()
373
+ body: list[str] = []
374
+ while pos <= self.n:
375
+ eol = s.find("\n", pos)
376
+ if eol < 0:
377
+ return False
378
+ line = s[pos:eol]
379
+ if (line.lstrip("\t") if strip else line) == delim:
380
+ close = _CLOSE_PAREN_RE.match(s, eol + 1)
381
+ if close is None:
382
+ return False
383
+ self._add_quoted("".join(body))
384
+ self.i = close.end()
385
+ return True
386
+ body.append(line + "\n")
387
+ pos = eol + 1
388
+ return False
389
+
390
+ def _redirect(self, fd: str | None, glued: bool) -> None:
391
+ s = self.s
392
+ if glued:
393
+ self.flush_word()
394
+ c = s[self.i]
395
+ nxt = s[self.i + 1:self.i + 2]
396
+ if c == "&":
397
+ op = "&>>" if s.startswith("&>>", self.i) else "&>"
398
+ elif c == ">":
399
+ if nxt == "(":
400
+ self.reason("process_substitution")
401
+ self.i += 2
402
+ return
403
+ op = {">": ">>", "&": ">&", "|": ">|"}.get(nxt, ">")
404
+ else:
405
+ if nxt == "(":
406
+ self.reason("process_substitution")
407
+ self.i += 2
408
+ return
409
+ if s.startswith("<<<", self.i):
410
+ self.reason("here_string")
411
+ op = "<<<"
412
+ elif s.startswith("<<-", self.i):
413
+ op = "<<-"
414
+ elif s.startswith("<<", self.i):
415
+ op = "<<"
416
+ else:
417
+ op = {"&": "<&", ">": "<>"}.get(nxt, "<")
418
+ self.i += len(op)
419
+ if self.pending is not None:
420
+ self.reason("missing_redirect_target")
421
+ # [fd, op, target, target_quoted, glued_before, after_words, body]
422
+ self.pending = [fd, op, None, False, glued, len(self.words), None]
423
+ self.redirs.append(self.pending)
424
+
425
+ def _read_heredocs(self) -> None:
426
+ s = self.s
427
+ for builder, strip, quoted in self.heredocs:
428
+ delim = builder[2]
429
+ body: list[str] = []
430
+ found = False
431
+ pos = self.i
432
+ while pos <= self.n:
433
+ eol = s.find("\n", pos)
434
+ end = self.n if eol < 0 else eol
435
+ line = s[pos:end]
436
+ if (line.lstrip("\t") if strip else line) == delim:
437
+ found = True
438
+ pos = self.n if eol < 0 else eol + 1
439
+ break
440
+ if eol < 0:
441
+ break
442
+ body.append(line + "\n")
443
+ pos = eol + 1
444
+ if not found:
445
+ self.reason("unclosed_heredoc")
446
+ self.i = self.n
447
+ self.heredocs = []
448
+ return
449
+ text = "".join(body)
450
+ builder[6] = text
451
+ if not quoted and ("$" in text or "`" in text):
452
+ self.reason("heredoc_expansion")
453
+ self.i = pos
454
+ self.heredocs = []
455
+
456
+ def _finish(self) -> None:
457
+ self.flush_word()
458
+ if self.pending is not None:
459
+ self.reason("missing_redirect_target")
460
+ self.pending = None
461
+ if self.heredocs:
462
+ self.reason("unclosed_heredoc")
463
+ if self.words or self.redirs:
464
+ self.segs.append({"words": self.words, "redirs": self.redirs, "sep": None})
465
+ elif self.segs:
466
+ last = self.segs[-1]["sep"]
467
+ if last in ("&&", "||", "|", "|&"):
468
+ self.reason("dangling_operator", len(self.segs) - 1)
469
+ elif last in (";", "&"):
470
+ self.trailing_op = last
471
+ self.segs[-1]["sep"] = None
472
+
473
+ def result(self) -> Lexed:
474
+ segments = []
475
+ for index, seg in enumerate(self.segs):
476
+ words = tuple(seg["words"])
477
+ if words:
478
+ first = words[0]
479
+ if first.text in _FIRST_WORD_OPAQUE or (
480
+ "=" in first.raw and _ASSIGN_RE.match(first.raw)
481
+ ):
482
+ self.reason("first_word", index)
483
+ segments.append(Segment(
484
+ words,
485
+ tuple(Redirect(*r) for r in seg["redirs"]),
486
+ seg["sep"],
487
+ ))
488
+ return Lexed(
489
+ tuple(segments), tuple(self.reasons), self.trailing_op, tuple(self.reason_at),
490
+ )
491
+
492
+
493
+ def lex(command: str, *, commit_message_form: bool = False) -> Lexed:
494
+ """Lex the FULL command text (docs/DESIGN.md 4.13.1 Tokenization, C-7).
495
+ commit_message_form places the one literal `$(cat <<'DELIM' ... )`
496
+ commit-message substitution as a single word (commit grammar only)."""
497
+ lexer = _Lexer(command, commit_message_form)
498
+ lexer.scan()
499
+ return lexer.result()
500
+
501
+
502
+ # --- the closed read-only grammar (docs/DESIGN.md 4.13.1, C-8) -----------------
503
+
504
+
505
+ @dataclass(frozen=True)
506
+ class _Spec:
507
+ """One program's option table: short options by character, long options
508
+ by name, each with its arity (0, 1, or 2 arguments; an argument may be
509
+ attached or separate). Anything outside the table is write-capable."""
510
+
511
+ short: dict
512
+ long: dict
513
+ exact: frozenset = frozenset()
514
+ long_optional: frozenset = frozenset()
515
+ numeric: bool = False
516
+ lenient: bool = False
517
+ max_operands: int | None = None
518
+ required: str | None = None
519
+
520
+
521
+ def _flags(chars: str, **extra: int) -> dict:
522
+ table = {c: 0 for c in chars}
523
+ table.update(extra)
524
+ return table
525
+
526
+
527
+ _SPECS: dict[str, _Spec] = {
528
+ "cat": _Spec(_flags("nbsvetA"), {}),
529
+ "head": _Spec(_flags("", n=1, c=1), {}, numeric=True),
530
+ "tail": _Spec(_flags("fF", n=1, c=1), {}, numeric=True),
531
+ "nl": _Spec(_flags("p", b=1, n=1, w=1, s=1, v=1, i=1, l=1), {}),
532
+ "wc": _Spec(_flags("lwcmL"), {}),
533
+ "ls": _Spec(_flags("1aAlhRtrSdFGinops"), {}, exact=frozenset({"--color=never"})),
534
+ "pwd": _Spec(_flags("LP"), {}, max_operands=0),
535
+ "echo": _Spec(_flags("neE"), {}, lenient=True),
536
+ "printf": _Spec({}, {}),
537
+ "which": _Spec(_flags("as"), {}),
538
+ "stat": _Spec(_flags("L", f=1, c=1), {"--format": 1}),
539
+ "file": _Spec(_flags("biL"), {"--mime": 0}),
540
+ "du": _Spec(_flags("shkmac", d=1), {}),
541
+ "df": _Spec(_flags("hk"), {}),
542
+ "cut": _Spec(_flags("", d=1, f=1, c=1, b=1), {}),
543
+ "tr": _Spec(_flags("dsc"), {}),
544
+ "basename": _Spec(_flags("a", s=1), {}),
545
+ "dirname": _Spec({}, {}),
546
+ "realpath": _Spec(_flags("qems"), {}),
547
+ "grep": _Spec(
548
+ _flags("rRnilLcvwxEFohHqs", A=1, B=1, C=1, e=1, m=1),
549
+ {"--include": 1, "--exclude": 1, "--exclude-dir": 1},
550
+ exact=frozenset({"--color=never"}),
551
+ ),
552
+ "rg": _Spec(
553
+ _flags("FisSwxvclLnNouU", e=1, f=1, A=1, B=1, C=1, g=1, t=1, T=1, m=1),
554
+ {
555
+ "--type": 1, "--max-depth": 1, "--hidden": 0, "--no-ignore": 0, "--files": 0,
556
+ "--files-with-matches": 0, "--count": 0, "--json": 0, "--no-config": 0,
557
+ },
558
+ required="--no-config",
559
+ ),
560
+ "diff": _Spec(
561
+ _flags("ucqrNwbBis", U=1), {"--brief": 0}, long_optional=frozenset({"--unified"})
562
+ ),
563
+ "cmp": _Spec(_flags("ls"), {}),
564
+ "jq": _Spec(_flags("rceSnsM"), {"--arg": 2, "--argjson": 2}),
565
+ "sed": _Spec(_flags("nEr", e=1), {}),
566
+ }
567
+ _FIND_TESTS_1 = frozenset(
568
+ {"-name", "-iname", "-path", "-ipath", "-regex", "-type", "-maxdepth", "-mindepth",
569
+ "-newer", "-mtime", "-mmin", "-size", "-perm"}
570
+ )
571
+ _FIND_FLAGS_0 = frozenset(
572
+ {"-empty", "-prune", "-print", "-print0", "-not", "!", "-o", "-a", "(", ")"}
573
+ )
574
+
575
+ READ_ONLY_PROGRAMS: tuple[str, ...] = tuple(sorted([*_SPECS, "find"]))
576
+
577
+
578
+ def _parse_args(spec: _Spec, args: list[str], *, posix: bool = False):
579
+ """(options, operands) or None when an option is outside the table or
580
+ lacks its argument. options is a list of (name, values). posix stops the
581
+ options at the first operand (getopt, what macOS runs): every later word
582
+ is an operand as written, and a `--` before it is recorded as an option
583
+ named `--`."""
584
+ opts: list[tuple[str, tuple[str, ...]]] = []
585
+ operands: list[str] = []
586
+ i = 0
587
+ n = len(args)
588
+ while i < n:
589
+ a = args[i]
590
+ if a == "--":
591
+ if posix:
592
+ opts.append(("--", ()))
593
+ operands.extend(args[i + 1:])
594
+ break
595
+ if a.startswith("--"):
596
+ if a in spec.exact:
597
+ opts.append((a, ()))
598
+ i += 1
599
+ continue
600
+ name, eq, val = a.partition("=")
601
+ if name in spec.long_optional:
602
+ opts.append((name, (val,) if eq else ()))
603
+ i += 1
604
+ continue
605
+ arity = spec.long.get(name)
606
+ if arity is None:
607
+ if spec.lenient:
608
+ operands.append(a)
609
+ i += 1
610
+ continue
611
+ return None
612
+ if eq:
613
+ if arity != 1:
614
+ return None
615
+ opts.append((name, (val,)))
616
+ i += 1
617
+ continue
618
+ values = args[i + 1:i + 1 + arity]
619
+ if len(values) < arity:
620
+ return None
621
+ opts.append((name, tuple(values)))
622
+ i += 1 + arity
623
+ continue
624
+ if a.startswith("-") and len(a) > 1:
625
+ if spec.numeric and a[1:].isdigit():
626
+ opts.append(("-NUM", (a[1:],)))
627
+ i += 1
628
+ continue
629
+ consumed = 0
630
+ bad = False
631
+ j = 1
632
+ while j < len(a):
633
+ arity = spec.short.get(a[j])
634
+ if arity is None:
635
+ bad = True
636
+ break
637
+ if arity == 0:
638
+ j += 1
639
+ continue
640
+ rest = a[j + 1:]
641
+ need = arity - 1 if rest else arity
642
+ values = args[i + 1:i + 1 + need]
643
+ if len(values) < need:
644
+ return None
645
+ opts.append(("-" + a[j], ((rest,) if rest else ()) + tuple(values)))
646
+ consumed = need
647
+ break
648
+ if bad:
649
+ if spec.lenient:
650
+ operands.append(a)
651
+ i += 1
652
+ continue
653
+ return None
654
+ i += 1 + consumed
655
+ continue
656
+ if posix:
657
+ operands.extend(args[i:])
658
+ break
659
+ operands.append(a)
660
+ i += 1
661
+ return opts, operands
662
+
663
+
664
+ def _find_ok(args: list[str]) -> bool:
665
+ i = 0
666
+ n = len(args)
667
+ while i < n and not (args[i].startswith("-") or args[i] in ("!", "(", ")")):
668
+ i += 1
669
+ while i < n:
670
+ word = args[i]
671
+ if word in _FIND_FLAGS_0:
672
+ i += 1
673
+ elif word in _FIND_TESTS_1:
674
+ if i + 1 >= n:
675
+ return False
676
+ i += 2
677
+ else:
678
+ return False
679
+ return True
680
+
681
+
682
+ def _sed_ok(args: list[str]) -> bool:
683
+ parsed = _parse_args(_SPECS["sed"], args)
684
+ if parsed is None:
685
+ return False
686
+ opts, operands = parsed
687
+ scripts = [values[-1] if values else "" for name, values in opts if name == "-e"]
688
+ if not scripts:
689
+ if not operands:
690
+ return False
691
+ scripts = [operands[0]]
692
+ return all(sed_script_ok(script) for script in scripts)
693
+
694
+
695
+ def _sed_regex_end(script: str, i: int, delim: str, mode: int) -> int:
696
+ """Index just past the closing delimiter of a regex or replacement that
697
+ starts at i, or -1. mode 0: backslash escapes everywhere, no bracket
698
+ expressions; mode 1 and 2 honor bracket expressions, with a backslash
699
+ inside one escaping (1) or literal (2)."""
700
+ n = len(script)
701
+ while i < n:
702
+ c = script[i]
703
+ if c == "\\":
704
+ i += 2
705
+ continue
706
+ if c == delim:
707
+ return i + 1
708
+ if mode and c == "[":
709
+ i += 1
710
+ if i < n and script[i] == "^":
711
+ i += 1
712
+ if i < n and script[i] == "]":
713
+ i += 1
714
+ while i < n and script[i] != "]":
715
+ if script[i] == "[" and i + 1 < n and script[i + 1] in ":.=":
716
+ close = script.find(script[i + 1] + "]", i + 2)
717
+ if close < 0:
718
+ return -1
719
+ i = close + 2
720
+ continue
721
+ if mode == 1 and script[i] == "\\":
722
+ i += 1
723
+ i += 1
724
+ if i >= n:
725
+ return -1
726
+ i += 1
727
+ continue
728
+ i += 1
729
+ return -1
730
+
731
+
732
+ def _sed_address(script: str, i: int, mode: int, second: bool) -> int:
733
+ """Index after one address starting at i (i itself when there is none),
734
+ or -1 when it is malformed."""
735
+ n = len(script)
736
+ if i >= n:
737
+ return i
738
+ c = script[i]
739
+ if c.isdigit():
740
+ while i < n and script[i].isdigit():
741
+ i += 1
742
+ if not second and i < n and script[i] == "~":
743
+ i += 1
744
+ if i >= n or not script[i].isdigit():
745
+ return -1
746
+ while i < n and script[i].isdigit():
747
+ i += 1
748
+ return i
749
+ if c == "$":
750
+ return i + 1
751
+ if second and c in "+~":
752
+ i += 1
753
+ if i >= n or not script[i].isdigit():
754
+ return -1
755
+ while i < n and script[i].isdigit():
756
+ i += 1
757
+ return i
758
+ if c == "/" or c == "\\":
759
+ if c == "\\":
760
+ if i + 1 >= n or script[i + 1] in "\\\n":
761
+ return -1
762
+ delim = script[i + 1]
763
+ start = i + 2
764
+ else:
765
+ delim = "/"
766
+ start = i + 1
767
+ end = _sed_regex_end(script, start, delim, mode)
768
+ if end < 0:
769
+ return -1
770
+ if end < n and script[end] == "I":
771
+ end += 1
772
+ return end
773
+ return i
774
+
775
+
776
+ def _sed_parse(script: str, mode: int):
777
+ """The (offset, command, flags) list of a script, or None when it is not
778
+ inside the closed sed grammar."""
779
+ n = len(script)
780
+ i = 0
781
+ out: list[tuple[int, str, str]] = []
782
+ while True:
783
+ while i < n and script[i] in " \t\n;":
784
+ i += 1
785
+ if i >= n:
786
+ return out
787
+ start = i
788
+ j = _sed_address(script, i, mode, False)
789
+ if j < 0:
790
+ return None
791
+ if j > i:
792
+ i = j
793
+ if i < n and script[i] == ",":
794
+ k = _sed_address(script, i + 1, mode, True)
795
+ if k < 0 or k == i + 1:
796
+ return None
797
+ i = k
798
+ while i < n and script[i] in " \t":
799
+ i += 1
800
+ if i < n and script[i] == "!":
801
+ i += 1
802
+ while i < n and script[i] in " \t":
803
+ i += 1
804
+ if i >= n:
805
+ return None
806
+ cmd = script[i]
807
+ i += 1
808
+ flags = ""
809
+ if cmd in "pdn=N":
810
+ pass
811
+ elif cmd == "q":
812
+ while i < n and script[i].isdigit():
813
+ flags += script[i]
814
+ i += 1
815
+ elif cmd == "s":
816
+ if i >= n or script[i] in "\\\n":
817
+ return None
818
+ delim = script[i]
819
+ end = _sed_regex_end(script, i + 1, delim, mode)
820
+ if end < 0:
821
+ return None
822
+ end = _sed_regex_end(script, end, delim, 0)
823
+ if end < 0:
824
+ return None
825
+ i = end
826
+ while i < n and script[i] in "gpIi0123456789":
827
+ flags += script[i]
828
+ i += 1
829
+ else:
830
+ return None
831
+ while i < n and script[i] in " \t":
832
+ i += 1
833
+ if i < n and script[i] not in ";\n":
834
+ return None
835
+ out.append((start, cmd, flags))
836
+
837
+
838
+ def sed_script_ok(script: str) -> bool:
839
+ """A script of addresses plus p d q = n N and s (flags g p I i and
840
+ digits). A script whose structure depends on how a sed treats a
841
+ delimiter inside a bracket expression is rejected: it must parse the
842
+ same under every reading."""
843
+ readings = [_sed_parse(script, mode) for mode in (0, 1, 2)]
844
+ return readings[0] is not None and readings[0] == readings[1] == readings[2]
845
+
846
+
847
+ def _redirects_read_only(segment: Segment) -> bool:
848
+ for r in segment.redirects:
849
+ if r.glued_before:
850
+ return False
851
+ if r.op == ">&" and r.fd == "2" and r.target == "1":
852
+ continue
853
+ if r.target == "/dev/null" and (
854
+ (r.op in (">", ">>") and r.fd in (None, "1", "2")) or (r.op == "&>" and r.fd is None)
855
+ ):
856
+ continue
857
+ return False
858
+ return True
859
+
860
+
861
+ def _segment_read_only(segment: Segment) -> bool:
862
+ if not segment.words or not _redirects_read_only(segment):
863
+ return False
864
+ program = segment.words[0].text
865
+ args = [w.text for w in segment.words[1:]]
866
+ if program == "find":
867
+ return _find_ok(args)
868
+ spec = _SPECS.get(program)
869
+ if spec is None:
870
+ return False
871
+ if program == "sed":
872
+ return _sed_ok(args)
873
+ parsed = _parse_args(spec, args)
874
+ if parsed is None:
875
+ return False
876
+ opts, operands = parsed
877
+ if spec.max_operands is not None and len(operands) > spec.max_operands:
878
+ return False
879
+ if spec.required is not None and not any(name == spec.required for name, _ in opts):
880
+ return False
881
+ return True
882
+
883
+
884
+ def cd_operand(segment: Segment) -> str | None:
885
+ """DIR of a segment that is exactly `cd DIR` with one plain word that
886
+ does not start with `-`; None for every other form (L3)."""
887
+ if (
888
+ len(segment.words) == 2
889
+ and not segment.redirects
890
+ and segment.words[0].text == "cd"
891
+ and segment.words[1].text
892
+ and not segment.words[1].text.startswith("-")
893
+ ):
894
+ return segment.words[1].text
895
+ return None
896
+
897
+
898
+ def is_read_only(lexed: Lexed) -> bool:
899
+ """The read-only grammar recognizes no write in the recorded command: the
900
+ command is placed and every segment is read-only, after at most one
901
+ leading `cd DIR &&`."""
902
+ if not lexed.placed or not lexed.segments:
903
+ return False
904
+ segments = list(lexed.segments)
905
+ if cd_operand(segments[0]) is not None:
906
+ if segments[0].sep_after != "&&" or len(segments) == 1:
907
+ return False
908
+ segments = segments[1:]
909
+ return all(_segment_read_only(seg) for seg in segments)
910
+
911
+
912
+ @dataclass(frozen=True)
913
+ class WriteTarget:
914
+ path: str | None
915
+ repo: str | None
916
+ kind: str
917
+ truncated: bool = False
918
+
919
+
920
+ @dataclass(frozen=True)
921
+ class WriteMatch:
922
+ form: str
923
+ targets: tuple[WriteTarget, ...]
924
+
925
+
926
+ _PATH_LIMIT = 1024
927
+ _SUFFIX_RE = re.compile(r"[A-Za-z0-9._~-]+\Z")
928
+ _TOUCH_SPEC = _Spec(_flags("cam", t=1, d=1, r=1), {})
929
+
930
+
931
+ def normalize_path(path: str) -> str:
932
+ """The lexical spelling of a path (D7): `os.path.normpath`, with the
933
+ exactly-two leading slashes it keeps collapsed to one."""
934
+ path = os.path.normpath(path)
935
+ if path.startswith("//"):
936
+ return "/" + path.lstrip("/")
937
+ return path
938
+
939
+
940
+ _normalize_base = functools.lru_cache(maxsize=512)(normalize_path)
941
+
942
+
943
+ def _base(cwd: str | None) -> str | None:
944
+ """A call cwd is a candidate base only when it is absolute (L9). The few
945
+ distinct cwds of a run are normalized once each."""
946
+ if isinstance(cwd, str) and cwd.startswith("/"):
947
+ return _normalize_base(cwd)
948
+ return None
949
+
950
+
951
+ def resolve_target(base: str | None, target: str) -> tuple[str | None, bool] | None:
952
+ """(path, truncated) for a target, or None when the text cannot place it:
953
+ an empty target, or a relative one with no absolute base (D7)."""
954
+ if not target:
955
+ return None
956
+ if target.startswith("/"):
957
+ path = normalize_path(target)
958
+ elif base is not None:
959
+ path = normalize_path(os.path.join(base, target))
960
+ else:
961
+ return None
962
+ if len(path) > _PATH_LIMIT:
963
+ return None, True
964
+ return path, False
965
+
966
+
967
+ def _targets(base: str | None, entries: list[tuple[str, str]]) -> tuple[WriteTarget, ...] | None:
968
+ out: list[WriteTarget] = []
969
+ for kind, target in entries:
970
+ resolved = resolve_target(base, target)
971
+ if resolved is None:
972
+ return None
973
+ out.append(WriteTarget(resolved[0], base, kind, resolved[1]))
974
+ return tuple(out)
975
+
976
+
977
+ def _plain_segment(segment: Segment) -> Segment:
978
+ return Segment(segment.words, (), segment.sep_after)
979
+
980
+
981
+ def _write_redirect(seg: Segment, base: str | None) -> WriteMatch | None:
982
+ if len(seg.redirects) != 1 or not seg.words:
983
+ return None
984
+ r = seg.redirects[0]
985
+ if (
986
+ r.op not in (">", ">>")
987
+ or r.fd not in (None, "1")
988
+ or r.glued_before
989
+ or r.after_words != len(seg.words)
990
+ or not r.target
991
+ ):
992
+ return None
993
+ if not _segment_read_only(_plain_segment(seg)):
994
+ return None
995
+ targets = _targets(base, [("redirect", r.target)])
996
+ return WriteMatch("redirect", targets) if targets else None
997
+
998
+
999
+ def _write_tee(segments: tuple[Segment, ...], base: str | None) -> WriteMatch | None:
1000
+ if len(segments) < 2 or any(seg.sep_after != "|" for seg in segments[:-1]):
1001
+ return None
1002
+ last = segments[-1]
1003
+ if last.redirects:
1004
+ return None
1005
+ words = [w.text for w in last.words]
1006
+ if words[:1] != ["tee"]:
1007
+ return None
1008
+ rest = words[1:]
1009
+ if rest[:1] == ["-a"]:
1010
+ rest = rest[1:]
1011
+ if len(rest) != 1 or rest[0].startswith("-"):
1012
+ return None
1013
+ if not all(_segment_read_only(seg) for seg in segments[:-1]):
1014
+ return None
1015
+ targets = _targets(base, [("tee", rest[0])])
1016
+ return WriteMatch("tee", targets) if targets else None
1017
+
1018
+
1019
+ def _write_sed_i(seg: Segment, base: str | None) -> WriteMatch | None:
1020
+ if seg.redirects:
1021
+ return None
1022
+ args = [w.text for w in seg.words[1:]]
1023
+ if not args:
1024
+ return None
1025
+ first = args[0]
1026
+ suffix = ""
1027
+ if first == "-i":
1028
+ rest = args[1:]
1029
+ if len(rest) == 3:
1030
+ suffix = rest[0]
1031
+ if suffix and not (suffix.startswith(".") and _SUFFIX_RE.match(suffix)):
1032
+ return None
1033
+ rest = rest[1:]
1034
+ elif len(rest) != 2:
1035
+ return None
1036
+ elif first.startswith("-i") and not first.startswith("--"):
1037
+ suffix = first[2:]
1038
+ if not _SUFFIX_RE.match(suffix):
1039
+ return None
1040
+ rest = args[1:]
1041
+ if len(rest) != 2:
1042
+ return None
1043
+ else:
1044
+ return None
1045
+ script, target = rest
1046
+ if script.startswith("-") or target.startswith("-") or not sed_script_ok(script):
1047
+ return None
1048
+ entries = [("sed_i", target)]
1049
+ resolved = resolve_target(base, target)
1050
+ if resolved is None:
1051
+ return None
1052
+ targets = list(_targets(base, entries) or ())
1053
+ if not targets:
1054
+ return None
1055
+ if suffix:
1056
+ path, truncated = resolved
1057
+ backup = None if path is None else path + suffix
1058
+ if backup is not None and len(backup) > _PATH_LIMIT:
1059
+ backup, truncated = None, True
1060
+ targets.append(WriteTarget(backup, base, "sed_backup", truncated))
1061
+ return WriteMatch("sed_i", tuple(targets))
1062
+
1063
+
1064
+ def _write_touch(seg: Segment, base: str | None) -> WriteMatch | None:
1065
+ if seg.redirects:
1066
+ return None
1067
+ parsed = _parse_args(_TOUCH_SPEC, [w.text for w in seg.words[1:]], posix=True)
1068
+ if parsed is None:
1069
+ return None
1070
+ opts, operands = parsed
1071
+ # S4: after the first operand every word is a file name on macOS, so an
1072
+ # option-looking one would make the claimed target set incomplete.
1073
+ if not any(name == "--" for name, _ in opts) and any(
1074
+ word.startswith("-") and len(word) > 1 for word in operands[1:]
1075
+ ):
1076
+ return None
1077
+ if sum(1 for name, _ in opts if name in ("-t", "-d", "-r")) > 1 or not operands:
1078
+ return None
1079
+ targets = _targets(base, [("touch", operand) for operand in operands])
1080
+ return WriteMatch("touch", targets) if targets else None
1081
+
1082
+
1083
+ def _write_cat_heredoc(seg: Segment, base: str | None) -> WriteMatch | None:
1084
+ if [w.text for w in seg.words] != ["cat"] or len(seg.redirects) != 2:
1085
+ return None
1086
+ out, doc = seg.redirects
1087
+ if (
1088
+ out.op != ">" or out.fd is not None or out.glued_before or out.after_words != 1
1089
+ or not out.target
1090
+ or doc.op not in ("<<", "<<-") or doc.fd is not None or doc.body is None
1091
+ ):
1092
+ return None
1093
+ targets = _targets(base, [("heredoc", out.target)])
1094
+ return WriteMatch("heredoc", targets) if targets else None
1095
+
1096
+
1097
+ def match_write_grammar(lexed: Lexed, cwd: str | None) -> WriteMatch | None:
1098
+ """The tiny write grammar (C-12): one of five exact forms, the producer
1099
+ proven read-only, every path named resolved lexically. cp and mv are
1100
+ never matched. None means the command is not in the grammar."""
1101
+ if not lexed.placed or not lexed.segments or lexed.trailing_op is not None:
1102
+ return None
1103
+ base = _base(cwd)
1104
+ segments = lexed.segments
1105
+ if len(segments) >= 2:
1106
+ return _write_tee(segments, base)
1107
+ seg = segments[0]
1108
+ if not seg.words:
1109
+ return None
1110
+ program = seg.words[0].text
1111
+ if program == "sed":
1112
+ return _write_sed_i(seg, base) or _write_redirect(seg, base)
1113
+ if program == "touch":
1114
+ return _write_touch(seg, base)
1115
+ if program == "cat" and len(seg.redirects) == 2:
1116
+ return _write_cat_heredoc(seg, base)
1117
+ return _write_redirect(seg, base)
1118
+
1119
+
1120
+ @dataclass(frozen=True)
1121
+ class GitPrefix:
1122
+ subcommand: str
1123
+ args: tuple[str, ...]
1124
+ c_dirs: tuple[str, ...]
1125
+ pins_location: bool
1126
+
1127
+
1128
+ def segment_bases(lexed: Lexed, cwd: str | None) -> tuple[str | None, ...]:
1129
+ """The candidate base directory in force when each segment starts (L9).
1130
+ The base is the call cwd when absolute; a leading `cd DIR &&` with an
1131
+ absolute plain DIR replaces it for the later segments; every other cd
1132
+ shape (relative, listed forms, joined by something other than `&&`, a
1133
+ later cd, pushd, popd) makes it unknown from then on. An opaque command
1134
+ has no base at all."""
1135
+ if not lexed.placed:
1136
+ return (None,) * len(lexed.segments)
1137
+ base = _base(cwd)
1138
+ out: list[str | None] = []
1139
+ for index, seg in enumerate(lexed.segments):
1140
+ out.append(base)
1141
+ if not seg.words or seg.words[0].text not in ("cd", "pushd", "popd"):
1142
+ continue
1143
+ operand = cd_operand(seg)
1144
+ if (
1145
+ index == 0
1146
+ and operand is not None
1147
+ and seg.sep_after == "&&"
1148
+ and operand.startswith("/")
1149
+ ):
1150
+ base = normalize_path(operand)
1151
+ else:
1152
+ base = None
1153
+ return tuple(out)
1154
+
1155
+
1156
+ def git_prefix(words: list[str]) -> GitPrefix | None:
1157
+ """The global options before a git subcommand, when they are only `-C
1158
+ DIR` (any number, in order), `-c K=V`, `--git-dir=...`, and
1159
+ `--work-tree=...`; None for any other option or a missing subcommand."""
1160
+ if not words or words[0] != "git":
1161
+ return None
1162
+ c_dirs: list[str] = []
1163
+ pins = False
1164
+ i = 1
1165
+ while i < len(words):
1166
+ word = words[i]
1167
+ if word == "-C":
1168
+ if i + 1 >= len(words):
1169
+ return None
1170
+ c_dirs.append(words[i + 1])
1171
+ i += 2
1172
+ elif word == "-c":
1173
+ if i + 1 >= len(words):
1174
+ return None
1175
+ i += 2
1176
+ elif word.startswith(("--git-dir=", "--work-tree=")):
1177
+ pins = True
1178
+ i += 1
1179
+ elif word.startswith("-"):
1180
+ return None
1181
+ else:
1182
+ return GitPrefix(word, tuple(words[i + 1:]), tuple(c_dirs), pins)
1183
+ return None
1184
+
1185
+
1186
+ def git_repo(base: str | None, prefix: GitPrefix) -> str | None:
1187
+ """The candidate repo of a git segment: the base with each -C applied in
1188
+ order; None when a relative -C meets an unknown base, or when
1189
+ --git-dir or --work-tree is present (L9)."""
1190
+ if prefix.pins_location:
1191
+ return None
1192
+ repo = base
1193
+ for directory in prefix.c_dirs:
1194
+ if not directory:
1195
+ continue
1196
+ if directory.startswith("/"):
1197
+ repo = normalize_path(directory)
1198
+ elif repo is not None:
1199
+ repo = normalize_path(os.path.join(repo, directory))
1200
+ return repo
1201
+
1202
+
1203
+ @dataclass(frozen=True)
1204
+ class CommitSegment:
1205
+ repo: str | None
1206
+
1207
+
1208
+ @dataclass(frozen=True)
1209
+ class CommitFacts:
1210
+ segments: tuple[CommitSegment, ...]
1211
+
1212
+ @property
1213
+ def is_commit(self) -> bool:
1214
+ return bool(self.segments)
1215
+
1216
+
1217
+ # A1: the only call completions whose result can give a commit id.
1218
+ COMMIT_COMPLETIONS = ("completed_ok", "completed_error")
1219
+ _COMMIT_ID_RE = re.compile(r"(?m)^\[[^\]\s]+ ([0-9a-f]{7,40})\]")
1220
+ _MAX_COMMIT_IDS_PER_CALL = 20
1221
+ _REJECTED_COMMIT_LONG = ("--dry-run", "--quiet", "--porcelain", "--short", "--long")
1222
+ # FA6: the same set without the quiet form, for commit_command_ran, which still
1223
+ # excludes a dry run but recognizes a quiet commit.
1224
+ _DRY_RUN_COMMIT_LONG = ("--dry-run", "--porcelain", "--short", "--long")
1225
+ _OPTION_CLUSTER_RE = re.compile(r"-[A-Za-z]+\Z")
1226
+ _SHELL_WRAPPERS = ("bash", "sh")
1227
+
1228
+
1229
+ def _commit_flags_ok(args: tuple[str, ...], *, allow_quiet: bool = False) -> bool:
1230
+ """False for a commit segment with a dry-run or quiet form: --dry-run,
1231
+ -q, --quiet, --porcelain, --short, --long, -z, an unambiguous
1232
+ abbreviation of a long one, or a short cluster containing q or z. Words
1233
+ after `--` are pathspecs. With allow_quiet (FA6's commit_command_ran) the
1234
+ quiet forms (-q, --quiet, a cluster with q) are accepted and every dry-run
1235
+ form is still rejected."""
1236
+ long_forms = _DRY_RUN_COMMIT_LONG if allow_quiet else _REJECTED_COMMIT_LONG
1237
+ rejected_short = "z" if allow_quiet else "qz"
1238
+ for word in args:
1239
+ if word == "--":
1240
+ break
1241
+ if word.startswith("--"):
1242
+ name = word.split("=", 1)[0]
1243
+ if len(name) >= 3 and any(r.startswith(name) for r in long_forms):
1244
+ return False
1245
+ elif _OPTION_CLUSTER_RE.match(word) and any(c in word for c in rejected_short):
1246
+ return False
1247
+ return True
1248
+
1249
+
1250
+ def _backgrounded_lists(lexed: Lexed) -> list[bool]:
1251
+ """For each segment, whether the and-or list that contains it is ended by
1252
+ `&` (shell semantics: `&` applies to the whole `&&`/`||` list, pipelines
1253
+ included). A list ends at `;`, a newline, `&`, or the end of the command
1254
+ (A1)."""
1255
+ segments = lexed.segments
1256
+ flags = [False] * len(segments)
1257
+ start = 0
1258
+ for index, seg in enumerate(segments):
1259
+ last = index == len(segments) - 1
1260
+ if not (last or seg.sep_after in (";", "\n", "&")):
1261
+ continue
1262
+ background = seg.sep_after == "&" or (last and lexed.trailing_op == "&")
1263
+ for j in range(start, index + 1):
1264
+ flags[j] = background
1265
+ start = index + 1
1266
+ return flags
1267
+
1268
+
1269
+ def _commit_segments(
1270
+ lexed: Lexed, cwd: str | None, depth: int
1271
+ ) -> list[tuple[CommitSegment, bool]] | None:
1272
+ """The commit segments of one placed command, each with whether it is
1273
+ backgrounded in the text (A1), or None when the call can count no id:
1274
+ the text is not placed, a wrapper is deeper than one level or holds text
1275
+ that is not placed, or a commit segment carries a dry-run or quiet flag
1276
+ (poisoning: such a call counts no id at all)."""
1277
+ if not lexed.placed:
1278
+ return None
1279
+ found: list[tuple[CommitSegment, bool]] = []
1280
+ background = _backgrounded_lists(lexed)
1281
+ for seg, base, bg in zip(lexed.segments, segment_bases(lexed, cwd), background):
1282
+ words = [w.text for w in seg.words]
1283
+ if (
1284
+ len(words) == 3
1285
+ and not seg.redirects
1286
+ and words[0] in _SHELL_WRAPPERS
1287
+ and words[1] in ("-c", "-lc")
1288
+ ):
1289
+ if depth >= 1:
1290
+ return None
1291
+ inner = _commit_segments(lex(words[2], commit_message_form=True), base, depth + 1)
1292
+ if inner is None:
1293
+ return None
1294
+ found.extend((commit, bg or inner_bg) for commit, inner_bg in inner)
1295
+ continue
1296
+ if words[:1] != ["git"]:
1297
+ continue
1298
+ prefix = git_prefix(words)
1299
+ if prefix is None or prefix.subcommand != "commit":
1300
+ continue
1301
+ if not _commit_flags_ok(prefix.args):
1302
+ return None
1303
+ found.append((CommitSegment(git_repo(base, prefix)), bg))
1304
+ return found
1305
+
1306
+
1307
+ def commit_command_ran(words: list[str] | tuple[str, ...]) -> bool:
1308
+ """FA6, the grammar fact (DESIGN 4.13.1): whether one segment's words are a
1309
+ `git commit` command, quiet forms included (-q, --quiet, a short cluster with
1310
+ q) and every dry-run form (--dry-run, --porcelain, --short, --long, -z, their
1311
+ abbreviations) excluded. A1's id grammar rejects the quiet forms because a
1312
+ quiet segment poisons the call's id count; this fact says only that the
1313
+ command is present, never that the shell reached it or that it printed an id.
1314
+ Segment-level: placement and backgrounding are judged by commit_ran_in_call."""
1315
+ prefix = git_prefix(list(words))
1316
+ return (
1317
+ prefix is not None
1318
+ and prefix.subcommand == "commit"
1319
+ and _commit_flags_ok(prefix.args, allow_quiet=True)
1320
+ )
1321
+
1322
+
1323
+ def _commit_ran_in_lexed(lexed: Lexed, depth: int) -> bool:
1324
+ """Whether some foreground commit segment of one lexed command is a
1325
+ commit_command_ran segment whose OWN reasons are empty. A reason raised in
1326
+ another segment (an expansion in an earlier `cd $WT`) does not hide it;
1327
+ any reason in the candidate itself (opaque or malformed syntax, quoting,
1328
+ heredoc data) fails closed, as does an `&` ending its and-or list."""
1329
+ flagged = {index for _, index in lexed.reason_at}
1330
+ background = _backgrounded_lists(lexed)
1331
+ for index, (seg, bg) in enumerate(zip(lexed.segments, background)):
1332
+ if bg or index in flagged:
1333
+ continue
1334
+ words = [w.text for w in seg.words]
1335
+ if (
1336
+ len(words) == 3
1337
+ and not seg.redirects
1338
+ and words[0] in _SHELL_WRAPPERS
1339
+ and words[1] in ("-c", "-lc")
1340
+ ):
1341
+ inner = lex(words[2], commit_message_form=True) if depth < 1 else None
1342
+ if inner is not None and _commit_ran_in_lexed(inner, depth + 1):
1343
+ return True
1344
+ continue
1345
+ if commit_command_ran(words):
1346
+ return True
1347
+ return False
1348
+
1349
+
1350
+ def commit_ran_in_call(command: str) -> bool:
1351
+ """FA6: whether a Bash command text contains a foreground commit_command_ran
1352
+ segment (the call-level presence fact; the caller adds the completion and no-id
1353
+ conditions). Reachability is not judged: control flow may skip the segment."""
1354
+ if not isinstance(command, str) or _COMMIT_SPELLING_RE.search(command) is None:
1355
+ return False
1356
+ return _commit_ran_in_lexed(lex(command, commit_message_form=True), 0)
1357
+
1358
+
1359
+ # A placed word is its pieces with quotes and backslashes removed, so a word
1360
+ # `commit` always shows up as the substring `commit` once those characters
1361
+ # (and a line continuation's newline) are stripped: a sound prefilter that
1362
+ # only skips commands that cannot hold a commit segment.
1363
+ _COMMIT_SPELLING_RE = re.compile(
1364
+ "c['\"\\\\\n]*o['\"\\\\\n]*m['\"\\\\\n]*m['\"\\\\\n]*i['\"\\\\\n]*t"
1365
+ )
1366
+
1367
+
1368
+ def commit_facts(command: str, cwd: str | None = None) -> CommitFacts:
1369
+ """The commit segments of a command (L8, amended by A1). Empty unless the
1370
+ command is placed by the lexer, no commit segment carries a dry-run or
1371
+ quiet flag, and no commit segment is backgrounded in the text."""
1372
+ if not isinstance(command, str) or _COMMIT_SPELLING_RE.search(command) is None:
1373
+ return CommitFacts(())
1374
+ found = _commit_segments(lex(command, commit_message_form=True), cwd, 0)
1375
+ if found is None or any(background for _, background in found):
1376
+ return CommitFacts(())
1377
+ return CommitFacts(tuple(commit for commit, _ in found))
1378
+
1379
+
1380
+ def commit_ids(facts: CommitFacts, result_text: str) -> tuple[str, ...]:
1381
+ """The last k line-start summary matches of a result, for k commit
1382
+ segments: an unverified, summary-shaped output line, not attributed to
1383
+ git (DESIGN 4.13.1). Empty when the result has fewer matches than k.
1384
+ The caller guarantees the call's completion is in COMMIT_COMPLETIONS."""
1385
+ k = len(facts.segments)
1386
+ if k == 0:
1387
+ return ()
1388
+ matches = _COMMIT_ID_RE.findall(result_text)
1389
+ if len(matches) < k:
1390
+ return ()
1391
+ return tuple(matches[-k:][:_MAX_COMMIT_IDS_PER_CALL])
1392
+
1393
+
1394
+ DELEGATION_TOOLS = ("Agent", "Workflow", "SendMessage", "Task")
1395
+ _DELEGATION_PROGRAMS = ("codex", "aider")
1396
+ _NO_COMMIT = CommitFacts(())
1397
+
1398
+
1399
+ @dataclass(frozen=True)
1400
+ class CallClass:
1401
+ """One Bash, Monitor, or delegation-tool call classified into its row of
1402
+ docs/DESIGN.md 4.13.1's truth table (L6). effect is None (no entry),
1403
+ 'possible', or 'claimed_target' (row 5: N4b maps a call that did not
1404
+ succeed to 'attempted'). basis is None, 'background_process',
1405
+ 'delegation', 'command_grammar', or 'opaque_command'. targets are the
1406
+ tiny grammar's paths (row 5 only). commit holds the commit segments of
1407
+ the call. repo is the call's candidate repository: the base in force at
1408
+ its last segment, or the call cwd for a command the lexer does not place.
1409
+ Completion is never decided here."""
1410
+
1411
+ row: int
1412
+ effect: str | None
1413
+ basis: str | None
1414
+ targets: tuple[WriteTarget, ...]
1415
+ commit: CommitFacts
1416
+ repo: str | None
1417
+ # FA6 (C1): the call CONTAINS a foreground commit command (a quiet commit
1418
+ # included); False for a background call. Presence only: the shell may never have
1419
+ # reached it (`git diff --quiet || git commit -q`). Reader-private like ToolCall._path_state:
1420
+ # out of equality, repr, and to_dict (the classification's own shape is unchanged).
1421
+ commit_command_present: bool = field(default=False, compare=False, repr=False)
1422
+
1423
+ def to_dict(self) -> dict:
1424
+ """The private, complete form (ToolCall.to_dict, N4b-1)."""
1425
+ return {
1426
+ "row": self.row,
1427
+ "effect": self.effect,
1428
+ "basis": self.basis,
1429
+ "targets": [
1430
+ {"path": t.path, "repo": t.repo, "kind": t.kind, "truncated": t.truncated}
1431
+ for t in self.targets
1432
+ ],
1433
+ "commit": [{"repo": seg.repo} for seg in self.commit.segments],
1434
+ "repo": self.repo,
1435
+ }
1436
+
1437
+
1438
+ def _has_nohup(command: str, lexed: Lexed) -> bool:
1439
+ if any(w.text == "nohup" for seg in lexed.segments for w in seg.words):
1440
+ return True
1441
+ structural = "unbalanced_quote" in lexed.reasons or "unclosed_heredoc" in lexed.reasons
1442
+ return structural and "nohup" in command.split()
1443
+
1444
+
1445
+ def _is_delegation(lexed: Lexed) -> bool:
1446
+ for seg in lexed.segments:
1447
+ words = [w.text for w in seg.words]
1448
+ if not words:
1449
+ continue
1450
+ program = words[0].rpartition("/")[2]
1451
+ if program in _DELEGATION_PROGRAMS:
1452
+ return True
1453
+ if program == "claude" and any(w in ("-p", "--print") for w in words[1:]):
1454
+ return True
1455
+ if "codex-companion.mjs" in "\0".join(words) and any(
1456
+ w.endswith("codex-companion.mjs") for w in words
1457
+ ):
1458
+ return True
1459
+ return False
1460
+
1461
+
1462
+ # A transcript repeats the same few commands many times, and the lexer is a
1463
+ # per-character loop, so the pure classification of a (tool, command, flags,
1464
+ # cwd) tuple is memoized. CallClass is frozen, so sharing one is safe. Very
1465
+ # long commands are not cached (they would pin large strings).
1466
+ _CACHE_COMMAND_LIMIT = 2048
1467
+
1468
+
1469
+ def classify_call(
1470
+ tool_name: str | None,
1471
+ command: object,
1472
+ *,
1473
+ run_in_background: object = False,
1474
+ backgrounded_hit: bool = False,
1475
+ cwd: str | None = None,
1476
+ ) -> CallClass | None:
1477
+ """classify_call's contract is _classify_call's below; this wrapper only
1478
+ memoizes it."""
1479
+ if isinstance(command, str) and len(command) <= _CACHE_COMMAND_LIMIT:
1480
+ return _classify_cached(
1481
+ tool_name, command, run_in_background is True, backgrounded_hit, cwd
1482
+ )
1483
+ return _classify_call(
1484
+ tool_name, command, run_in_background=run_in_background,
1485
+ backgrounded_hit=backgrounded_hit, cwd=cwd,
1486
+ )
1487
+
1488
+
1489
+ @functools.lru_cache(maxsize=4096)
1490
+ def _classify_cached(
1491
+ tool_name: str | None, command: str, background: bool, backgrounded_hit: bool,
1492
+ cwd: str | None,
1493
+ ) -> CallClass | None:
1494
+ return _classify_call(
1495
+ tool_name, command, run_in_background=background, backgrounded_hit=backgrounded_hit,
1496
+ cwd=cwd,
1497
+ )
1498
+
1499
+
1500
+ def _classify_call(
1501
+ tool_name: str | None,
1502
+ command: object,
1503
+ *,
1504
+ run_in_background: object = False,
1505
+ backgrounded_hit: bool = False,
1506
+ cwd: str | None = None,
1507
+ ) -> CallClass | None:
1508
+ """Rows 2 to 6 of the truth table for a Bash or Monitor call, and row 3
1509
+ for the delegation tools; None for every other tool name (the file
1510
+ tools, the read-only tools, and unknown tools belong to the ledger).
1511
+ backgrounded_hit says whether an anchored bash_backgrounded marker hit
1512
+ belongs to this call's result."""
1513
+ if tool_name in DELEGATION_TOOLS:
1514
+ return CallClass(3, "possible", "delegation", (), _NO_COMMIT, None)
1515
+ if tool_name not in ("Bash", "Monitor"):
1516
+ return None
1517
+ is_bash = tool_name == "Bash"
1518
+ base = _base(cwd)
1519
+ background = run_in_background is True or backgrounded_hit
1520
+ if not isinstance(command, str):
1521
+ if background:
1522
+ return CallClass(2, "possible", "background_process", (), _NO_COMMIT, base)
1523
+ return CallClass(6, "possible", "opaque_command", (), _NO_COMMIT, base)
1524
+ lexed = lex(command)
1525
+ commit = commit_facts(command, cwd) if is_bash else _NO_COMMIT
1526
+ if lexed.placed and lexed.segments:
1527
+ repo = segment_bases(lexed, cwd)[-1]
1528
+ else:
1529
+ repo = base
1530
+ if background or lexed.trailing_op == "&" or _has_nohup(command, lexed):
1531
+ return CallClass(2, "possible", "background_process", (), commit, repo)
1532
+ present = is_bash and commit_ran_in_call(command)
1533
+ if is_bash and _is_delegation(lexed):
1534
+ return CallClass(3, "possible", "delegation", (), commit, repo, present)
1535
+ if is_read_only(lexed):
1536
+ return CallClass(4, None, None, (), commit, repo, present)
1537
+ if is_bash:
1538
+ match = match_write_grammar(lexed, cwd)
1539
+ if match is not None:
1540
+ return CallClass(
1541
+ 5, "claimed_target", "command_grammar", match.targets, commit, repo, present,
1542
+ )
1543
+ return CallClass(6, "possible", "opaque_command", (), commit, repo, present)