align-agent 0.4.3__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 (60) hide show
  1. align_agent/__init__.py +11 -0
  2. align_agent/_audit/common.py +58 -0
  3. align_agent/_audit/deepswe.py +260 -0
  4. align_agent/_audit/excerpts.py +203 -0
  5. align_agent/_audit/extract.py +798 -0
  6. align_agent/_audit/materialize.py +624 -0
  7. align_agent/_audit/score.py +325 -0
  8. align_agent/_audit/shellparse.py +1141 -0
  9. align_agent/_audit/tarsur.py +153 -0
  10. align_agent/_vendor/src/__init__.py +3 -0
  11. align_agent/_vendor/src/harness/__init__.py +13 -0
  12. align_agent/_vendor/src/harness/arms.py +524 -0
  13. align_agent/_vendor/src/harness/completion.py +104 -0
  14. align_agent/_vendor/src/harness/dependency_notice.py +616 -0
  15. align_agent/_vendor/src/harness/freshness_hook.py +131 -0
  16. align_agent/_vendor/src/harness/lean.py +41 -0
  17. align_agent/_vendor/src/harness/mini_real.py +1040 -0
  18. align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
  19. align_agent/_vendor/src/harness/tail_diff.py +292 -0
  20. align_agent/_vendor/src/harness/viewport.py +220 -0
  21. align_agent/_vendor/src/metrics/__init__.py +38 -0
  22. align_agent/_vendor/src/metrics/coherence.py +354 -0
  23. align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
  24. align_agent/_vendor/src/metrics/replay.py +399 -0
  25. align_agent/_vendor/src/metrics/statistics.py +185 -0
  26. align_agent/_vendor/src/metrics/verification.py +104 -0
  27. align_agent/_vendor/src/parsers/__init__.py +33 -0
  28. align_agent/_vendor/src/parsers/base_parser.py +349 -0
  29. align_agent/_vendor/src/parsers/file_events.py +806 -0
  30. align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
  31. align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
  32. align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
  33. align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
  34. align_agent/cli.py +101 -0
  35. align_agent/core.py +161 -0
  36. align_agent/run.py +84 -0
  37. align_agent/sessions.py +985 -0
  38. align_agent/writethrough/__init__.py +4 -0
  39. align_agent/writethrough/_lib/__init__.py +1 -0
  40. align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
  41. align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
  42. align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
  43. align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
  44. align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
  45. align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
  46. align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
  47. align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
  48. align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
  49. align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
  50. align_agent/writethrough/claude_code.py +81 -0
  51. align_agent/writethrough/engine.py +647 -0
  52. align_agent/writethrough/hooks.py +227 -0
  53. align_agent/writethrough/mcp_server.py +125 -0
  54. align_agent/writethrough/protocol.py +68 -0
  55. align_agent-0.4.3.dist-info/METADATA +224 -0
  56. align_agent-0.4.3.dist-info/RECORD +60 -0
  57. align_agent-0.4.3.dist-info/WHEEL +5 -0
  58. align_agent-0.4.3.dist-info/entry_points.txt +6 -0
  59. align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
  60. align_agent-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1141 @@
