Files
Drew T 827295e241 tools+docs(phase-33.5): task 13.5 — the tools audit + the two dictionaries: tools/tool_census.py (two agreeing enumerations of 327 tool files; docstring/SETUP row/consumers/class derived from the tree; the authored half in config/tool_dictionary.tsv — phase · portability · the NEED each tool answers · what · adapts · verdict — with coverage asserted both ways) → docs/tool-index.md (need-keyed, KEEP-GEN, Reference-index row, wiki + how-to pointers), the kit's tools/MANIFEST.md regenerated (header states live 293 + superseded 28 = 321 rows), and the two verbatim corpora in-tree (Drew, confirmed S91): decomp-architect/corpus/tools/<phase>/ (302 copies + 28 superseded pointers + INDEX) and corpus/cookbook/ (the cookbook, its symptom index, the codegen map, a front page stating what transfers per compiler) — sha1-equal to their sources by tool_census --check in tools-health, regenerated by make kit-corpus; kit_lint exempts the corpus dirs (verbatim evidence) but syntax-checks them; G66 (consult the tool dictionary first) + G67 (translate an inherited idiom through its pass) + two memory seeds (34 at install); SETUP Step 6 installs docs/knowledge-corpus.md and checks the manifest against its own stated total; the ops-setup dictionary rows; the intake's Phase 7 cites G66/G67 and Phase 10 + Part C name the raw-cast → declared-symbol step; templates/layout-contract.md (the five-tool probe, a draft for the split). The review under Drew's criterion: 93 no-consumer tools (one Opus agent's draft, verified: 0 defects, every successor live, 0 live consumers, 0 collisions; four one-off verdicts overturned to STILL-NEEDED) → 34 retired by git mv to tools/sunset/ (28 superseded, 6 one-offs; README review table; SETUP rows moved; Archive-index group). Run 4 (fresh throwaway, the final kit): stopped on my Step-6 check (321 vs the live 293) → both sides derived → resumed → PASS 10/10, manifest 56 == 56, 4 commits, guardrails held (the one foreign path was the timeline regenerated by the detached tools-health). tools-health OK; doc_links --strict rc 0; audit_public OK over 6,842 paths; the purge probe PASSED (Phase 34's gate open). decision-log "P33.5 S91" + accelerators "P33.5 S91" banked; log + checkpoint (NEXT = task 14, xHigh, fresh session)
2026-09-07 22:09:15 -06:00

171 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Recover agent-written draft files from a workflow run's TRANSCRIPTS. (P30 S48)
WHY THIS EXISTS. A 28-agent wave wrote its drafts into one shared directory, and later agents
deleted earlier agents' files while tidying. 21 adversarially-VERIFIED cracks looked destroyed and
were written off; a 5M-token re-crack wave was launched before Drew asked the right question —
"can you just analyse the workflow results to get those back?"
They were all recoverable. Every agent tool call is recorded in the run transcript WITH ITS CONTENT,
so the transcript is the backup for anything an agent produced. Recovery is deterministic and costs
zero agent tokens.
THE METHOD, and why the two obvious shortcuts fail:
* Taking each `Write` record's content recovers only the drafts that were written once — agents
REFINE. (Measured: 8 of 21.)
* Taking each `Edit`'s `new_string` as a file is worse: it is a FRAGMENT, not a file.
So: replay the mutation history per (agent, file_path) — `Write` sets the content, `Edit` applies
old->new to the current content — and snapshot after EVERY mutation. The wanted file state is one
of those snapshots, usually but not always the last (an agent may end on a failed experiment).
(Measured: 20 of 21.)
* The last file never used Write/Edit at all — it was written by a shell heredoc. So also scan
`Bash` commands for `<<'EOF' … EOF` bodies. (Measured: 21 of 21.)
Snapshots are emitted newest-first so a caller can gate them in order and stop at the first MATCH;
`--gate` does exactly that when given the per-function asm subdir map.
tools/recover_drafts.py <run_dir> --names func_A,func_B --out .run/recover
tools/recover_drafts.py <run_dir> --names-json lost.json --out .run/recover \
--gate targets.json # [{"name":..., "sub":...}] -> runs match_one, writes recovered.json
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HEREDOC_RE = re.compile(r"<<\s*'?(\w+)'?\s*\n(.*?)\n\1", re.S)
def _tool_blocks(obj, out):
"""Every {name, input} tool-use block anywhere in a transcript record."""
if isinstance(obj, dict):
if isinstance(obj.get("input"), dict) and obj.get("name"):
out.append((obj["name"], obj["input"]))
for v in obj.values():
_tool_blocks(v, out)
elif isinstance(obj, list):
for v in obj:
_tool_blocks(v, out)
def snapshots(run_dir, names):
"""{fn: [content, ...]} newest-first, deduped, from every agent transcript in `run_dir`."""
want = set(names)
per_fn = {n: [] for n in want}
for path in sorted(glob.glob(os.path.join(run_dir, "agent-*.jsonl"))):
state = {} # file_path -> current content, PER AGENT
for line in open(path, errors="ignore"):
if not any(t in line for t in ('"Write"', '"Edit"', '"Bash"')):
continue
try:
rec = json.loads(line)
except Exception:
continue
ops = []
_tool_blocks(rec, ops)
for name, inp in ops:
if name == "Bash": # heredoc-written files
cmd = inp.get("command", "") or ""
for _tag, body in HEREDOC_RE.findall(cmd):
for fn in want:
if fn in body and "{" in body:
per_fn[fn].append(body)
continue
fp = inp.get("file_path", "") or ""
if not fp.endswith(".c"):
continue
fn = os.path.basename(fp)[:-2]
if fn not in want:
continue
if name == "Write":
state[fp] = inp.get("content", "")
else:
cur = state.get(fp)
if cur is None:
continue # an Edit to a file this agent never wrote
old, new = inp.get("old_string", ""), inp.get("new_string", "")
if not old or old not in cur:
continue # stale/failed edit — keep the prior state
state[fp] = cur.replace(old, new) if inp.get("replace_all") else cur.replace(old, new, 1)
per_fn[fn].append(state[fp])
out = {}
for fn, versions in per_fn.items():
seen, uniq = set(), []
for c in reversed(versions): # newest first
h = hash(c)
if h in seen:
continue
seen.add(h)
uniq.append(c)
if uniq:
out[fn] = uniq
return out
def gate(fn, path, sub):
r = subprocess.run(f"python3 tools/match_one.py {fn} --c {path} --asm-subdir {sub}",
shell=True, cwd=REPO, capture_output=True, text=True, timeout=600)
first = (r.stdout or "").strip().split("\n")[0]
return first.startswith("MATCH"), first[:80]
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("run_dir", help="the workflow run's transcript dir (…/subagents/workflows/wf_…)")
ap.add_argument("--names", help="comma-separated func_XXXXXXXX to recover")
ap.add_argument("--names-json", help="JSON list of {'fn':…} or bare names")
ap.add_argument("--out", default=".run/recover")
ap.add_argument("--gate", metavar="TARGETS_JSON",
help="[{name, sub}] — gate snapshots newest-first, keep the first MATCH")
a = ap.parse_args()
names = set()
if a.names:
names |= {x.strip() for x in a.names.split(",") if x.strip()}
if a.names_json:
for r in json.load(open(a.names_json)):
names.add(r["fn"] if isinstance(r, dict) else r)
if not names:
ap.error("give --names or --names-json")
snaps = snapshots(a.run_dir, names)
outdir = os.path.join(REPO, a.out)
for fn, versions in snaps.items():
d = os.path.join(outdir, fn)
os.makedirs(d, exist_ok=True)
for i, c in enumerate(versions):
open(os.path.join(d, f"v{i:02d}.c"), "w").write(c)
missing = sorted(names - set(snaps))
print(f"recover_drafts: {len(snaps)}/{len(names)} functions have snapshots "
f"({sum(len(v) for v in snaps.values())} versions) -> {a.out}"
+ (f"; NO RECORD: {missing}" if missing else ""))
if not a.gate:
return 0
subs = {t["name"]: t["sub"] for t in json.load(open(a.gate))}
hits = {}
for fn, versions in sorted(snaps.items()):
sub = subs.get(fn)
if not sub:
print(f" {fn}: no asm subdir given — skipped"); continue
for i, _c in enumerate(versions):
p = os.path.join(a.out, fn, f"v{i:02d}.c")
ok, line = gate(fn, p, sub)
if ok:
hits[fn] = p
print(f" MATCH {fn} <- v{i:02d}.c")
break
else:
print(f" no version matches: {fn} ({len(versions)} tried)")
json.dump(hits, open(os.path.join(REPO, a.out, "recovered.json"), "w"), indent=1)
print(f"recover_drafts: {len(hits)}/{len(snaps)} gated MATCH -> {a.out}/recovered.json")
return 0
if __name__ == "__main__":
sys.exit(main())