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