From c8c42cd6db5840abc6030002d2c4b6352338b641 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:04:11 +0800 Subject: [PATCH] autoresearch: tidy-up helper to pause and resume a campaign (#66) Add references/tidy-up.md and helpers/tidy_attempts.py. The helper inventories every attempt worktree/branch, decides keep/drop/ask by rule, packs kept attempts onto their own branches (build artifacts ignored, pushed when a remote exists, worktree removed), deletes empty ones, and writes research/ATTEMPTS.md with each attempt's hypothesis, validator outcome, and disposition. Only genuinely uncertain rows are batched into one question at the end. The stage is unchanged so the campaign resumes from the soft gate; STATE.md gains an optional last_tidy date. Closes #66 Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 +- skills/autoresearch/SKILL.md | 3 + skills/autoresearch/helpers/tidy_attempts.py | 297 ++++++++++++++++++ .../references/attempt-protocol.md | 8 +- skills/autoresearch/references/stages/run.md | 3 +- .../autoresearch/references/state-schema.md | 1 + skills/autoresearch/references/tidy-up.md | 74 +++++ tests/test_tidy_attempts.py | 150 +++++++++ 8 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 skills/autoresearch/helpers/tidy_attempts.py create mode 100644 skills/autoresearch/references/tidy-up.md create mode 100644 tests/test_tidy_attempts.py diff --git a/CLAUDE.md b/CLAUDE.md index 26ee808..69afb8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,9 @@ resources, keep public names stable and each skill installable on its own. **autoresearch** routes topics → db → validator → run from `research/STATE.md`. Its user-confirmed acceptance gates, attempt budgets, and sealed holdout are -research invariants. Status questions are read-only. +research invariants. Status questions are read-only. A tidy-up +(`references/tidy-up.md`, `helpers/tidy_attempts.py`) packs attempts into +their branches and writes `research/ATTEMPTS.md` without ending the campaign. **brainstorm-ideas** keeps the main mentor and a selected advisor as separate roles. Advisor profiles and literature live in `advisors//`; the selected diff --git a/skills/autoresearch/SKILL.md b/skills/autoresearch/SKILL.md index 1ae2bc0..5ce2eaf 100644 --- a/skills/autoresearch/SKILL.md +++ b/skills/autoresearch/SKILL.md @@ -48,6 +48,9 @@ configuration and sound examples: `references/cycle-end-hook.md`. finding to report, not permission to initialize a campaign. - **Start, resume, or run a named stage:** follow Procedure below with the user's existing choices and authorization. The stage gates still apply. +- **Tidy up, pause, or close a campaign:** follow `references/tidy-up.md`. + It packs every attempt into its branch, removes the worktrees, and writes + `research/ATTEMPTS.md`; the stage is unchanged and the campaign can resume. ## Procedure diff --git a/skills/autoresearch/helpers/tidy_attempts.py b/skills/autoresearch/helpers/tidy_attempts.py new file mode 100644 index 0000000..298274c --- /dev/null +++ b/skills/autoresearch/helpers/tidy_attempts.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Tidy up autoresearch attempt worktrees so a campaign can pause and resume. + +Usage: + python3 tidy_attempts.py inventory [--project DIR] [--json inv.json] + python3 tidy_attempts.py apply --from inv.json [--project DIR] [--drop 003,007] [--keep 012] + python3 tidy_attempts.py record --from inv.json [--project DIR] [--out research/ATTEMPTS.md] + +`inventory` is read-only. It lists every `attempt-NNN` worktree/branch with +its LOG.md hypothesis, validator outcome, uncommitted work, build artifacts, +and push state, and decides per attempt: + + keep anything with a LOG.md, a report.json, or code changes + drop an empty worktree: no log, no report, no commits, no changes + ask changes but no LOG.md, or large untracked files that match no + build-artifact pattern -- the user decides these at the end + +`apply` executes the decisions from a saved inventory: kept worktrees get +their artifacts ignored, everything else committed to their own attempt +branch, pushed when a remote exists, and the worktree directory removed +(the branch is the record; `git worktree add .worktrees/attempt-NNN +attempt-NNN` restores it). Dropped worktrees and branches are deleted. +`ask` rows are left untouched unless named in --drop or --keep. A second +`apply` on the same snapshot is safe: rows already handled are skipped. + +`record` writes research/ATTEMPTS.md: one row per attempt in the snapshot, +with the disposition observed now (pushed / local only / dropped). +""" + +import argparse +import datetime as dt +import fnmatch +import json +import re +import subprocess +import sys +from pathlib import Path + +ARTIFACT_DIRS = ["__pycache__", "build", "dist", "target", "node_modules", + ".venv", "venv", ".pytest_cache", ".mypy_cache", ".ruff_cache"] +ARTIFACT_GLOBS = ["*.pyc", "*.pyo", "*.egg-info", "*.o", "*.so", "*.a", + "*.dylib", "*.class", "*.jar"] +LARGE_BYTES = 10 * 1024 * 1024 +BRANCH_RE = re.compile(r"attempt-(\d{3,})$") +LOG_FIELD_RE = re.compile(r"^\W*(kind|parent|hypothesis)\W+(.+?)\s*$", re.I | re.M) + + +def git(cwd, *args, check=True): + r = subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True) + if check and r.returncode: + raise RuntimeError(f"git {' '.join(args)} in {cwd}: {r.stderr.strip()}") + return r.stdout + + +def artifact_pattern(path): + parts = Path(path).parts + for d in ARTIFACT_DIRS: + if d in parts[:-1] or (parts[-1] == d): + return d + "/" + for g in ARTIFACT_GLOBS: + if any(fnmatch.fnmatch(p, g) for p in parts): + return g + return None + + +def read_file(worktree, branch, name, project): + if worktree and (Path(worktree) / name).is_file(): + return (Path(worktree) / name).read_text(errors="replace") + if branch: + r = subprocess.run(["git", "-C", str(project), "show", f"{branch}:{name}"], + capture_output=True, text=True) + if r.returncode == 0: + return r.stdout + return None + + +def parse_log(text): + fields = {"kind": "", "parent": "", "hypothesis": ""} + for key, value in LOG_FIELD_RE.findall(text or ""): + key = key.lower() + if not fields[key]: + fields[key] = value.strip("*_ ") + return fields + + +def parse_report(text): + try: + d = json.loads(text) + except (TypeError, ValueError): + return None, None + return d.get("status"), d.get("score") + + +def inventory(project): + project = Path(project).resolve() + remotes = git(project, "remote").split() + remote = "origin" if "origin" in remotes else (remotes[0] if remotes else None) + branches = {b for b in git(project, "for-each-ref", "--format=%(refname:short)", + "refs/heads/attempt-*").split() if BRANCH_RE.search(b)} + worktrees = {} + cur = {} + for line in git(project, "worktree", "list", "--porcelain").splitlines() + [""]: + if line.startswith("worktree "): + cur = {"path": line[9:]} + elif line.startswith("branch "): + cur["branch"] = line[7:].replace("refs/heads/", "") + elif not line and cur: + name = cur.get("branch") or Path(cur["path"]).name + if BRANCH_RE.search(name): + worktrees[name] = cur["path"] + cur = {} + head = git(project, "rev-parse", "HEAD").strip() + attempts = [] + for name in sorted(branches | set(worktrees), key=lambda n: int(BRANCH_RE.search(n).group(1))): + wt = worktrees.get(name) + has_branch = name in branches + log = read_file(wt, name if has_branch else None, "LOG.md", project) + status, score = parse_report(read_file(wt, name if has_branch else None, + "report.json", project)) + dirty, untracked, artifacts, large = 0, [], set(), [] + if wt: + for line in git(wt, "status", "--porcelain", "--untracked-files=all").splitlines(): + path = line[3:] + if line.startswith("??"): + pat = artifact_pattern(path) + if pat: + artifacts.add(pat) + else: + untracked.append(path) + if (Path(wt) / path).stat().st_size >= LARGE_BYTES: + large.append(path) + else: + dirty += 1 + own_commits = int(git(project, "rev-list", "--count", f"{head}..{name}").strip()) \ + if has_branch else 0 + pushed = False + if remote and has_branch: + r = subprocess.run(["git", "-C", str(project), "rev-parse", f"{remote}/{name}"], + capture_output=True, text=True) + pushed = r.returncode == 0 and r.stdout.strip() == git(project, "rev-parse", name).strip() + has_work = bool(dirty or untracked or own_commits) + if not wt: + decision, reason = "keep", "branch only, no worktree" + elif log is None and status is None and not has_work: + decision, reason = "drop", "empty worktree" + elif log is None and (has_work or status is not None): + decision, reason = "ask", "no LOG.md but has changes" + elif large: + decision, reason = "ask", "large untracked file(s): " + ", ".join(large) + else: + decision, reason = "keep", "has record" + attempts.append({ + "id": int(BRANCH_RE.search(name).group(1)), "name": name, + "branch": has_branch, "worktree": wt, "log": log is not None, + **parse_log(log), "status": status or ("unscored" if log else None), + "score": score, "dirty": dirty, "untracked": untracked, + "artifacts": sorted(artifacts), "large": large, + "own_commits": own_commits, "pushed": pushed, + "decision": decision, "reason": reason, + }) + return {"project": str(project), "remote": remote, "date": dt.date.today().isoformat(), + "attempts": attempts} + + +def apply(project, inv, drop=frozenset(), keep=frozenset()): + project = Path(project).resolve() + remote = inv.get("remote") + result = {"kept": [], "dropped": [], "skipped": [], "warnings": []} + for a in inv["attempts"]: + name, wt = a["name"], a["worktree"] + branch_exists = subprocess.run(["git", "-C", str(project), "rev-parse", "--verify", + "-q", "refs/heads/" + name], capture_output=True).returncode == 0 + if a["id"] in drop or a["decision"] == "drop": + if wt and Path(wt).exists(): + git(project, "worktree", "remove", "--force", wt) + if branch_exists: + git(project, "branch", "-D", name) + result["dropped"].append(a["id"]) + continue + if a["decision"] == "ask" and a["id"] not in keep: + result["skipped"].append(a["id"]) + continue + if not branch_exists and not (wt and Path(wt).exists()): + continue # already dropped in an earlier pass + if wt and Path(wt).exists(): + if a["artifacts"]: + gi = Path(wt) / ".gitignore" + have = gi.read_text().splitlines() if gi.exists() else [] + new = [p for p in a["artifacts"] if p not in have] + if new: + gi.write_text("\n".join(have + new) + "\n") + git(wt, "add", "-A") + if subprocess.run(["git", "-C", wt, "diff", "--cached", "--quiet"]).returncode: + git(wt, "commit", "-q", "-m", f"{name}: tidy-up") + if remote: + r = subprocess.run(["git", "-C", str(project), "push", "-q", remote, name], + capture_output=True, text=True) + if r.returncode: + result["warnings"].append(f"{name}: push failed: {r.stderr.strip()}") + if wt and Path(wt).exists(): + git(project, "worktree", "remove", "--force", wt) + result["kept"].append(a["id"]) + git(project, "worktree", "prune") + return result + + +def record(project, inv, out=None): + project = Path(project).resolve() + live_inv = inventory(project) + live = {a["id"]: a for a in live_inv["attempts"]} + remote = live_inv["remote"] + state = project / "research" / "STATE.md" + state_lines = [l for l in state.read_text().splitlines() + if re.match(r"- (stage|topic|authorized_attempts|next_attempt|next_cycle):", l)] \ + if state.exists() else [] + lines = [f"# Attempts", "", + f"Tidied {dt.date.today().isoformat()}. Regenerated by " + f"`helpers/tidy_attempts.py record`; edit the notes column freely.", ""] + lines += state_lines + [""] + if not remote: + lines += ["**No remote is configured: every kept branch is local only.** " + "Local commits are not a backup. Add a remote and push the " + "`attempt-*` branches, or copy `git bundle --all` off this machine.", ""] + lines += ["| id | kind | parent | status | score | branch | disposition | hypothesis |", + "|---|---|---|---|---|---|---|---|"] + for a in inv["attempts"]: + cur = live.get(a["id"]) + if cur is None: + disp, branch = "dropped", "—" + else: + disp = "pushed" if cur["pushed"] else "local only" + branch = f"`{a['name']}`" + score = "—" if a["score"] is None else f"{a['score']:g}" + lines.append(f"| {a['id']:03d} | {a['kind'] or '—'} | {a['parent'] or '—'} | " + f"{a['status'] or '—'} | {score} | {branch} | {disp} | " + f"{a['hypothesis'] or '—'} |") + lines += ["", "## Resume", "", + "- Restore any kept attempt with `git worktree add .worktrees/attempt-NNN attempt-NNN`; " + "its `LOG.md` and `report.json` are on the branch.", + "- Dropped rows keep only this summary; their code is gone.", + "- Continue from the `autoresearch` soft gate: pick a direction from the last " + "`docs/discussion/cycle-NN.md` and authorize attempts in `research/STATE.md`.", ""] + text = "\n".join(lines) + if out: + out = Path(out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text) + return text + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("command", choices=["inventory", "apply", "record"]) + ap.add_argument("--project", default=".") + ap.add_argument("--json", help="inventory: write the full snapshot here") + ap.add_argument("--from", dest="snapshot", help="apply/record: snapshot from inventory --json") + ap.add_argument("--drop", default="", help="apply: comma-separated attempt ids to drop") + ap.add_argument("--keep", default="", help="apply: comma-separated ask-row ids to keep") + ap.add_argument("--out", default="research/ATTEMPTS.md", help="record: output path") + args = ap.parse_args(argv) + project = Path(args.project).resolve() + if args.command == "inventory": + inv = inventory(project) + if args.json: + Path(args.json).write_text(json.dumps(inv, indent=1)) + counts = {d: [a for a in inv["attempts"] if a["decision"] == d] + for d in ("keep", "drop", "ask")} + print(f"remote: {inv['remote'] or 'none'}; " + "; ".join( + f"{d}: {len(v)}" for d, v in counts.items())) + if counts["drop"]: + print("drop (empty): " + ", ".join(a["name"] for a in counts["drop"])) + for a in counts["ask"]: + print(f"ask {a['name']}: {a['reason']}; {a['dirty']} modified, " + f"{len(a['untracked'])} untracked, {a['own_commits']} commits") + if args.json: + print(f"snapshot: {args.json}") + return 0 + if not args.snapshot: + ap.error("--from is required") + inv = json.loads(Path(args.snapshot).read_text()) + if args.command == "apply": + drop = {int(x) for x in args.drop.split(",") if x.strip()} + keep = {int(x) for x in args.keep.split(",") if x.strip()} + r = apply(project, inv, drop, keep) + print(f"kept: {len(r['kept'])}; dropped: {len(r['dropped'])}; " + f"left for decision: {len(r['skipped'])}") + for w in r["warnings"]: + print("warning:", w, file=sys.stderr) + return 1 if r["warnings"] else 0 + record(project, inv, out=project / args.out) + print(f"wrote {project / args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/autoresearch/references/attempt-protocol.md b/skills/autoresearch/references/attempt-protocol.md index d7f4c72..875fcca 100644 --- a/skills/autoresearch/references/attempt-protocol.md +++ b/skills/autoresearch/references/attempt-protocol.md @@ -35,6 +35,8 @@ Every attempt, no exceptions: never silently retried; retrying with a fix is a *new* attempt. 6. **Commit and leave intact.** Commit everything on the worktree's `attempt-NNN` branch — the generated code, `LOG.md`, `report.json` — - and leave the worktree in place. Worktrees are the audit trail: - reflection reads their LOG.md files, and the cycle-end sync pushes the - `attempt-NNN` branches to the remote. + and leave the worktree in place until a tidy-up (`tidy-up.md`) packs it + away. Worktrees are the audit trail: reflection reads their LOG.md files, + and the cycle-end sync pushes the `attempt-NNN` branches to the remote. + After a tidy-up the branch is the audit trail; restore a worktree with + `git worktree add .worktrees/attempt-NNN attempt-NNN` when needed. diff --git a/skills/autoresearch/references/stages/run.md b/skills/autoresearch/references/stages/run.md index 7d17587..6f8bc0b 100644 --- a/skills/autoresearch/references/stages/run.md +++ b/skills/autoresearch/references/stages/run.md @@ -183,7 +183,8 @@ gate is never worked around; a user-approved exception goes into report, not a newly improvised list. State which one the agent recommends and why it outranks the alternatives. The user decides both **which direction(s)** to pursue and **how many attempts** to - authorize (a number; 0 = stop; the user's own directions and + authorize (a number; 0 = stop, then offer the tidy-up in + `../tidy-up.md`; the user's own directions and amendments welcome as free text). Attempts are the unit the user authorizes; never ask them to reason in rounds or cycles — those are internal bookkeeping. Insight promotions proposed in the report — including every diff --git a/skills/autoresearch/references/state-schema.md b/skills/autoresearch/references/state-schema.md index b05a6db..7660746 100644 --- a/skills/autoresearch/references/state-schema.md +++ b/skills/autoresearch/references/state-schema.md @@ -19,6 +19,7 @@ keep them): - next_attempt: 1 # next .worktrees/attempt-NNN number - next_cycle: 1 # next reflection cycle number - cycle_end_hook: [] # sound, bell, or command argv; [] or absent disables it + - last_tidy: (none) # date of the last tidy-up (references/tidy-up.md); absent = never - gates: - survey_gate: pending # pending | passed YYYY-MM-DD - validator_gate: pending # pending | passed YYYY-MM-DD diff --git a/skills/autoresearch/references/tidy-up.md b/skills/autoresearch/references/tidy-up.md new file mode 100644 index 0000000..d76aeba --- /dev/null +++ b/skills/autoresearch/references/tidy-up.md @@ -0,0 +1,74 @@ +# Tidy up (pause a campaign, keep everything that matters) + +Tidy-up is not an end state. It packs every attempt into its own branch, +removes the worktree directories, and writes one index so the campaign can be +resumed later, by anyone, on any machine that has the branches. Enter it +when the user asks to tidy, pause, or close a campaign, or answers `0` at a +soft gate and wants the workspace cleaned. + +Hard rules: no new attempts, no holdout query, no edits to any attempt's +code or LOG.md, and never describe local commits as a backup. + +## Procedure + +1. **Inventory (read-only).** Run from the project root; the helper path is + relative to this file's installed skill directory: + + ```sh + python3 ../helpers/tidy_attempts.py inventory --json research/.tidy-inventory.json + ``` + + It prints the remote, the counts per decision, the empty worktrees it will + drop, and only the `ask` rows. The full table is in the JSON. Decisions: + - **keep** — has a LOG.md, a `report.json`, or code changes. Also every + branch that no longer has a worktree. + - **drop** — an empty worktree: no log, no report, no commits, no changes. + Nothing is lost by deleting it. + - **ask** — changes but no LOG.md (an attempt that skipped the protocol), + or an untracked file over 10 MB that matches no build-artifact pattern. +2. **Apply the clear cases first.** Do not ask about them; with a hundred + attempts most rows are obvious. + + ```sh + python3 ../helpers/tidy_attempts.py apply --from research/.tidy-inventory.json + ``` + + For each kept worktree this appends the detected artifact patterns + (`__pycache__/`, `build/`, `*.so`, …) to its `.gitignore`, commits + everything else on its `attempt-NNN` branch, pushes when a remote exists, + and removes the worktree directory. Dropped worktrees and their branches are + deleted. `ask` rows are left untouched. Push failures are printed as + warnings and the run continues; report them. +3. **One question for the rest.** Group the `ask` rows by reason and ask once, + with a recommended default per group: attempts without a LOG.md are + usually worth keeping if they have commits and dropping if they only have + scratch edits; large files are usually results worth keeping or artifacts + worth ignoring. Accept per-id overrides in free text. Then re-run `apply` + on the same snapshot with `--drop ` and `--keep `; rows handled + in the first pass are skipped. To keep an attempt but not a large file, + add the file to that worktree's `.gitignore` before the second pass. +4. **Record.** + + ```sh + python3 ../helpers/tidy_attempts.py record --from research/.tidy-inventory.json + ``` + + Writes `research/ATTEMPTS.md`: the STATE.md position, one row per attempt + (kind, parent, validator status and score, branch, disposition + `pushed` / `local only` / `dropped`, hypothesis), and how to resume. Dropped + rows keep their hypothesis and outcome from the snapshot. With no remote the + file opens with a local-only warning and the two ways to fix it (add a + remote and push, or copy a `git bundle --all` off the machine). +5. **State and sync.** Set `last_tidy: YYYY-MM-DD` in `research/STATE.md`, + leave `stage` as it is, commit `ATTEMPTS.md` and `STATE.md` on main, push + main when a remote exists, and delete `research/.tidy-inventory.json`. + Tell the user the counts, the warnings, and whether the branches are off + the machine. + +## Resuming + +A status request reports the last tidy date and the counts from +`ATTEMPTS.md`. To continue, the user authorizes attempts at the soft gate as +usual; the loop keeps numbering from `next_attempt`. Any kept attempt is +restored with `git worktree add .worktrees/attempt-NNN attempt-NNN` when a +new attempt needs to build on it. diff --git a/tests/test_tidy_attempts.py b/tests/test_tidy_attempts.py new file mode 100644 index 0000000..050c81f --- /dev/null +++ b/tests/test_tidy_attempts.py @@ -0,0 +1,150 @@ +"""Offline end-to-end test of the autoresearch tidy-up helper on a fixture repo.""" + +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = (Path(__file__).resolve().parents[1] / "skills" / "autoresearch" + / "helpers" / "tidy_attempts.py") +spec = importlib.util.spec_from_file_location("tidy_attempts", HELPER) +tidy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tidy) + + +def git(cwd, *args): + return subprocess.run(["git", "-C", str(cwd), *args], check=True, + capture_output=True, text=True).stdout + + +def add_attempt(project, n, log=True, report=None, code=True, commit=True): + wt = project / ".worktrees" / f"attempt-{n:03d}" + git(project, "worktree", "add", "-q", "-b", f"attempt-{n:03d}", str(wt), "main") + if log: + (wt / "LOG.md").write_text( + f"# Attempt {n:03d}\n\n- **kind** — draft\n- **parent** — none\n" + f"- **hypothesis** — idea number {n}\n") + if report is not None: + (wt / "report.json").write_text(json.dumps(report)) + if code: + (wt / "solver.py").write_text(f"print({n})\n") + if commit and (log or report or code): + git(wt, "add", "-A") + git(wt, "commit", "-q", "-m", f"attempt {n}") + return wt + + +@pytest.fixture +def project(tmp_path): + p = tmp_path / "proj" + p.mkdir() + git(p, "init", "-q", "-b", "main") + git(p, "config", "user.email", "t@example.com") + git(p, "config", "user.name", "t") + (p / "research").mkdir() + (p / "research" / "STATE.md").write_text( + "# Autoresearch State\n\n- stage: run\n- topic: demo\n" + "- authorized_attempts: 0\n- next_attempt: 5\n") + (p / "research" / "GOAL.md").write_text("bar: 0.9\n") + git(p, "add", "-A") + git(p, "commit", "-q", "-m", "init") + # 001: scored, committed, plus a build artifact and an uncommitted edit -> keep + wt1 = add_attempt(p, 1, report={"status": "scored", "score": 0.42}) + (wt1 / "__pycache__").mkdir() + (wt1 / "__pycache__" / "solver.cpython-311.pyc").write_bytes(b"\0" * 10) + (wt1 / "solver.py").write_text("print('edited')\n") + # 002: validator error (timeout-like), fully committed -> keep + add_attempt(p, 2, report={"status": "error", "score": None, + "errors": [{"code": "timeout"}]}) + # 003: code but no LOG.md -> ask + add_attempt(p, 3, log=False, commit=False) + # 004: nothing at all -> drop automatically + add_attempt(p, 4, log=False, code=False) + return p + + +def test_inventory_classifies_every_worktree(project): + inv = tidy.inventory(project) + by_id = {a["id"]: a for a in inv["attempts"]} + assert set(by_id) == {1, 2, 3, 4} + assert by_id[1]["decision"] == "keep" + assert by_id[1]["status"] == "scored" and by_id[1]["score"] == 0.42 + assert by_id[1]["dirty"] and by_id[1]["artifacts"] == ["__pycache__/"] + assert by_id[1]["hypothesis"] == "idea number 1" + assert by_id[2]["decision"] == "keep" and by_id[2]["status"] == "error" + assert by_id[3]["decision"] == "ask" and "no LOG.md" in by_id[3]["reason"] + assert by_id[4]["decision"] == "drop" + assert inv["remote"] is None + assert all(a["pushed"] is False for a in inv["attempts"]) + + +def test_apply_keeps_drops_and_leaves_ask_rows_alone(project, tmp_path): + snapshot = tmp_path / "inv.json" + snapshot.write_text(json.dumps(tidy.inventory(project))) + result = tidy.apply(project, json.loads(snapshot.read_text()), drop=set()) + assert result["kept"] == [1, 2] and result["dropped"] == [4] + assert result["skipped"] == [3] + # kept: edit committed, artifact ignored, worktree removed, branch intact + assert not (project / ".worktrees" / "attempt-001").exists() + files = git(project, "ls-tree", "-r", "--name-only", "attempt-001").split() + assert "solver.py" in files and ".gitignore" in files + assert not any(f.startswith("__pycache__") for f in files) + assert "edited" in git(project, "show", "attempt-001:solver.py") + assert "__pycache__/" in git(project, "show", "attempt-001:.gitignore") + # dropped: worktree and branch gone + assert not (project / ".worktrees" / "attempt-004").exists() + assert "attempt-004" not in git(project, "branch", "--list", "attempt-*") + # ask row untouched + assert (project / ".worktrees" / "attempt-003" / "solver.py").exists() + + +def test_apply_pushes_when_a_remote_exists(project, tmp_path): + bare = tmp_path / "remote.git" + git(tmp_path, "init", "-q", "--bare", str(bare)) + git(project, "remote", "add", "origin", str(bare)) + inv = tidy.inventory(project) + assert inv["remote"] == "origin" + tidy.apply(project, inv, drop={3}) + assert "attempt-001" in git(bare, "branch", "--list") + assert "attempt-003" not in git(project, "branch", "--list", "attempt-*") + record = tidy.record(project, inv) + assert "attempt-001" in record and "pushed" in record + assert "local only" not in record + + +def test_second_apply_pass_handles_ask_rows_and_is_idempotent(project): + inv = tidy.inventory(project) + first = tidy.apply(project, inv) + assert first["skipped"] == [3] + second = tidy.apply(project, inv, keep={3}) + assert second["kept"] == [1, 2, 3] and second["dropped"] == [4] + assert "attempt-003" in git(project, "branch", "--list", "attempt-*") + assert "solver.py" in git(project, "ls-tree", "-r", "--name-only", "attempt-003") + assert not (project / ".worktrees" / "attempt-003").exists() + third = tidy.apply(project, inv, keep={3}) + assert third["kept"] == [1, 2, 3] and third["warnings"] == [] + + +def test_record_marks_dropped_rows_and_warns_without_remote(project, tmp_path): + inv = tidy.inventory(project) + tidy.apply(project, inv, drop={3}) + out = project / "research" / "ATTEMPTS.md" + text = tidy.record(project, inv, out=out) + assert out.read_text() == text + rows = [l for l in text.splitlines() if l.startswith("| 00")] + assert len(rows) == 4 + assert "| 001 |" in text and "scored" in text and "0.42" in text + assert "| 003 |" in text and "dropped" in text + assert "| 004 |" in text + assert "local only" in text and "not a backup" in text + assert "stage: run" in text and "next_attempt: 5" in text + + +def test_cli_inventory_prints_summary_and_ask_rows_only(project, capsys): + rc = tidy.main(["inventory", "--project", str(project)]) + assert rc == 0 + out = capsys.readouterr().out + assert "keep: 2" in out and "drop: 1" in out and "ask: 1" in out + assert "attempt-003" in out and "attempt-001" not in out