1
+ """Independent shell-command file-operation extractor for the RQ1 audit. Usage: imported by scripts/audit/extract.py (python3 scripts/audit/shellparse.py runs self-tests).
2
+
3
+ Written from scratch for the audit; it does not import src/parsers. Given one
4
+ shell command string and the working directory it starts in, it returns the
5
+ file operations the command performs, in order, each with:
6
+
7
+ op "R" (content reaches the agent) or "W" (file changed)
8
+ path absolute path after cwd resolution (canonicalised later)
9
+ kind R: "full" | "range" | "lines" | "listing"
10
+ W: "anchored" | "overwrite" | "replace" | "append" | "delete" |
11
+ "restore" | "touch"
12
+ mech the utility (sed, perl, python, patch, cp, redirect, ...)
13
+ anchor for anchored writes: what locates the edit (see below)
14
+ view for reads: {"range": [a, b]} when the shown lines are known
15
+ cmd_i index of the simple command inside the string
16
+ guard the connector before this simple command ("&&", "||", ";", "|")
17
+
18
+ Anchors:
19
+ {"type": "sed", "cmds": [...]} parsed sed script (s///, d, i, a, c with addresses)
20
+ {"type": "perl", "subs": [...]} s/// substitutions of perl -pe/-i
21
+ {"type": "py", "replaces": [...], "regex": [...], "line_ops": bool, "reads_self": bool}
22
+ {"type": "patch", "hunks": [...]} unified or apply_patch hunks
23
+ {"type": "ed", "script": str}
24
+ {"type": "awk", "program": str}
25
+
26
+ A read whose stdout goes to a file or into a counting/digest sink is not a
27
+ view. cwd changes (`cd`) are tracked through the command and returned.
28
+ """
29
+
30
+ import posixpath
31
+ import re
32
+ import shlex
33
+ from typing import Dict, List, Optional, Tuple
34
+
35
+ HEREDOC_RE = re.compile(r"""(?<!<)<<(-?)(?!<)\s*(\\?)(['"]?)([A-Za-z_][A-Za-z0-9_.-]*)\3""")
36
+ SEPS = {";", ";;", "&&", "||", "&", "\n"}
37
+ REDIR_TOKENS = {">", ">>", "<", "&>", "&>>", ">|", "2>", "2>>", "1>", "1>>", ">&", "2>&1", "<>"}
38
+ VIEW_VERBS = {"cat", "nl", "head", "tail", "less", "more", "bat", "batcat", "tac"}
39
+ GREP_VERBS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep"}
40
+ SINKS = {"wc", "md5sum", "sha1sum", "sha256sum", "cksum", "xxd", "od", "hexdump", "file", "stat", "true"}
41
+ LITERAL_SOURCES = {"echo", "printf"}
42
+ INTERP_PREFIX = ("python", "pypy")
43
+ WRAPPERS = {"sudo", "command", "exec", "nohup", "time", "env", "xargs", "nice", "stdbuf"}
44
+
45
+
46
+ def split_heredocs(cmd: str) -> Tuple[str, List[str]]:
47
+ """Remove heredoc bodies; replace each operator by a placeholder word."""
48
+ lines = (cmd or "").split("\n")
49
+ out, bodies, i = [], [], 0
50
+ while i < len(lines):
51
+ line = lines[i]
52
+ i += 1
53
+ ops = list(HEREDOC_RE.finditer(line))
54
+ if not ops:
55
+ out.append(line)
56
+ continue
57
+ new_line = line
58
+ for m in reversed(ops):
59
+ k = len(bodies) + (len(ops) - 1 - ops.index(m))
60
+ new_line = new_line[:m.start()] + f" __HEREDOC_{k}__ " + new_line[m.end():]
61
+ out.append(new_line)
62
+ for m in ops:
63
+ strip_tabs = m.group(1) == "-"
64
+ delim = m.group(4)
65
+ body = []
66
+ while i < len(lines):
67
+ ln = lines[i]
68
+ cmp = ln.lstrip("\t") if strip_tabs else ln
69
+ if cmp.strip() == delim:
70
+ i += 1
71
+ break
72
+ body.append(ln)
73
+ i += 1
74
+ bodies.append("\n".join(body))
75
+ return "\n".join(out), bodies
76
+
77
+
78
+ def mask_heredocs(cmd: str) -> str:
79
+ """The command text with every heredoc body blanked (line count kept),
80
+ so positions of shell words can be searched without hitting payloads."""
81
+ lines = (cmd or "").split("\n")
82
+ out, i = [], 0
83
+ while i < len(lines):
84
+ line = lines[i]
85
+ out.append(line)
86
+ i += 1
87
+ for m in HEREDOC_RE.finditer(line):
88
+ strip_tabs = m.group(1) == "-"
89
+ delim = m.group(4)
90
+ while i < len(lines):
91
+ ln = lines[i]
92
+ cmp = ln.lstrip("\t") if strip_tabs else ln
93
+ out.append(" " * len(ln)) # same length: offsets stay valid
94
+ i += 1
95
+ if cmp.strip() == delim:
96
+ break
97
+ return "\n".join(out)
98
+
99
+
100
+ _OPS_RE = re.compile(r"\|\||&&|;;|&>>|>>|<<<|<<|&>|>&|<&|>\||[();<>|&\n]")
101
+ _PUNCT = set("();<>|&\n")
102
+
103
+
104
+ def tokenize(shell: str) -> List[str]:
105
+ """shlex words; runs of punctuation (which shlex groups, e.g. ')\n')
106
+ are split back into single shell operators."""
107
+ shell = shell.replace("\\\n", " ")
108
+ try:
109
+ lx = shlex.shlex(shell, posix=True, punctuation_chars="();<>|&\n")
110
+ lx.whitespace = " \t\r"
111
+ lx.whitespace_split = True
112
+ lx.commenters = "#"
113
+ raw = list(lx)
114
+ except ValueError:
115
+ return []
116
+ out = []
117
+ for t in raw:
118
+ if t and set(t) <= _PUNCT and len(t) > 1 and t not in ("&&", "||", ";;", ">>", "<<", "&>", ">&", "<&", ">|", "&>>", "<<<"):
119
+ out.extend(_OPS_RE.findall(t))
120
+ else:
121
+ out.append(t)
122
+ return out
123
+
124
+
125
+ def simple_commands(cmd: str) -> List[Dict]:
126
+ """Simple commands with argv, redirections, stdin heredoc body, pipeline
127
+ membership and the connector that precedes each one."""
128
+ shell, bodies = split_heredocs(cmd)
129
+ toks = tokenize(shell)
130
+ if not toks and shell.strip():
131
+ # Unbalanced quoting somewhere: tokenise line by line and keep the
132
+ # lines that parse (a stray apostrophe in one line must not hide the
133
+ # rest of the command).
134
+ toks = []
135
+ for line in shell.split("\n"):
136
+ lt = tokenize(line)
137
+ if lt:
138
+ toks.extend(lt + ["\n"])
139
+ if not toks:
140
+ return [{"argv": [], "redirs": [], "heredoc": None, "pipe": 0, "guard": ";", "depth": 0,
141
+ "unparsed": True}]
142
+ units, pipe, depth = [], 0, 0
143
+ guard = ";"
144
+
145
+ def new():
146
+ return {"argv": [], "redirs": [], "heredoc": None, "pipe": pipe, "guard": guard, "depth": depth}
147
+
148
+ cur = new()
149
+ pending = None
150
+ for t in toks:
151
+ m = re.fullmatch(r"__HEREDOC_(\d+)__", t)
152
+ if m:
153
+ k = int(m.group(1))
154
+ cur["heredoc"] = bodies[k] if k < len(bodies) else ""
155
+ continue
156
+ if pending is not None:
157
+ cur["redirs"].append((pending, t))
158
+ pending = None
159
+ continue
160
+ if t == "|":
161
+ if cur["argv"] or cur["redirs"]:
162
+ units.append(cur)
163
+ guard = "|"
164
+ cur = new()
165
+ continue
166
+ if t in ("(", ")"):
167
+ if cur["argv"] or cur["redirs"]:
168
+ units.append(cur)
169
+ depth += 1 if t == "(" else -1
170
+ units.append({"argv": [t], "redirs": [], "heredoc": None, "pipe": pipe, "guard": guard,
171
+ "depth": depth, "paren": True})
172
+ pipe += 1
173
+ guard = ";"
174
+ cur = new()
175
+ continue
176
+ if t in SEPS or (set(t) <= set(";&|\n") and "\n" in t):
177
+ if cur["argv"] or cur["redirs"]:
178
+ units.append(cur)
179
+ pipe += 1
180
+ tt = t.replace("\n", "")
181
+ guard = tt if tt in ("&&", "||") else ";"
182
+ cur = new()
183
+ continue
184
+ if set(t) <= set("<>&") and ("<" in t or ">" in t):
185
+ pending = t
186
+ continue
187
+ if re.fullmatch(r"\d?>&\d", t) or t in ("2>&1", ">&2", "1>&2"):
188
+ continue
189
+ cur["argv"].append(t)
190
+ if cur["argv"] or cur["redirs"]:
191
+ units.append(cur)
192
+ # Fold "2" fd prefixes: tokenizer yields "2" then ">" for "2>"; drop a bare
193
+ # digit argv word that is followed by a redirection of the same unit.
194
+ for u in units:
195
+ if u["argv"] and u["redirs"] and re.fullmatch(r"[0-9]", u["argv"][-1]):
196
+ u["argv"] = u["argv"][:-1]
197
+ return units
198
+
199
+
200
+ def strip_prefixes(argv: List[str]) -> List[str]:
201
+ argv = [a for a in argv if a != ""]
202
+ while argv and (re.match(r"^[A-Za-z_]\w*=", argv[0]) or argv[0] in WRAPPERS):
203
+ if argv[0] == "env":
204
+ argv = argv[1:]
205
+ while argv and (argv[0].startswith("-") or re.match(r"^[A-Za-z_]\w*=", argv[0])):
206
+ argv = argv[1:]
207
+ continue
208
+ argv = argv[1:]
209
+ if argv and argv[0] == "timeout":
210
+ rest = argv[1:]
211
+ while rest and rest[0].startswith("-"):
212
+ rest = rest[1:]
213
+ argv = rest[1:] if rest else []
214
+ return argv
215
+
216
+
217
+ def path_like(tok: str) -> bool:
218
+ if not tok or tok.startswith("-") or tok in (".", "..", "/", "~"):
219
+ return False
220
+ if tok.startswith(("/dev/", "http://", "https://")) or tok == "/dev/null":
221
+ return False
222
+ if tok.startswith("/proc/") and not tok.startswith("/proc/self/cwd/"):
223
+ return False
224
+ if any(c in tok for c in "{}$*?!()<>\"'`=\n|;"):
225
+ return False
226
+ if tok.isdigit():
227
+ return False
228
+ return True
229
+
230
+
231
+ def resolve(path: str, cwd: str) -> str:
232
+ if path.startswith("~"):
233
+ path = "/root" + path[1:]
234
+ if path.startswith("/proc/self/cwd/"):
235
+ # the shell's working directory, spelled through procfs
236
+ path = posixpath.join(cwd or "/", path[len("/proc/self/cwd/"):])
237
+ p = path if path.startswith("/") else posixpath.join(cwd or "/", path)
238
+ return posixpath.normpath(p)
239
+
240
+
241
+ # --- sed ---------------------------------------------------------------
242
+
243
+ def _read_delimited(s: str, i: int, d: str) -> Tuple[str, int]:
244
+ """Read text up to the next unescaped delimiter d starting at s[i]."""
245
+ out = []
246
+ while i < len(s):
247
+ c = s[i]
248
+ if c == "\\" and i + 1 < len(s):
249
+ if s[i + 1] == d:
250
+ out.append(d)
251
+ else:
252
+ out.append(c + s[i + 1])
253
+ i += 2
254
+ continue
255
+ if c == d:
256
+ return "".join(out), i + 1
257
+ out.append(c)
258
+ i += 1
259
+ return "".join(out), -1
260
+
261
+
262
+ def parse_sed_script(script: str) -> List[Dict]:
263
+ """Parse a sed script into commands with addresses. Unknown syntax gives
264
+ an entry with cmd '?'."""
265
+ cmds = []
266
+ i, n = 0, len(script)
267
+ while i < n:
268
+ while i < n and script[i] in " \t\n;":
269
+ i += 1
270
+ if i >= n:
271
+ break
272
+ addr = []
273
+ for k in range(2):
274
+ if k == 1 and i + 1 < n and script[i] in "+~" and script[i + 1].isdigit():
275
+ # GNU extension: addr,+N (N lines after) or addr,~N (to a multiple of N)
276
+ j = i + 1
277
+ while j < n and script[j].isdigit():
278
+ j += 1
279
+ addr.append({"rel" if script[i] == "+" else "mult": int(script[i + 1:j])})
280
+ i = j
281
+ break
282
+ if i < n and script[i].isdigit():
283
+ j = i
284
+ while j < n and (script[j].isdigit()):
285
+ j += 1
286
+ addr.append({"line": int(script[i:j])})
287
+ i = j
288
+ elif i < n and script[i] == "$":
289
+ addr.append({"line": "$"})
290
+ i += 1
291
+ elif i < n and script[i] in "/\\":
292
+ d = "/"
293
+ if script[i] == "\\" and i + 1 < n:
294
+ d = script[i + 1]
295
+ i += 1
296
+ pat, j = _read_delimited(script, i + 1, d)
297
+ if j < 0:
298
+ return cmds + [{"cmd": "?", "raw": script[i:]}]
299
+ addr.append({"re": pat})
300
+ i = j
301
+ while i < n and script[i] in "I":
302
+ i += 1
303
+ else:
304
+ break
305
+ if i < n and script[i] == ",":
306
+ i += 1
307
+ continue
308
+ break
309
+ while i < n and script[i] in " \t":
310
+ i += 1
311
+ if i < n and script[i] == "!":
312
+ i += 1
313
+ if i >= n:
314
+ cmds.append({"addr": addr, "cmd": "p"})
315
+ break
316
+ c = script[i]
317
+ if c == "s" and i + 1 < n:
318
+ d = script[i + 1]
319
+ pat, j = _read_delimited(script, i + 2, d)
320
+ if j < 0:
321
+ return cmds + [{"cmd": "?", "raw": script[i:]}]
322
+ rep, k = _read_delimited(script, j, d)
323
+ if k < 0:
324
+ return cmds + [{"cmd": "?", "raw": script[i:]}]
325
+ m = re.match(r"[gpiIeEmM0-9w]*", script[k:])
326
+ flags = m.group(0) if m else ""
327
+ cmds.append({"addr": addr, "cmd": "s", "pat": pat, "rep": rep, "flags": flags})
328
+ i = k + len(flags)
329
+ elif c in "aic":
330
+ j = i + 1
331
+ backslash = False
332
+ if j < n and script[j] == "\\":
333
+ j += 1
334
+ backslash = True
335
+ if j < n and script[j] == "\n":
336
+ j += 1
337
+ while j < n and script[j] in " \t" and not backslash:
338
+ j += 1
339
+ # GNU one-liner: text runs to end of line (or end of script)
340
+ end = script.find("\n", j)
341
+ text_lines = []
342
+ if end < 0:
343
+ text_lines.append(script[j:])
344
+ i = n
345
+ else:
346
+ text_lines.append(script[j:end])
347
+ i = end + 1
348
+ # continuation lines ending with backslash
349
+ while text_lines[-1].endswith("\\") and i < n:
350
+ text_lines[-1] = text_lines[-1][:-1]
351
+ end = script.find("\n", i)
352
+ if end < 0:
353
+ text_lines.append(script[i:])
354
+ i = n
355
+ else:
356
+ text_lines.append(script[i:end])
357
+ i = end + 1
358
+ text = "\n".join(text_lines).replace("\\n", "\n")
359
+ cmds.append({"addr": addr, "cmd": c, "text": text})
360
+ elif c in "dpPDNnqQ=hHgGxlz":
361
+ cmds.append({"addr": addr, "cmd": c})
362
+ i += 1
363
+ elif c == "y" and i + 1 < n:
364
+ d = script[i + 1]
365
+ a, j = _read_delimited(script, i + 2, d)
366
+ b, k = _read_delimited(script, j, d) if j > 0 else ("", -1)
367
+ cmds.append({"addr": addr, "cmd": "y", "a": a, "b": b})
368
+ i = k if k > 0 else n
369
+ elif c in "{}":
370
+ cmds.append({"addr": addr, "cmd": c})
371
+ i += 1
372
+ elif c in "rwRW":
373
+ end = script.find("\n", i)
374
+ cmds.append({"addr": addr, "cmd": c, "file": script[i + 1:end if end > 0 else n].strip()})
375
+ i = end + 1 if end > 0 else n
376
+ else:
377
+ cmds.append({"cmd": "?", "raw": script[i:]})
378
+ break
379
+ return cmds
380
+
381
+
382
+ def _sed_parts(argv: List[str]):
383
+ """(script_texts, files, inplace, quiet, has_z, has_E)."""
384
+ scripts, files = [], []
385
+ inplace = quiet = has_z = has_E = False
386
+ i = 1
387
+ had_e = False
388
+ while i < len(argv):
389
+ a = argv[i]
390
+ if a in ("-e", "--expression") and i + 1 < len(argv):
391
+ scripts.append(argv[i + 1])
392
+ had_e = True
393
+ i += 2
394
+ continue
395
+ if a.startswith("--expression="):
396
+ scripts.append(a.split("=", 1)[1])
397
+ had_e = True
398
+ elif a == "-f" and i + 1 < len(argv):
399
+ scripts.append("__FILE__:" + argv[i + 1])
400
+ had_e = True
401
+ i += 2
402
+ continue
403
+ elif a.startswith("--in-place") or (a.startswith("-") and not a.startswith("--") and "i" in a[1:].split("=")[0][:1] + ("i" if re.match(r"^-[a-zA-Z]*i", a) else "")):
404
+ inplace = True
405
+ if re.match(r"^-[a-zA-Z]*n", a):
406
+ quiet = True
407
+ if "E" in a or "r" in a[1:2]:
408
+ has_E = True
409
+ elif a in ("-n", "--quiet", "--silent") or re.match(r"^-[a-zA-Z]*n[a-zA-Z]*$", a):
410
+ quiet = True
411
+ if "i" in a:
412
+ inplace = True
413
+ if "E" in a or "r" in a:
414
+ has_E = True
415
+ elif a in ("-z", "--null-data") or re.match(r"^-[a-zA-Z]*z", a):
416
+ has_z = True
417
+ elif a in ("-E", "-r", "--regexp-extended"):
418
+ has_E = True
419
+ elif a.startswith("-") and len(a) > 1:
420
+ if re.match(r"^-[a-zA-Z]+$", a):
421
+ if "i" in a:
422
+ inplace = True
423
+ if "n" in a:
424
+ quiet = True
425
+ if "E" in a or "r" in a:
426
+ has_E = True
427
+ if "z" in a:
428
+ has_z = True
429
+ elif a == "" and inplace:
430
+ pass # BSD backup suffix
431
+ else:
432
+ if not had_e and not scripts:
433
+ scripts.append(a)
434
+ else:
435
+ files.append(a)
436
+ i += 1
437
+ return scripts, files, inplace, quiet, has_z, has_E
438
+
439
+
440
+ def sed_view_range(cmds: List[Dict]) -> Optional[Tuple[int, int]]:
441
+ """For `sed -n 'a,bp'` (or 'Np'), the printed line range."""
442
+ ps = [c for c in cmds if c.get("cmd") == "p"]
443
+ if len(ps) != 1 or len(cmds) > 2:
444
+ return None
445
+ addr = ps[0].get("addr") or []
446
+ if len(addr) == 1 and isinstance(addr[0].get("line"), int):
447
+ return addr[0]["line"], addr[0]["line"]
448
+ if len(addr) == 2 and isinstance(addr[0].get("line"), int):
449
+ if isinstance(addr[1].get("rel"), int):
450
+ return addr[0]["line"], addr[0]["line"] + addr[1]["rel"]
451
+ b = addr[1].get("line")
452
+ return addr[0]["line"], (b if isinstance(b, int) else 10 ** 9)
453
+ return None
454
+
455
+
456
+ # --- python scripts ------------------------------------------------------
457
+
458
+ _PY_STR = r"""(?:[rbfuRBFU]{0,2})(?:'''(?:.|\n)*?'''|\"\"\"(?:.|\n)*?\"\"\"|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*")"""
459
+ _PY_REPLACE = re.compile(r"\.replace\(\s*(" + _PY_STR + r")\s*,\s*(" + _PY_STR + r")")
460
+ _PY_RESUB = re.compile(r"re\.sub[n]?\(\s*(" + _PY_STR + r")\s*,\s*(" + _PY_STR + r")?")
461
+ _PY_OPEN = re.compile(r"""open\(\s*(?P<arg>[^,)]+?)\s*(?:,\s*(?:mode\s*=\s*)?(?P<mode>[rbfu]?['"][^'"]*['"]))?\s*[,)]""")
462
+ _PY_ASSIGN = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*=\s*(?:(?:pathlib\.)?Path\(\s*)?(?P<s>[rbfu]?(['"])(?:[^'"\n]+)\3)""")
463
+ _PY_PATHWRITE = re.compile(r"""(?:(?:pathlib\.)?Path\(\s*(?P<lit>[rbfu]?(['"])[^'"\n]+\2)\s*\)|(?P<v>\b[A-Za-z_]\w*))\s*\.write_(?:text|bytes)\(""")
464
+ _PY_PATHREAD = re.compile(r"""(?:(?:pathlib\.)?Path\(\s*(?P<lit>[rbfu]?(['"])[^'"\n]+\2)\s*\)|(?P<v>\b[A-Za-z_]\w*))\s*\.read_(?:text|bytes)\(""")
465
+ _PY_LINEOPS = re.compile(r"\w*lines\[\s*[\w+\- ]+\]\s*=|\.insert\(\s*[\w+\- ]+,|del\s+\w+\[|\w+\[\s*\d+\s*:\s*\d+\s*\]|splitlines\(\)\[")
466
+ _PY_FIND = re.compile(r"\.(?:find|index|rfind|rindex|startswith|count)\(\s*(" + _PY_STR + r")|(" + _PY_STR + r")\s+(?:not\s+)?in\s+\w+")
467
+
468
+
469
+ def py_literal(tok: str) -> Optional[str]:
470
+ try:
471
+ import ast
472
+ import warnings
473
+ with warnings.catch_warnings():
474
+ warnings.simplefilter("ignore")
475
+ v = ast.literal_eval(tok)
476
+ if isinstance(v, bytes):
477
+ v = v.decode("utf-8", "replace")
478
+ return v if isinstance(v, str) else None
479
+ except Exception:
480
+ return None
481
+
482
+
483
+ def python_script_ops(script: str, cwd: str, strict: bool = False) -> Tuple[List[Dict], List[str]]:
484
+ """(write ops, read paths) of a Python script, by literal or variable."""
485
+ if not script:
486
+ return [], []
487
+ names: Dict[str, str] = {}
488
+ for m in _PY_ASSIGN.finditer(script):
489
+ lit = py_literal(m.group("s"))
490
+ if lit:
491
+ names[m.group("v")] = lit
492
+
493
+ def arg_path(arg: str) -> Optional[str]:
494
+ arg = arg.strip()
495
+ lit = py_literal(arg)
496
+ if lit is not None:
497
+ return lit
498
+ m = re.fullmatch(r"(?:pathlib\.)?Path\(\s*(" + _PY_STR + r")\s*\)", arg)
499
+ if m:
500
+ return py_literal(m.group(1))
501
+ if re.fullmatch(r"[A-Za-z_]\w*", arg):
502
+ return names.get(arg)
503
+ return None
504
+
505
+ writes, reads = [], []
506
+ wline: Dict[str, int] = {}
507
+
508
+ def line_of(pos: int) -> int:
509
+ return script.count("\n", 0, pos) + 1
510
+
511
+ for m in _PY_OPEN.finditer(script):
512
+ p = arg_path(m.group("arg"))
513
+ if not p:
514
+ continue
515
+ mode = py_literal(m.group("mode")) if m.group("mode") else "r"
516
+ mode = mode or "r"
517
+ if any(c in mode for c in "wa+x"):
518
+ writes.append((p, "a" in mode))
519
+ wline[p] = min(wline.get(p, 10 ** 9), line_of(m.start()))
520
+ else:
521
+ reads.append(p)
522
+ for m in _PY_PATHWRITE.finditer(script):
523
+ p = py_literal(m.group("lit")) if m.group("lit") else names.get(m.group("v") or "")
524
+ if p:
525
+ writes.append((p, False))
526
+ wline[p] = min(wline.get(p, 10 ** 9), line_of(m.start()))
527
+ for m in _PY_PATHREAD.finditer(script):
528
+ p = py_literal(m.group("lit")) if m.group("lit") else names.get(m.group("v") or "")
529
+ if p:
530
+ reads.append(p)
531
+ replaces = []
532
+ for m in _PY_REPLACE.finditer(script):
533
+ a, b = py_literal(m.group(1)), py_literal(m.group(2))
534
+ if a is not None and b is not None:
535
+ replaces.append({"old": a, "new": b})
536
+ regex = []
537
+ for m in _PY_RESUB.finditer(script):
538
+ a = py_literal(m.group(1))
539
+ b = py_literal(m.group(2)) if m.group(2) else None
540
+ if a is not None:
541
+ regex.append({"pat": a, "rep": b})
542
+ # compiled patterns: pat = re.compile(LIT) ... pat.sub(REP, text)
543
+ compiled = {}
544
+ for m in re.finditer(r"(\b[A-Za-z_]\w*)\s*=\s*re\.compile\(\s*(" + _PY_STR + r")", script):
545
+ lit = py_literal(m.group(2))
546
+ if lit is not None:
547
+ compiled[m.group(1)] = lit
548
+ for m in re.finditer(r"(\b[A-Za-z_]\w*)\.subn?\(\s*(" + _PY_STR + r")?", script):
549
+ if m.group(1) in compiled:
550
+ regex.append({"pat": compiled[m.group(1)], "rep": py_literal(m.group(2)) if m.group(2) else None})
551
+ line_ops = bool(_PY_LINEOPS.search(script))
552
+ finds = []
553
+ for m in _PY_FIND.finditer(script):
554
+ lit = py_literal(m.group(1) or m.group(2) or "")
555
+ if lit and len(lit) >= 3:
556
+ finds.append(lit)
557
+ ops = []
558
+ read_set = {resolve(r, cwd) for r in reads if path_like(r)}
559
+ for p, append in dict.fromkeys(writes):
560
+ if not path_like(p):
561
+ continue
562
+ ap = resolve(p, cwd)
563
+ first_write_line = wline.get(p)
564
+ self_read = ap in read_set
565
+ if append:
566
+ ops.append({"op": "W", "path": ap, "kind": "append", "mech": "python",
567
+ "anchor": None, "write_line": first_write_line})
568
+ elif self_read and (replaces or regex or (not strict and (line_ops or finds))):
569
+ ops.append({"op": "W", "path": ap, "kind": "anchored", "mech": "python",
570
+ "anchor": {"type": "py", "replaces": replaces, "regex": regex, "finds": finds[:20],
571
+ "line_ops": line_ops, "reads_self": True},
572
+ "write_line": first_write_line})
573
+ elif self_read:
574
+ # read-transform-write with no textual anchor (json.load/dump, a
575
+ # program updating its data file): a whole-file rewrite
576
+ ops.append({"op": "W", "path": ap, "kind": "rewrite", "mech": "python",
577
+ "anchor": None, "write_line": first_write_line})
578
+ else:
579
+ ops.append({"op": "W", "path": ap, "kind": "overwrite_unseen", "mech": "python",
580
+ "anchor": None, "write_line": first_write_line})
581
+ return ops, sorted(read_set)
582
+
583
+
584
+ # --- patches ---------------------------------------------------------------
585
+
586
+ _HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
587
+
588
+
589
+ def parse_unified(body: str, cwd: str, strip: int = 1) -> List[Dict]:
590
+ """Per-file hunks of a unified diff (or OpenAI apply_patch envelope)."""
591
+ out: Dict[str, Dict] = {}
592
+ cur = None
593
+ hunk = None
594
+ lines = (body or "").split("\n")
595
+ for ln in lines:
596
+ if ln.startswith("+++ "):
597
+ p = ln[4:].split("\t")[0].strip()
598
+ if p == "/dev/null":
599
+ cur = None
600
+ continue
601
+ parts = p.split("/")
602
+ p = "/".join(parts[strip:]) if strip and len(parts) > strip and p.startswith(("a/", "b/")) else p
603
+ cur = out.setdefault(resolve(p, cwd), {"hunks": []})
604
+ hunk = None
605
+ elif ln.startswith("*** Update File:") or ln.startswith("*** Add File:"):
606
+ p = ln.split(":", 1)[1].strip()
607
+ cur = out.setdefault(resolve(p, cwd), {"hunks": [], "envelope": ln.split(":")[0][4:]})
608
+ hunk = None
609
+ elif ln.startswith("--- ") or ln.startswith("diff ") or ln.startswith("index ") or ln.startswith("*** "):
610
+ continue
611
+ elif cur is not None and (ln.startswith("@@")):
612
+ m = _HUNK_RE.match(ln)
613
+ hunk = {"old_start": int(m.group(1)) if m else None, "new_start": int(m.group(3)) if m else None,
614
+ "lines": []}
615
+ cur["hunks"].append(hunk)
616
+ elif cur is not None and ln[:1] in (" ", "+", "-"):
617
+ if hunk is None:
618
+ hunk = {"old_start": None, "new_start": None, "lines": []}
619
+ cur["hunks"].append(hunk)
620
+ hunk["lines"].append(ln)
621
+ return [{"path": p, **v} for p, v in out.items()]
622
+
623
+
624
+ def hunk_texts(h: Dict) -> Tuple[str, str]:
625
+ old = [ln[1:] for ln in h["lines"] if ln[:1] in (" ", "-")]
626
+ new = [ln[1:] for ln in h["lines"] if ln[:1] in (" ", "+")]
627
+ return "\n".join(old), "\n".join(new)
628
+
629
+
630
+ # --- main extractor ---------------------------------------------------------
631
+
632
+ def _grep_parts(argv):
633
+ flags, operands = [], []
634
+ i = 1
635
+ arg_flags = {"-e", "-f", "--regexp", "-A", "-B", "-C", "-m", "--max-count", "--include", "--exclude",
636
+ "--exclude-dir", "-g", "--glob", "-t", "--type", "-d", "-D"}
637
+ pats = []
638
+ while i < len(argv):
639
+ a = argv[i]
640
+ if a in arg_flags and i + 1 < len(argv):
641
+ if a in ("-e", "--regexp"):
642
+ pats.append(argv[i + 1])
643
+ flags.append(a)
644
+ i += 2
645
+ continue
646
+ if a.startswith("-") and len(a) > 1:
647
+ flags.append(a)
648
+ else:
649
+ operands.append(a)
650
+ i += 1
651
+ if not pats and operands:
652
+ pats.append(operands[0])
653
+ operands = operands[1:]
654
+ no_content = any(f in ("-l", "-L", "-c", "-q", "--files-with-matches", "--files-without-match", "--count",
655
+ "--quiet", "--silent") or (re.match(r"^-[a-zA-Z]+$", f) and set(f[1:]) & set("lLcq"))
656
+ for f in flags)
657
+ numbered = any(f in ("-n", "--line-number") or (re.match(r"^-[a-zA-Z]+$", f) and "n" in f) for f in flags)
658
+ return operands, no_content, numbered, pats
659
+
660
+
661
+ def head_tail_n(argv) -> Optional[int]:
662
+ for i, a in enumerate(argv[1:], 1):
663
+ m = re.fullmatch(r"-n\s*(\d+)|-(\d+)|--lines=(\d+)", a)
664
+ if m:
665
+ return int(next(g for g in m.groups() if g))
666
+ if a == "-n" and i + 1 < len(argv) and re.fullmatch(r"\+?\d+", argv[i + 1]):
667
+ return int(argv[i + 1].lstrip("+"))
668
+ return None
669
+
670
+
671
+ _DEPTH = [0]
672
+
673
+
674
+ def extract(cmd: str, cwd: str, known_files: Optional[Dict[str, str]] = None,
675
+ persist_cd: bool = True) -> Tuple[List[Dict], str, Dict]:
676
+ """File operations of one shell command. Returns (ops, cwd_after, info).
677
+
678
+ known_files maps absolute path -> text for files whose content the agent
679
+ wrote itself (heredoc scripts), so `python3 fix.py` can be analysed."""
680
+ known_files = known_files if known_files is not None else {}
681
+ units = simple_commands(cmd)
682
+ info = {"unparsed": any(u.get("unparsed") for u in units), "n_units": len(units), "verbs": []}
683
+ ops: List[Dict] = []
684
+ cwd0 = cwd
685
+ stack = []
686
+ # which pipes end in a sink or a no-content grep
687
+ last_in_pipe: Dict[int, Dict] = {}
688
+ for u in units:
689
+ if not u.get("paren"):
690
+ last_in_pipe[u["pipe"]] = u
691
+ pipe_members: Dict[int, List[Dict]] = {}
692
+ for u in units:
693
+ pipe_members.setdefault(u["pipe"], []).append(u)
694
+
695
+ def pipe_swallows(u) -> bool:
696
+ """Content produced by u does not reach the agent."""
697
+ members = pipe_members.get(u["pipe"], [])
698
+ idx = members.index(u)
699
+ downstream = members[idx + 1:]
700
+ # redirected stdout of u itself
701
+ for op, t in u["redirs"]:
702
+ if op in (">", ">>", ">|", "&>", "&>>", "1>", "1>>") and t not in ("/dev/stderr",):
703
+ return True
704
+ for v in downstream:
705
+ argv = strip_prefixes(v["argv"])
706
+ verb = argv[0].split("/")[-1] if argv else ""
707
+ if verb in SINKS:
708
+ return True
709
+ if verb in GREP_VERBS:
710
+ _, no_content, _, _ = _grep_parts(argv)
711
+ if no_content:
712
+ return True
713
+ if verb in ("tee",) and all(t.startswith("/dev/") for t in argv[1:] if not t.startswith("-")) is False:
714
+ # tee writes a copy but still prints: content reaches the agent
715
+ pass
716
+ if downstream:
717
+ last = downstream[-1]
718
+ for op, t in last["redirs"]:
719
+ if op in (">", ">>", ">|", "&>", "&>>", "1>", "1>>") and t != "/dev/stderr":
720
+ return True
721
+ return False
722
+
723
+ def filtered(u) -> bool:
724
+ """Content passes through a filter (grep/head/tail/sed/awk) downstream."""
725
+ members = pipe_members.get(u["pipe"], [])
726
+ idx = members.index(u)
727
+ for v in members[idx + 1:]:
728
+ argv = strip_prefixes(v["argv"])
729
+ verb = argv[0].split("/")[-1] if argv else ""
730
+ if verb in GREP_VERBS or verb in ("head", "tail", "sed", "awk", "cut", "sort", "uniq", "tr", "less", "more"):
731
+ return True
732
+ return False
733
+
734
+ literal_pipes = set()
735
+ for u in units:
736
+ argv = strip_prefixes(u["argv"])
737
+ verb = argv[0].split("/")[-1] if argv else ""
738
+ if verb in LITERAL_SOURCES or (verb == "cat" and u["heredoc"] is not None and
739
+ not [a for a in argv[1:] if not a.startswith("-")]):
740
+ literal_pipes.add(u["pipe"])
741
+
742
+ for ui, u in enumerate(units):
743
+ if u.get("paren"):
744
+ if u["argv"][0] == "(":
745
+ stack.append(cwd)
746
+ elif stack:
747
+ cwd = stack.pop()
748
+ continue
749
+ argv = strip_prefixes(u["argv"])
750
+ verb = argv[0].split("/")[-1] if argv else ""
751
+ info["verbs"].append(verb)
752
+ guard = u["guard"]
753
+ literal = u["pipe"] in literal_pipes
754
+ new_ops: List[Dict] = []
755
+ operands = [a for a in argv[1:] if not a.startswith("-")]
756
+
757
+ if verb == "cd":
758
+ tgt = operands[0] if operands else "~"
759
+ if tgt == "-" or any(c in tgt for c in "$`*?{}"):
760
+ continue # previous or computed directory: unknown, keep cwd
761
+ cwd = resolve(tgt, cwd)
762
+ continue
763
+ if verb in ("pushd",) and operands:
764
+ stack.append(cwd)
765
+ cwd = resolve(operands[0], cwd)
766
+ continue
767
+ if verb == "popd" and stack:
768
+ cwd = stack.pop()
769
+ continue
770
+
771
+ swallowed = pipe_swallows(u)
772
+ if verb in VIEW_VERBS:
773
+ files = [a for a in operands if path_like(a)]
774
+ if verb in ("head", "tail"):
775
+ files = [a for a in argv[1:] if path_like(a) and not re.fullmatch(r"\+?\d+", a)]
776
+ # drop the numeric argument of -n
777
+ cleaned = []
778
+ skip = False
779
+ for a in argv[1:]:
780
+ if skip:
781
+ skip = False
782
+ continue
783
+ if a in ("-n", "-c", "--lines", "--bytes"):
784
+ skip = True
785
+ continue
786
+ if not a.startswith("-") and path_like(a):
787
+ cleaned.append(a)
788
+ files = cleaned
789
+ for op_, t in u["redirs"]:
790
+ if op_ == "<":
791
+ files.append(t)
792
+ for f in files:
793
+ if swallowed:
794
+ continue
795
+ view = {}
796
+ if verb == "head":
797
+ n = head_tail_n(argv) or 10
798
+ view = {"range": [1, n]}
799
+ elif verb == "tail":
800
+ view = {"tail": head_tail_n(argv) or 10}
801
+ elif verb in ("cat", "nl", "bat", "batcat", "less", "more", "tac"):
802
+ view = {"range": [1, 10 ** 9]}
803
+ if verb == "nl" or "-n" in argv:
804
+ view["numbered"] = True
805
+ kind = "full" if view.get("range") == [1, 10 ** 9] else "range"
806
+ if filtered(u):
807
+ kind, view = "lines", {"filtered": True}
808
+ new_ops.append({"op": "R", "path": resolve(f, cwd), "kind": kind, "mech": verb, "view": view})
809
+ elif verb in GREP_VERBS:
810
+ files, no_content, numbered, pats = _grep_parts(argv)
811
+ recursive = any(re.match(r"^-[a-zA-Z]*[rR]", f) or f in ("--recursive",) for f in argv[1:]
812
+ if f.startswith("-")) or verb in ("rg", "ag")
813
+ if not no_content and not swallowed:
814
+ for f in files:
815
+ if path_like(f):
816
+ new_ops.append({"op": "R", "path": resolve(f, cwd), "kind": "lines", "mech": verb,
817
+ "view": {"grep": pats[:1], "numbered": numbered,
818
+ "recursive": recursive}})
819
+ elif verb == "sed":
820
+ scripts, files, inplace, quiet, has_z, has_E = _sed_parts(argv)
821
+ files = [f for f in files if path_like(f)] + [t for o, t in u["redirs"] if o == "<"]
822
+ cmds = []
823
+ for s in scripts:
824
+ if s.startswith("__FILE__:"):
825
+ cmds.append({"cmd": "?", "raw": s})
826
+ else:
827
+ cmds.extend(parse_sed_script(s))
828
+ if inplace:
829
+ for f in files:
830
+ new_ops.append({"op": "W", "path": resolve(f, cwd), "kind": "anchored", "mech": "sed",
831
+ "anchor": {"type": "sed", "cmds": cmds, "z": has_z, "E": has_E, "raw": scripts}})
832
+ elif not swallowed:
833
+ rng = sed_view_range(cmds) if quiet else None
834
+ for f in files:
835
+ if rng:
836
+ view = {"range": list(rng)}
837
+ kind = "range"
838
+ elif not quiet and not any(c.get("cmd") in ("d",) for c in cmds):
839
+ view, kind = {"transformed": True}, "lines"
840
+ else:
841
+ view, kind = {"sed": True}, "lines"
842
+ if filtered(u):
843
+ kind, view = "lines", {"filtered": True, **view}
844
+ new_ops.append({"op": "R", "path": resolve(f, cwd), "kind": kind, "mech": "sed", "view": view})
845
+ elif verb in ("awk", "gawk", "mawk"):
846
+ inplace = "inplace" in argv
847
+ prog, files = None, []
848
+ skip = False
849
+ for i, a in enumerate(argv[1:], 1):
850
+ if skip:
851
+ skip = False
852
+ continue
853
+ if a in ("-f", "-v", "-i", "-F"):
854
+ skip = True
855
+ if a == "-f" and i + 1 < len(argv):
856
+ prog = "__FILE__"
857
+ continue
858
+ if a.startswith("-"):
859
+ continue
860
+ if prog is None:
861
+ prog = a
862
+ else:
863
+ files.append(a)
864
+ files = [f for f in files if path_like(f)]
865
+ if inplace:
866
+ for f in files:
867
+ new_ops.append({"op": "W", "path": resolve(f, cwd), "kind": "anchored", "mech": "awk",
868
+ "anchor": {"type": "awk", "program": prog}})
869
+ elif not swallowed:
870
+ for f in files:
871
+ new_ops.append({"op": "R", "path": resolve(f, cwd), "kind": "lines", "mech": "awk",
872
+ "view": {"awk": prog}})
873
+ elif verb == "perl":
874
+ files, script, skip, inplace, printing = [], None, False, False, False
875
+ for a in argv[1:]:
876
+ if skip:
877
+ script = a
878
+ skip = False
879
+ continue
880
+ if a.startswith("-") and not a.startswith("--"):
881
+ if "i" in a[1:]:
882
+ inplace = True
883
+ if set(a[1:]) & set("pn"):
884
+ printing = True
885
+ if a[-1] in "eE":
886
+ skip = True
887
+ continue
888
+ files.append(a)
889
+ if script is None and files:
890
+ script, files = files[0], files[1:]
891
+ files = [f for f in files if path_like(f)]
892
+ subs = []
893
+ for m in re.finditer(r"s(.)(.*?)(?<!\\)\1(.*?)(?<!\\)\1([a-z]*)", script or "", re.S):
894
+ subs.append({"pat": m.group(2), "rep": m.group(3), "flags": m.group(4)})
895
+ if inplace:
896
+ for f in files:
897
+ new_ops.append({"op": "W", "path": resolve(f, cwd), "kind": "anchored", "mech": "perl",
898
+ "anchor": {"type": "perl", "subs": subs, "script": script}})
899
+ elif printing and not swallowed:
900
+ for f in files:
901
+ new_ops.append({"op": "R", "path": resolve(f, cwd), "kind": "lines", "mech": "perl", "view": {}})
902
+ elif script:
903
+ w, r = python_script_ops("", cwd) # perl scripts: literal open() rarely used; skip
904
+ elif verb.startswith(INTERP_PREFIX) or verb in ("ipython",):
905
+ script = None
906
+ src = None
907
+ if "-c" in argv and argv.index("-c") + 1 < len(argv):
908
+ script = argv[argv.index("-c") + 1]
909
+ src = "-c"
910
+ elif u["heredoc"] is not None:
911
+ script = u["heredoc"]
912
+ src = "heredoc"
913
+ else:
914
+ for a in argv[1:]:
915
+ if a.startswith("-"):
916
+ if a == "-m":
917
+ break
918
+ continue
919
+ ap = resolve(a, cwd)
920
+ if ap in known_files:
921
+ script = known_files[ap]
922
+ src = "file:" + ap
923
+ break
924
+ if script is not None:
925
+ # a program run by path (not an inline edit script) counts as an
926
+ # anchored edit only when it performs literal text substitution
927
+ w, r = python_script_ops(script, cwd, strict=bool(src and src.startswith("file:")))
928
+ for o in w:
929
+ o["script_src"] = src
930
+ new_ops.extend(w)
931
+ elif verb in ("cp", "mv", "install"):
932
+ targets = [a for a in operands if path_like(a)]
933
+ if len(targets) >= 2:
934
+ dst = resolve(targets[-1], cwd)
935
+ for s in targets[:-1]:
936
+ sp = resolve(s, cwd)
937
+ d_ = dst
938
+ new_ops.append({"op": "W", "path": d_, "kind": "replace", "mech": verb, "src": sp,
939
+ "dst_may_be_dir": True})
940
+ if verb == "mv":
941
+ new_ops.append({"op": "W", "path": sp, "kind": "delete", "mech": "mv"})
942
+ elif verb == "rm":
943
+ for a in operands:
944
+ if path_like(a):
945
+ new_ops.append({"op": "W", "path": resolve(a, cwd), "kind": "delete", "mech": "rm"})
946
+ elif verb == "touch":
947
+ for a in operands:
948
+ if path_like(a):
949
+ new_ops.append({"op": "W", "path": resolve(a, cwd), "kind": "touch", "mech": "touch"})
950
+ elif verb == "tee":
951
+ app = "-a" in argv or "--append" in argv
952
+ for a in operands:
953
+ if path_like(a):
954
+ new_ops.append({"op": "W", "path": resolve(a, cwd),
955
+ "kind": "append" if app else ("overwrite" if literal else "replace"),
956
+ "mech": "tee", "text": _literal_text(units, u) if literal else None})
957
+ elif verb == "truncate":
958
+ for a in operands:
959
+ if path_like(a) and not re.fullmatch(r"\d+", a):
960
+ new_ops.append({"op": "W", "path": resolve(a, cwd), "kind": "replace", "mech": "truncate"})
961
+ elif verb in ("patch",):
962
+ body = u["heredoc"]
963
+ strip = 1
964
+ for a in argv[1:]:
965
+ m = re.fullmatch(r"-p(\d+)", a)
966
+ if m:
967
+ strip = int(m.group(1))
968
+ named = [a for a in operands if path_like(a) and not a.endswith((".patch", ".diff"))]
969
+ pfile = None
970
+ for op_, t in u["redirs"]:
971
+ if op_ == "<":
972
+ pfile = resolve(t, cwd)
973
+ for a in argv[1:]:
974
+ if a.startswith("-i") and len(a) > 2:
975
+ pfile = resolve(a[2:], cwd)
976
+ if body is None and pfile and pfile in known_files:
977
+ body = known_files[pfile]
978
+ files = parse_unified(body or "", cwd, strip)
979
+ if files:
980
+ for fobj in files:
981
+ new_ops.append({"op": "W", "path": fobj["path"], "kind": "anchored", "mech": "patch",
982
+ "anchor": {"type": "patch", "hunks": fobj["hunks"]}})
983
+ for a in named:
984
+ new_ops.append({"op": "W", "path": resolve(a, cwd), "kind": "anchored", "mech": "patch",
985
+ "anchor": {"type": "patch", "hunks": []}})
986
+ elif verb in ("applypatch", "apply_patch"):
987
+ files = parse_unified(u["heredoc"] or (argv[1] if len(argv) > 1 else ""), cwd, 0)
988
+ for fobj in files:
989
+ new_ops.append({"op": "W", "path": fobj["path"], "kind": "anchored", "mech": "applypatch",
990
+ "anchor": {"type": "patch", "hunks": fobj["hunks"]}})
991
+ elif verb in ("ed", "ex"):
992
+ files = [a for a in operands if path_like(a)]
993
+ for f in files:
994
+ new_ops.append({"op": "W", "path": resolve(f, cwd), "kind": "anchored", "mech": "ed",
995
+ "anchor": {"type": "ed", "script": u["heredoc"] or ""}})
996
+ elif verb == "git" and operands:
997
+ sub = operands[0]
998
+ rest = operands[1:]
999
+ after_dd = argv[argv.index("--") + 1:] if "--" in argv else None
1000
+ if sub == "apply":
1001
+ body = u["heredoc"]
1002
+ pf = [a for a in rest if path_like(a)]
1003
+ if body is None and pf and resolve(pf[0], cwd) in known_files:
1004
+ body = known_files[resolve(pf[0], cwd)]
1005
+ for fobj in parse_unified(body or "", cwd, 1):
1006
+ new_ops.append({"op": "W", "path": fobj["path"], "kind": "anchored", "mech": "git-apply",
1007
+ "anchor": {"type": "patch", "hunks": fobj["hunks"]}})
1008
+ elif sub in ("checkout", "restore"):
1009
+ named = after_dd if after_dd is not None else [r for r in rest if path_like(r) or r == "."]
1010
+ if "." in named or not named:
1011
+ if sub == "checkout" and rest and not after_dd and not any(path_like(r) for r in rest):
1012
+ new_ops.append({"op": "W", "path": "*", "kind": "restore", "mech": "git-checkout-branch"})
1013
+ else:
1014
+ new_ops.append({"op": "W", "path": "*", "kind": "restore", "mech": "git"})
1015
+ else:
1016
+ for p in named:
1017
+ new_ops.append({"op": "W", "path": resolve(p, cwd), "kind": "restore", "mech": "git"})
1018
+ elif sub == "stash" and (not rest or rest[0] in ("push", "save")):
1019
+ new_ops.append({"op": "W", "path": "*", "kind": "restore", "mech": "git-stash"})
1020
+ elif sub == "stash" and rest and rest[0] in ("pop", "apply"):
1021
+ new_ops.append({"op": "W", "path": "*", "kind": "replace", "mech": "git-stash-pop"})
1022
+ elif sub == "reset" and "--hard" in argv:
1023
+ new_ops.append({"op": "W", "path": "*", "kind": "restore", "mech": "git-reset"})
1024
+ elif sub in ("diff", "show") and not swallowed:
1025
+ named = after_dd if after_dd is not None else [r for r in rest if path_like(r) and "/" in r or
1026
+ re.search(r"\.[A-Za-z]{1,6}$", r or "")]
1027
+ for p in named:
1028
+ new_ops.append({"op": "R", "path": resolve(p, cwd), "kind": "lines", "mech": "git-diff",
1029
+ "view": {"diff": True}})
1030
+ elif verb == "str_replace_editor":
1031
+ pass # handled by the SWE-agent adapter
1032
+ elif verb in ("bash", "sh", "source", ".") and _DEPTH[0] < 2:
1033
+ # a shell script the agent wrote earlier in the trajectory: its
1034
+ # commands are the agent's commands
1035
+ script = None
1036
+ if "-c" in argv and argv.index("-c") + 1 < len(argv):
1037
+ script = argv[argv.index("-c") + 1]
1038
+ else:
1039
+ for a in argv[1:]:
1040
+ if not a.startswith("-"):
1041
+ script = known_files.get(resolve(a, cwd))
1042
+ break
1043
+ if script is None and u["heredoc"] is not None:
1044
+ script = u["heredoc"]
1045
+ if script:
1046
+ _DEPTH[0] += 1
1047
+ try:
1048
+ sub_ops, _, _ = extract(script, cwd, known_files, persist_cd=False)
1049
+ finally:
1050
+ _DEPTH[0] -= 1
1051
+ for so in sub_ops:
1052
+ so["via_script"] = True
1053
+ new_ops.extend(sub_ops)
1054
+
1055
+ # output redirections of this unit
1056
+ for op_, t in u["redirs"]:
1057
+ if not path_like(t) and not t.startswith("/tmp"):
1058
+ if not t.startswith("/"):
1059
+ continue
1060
+ if t.startswith("/dev/"):
1061
+ continue
1062
+ if op_ in (">", ">|", "&>", "1>"):
1063
+ text = _literal_text(units, u) if literal else None
1064
+ new_ops.append({"op": "W", "path": resolve(t, cwd), "kind": "overwrite" if literal else "replace",
1065
+ "mech": "redirect", "text": text,
1066
+ "derived_from": [o["path"] for o in new_ops if o["op"] == "R" or o.get("_src")]})
1067
+ elif op_ in (">>", "&>>", "1>>"):
1068
+ new_ops.append({"op": "W", "path": resolve(t, cwd), "kind": "append", "mech": "redirect",
1069
+ "text": _literal_text(units, u) if literal else None})
1070
+ # remember heredoc-created files (scripts, patches)
1071
+ if u["heredoc"] is not None:
1072
+ for o in new_ops:
1073
+ if o["op"] == "W" and o["kind"] == "overwrite" and o["mech"] in ("redirect", "tee"):
1074
+ known_files[o["path"]] = u["heredoc"]
1075
+ for o in new_ops:
1076
+ o["cmd_i"] = ui
1077
+ o["guard"] = guard
1078
+ if o.get("via_script"):
1079
+ # keep the inner command's verb (e.g. `ed` inside `bash x.sh`),
1080
+ # so "ed: command not found" marks it failed
1081
+ o["outer_verb"] = verb
1082
+ if o.get("verb") is None:
1083
+ o["verb"] = verb
1084
+ else:
1085
+ o["verb"] = verb
1086
+ ops.extend(new_ops)
1087
+ if not persist_cd:
1088
+ cwd = cwd0
1089
+ return ops, cwd, info
1090
+
1091
+
1092
+ def _literal_text(units, u) -> Optional[str]:
1093
+ """The literal text flowing into u's pipe (echo/printf args or a heredoc)."""
1094
+ for v in units:
1095
+ if v["pipe"] != u["pipe"]:
1096
+ continue
1097
+ argv = strip_prefixes(v["argv"])
1098
+ verb = argv[0].split("/")[-1] if argv else ""
1099
+ if verb == "cat" and v["heredoc"] is not None:
1100
+ return v["heredoc"] + "\n"
1101
+ if verb == "echo":
1102
+ args = [a for a in argv[1:] if a not in ("-e", "-n", "-E")]
1103
+ return " ".join(args) + ("" if "-n" in argv else "\n")
1104
+ if verb == "printf":
1105
+ return " ".join(argv[1:2]).replace("\\n", "\n")
1106
+ if v["heredoc"] is not None and verb in ("tee",):
1107
+ return v["heredoc"] + "\n"
1108
+ return None
1109
+
1110
+
1111
+ def _selftest():
1112
+ cases = [
1113
+ ("cd src && sed -i 's/a/b/' x.py", "/repo"),
1114
+ ("sed -n '10,20p' a.py", "/repo"),
1115
+ ("sed 's/a/b/' a.py > /tmp/o", "/repo"),
1116
+ ("cat <<'EOF' > fix.py\nimport re\np='a.py'\ns=open(p).read()\nopen(p,'w').write(s.replace('x = 1','x = 2'))\nEOF\npython3 fix.py", "/repo"),
1117
+ ("grep -n foo a.py | head", "/repo"),
1118
+ ("grep -c foo a.py", "/repo"),
1119
+ ("nl -ba a.py | sed -n '1,40p'", "/repo"),
1120
+ ("sed -i '42d;45s/foo/bar/' Makefile", "/repo"),
1121
+ ("sed -i '/pattern/a\\ new line' a.py", "/repo"),
1122
+ ("perl -0pi -e 's/foo\\n/bar/' a.py", "/repo"),
1123
+ ("python3 - <<'PY'\nfrom pathlib import Path\np=Path('a.py')\ns=p.read_text()\np.write_text(s.replace('a','b'))\nPY", "/repo"),
1124
+ ("applypatch << 'PATCH'\n*** Begin Patch\n*** Update File: a.py\n@@\n-x = 1\n+x = 2\n*** End Patch\nPATCH", "/repo"),
1125
+ ("(cd sub && cat f.txt) && cat g.txt", "/repo"),
1126
+ ("head -n 50 a.py", "/repo"),
1127
+ ("echo 'x' >> a.py && cat a.py | wc -l", "/repo"),
1128
+ ("git checkout -- a.py b.py", "/repo"),
1129
+ ("cp a.py a.py.bak && sed -i 's/x/y/' a.py", "/repo"),
1130
+ ]
1131
+ kf: Dict[str, str] = {}
1132
+ for c, cwd in cases:
1133
+ ops, cwd2, info = extract(c, cwd, kf)
1134
+ print(repr(c[:70]))
1135
+ for o in ops:
1136
+ print(" ", {k: v for k, v in o.items() if k not in ("guard",)})
1137
+ print(" cwd->", cwd2)
1138
+
1139
+
1140
+ if __name__ == "__main__":
1141
+ _selftest()