Browse Source

feat(skills): Extend summon with picker/recover/rebind modes

Grow summon from a cross-account transfer tool into a general Desktop
session toolbox. Transfer (push/pull) is untouched; three new modes
ride an optional positional:

- rebind <id> --cwd <newpath>: fix a session's recorded cwd after a
  project folder moves. Backs up every matching wrapper outside the
  live store (~/.claude/summon-backups/<ts>/), atomically rewrites
  cwd and rebases originCwd/worktreePath (worktree suffix math), then
  bridges the transcript JSONL into the new munged project dir --
  Desktop resolves transcripts via enc(cwd), so the wrapper edit
  alone is not enough. Verifies by re-read, restores from backup on
  mismatch. Verified against the live store on a throwaway session.
- pick / recover <id>: picker over the whole session store (fzf when
  interactive, numbered fallback with --select) that resolves the
  transcript -- expected munged path first, then a scan of all
  project dirs for <cliSessionId>.jsonl (the wrapper-uuid !=
  transcript-filename trap) -- and emits a paste-ready, token-cheap
  recovery prompt on stdout (tail-read instruction, never whole-file
  ingestion). Context panels go to stderr so `summon pick | clip`
  stays clean.
- doctor: scan every wrapper for broken cwd bindings; per-finding
  rebind suggestion, exit 10 on findings, --json envelope
  (claude-mods.summon.doctor/v1).

Semantic exit codes (0/2/3/10), first-comment-block contract, and 11
new hermetic sandbox tests (18 total, all passing). Satisfies the
queued 'ccr picker' idea from project memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 3 weeks ago
parent
commit
c86ff03235
4 changed files with 787 additions and 22 deletions
  1. 1 1
      README.md
  2. 71 3
      skills/summon/SKILL.md
  3. 492 18
      skills/summon/scripts/summon.py
  4. 223 0
      skills/summon/tests/test_summon.py

+ 1 - 1
README.md

@@ -294,7 +294,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [push-gate](skills/push-gate/) | Pre-push safety gate - gitleaks + regex secret scan, forbidden-file check, no bypass |
 | [fleet-ops](skills/fleet-ops/) | Manage a fleet of concurrent Claude sessions - landing queue with test gate, pre-land scrub (experimental) |
 | [fleet-worker](skills/fleet-worker/) | Delegate tasks to cheap headless GLM (or any Anthropic-compatible) workers - per-task git worktree + isolated config, result gating, fan-out that hands winning branches to fleet-ops landing |
-| [summon](skills/summon/) | Transfer Claude Desktop Code-tab sessions between accounts - push/pull with picker |
+| [summon](skills/summon/) | Claude Desktop session toolbox - cross-account transfer, recovery picker, cwd rebind, store doctor |
 | [doc-scanner](skills/doc-scanner/) | Scan and synthesize project documentation |
 | [adr-ops](skills/adr-ops/) | Architecture Decision Records - when-to-write, canonical format, supersession lifecycle, scaffold/index/lint tools |
 | [okf-ops](skills/okf-ops/) | Open Knowledge Format - assess a doc repo's frontmatter-readiness, validate a bundle for conformance, decide per-repo adoption |

+ 71 - 3
skills/summon/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: summon
-description: "Transfer Claude Desktop Code-tab sessions between Claude accounts — copy (default) or move (--move) the session metadata file so the session shows up in another account's left-hand sidebar (the session picker on the left side of Desktop's Code tab). Two natural framings: push (run while still on your current near-limit account, send sessions to the next one, then Logout/Login as the natural switch) or pull (after switching accounts, bring earlier sessions into the now-active one). Push is the recommended workflow because the Logout/Login becomes invisible — it IS the switch you were going to do anyway. Triggers on: summon, summon sessions, push sessions, pull sessions, before switching accounts, account approaching usage limit, account ran out of usage, prepare next account, mid-flight desktop sessions, claude desktop multi-account workflow, transfer claude desktop sessions across accounts, peek session, see desktop sessions across accounts. Default copy keeps the session visible in both accounts' sidebars; --move for lean cleanup. Transcript JSONLs are account-agnostic and stay where they are — both wrappers point at the same conversation. No API calls, no summarisation, full transcripts intact. The left-hand session picker is loaded at login, so a Logout/Login on the destination is required for new sessions to appear there."
+description: "Claude Desktop session toolbox: cross-account transfer, recovery picker, cwd rebind after folder moves, and a session-store doctor. Transfer (default mode): copy (default) or move (--move) the session metadata file so the session shows up in another account's left-hand sidebar  push (run while still on your current near-limit account, send sessions to the next one, then Logout/Login as the natural switch) or pull (after switching, bring earlier sessions into the now-active one); push is recommended because the Logout/Login IS the switch you were doing anyway. Toolbox modes: `summon pick` / `summon recover <id>` (picker over the whole session store that resolves the transcript JSONL — handling the wrapper-uuid vs transcript-filename trap — and emits a ready-to-paste, token-cheap recovery prompt for a new session), `summon rebind <id> --cwd <newpath>` (fix a session's recorded cwd after a project folder moves, with backup + transcript bridging), `summon doctor` (scan all sessions for broken cwd bindings and report which need rebinding). Triggers on: summon, summon sessions, push sessions, pull sessions, before switching accounts, account approaching usage limit, account ran out of usage, prepare next account, mid-flight desktop sessions, claude desktop multi-account workflow, transfer claude desktop sessions across accounts, peek session, see desktop sessions across accounts, recover session, resume old session, session picker, rebind session, moved project folder, session cwd broken, session won't reopen after folder move, broken session bindings, session doctor. Default copy keeps the session visible in both accounts' sidebars; transcript JSONLs are account-agnostic and stay where they are. No API calls, no summarisation, full transcripts intact. The left-hand session picker is loaded at login, so a Logout/Login on the destination is required for new sessions to appear there."
 license: MIT
 allowed-tools: "Read Write Bash"
 metadata:
@@ -9,7 +9,16 @@ metadata:
 
 # Summon
 
-Copy (or move) Claude Desktop Code-tab sessions across accounts so they're visible from the account you switch to next. Full transcripts intact; no API calls; no summarisation.
+Claude Desktop session toolbox. Four jobs, one store:
+
+| Mode | Invocation | Job |
+|------|-----------|-----|
+| **Transfer** (default) | `summon [flags]` | Copy/move sessions across accounts so they're visible from the account you switch to next |
+| **Pick / Recover** | `summon pick` · `summon recover <id>` | Find a past session, resolve its transcript, emit a paste-ready recovery prompt for a new session |
+| **Rebind** | `summon rebind <id> --cwd <newpath>` | Fix a session's recorded cwd after the project folder moved |
+| **Doctor** | `summon doctor [--json]` | Scan every session for broken cwd bindings; report which need rebinding |
+
+Full transcripts intact; no API calls; no summarisation. Transfer is documented first; the toolbox modes follow under [Toolbox modes](#toolbox-modes-pick--recover--rebind--doctor).
 
 ## When to run it
 
@@ -33,6 +42,8 @@ Each Desktop session has two halves:
 
 Summon copies (or with `--move`, relocates) the metadata wrapper into the destination account's dir. The transcript stays put — both wrappers point at the same conversation. After Logout/Login on the destination, the new entries appear in the **left-hand session picker** (Desktop's Code-tab sidebar).
 
+**The uuid-mismatch trap.** The wrapper filename uuid (`local_<uuid>.json` / `sessionId`) does **not** name the transcript — the transcript file is named by the wrapper's `cliSessionId`, a different uuid (e.g. wrapper `local_6577b24c-…` → transcript `e640a2a8-….jsonl`). And the transcript's parent dir is the *munged cwd* (`X:\Roam\LCMap\.claude\worktrees\funny-hypatia-5e54f7` → `X--Roam-LCMap--claude-worktrees-funny-hypatia-5e54f7`), which occasionally doesn't derive from the wrapper's recorded cwd at all. All toolbox modes resolve via `cliSessionId` at the expected munged path first, then fall back to scanning every project dir for `<cliSessionId>.jsonl`.
+
 ## Run
 
 ```bash
@@ -71,6 +82,61 @@ Mechanically identical — the file moves are the same regardless of which frami
 | `--select <picks>` | | Non-interactive selection: `--select "1,2,4"` or `--select all`. Replaces the picker prompt for scripted callers |
 | `--yes` | | Skip the final confirmation prompt only — selection is still required (picker prompt, piped stdin, or `--select`) |
 
+## Toolbox modes (pick / recover / rebind / doctor)
+
+Semantic exit codes across all modes: `0` ok, `2` usage/ambiguous id, `3` session or path not found, `10` doctor found broken sessions.
+
+### `summon pick` — session picker → recovery prompt
+
+Interactive picker over the **whole** session store (all accounts, default last 30 days — `--days N`/`--all` to widen, `--cwd`/`--title` to narrow). Uses `fzf` when it's on PATH and the terminal is interactive; falls back to a numbered list (`--select N` answers it non-interactively). A `●` marks sessions active in the last 10 minutes — don't recover a session that's still running.
+
+Selecting a session emits a **paste-ready recovery prompt on stdout** (context panel on stderr, so `summon pick | clip` stays clean):
+
+```
+Continue a previous Claude session.
+
+Title:      Fix overlapping photo pins with gentle displacement
+Branch:     claude/funny-hypatia-5e54f7
+Orig cwd:   X:\Roam\LCMap\.claude\worktrees\funny-hypatia-5e54f7  (missing on disk — folder moved?)
+Transcript: C:\Users\Mack\.claude\projects\X--Roam-LCMap-…\e640a2a8-….jsonl
+
+First read only the TAIL of the transcript (last ~150-200 lines) … continue the work from there in the current directory.
+```
+
+The prompt is deliberately **token-cheap**: it points the new session at the transcript file and instructs selective tail-reading — never whole-file ingestion (a long session's JSONL can be many MB).
+
+### `summon recover <id>` — same, for a known session
+
+`summon recover 6577b24c` — id is a `sessionId` or `cliSessionId`, prefix ok. Same prompt output as pick.
+
+### `summon rebind <id> --cwd <newpath>` — fix cwd after a folder move
+
+When a project folder moves (e.g. `X:\Roam\LCMap` → `X:\Maplab\LCMap`), sessions bound to the old cwd fail to restart in the Desktop UI. Rebind repairs the binding:
+
+```bash
+summon rebind 6577b24c --cwd "X:\Maplab\LCMap\.claude\worktrees\funny-hypatia-5e54f7"
+```
+
+1. **Backs up** every matching wrapper to `~/.claude/summon-backups/<timestamp>/` (outside the live store) before touching anything
+2. **Atomically rewrites** `cwd`, and rebases `originCwd`/`worktreePath` (worktree sessions record the project *root* in `originCwd` — the suffix math is handled)
+3. **Bridges the transcript**: Desktop resolves the transcript via the munged *new* cwd, so the `<cliSessionId>.jsonl` is copied (never moved) into the new munged project dir. `--no-transcript` skips this
+4. **Verifies** by re-reading the wrapper; on mismatch it restores from the backup
+5. If the same session was transfer-copied into several accounts, **all copies are rebound**
+
+`--dry-run` previews; `--force` allows a `--cwd` that doesn't exist yet. The new cwd must normally exist on disk. After a rebind, restart Desktop (or Logout/Login) so the sidebar re-reads the wrapper.
+
+Wrapper edit + backup + transcript bridge are verified against the live store (throwaway-session test, 2026-07-03). End-to-end "session reopens in the Desktop UI after rebind" — confirm on your first real rebind before bulk-rebinding.
+
+### `summon doctor` — find broken sessions
+
+Scans **every** wrapper (all accounts, all time) and reports sessions whose recorded cwd no longer exists on disk, with a ready-made `summon rebind <id> --cwd <new-location>` line per finding. Also counts transcript-missing and found-by-scan sessions. Exit `10` when anything is broken; `--json` emits a `claude-mods.summon.doctor/v1` envelope for scripted use:
+
+```bash
+summon doctor --json | jq -r '.data[] | "\(.sessionId)  \(.cwd)"'
+```
+
+Broken-cwd findings are mostly **pruned worktrees** (the session ended, the worktree was cleaned — nothing to fix unless you want to recover it, which needs no rebind: `summon recover` works regardless of cwd) and **moved project folders** (the real rebind case).
+
 ## Auto-detect rules
 
 - **Destination**: account with the most recent filesystem activity (mtime of any session JSON). This reliably tracks the active Desktop account.
@@ -136,7 +202,7 @@ ln -s ~/.claude/skills/summon/bin/summon ~/.local/bin/summon
 copy "$env:USERPROFILE\.claude\skills\summon\bin\summon.cmd" "$env:USERPROFILE\bin\summon.cmd"
 ```
 
-Then `summon --pick` works directly from any shell.
+Then `summon pick`, `summon doctor`, etc. work directly from any shell.
 
 ## Architecture reference
 
@@ -151,3 +217,5 @@ Full file system layout, session schemas, account binding, and the validated cro
 - **Hardcoding account UUIDs** — use `--list-accounts` first, then email substring (more readable, less brittle).
 - **Treating this as a transfer for archived sessions** — it's for mid-flight work; archived sessions belong in the source account's archive view.
 - **Using `--move` for sessions you might want to access from both accounts** — copy is default precisely because multi-account workflows are the common case.
+- **Rebinding without checking the new path** — `rebind` refuses a nonexistent `--cwd` for a reason; a typo'd rebind is two edits instead of one. `--force` is for pre-creating bindings, not for skipping the check.
+- **Recovering by pasting the whole transcript** — the recovery prompt exists so the new session tail-reads the JSONL selectively. Feeding a full multi-MB transcript into a fresh session burns the context you were trying to save.

+ 492 - 18
skills/summon/scripts/summon.py

@@ -1,17 +1,26 @@
 #!/usr/bin/env python3
-"""summon — pull Claude Desktop Code-tab sessions from another account into the active one.
-
-See SKILL.md for full documentation.
-
-Defaults:
-  - move (not copy)
-  - last 14 days only
-  - skip remote-VM sessions (cwd starts with /sessions/)
-  - prompt for confirmation
-  - hierarchy display: Account -> Project -> Session
-
-Auto-detects destination as the most-recently-active account.
-Source defaults to "all other accounts" — narrow with --from.
+"""summon — Claude Desktop session toolbox: cross-account transfer + recover/rebind/doctor.
+
+Usage:   summon [MODE] [ID] [OPTIONS]
+Input:   optional MODE positional (rebind|pick|recover|doctor; omit for transfer),
+         ID = sessionId/cliSessionId prefix for rebind/recover; picker reads stdin
+Output:  transfer/pick/doctor render TTY panels; recover/pick emit a paste-ready
+         recovery prompt on stdout (context panel on stderr); doctor --json emits
+         {"data": [...], "meta": {"schema": "claude-mods.summon.doctor/v1"}}
+Stderr:  context panels for recover/pick, warnings, errors
+Exit:    0 ok, 2 usage/ambiguous id, 3 session or path not found,
+         10 doctor found broken sessions
+
+Examples:
+  summon --to mknv74                          # transfer: push sessions to next account
+  summon pick                                 # fzf/numbered picker -> recovery prompt
+  summon recover 6577b24c                     # recovery prompt for one session
+  summon rebind 6577b24c --cwd X:\\Maplab\\LCMap\\.claude\\worktrees\\funny-hypatia-5e54f7
+  summon doctor                               # scan all sessions for broken cwd bindings
+  summon doctor --json | jq '.data[]'
+
+Transfer mode (no positional) is documented in SKILL.md: copy by default, --move
+to relocate, destination auto-detected as the most-recently-active account.
 
 Output rendering follows docs/TERMINAL-DESIGN.md (Terminal Panel Design System).
 """
@@ -913,26 +922,472 @@ def _extract_text(content) -> str:
     return ""
 
 
+# ============================================================
+#  Toolbox modes: rebind / pick / recover / doctor
+# ============================================================
+
+def eecho(*lines):
+    """echo() to stderr — context/panels for modes whose stdout is the data product."""
+    if not lines:
+        lines = ("",)
+    for line in lines:
+        try:
+            print(line, file=sys.stderr)
+        except UnicodeEncodeError:
+            enc = getattr(sys.stderr, "encoding", None) or "ascii"
+            print(str(line).encode(enc, errors="replace").decode(enc, errors="replace"),
+                  file=sys.stderr)
+
+
+def resolve_transcript(s: Session) -> tuple[Path | None, str]:
+    """Locate a session's transcript JSONL.
+
+    Returns (path, how) with how in:
+      "expected"  — at ~/.claude/projects/<enc(cwd)>/<cliSessionId>.jsonl
+      "scanned"   — found by scanning all project dirs for <cliSessionId>.jsonl
+                    (the wrapper-uuid != transcript-filename trap: the transcript
+                    is named by cliSessionId, and may live under a munged dir that
+                    doesn't derive from the wrapper's recorded cwd)
+      ""          — not found anywhere (path is None)
+    """
+    if not s.cli_id:
+        return None, ""
+    expected = s.transcript_path()
+    if expected and expected.exists():
+        return expected, "expected"
+    hits = sorted(cli_jsonl_root().glob(f"*/{s.cli_id}.jsonl"))
+    if hits:
+        return hits[0], "scanned"
+    return None, ""
+
+
+def find_wrappers_by_id(query: str, accounts: list[Account]) -> tuple[list[Session], bool]:
+    """All wrapper files for one logical session (copies may exist in several accounts).
+
+    Matches sessionId or cliSessionId, exact first, then prefix. Returns
+    (matches, ambiguous): ambiguous=True when a prefix hits MORE than one
+    distinct logical session.
+    """
+    q = query.lower().removeprefix("local_")
+    exact: list[Session] = []
+    prefix: list[Session] = []
+    for acct in accounts:
+        for s in load_sessions(acct):
+            sid = s.sid.lower().removeprefix("local_")
+            cli = s.cli_id.lower()
+            if sid == q or cli == q:
+                exact.append(s)
+            elif sid.startswith(q) or cli.startswith(q):
+                prefix.append(s)
+    matches = exact or prefix
+    logical = {s.sid for s in matches}
+    return matches, len(logical) > 1
+
+
+def _rebase_path(old_val: str, old_cwd: str, new_cwd: str) -> str:
+    """Rebase a sibling path field (originCwd/worktreePath) after cwd moves.
+
+    Worktree sessions record originCwd as the project ROOT while cwd is the
+    worktree path under it — so a plain prefix replace isn't enough:
+      old_val == old_cwd                      -> new_cwd
+      old_cwd == old_val + suffix (root case) -> strip the same suffix off new_cwd
+      old_val == old_cwd + suffix (child)     -> new_cwd + suffix
+    Anything else is left untouched.
+    """
+    if not old_val:
+        return old_val
+    if old_val == old_cwd:
+        return new_cwd
+    if old_cwd.startswith(old_val):
+        suffix = old_cwd[len(old_val):]
+        if suffix and new_cwd.endswith(suffix):
+            return new_cwd[: len(new_cwd) - len(suffix)]
+    if old_val.startswith(old_cwd):
+        return new_cwd + old_val[len(old_cwd):]
+    return old_val
+
+
+def mode_rebind(args, accounts: list[Account]) -> int:
+    """Fix a session's recorded cwd after a folder move.
+
+    Backs up each wrapper OUTSIDE the live store, atomically rewrites
+    cwd/originCwd/worktreePath, bridges the transcript into the new munged
+    project dir (Desktop resolves it via enc(cwd)), and verifies by re-read.
+    """
+    if not args.target:
+        eecho("usage: summon rebind <id> --cwd <newpath>")
+        return 2
+    if not args.cwd:
+        eecho("rebind needs --cwd <newpath> — the folder's new location")
+        return 2
+
+    new_path = Path(args.cwd)
+    if new_path.exists():
+        new_cwd = str(new_path.resolve())
+    elif args.force:
+        new_cwd = str(new_path)
+    else:
+        eecho(f"new cwd does not exist on disk: {args.cwd}",
+              "(pass --force to rebind to a not-yet-existing path)")
+        return 3
+
+    matches, ambiguous = find_wrappers_by_id(args.target, accounts)
+    if not matches:
+        eecho(f"no session matching: {args.target}")
+        return 3
+    if ambiguous:
+        eecho(f"'{args.target}' matches {len({m.sid for m in matches})} different sessions — be more specific:")
+        for m in matches[:8]:
+            eecho(f"  {m.sid}  {m.title!r}  {m.cwd}")
+        return 2
+
+    stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
+    backup_root = Path.home() / ".claude" / "summon-backups" / stamp
+
+    echo(panel_open(f"summon {Term.g('·', '|')} rebind",
+                    indicator="dry-run" if args.dry_run else matches[0].sid[:14]))
+    echo(panel_blank())
+    old_cwd = matches[0].cwd
+    echo(summary_line(f"{old_cwd}"))
+    echo(summary_line(f"{Term.g('→', '->')} {new_cwd}"))
+    echo(panel_blank())
+
+    problems = 0
+    transcript_note = ""
+    for i, s in enumerate(matches):
+        is_last = i == len(matches) - 1
+        label = f"{s.account.short}/{s.path.name}"
+
+        if args.dry_run:
+            echo(leaf(0, label, meta=Term.color("meta", "would rebind"),
+                      last=is_last, depth=1))
+            continue
+
+        # 1. Backup outside the live store
+        backup_root.mkdir(parents=True, exist_ok=True)
+        backup = backup_root / f"{s.account.uuid}__{s.path.parent.name}__{s.path.name}"
+        shutil.copy2(s.path, backup)
+
+        # 2. Atomic rewrite
+        data = dict(s.data)
+        data["cwd"] = new_cwd
+        for field in ("originCwd", "worktreePath"):
+            if field in data:
+                data[field] = _rebase_path(str(data[field] or ""), s.cwd, new_cwd)
+        tmp = s.path.with_name(s.path.name + ".tmp")
+        tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
+        os.replace(tmp, s.path)
+
+        # 3. Verify by re-read
+        try:
+            reread = json.loads(s.path.read_text(encoding="utf-8"))
+        except (json.JSONDecodeError, OSError):
+            reread = {}
+        if reread.get("cwd") != new_cwd:
+            problems += 1
+            shutil.copy2(backup, s.path)  # restore from backup
+            echo(leaf(0, label, meta=Term.color("alarm", "verify FAILED — restored"),
+                      last=is_last, depth=1))
+            continue
+
+        echo(leaf(0, label, meta=Term.color("ok", "rebound"), last=is_last, depth=1))
+
+    # 4. Transcript bridge — Desktop looks in enc(new cwd) after the rebind
+    s0 = matches[0]
+    if s0.cli_id:
+        old_transcript, how = resolve_transcript(s0)  # s0.data still holds OLD cwd
+        new_dir = cli_jsonl_root() / encode_cwd(new_cwd)
+        new_transcript = new_dir / f"{s0.cli_id}.jsonl"
+        if new_transcript.exists():
+            transcript_note = "transcript already at new path"
+        elif not old_transcript:
+            transcript_note = "transcript missing everywhere — session may not reopen"
+            problems += 1
+        elif args.no_transcript:
+            transcript_note = f"transcript NOT copied (--no-transcript): {old_transcript}"
+        elif args.dry_run:
+            transcript_note = f"would copy transcript {Term.g('→', '->')} {new_transcript}"
+        else:
+            new_dir.mkdir(parents=True, exist_ok=True)
+            shutil.copy2(old_transcript, new_transcript)
+            transcript_note = f"transcript copied ({how}) {Term.g('→', '->')} {new_transcript}"
+
+    echo(panel_blank())
+    if transcript_note:
+        echo(summary_line(transcript_note))
+    if not args.dry_run:
+        echo(summary_line(f"backup: {backup_root}"))
+    echo(panel_blank())
+    healths = [("ok", f"{len(matches)} wrapper(s)")]
+    if problems:
+        healths.append(("alarm", f"{problems} problem(s)"))
+    echo(panel_close(healths=healths))
+    if not args.dry_run and not problems:
+        echo()
+        echo(Term.color("warn",
+             "next: restart Desktop (or Logout/Login) so the sidebar re-reads the wrapper."))
+    return 1 if problems else 0
+
+
+_RECOVERY_INSTRUCTION = (
+    "First read only the TAIL of the transcript (last ~150-200 lines) to see where "
+    "it left off — do not ingest the whole file; read earlier chunks selectively "
+    "only if something is unclear. Then: summarize the session state in a few "
+    "bullets (goal, what's done, what was in flight, blockers), and continue the "
+    "work from there in the current directory."
+)
+
+
+def emit_recovery_prompt(s: Session) -> int:
+    """Print a paste-ready recovery prompt for a new session (stdout = the prompt)."""
+    transcript, how = resolve_transcript(s)
+
+    sep = Term.g("·", "|")
+    eecho(panel_open(f"summon {Term.g('·', '|')} recover", indicator=s.account.short))
+    eecho(panel_blank())
+    eecho(summary_line(f"{s.title!r}   {s.turns}t {sep} {_ago(s.last_activity_ms)}"))
+    if how == "scanned":
+        eecho(summary_line("transcript found by scan — wrapper cwd does not derive its location"))
+    eecho(panel_blank())
+    eecho(panel_close(healths=[("ok", "prompt on stdout")] if transcript
+                      else [("alarm", "no transcript")]))
+
+    if not transcript:
+        eecho(f"no transcript found for cliSessionId {s.cli_id or '(none)'} — cannot recover")
+        return 3
+
+    cwd_note = ""
+    if s.cwd and not s.cwd.startswith("/sessions/") and not Path(s.cwd).exists():
+        cwd_note = "  (missing on disk — folder moved?)"
+
+    lines = [
+        "Continue a previous Claude session.",
+        "",
+        f"Title:      {s.title}",
+    ]
+    branch = str(s.data.get("branch") or "")
+    if branch:
+        lines.append(f"Branch:     {branch}")
+    lines.append(f"Orig cwd:   {s.cwd}{cwd_note}")
+    lines.append(f"Transcript: {transcript}")
+    lines += ["", _RECOVERY_INSTRUCTION]
+    for line in lines:
+        _print_safe(line)
+    return 0
+
+
+def mode_recover(args, accounts: list[Account]) -> int:
+    if not args.target:
+        eecho("usage: summon recover <id>   (sessionId or cliSessionId, prefix ok)")
+        return 2
+    matches, ambiguous = find_wrappers_by_id(args.target, accounts)
+    if not matches:
+        eecho(f"no session matching: {args.target}")
+        return 3
+    if ambiguous:
+        eecho(f"'{args.target}' matches {len({m.sid for m in matches})} different sessions — be more specific:")
+        for m in matches[:8]:
+            eecho(f"  {m.sid}  {m.title!r}  {m.cwd}")
+        return 2
+    return emit_recovery_prompt(matches[0])
+
+
+def _is_live(s: Session, now_ms: int) -> bool:
+    """Heuristic 'running state': wrapper touched within the last 10 minutes."""
+    recent = now_ms - 10 * 60_000
+    if s.last_activity_ms >= recent:
+        return True
+    try:
+        return int(s.path.stat().st_mtime * 1000) >= recent
+    except OSError:
+        return False
+
+
+def mode_pick(args, accounts: list[Account]) -> int:
+    """Interactive picker over the whole session store -> recovery prompt."""
+    sessions: list[Session] = []
+    for acct in accounts:
+        sessions.extend(load_sessions(acct))
+
+    days = None if args.all else (args.days if args.days is not None else 30)
+    candidates = filter_sessions(sessions, days=days,
+                                 cwd_pattern=args.cwd, title_pattern=args.title)
+    if not candidates:
+        eecho(f"no sessions match ({_window_label(days)})")
+        return 3
+
+    now_ms = int(time.time() * 1000)
+
+    # fzf path — only when genuinely interactive and not answered via --select
+    use_fzf = (not args.select and shutil.which("fzf")
+               and sys.stdin.isatty() and sys.stdout.isatty())
+    if use_fzf:
+        rows = []
+        for i, s in enumerate(candidates, 1):
+            live = "● " if _is_live(s, now_ms) else "  "
+            rows.append(f"{i}\t{live}{s.title}\t{s.cwd}\t{_ago(s.last_activity_ms)}\t{s.turns}t")
+        import subprocess
+        r = subprocess.run(
+            ["fzf", "--delimiter", "\t", "--with-nth", "2,3,4,5",
+             "--height", "60%", "--reverse",
+             "--prompt", "recover> ",
+             "--header", "● = active in last 10m — pick a session to build a recovery prompt"],
+            input="\n".join(rows).encode("utf-8"), stdout=subprocess.PIPE)
+        if r.returncode != 0 or not r.stdout.strip():
+            eecho("cancelled.")
+            return 0
+        idx = int(r.stdout.decode("utf-8", "replace").split("\t", 1)[0])
+        return emit_recovery_prompt(candidates[idx - 1])
+
+    # Fallback: numbered list (stderr), selection via --select or stdin
+    eecho(panel_open(f"summon {Term.g('·', '|')} pick",
+                     indicator=_window_label(days)))
+    eecho(panel_blank())
+    for i, s in enumerate(candidates, 1):
+        live = Term.g("●", "*") + " " if _is_live(s, now_ms) else "  "
+        eecho(leaf(i, f"{live}{s.title}", meta=f"{s.turns}t",
+                   age=_ago(s.last_activity_ms),
+                   last=(i == len(candidates)), depth=1))
+    eecho(panel_blank())
+    eecho(panel_close(hotkeys=[("#", "select"), ("blank", "cancel")]))
+
+    raw = args.select
+    if not raw:
+        try:
+            # Prompt on stderr — stdout stays clean for the recovery prompt.
+            print("recover> (number, blank to cancel): ", end="", file=sys.stderr, flush=True)
+            raw = input().strip()
+        except EOFError:
+            raw = ""
+    if not raw:
+        eecho("cancelled.")
+        return 0
+    try:
+        idx = int(raw.split(",")[0].strip())
+    except ValueError:
+        eecho(f"not a session number: {raw!r}")
+        return 2
+    if not (1 <= idx <= len(candidates)):
+        eecho(f"out of range: {idx}")
+        return 2
+    return emit_recovery_prompt(candidates[idx - 1])
+
+
+def mode_doctor(args, accounts: list[Account]) -> int:
+    """Scan every wrapper for broken cwd bindings + transcript resolution."""
+    broken: "OrderedDict[str, dict]" = OrderedDict()  # sessionId -> finding
+    transcript_missing = 0
+    transcript_scanned = 0
+    checked = 0
+    remote = 0
+
+    for acct in accounts:
+        for s in load_sessions(acct):
+            if s.is_remote:
+                remote += 1
+                continue
+            checked += 1
+            cwd_ok = bool(s.cwd) and Path(s.cwd).exists()
+            transcript, how = resolve_transcript(s)
+            if how == "scanned":
+                transcript_scanned += 1
+            if transcript is None:
+                transcript_missing += 1
+            if not cwd_ok:
+                f = broken.setdefault(s.sid, {
+                    "sessionId": s.sid,
+                    "cliSessionId": s.cli_id,
+                    "title": s.title,
+                    "cwd": s.cwd,
+                    "accounts": [],
+                    "archived": bool(s.data.get("isArchived")),
+                    "transcript": str(transcript) if transcript else None,
+                    "lastActivityAt": s.last_activity_ms,
+                })
+                if s.account.short not in f["accounts"]:
+                    f["accounts"].append(s.account.short)
+
+    findings = list(broken.values())
+
+    if args.json:
+        print(json.dumps({
+            "data": findings,
+            "meta": {"count": len(findings), "checked": checked,
+                     "remoteSkipped": remote,
+                     "transcriptMissing": transcript_missing,
+                     "transcriptFoundByScan": transcript_scanned,
+                     "schema": "claude-mods.summon.doctor/v1"},
+        }, indent=2))
+        return 10 if findings else 0
+
+    sep = Term.g("·", "|")
+    echo(panel_open(f"summon {Term.g('·', '|')} doctor",
+                    indicator=f"{checked} sessions"))
+    echo(panel_blank())
+    echo(summary_line(f"{checked} local {sep} {remote} remote skipped {sep} "
+                      f"{transcript_missing} transcript-missing {sep} "
+                      f"{transcript_scanned} found-by-scan"))
+    echo(panel_blank())
+
+    if not findings:
+        echo(section("all cwd bindings resolve", color_token="ok"))
+        echo(panel_blank())
+        echo(panel_close(healths=[("ok", "healthy")]))
+        return 0
+
+    echo(section("broken cwd bindings — recorded folder no longer exists",
+                 len(findings), color_token="alarm"))
+    findings.sort(key=lambda f: -f["lastActivityAt"])
+    for i, f in enumerate(findings):
+        is_last = i == len(findings) - 1
+        tag = "arch" if f["archived"] else ",".join(f["accounts"])
+        echo(leaf(0, f["title"], meta=Term.color("warn", tag),
+                  age=_ago(f["lastActivityAt"]), last=is_last, depth=1))
+        pipe = Term.g("│", "|")
+        echo(f"{pipe}        {Term.color('meta', f['cwd'])}")
+        short = f["sessionId"].removeprefix("local_")[:8]
+        echo(f"{pipe}        {Term.color('meta', f'summon rebind {short} --cwd <new-location>')}")
+
+    echo(panel_blank())
+    echo(panel_close(healths=[("alarm", f"{len(findings)} broken")]))
+    return 10
+
+
 # ============================================================
 #  Main
 # ============================================================
 
 def main():
     p = argparse.ArgumentParser(
-        description="Summon Claude Desktop sessions from another account.",
+        description="Claude Desktop session toolbox — cross-account transfer, "
+                    "recovery picker, cwd rebind, store doctor.",
+        epilog="examples:\n"
+               "  summon --to mknv74              push sessions to the next account\n"
+               "  summon pick                     picker -> paste-ready recovery prompt\n"
+               "  summon recover 6577b24c         recovery prompt for one session\n"
+               "  summon rebind 6577b24c --cwd X:\\Maplab\\LCMap   fix cwd after folder move\n"
+               "  summon doctor                   scan for broken cwd bindings\n",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
     )
+    p.add_argument("mode", nargs="?", choices=["rebind", "pick", "recover", "doctor"],
+                   help="Toolbox mode; omit for cross-account transfer")
+    p.add_argument("target", nargs="?",
+                   help="Session id for rebind/recover (sessionId or cliSessionId, prefix ok)")
     p.add_argument("--to", help="Destination account (UUID prefix or email substring)")
     p.add_argument("--from", dest="from_",
                    help="Restrict source to one account (default: all non-destination accounts)")
     # Time-window filter: --days N (custom) or one of the convenience aliases.
-    # Defaults to 14 days; --all disables.
-    p.add_argument("--days", type=int, default=3, help="Time window in days (default 3)")
+    # Transfer defaults to 3 days, pick to 30; --all disables.
+    p.add_argument("--days", type=int, default=None,
+                   help="Time window in days (default: 3 for transfer, 30 for pick)")
     p.add_argument("--all", action="store_true", help="Disable time filter (any age)")
     p.add_argument("--1d", dest="window_1d", action="store_true", help="Last 24h (alias)")
     p.add_argument("--3d", dest="window_3d", action="store_true", help="Last 3 days (alias)")
     p.add_argument("--7d", dest="window_7d", action="store_true", help="Last 7 days (alias)")
     p.add_argument("--30d", dest="window_30d", action="store_true", help="Last 30 days (alias)")
-    p.add_argument("--cwd", default="", help="Substring match against cwd")
+    p.add_argument("--cwd", default="",
+                   help="Transfer/pick: substring match against cwd. "
+                        "Rebind: the folder's NEW location (full path)")
     p.add_argument("--title", default="", help="Substring match against title")
     p.add_argument("--pick", action="store_true", help=argparse.SUPPRESS)  # legacy flag — default behavior now
     p.add_argument("--move", action="store_true",
@@ -946,6 +1401,12 @@ def main():
     p.add_argument("--yes", action="store_true",
                    help="Skip the final confirmation prompt only — selection is still "
                         "required (interactively, via piped stdin, or via --select)")
+    p.add_argument("--no-transcript", action="store_true",
+                   help="Rebind: skip copying the transcript into the new munged project dir")
+    p.add_argument("--force", action="store_true",
+                   help="Rebind: allow --cwd paths that don't exist on disk yet")
+    p.add_argument("--json", action="store_true",
+                   help="Doctor: emit findings as a JSON envelope on stdout")
     args = p.parse_args()
 
     claude_dir = appdata_claude()
@@ -956,6 +1417,19 @@ def main():
     if not accounts:
         sys.exit(f"No accounts with sessions under {claude_dir}/claude-code-sessions/")
 
+    # --- Toolbox modes ---
+
+    if args.mode == "rebind":
+        sys.exit(mode_rebind(args, accounts))
+    if args.mode == "recover":
+        sys.exit(mode_recover(args, accounts))
+    if args.mode == "pick":
+        sys.exit(mode_pick(args, accounts))
+    if args.mode == "doctor":
+        sys.exit(mode_doctor(args, accounts))
+    if args.target:
+        p.error("unexpected positional argument — transfer mode takes flags only")
+
     # --- Modes that exit early ---
 
     if args.list_accounts:
@@ -1015,7 +1489,7 @@ def main():
     elif args.window_30d:
         days = 30
     else:
-        days = args.days
+        days = args.days if args.days is not None else 3
 
     candidates = filter_sessions(all_sessions, days=days,
                                  cwd_pattern=args.cwd, title_pattern=args.title)

+ 223 - 0
skills/summon/tests/test_summon.py

@@ -13,6 +13,19 @@ Covers:
   5. Without --yes, confirming 'y' proceeds
   6. --yes with empty stdin cancels instead of auto-selecting all
   7. Non-cp1252 chars in session titles don't crash on cp1252 stdout
+
+Toolbox modes (rebind / recover / pick / doctor):
+  8.  rebind rewrites cwd + rebases originCwd/worktreePath, backs up outside
+      the store, bridges the transcript into the new munged dir, exits 0
+  9.  rebind --dry-run touches nothing
+  10. rebind with unknown id exits 3; ambiguous prefix exits 2
+  11. rebind to a nonexistent path exits 3 without --force, 0 with it
+  12. doctor flags the broken-cwd session (exit 10, --json envelope), and
+      goes healthy (exit 0) after the rebind
+  13. recover emits a paste-ready prompt on stdout; transcript found via the
+      scan fallback when the munged dir doesn't derive from the recorded cwd
+  14. recover with unknown id exits 3
+  15. pick --select N emits the recovery prompt for the Nth candidate
 """
 
 from __future__ import annotations
@@ -120,6 +133,213 @@ def with_sandbox(titles=("alpha", "beta", "gamma")):
     return tmp, env, src_ws, dest_ws
 
 
+def encode_cwd(cwd: str) -> str:
+    """Mirror of summon.py's munging: cwd -> ~/.claude/projects/ subdir name."""
+    return (cwd.replace(":", "-").replace("\\", "-")
+               .replace("/", "-").replace(".", "-"))
+
+
+def build_toolbox_sandbox(tmp: Path) -> dict:
+    """One account, three sessions exercising the toolbox modes.
+
+    sess-a  'moved'   worktree session; recorded cwd no longer exists (project
+                      moved old-root -> new-root); transcript under enc(old cwd)
+    sess-b  'healthy' cwd exists, transcript at the expected munged path
+    sess-c  'mismatch' cwd exists, but the transcript lives under a munged dir
+                      that does NOT derive from the recorded cwd (scan-fallback)
+    """
+    home = tmp / "home"
+    appdata = home / "AppData" / "Roaming"
+    env = os.environ.copy()
+    env["HOME"] = str(home)
+    env["USERPROFILE"] = str(home)
+    env["APPDATA"] = str(appdata)
+    env.pop("PYTHONIOENCODING", None)
+    env.pop("TERM_ASCII", None)
+
+    cdir = claude_dir(env)
+    projects = home / ".claude" / "projects"
+    now_ms = int(time.time() * 1000)
+
+    old_root = tmp / "proj-old"                    # moved away -> missing
+    new_root = tmp / "proj-new"
+    old_wt = old_root / ".claude" / "worktrees" / "wt1"
+    new_wt = new_root / ".claude" / "worktrees" / "wt1"
+    new_wt.mkdir(parents=True)
+    good = tmp / "proj-good"
+    good.mkdir()
+
+    ws = cdir / "claude-code-sessions" / SRC_UUID / "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
+    ws.mkdir(parents=True)
+
+    def wrapper(sid, cli, title, cwd, age_min, **extra):
+        d = {"sessionId": f"local_{sid}", "cliSessionId": cli, "title": title,
+             "cwd": cwd, "lastActivityAt": now_ms - age_min * 60_000,
+             "completedTurns": 7}
+        d.update(extra)
+        (ws / f"local_{sid}.json").write_text(json.dumps(d), encoding="utf-8")
+
+    wrapper("aaaa-moved", "cli-moved", "moved session", str(old_wt), 30,
+            originCwd=str(old_root), worktreePath=str(old_wt),
+            branch="claude/wt1")
+    wrapper("bbbb-healthy", "cli-healthy", "healthy session", str(good), 60)
+    wrapper("cccc-mismatch", "cli-mismatch", "mismatch session", str(good), 90)
+
+    for enc_dir, cli in ((encode_cwd(str(old_wt)), "cli-moved"),
+                         (encode_cwd(str(good)), "cli-healthy"),
+                         ("X--some-unrelated-munged-dir", "cli-mismatch")):
+        d = projects / enc_dir
+        d.mkdir(parents=True, exist_ok=True)
+        (d / f"{cli}.jsonl").write_text(
+            '{"type":"user","message":{"content":"hello"}}\n', encoding="utf-8")
+
+    return {"env": env, "ws": ws, "projects": projects,
+            "old_wt": old_wt, "new_wt": new_wt, "new_root": new_root,
+            "old_root": old_root, "home": home}
+
+
+def run_mode(env: dict, argv: list[str], stdin_text: str = "") -> tuple[int, str, str]:
+    r = subprocess.run([sys.executable, str(SCRIPT)] + argv,
+                       input=stdin_text.encode("utf-8"),
+                       env=env, capture_output=True, timeout=60)
+    return (r.returncode,
+            r.stdout.decode("utf-8", "replace"),
+            r.stderr.decode("utf-8", "replace"))
+
+
+def toolbox_tests() -> None:
+    tmp = Path(tempfile.mkdtemp(prefix="summon-toolbox-"))
+    try:
+        sb = build_toolbox_sandbox(tmp)
+        env, ws = sb["env"], sb["ws"]
+        new_wt, new_root = sb["new_wt"], sb["new_root"]
+        wrapper_a = ws / "local_aaaa-moved.json"
+
+        # 9. dry-run first (order matters: before the real rebind)
+        rc, out, err = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(new_wt),
+                                      "--dry-run"])
+        data = json.loads(wrapper_a.read_text(encoding="utf-8"))
+        backups = sb["home"] / ".claude" / "summon-backups"
+        if rc == 0 and data["cwd"] == str(sb["old_wt"]) and not backups.exists():
+            ok("rebind --dry-run touches nothing")
+        else:
+            no("rebind --dry-run touches nothing",
+               f"rc={rc} cwd={data['cwd']!r} backups={backups.exists()}")
+
+        # 10a. unknown id
+        rc, _, _ = run_mode(env, ["rebind", "zzzz-nope", "--cwd", str(new_wt)])
+        if rc == 3:
+            ok("rebind unknown id exits 3")
+        else:
+            no("rebind unknown id exits 3", f"rc={rc}")
+
+        # 10b. ambiguous prefix: 'cli-' hits several cliSessionIds... use
+        # wrapper-id ambiguity instead — 'aaaa' vs 'aaaa-moved' is unique, so
+        # craft the collision on the shared '' prefix? No: use 'cli-m' which
+        # prefixes cli-moved and cli-mismatch — two distinct sessions.
+        rc, _, err = run_mode(env, ["rebind", "cli-m", "--cwd", str(new_wt)])
+        if rc == 2 and "different sessions" in err:
+            ok("rebind ambiguous prefix exits 2")
+        else:
+            no("rebind ambiguous prefix exits 2", f"rc={rc} err-tail={err[-200:]!r}")
+
+        # 11. nonexistent target path
+        ghost = tmp / "not-there-yet"
+        rc, _, _ = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(ghost)])
+        if rc == 3:
+            ok("rebind to nonexistent path exits 3 without --force")
+        else:
+            no("rebind to nonexistent path exits 3 without --force", f"rc={rc}")
+
+        # 12a. doctor pre-rebind: flags exactly the moved session, exit 10
+        rc, out, _ = run_mode(env, ["doctor", "--json"])
+        try:
+            env_doc = json.loads(out)
+        except json.JSONDecodeError:
+            env_doc = {"data": [], "meta": {}}
+        ids = [f["sessionId"] for f in env_doc.get("data", [])]
+        if (rc == 10 and ids == ["local_aaaa-moved"]
+                and env_doc["meta"].get("checked") == 3
+                and env_doc["meta"].get("schema") == "claude-mods.summon.doctor/v1"):
+            ok("doctor flags the broken cwd (exit 10, --json envelope)")
+        else:
+            no("doctor flags the broken cwd (exit 10, --json envelope)",
+               f"rc={rc} ids={ids} meta={env_doc.get('meta')}")
+
+        # 8. real rebind: wrapper rewritten, siblings rebased, backup taken,
+        #    transcript bridged into enc(new cwd)
+        rc, out, err = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(new_wt)])
+        data = json.loads(wrapper_a.read_text(encoding="utf-8"))
+        resolved_new_wt = str(new_wt.resolve())
+        resolved_new_root = str(new_root.resolve())
+        bridged = (sb["projects"] / encode_cwd(resolved_new_wt) / "cli-moved.jsonl")
+        original = (sb["projects"] / encode_cwd(str(sb["old_wt"])) / "cli-moved.jsonl")
+        backup_files = list(backups.rglob("*.json")) if backups.exists() else []
+        checks = {
+            "rc": rc == 0,
+            "cwd": data["cwd"] == resolved_new_wt,
+            "originCwd": data["originCwd"] == resolved_new_root,
+            "worktreePath": data["worktreePath"] == resolved_new_wt,
+            "backup": len(backup_files) == 1,
+            "bridged": bridged.exists(),
+            "copy-not-move": original.exists(),
+        }
+        if all(checks.values()):
+            ok("rebind rewrites wrapper + backup + transcript bridge")
+        else:
+            no("rebind rewrites wrapper + backup + transcript bridge",
+               f"failed={[k for k, v in checks.items() if not v]} rc={rc}")
+
+        # 12b. doctor post-rebind: healthy, exit 0
+        rc, out, _ = run_mode(env, ["doctor", "--json"])
+        try:
+            env_doc = json.loads(out)
+        except json.JSONDecodeError:
+            env_doc = {"data": ["unparsed"]}
+        if rc == 0 and env_doc.get("data") == []:
+            ok("doctor healthy after rebind (exit 0)")
+        else:
+            no("doctor healthy after rebind (exit 0)",
+               f"rc={rc} data={env_doc.get('data')}")
+
+        # 11b. --force allows a not-yet-existing path (rebind back and forth)
+        rc, _, _ = run_mode(env, ["rebind", "aaaa-moved", "--cwd", str(ghost), "--force"])
+        data = json.loads(wrapper_a.read_text(encoding="utf-8"))
+        if rc == 0 and data["cwd"] == str(ghost):
+            ok("rebind --force accepts a nonexistent path")
+        else:
+            no("rebind --force accepts a nonexistent path", f"rc={rc} cwd={data['cwd']!r}")
+
+        # 13. recover: prompt on stdout, scan fallback for the mismatched dir
+        rc, out, err = run_mode(env, ["recover", "cccc-mismatch"])
+        scan_path = sb["projects"] / "X--some-unrelated-munged-dir" / "cli-mismatch.jsonl"
+        if (rc == 0 and str(scan_path) in out
+                and "Continue a previous Claude session" in out
+                and "TAIL of the transcript" in out
+                and "Continue a previous" not in err):
+            ok("recover emits prompt; scan fallback finds mismatched transcript")
+        else:
+            no("recover emits prompt; scan fallback finds mismatched transcript",
+               f"rc={rc} out-head={out[:200]!r}")
+
+        # 14. recover unknown id
+        rc, _, _ = run_mode(env, ["recover", "zzzz-nope"])
+        if rc == 3:
+            ok("recover unknown id exits 3")
+        else:
+            no("recover unknown id exits 3", f"rc={rc}")
+
+        # 15. pick --select 2 -> second-newest candidate (healthy session)
+        rc, out, _ = run_mode(env, ["pick", "--select", "2"])
+        if rc == 0 and "healthy session" in out and "cli-healthy.jsonl" in out:
+            ok("pick --select 2 recovers the 2nd candidate")
+        else:
+            no("pick --select 2 recovers the 2nd candidate",
+               f"rc={rc} out-head={out[:200]!r}")
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
 def main() -> int:
     # 1. Piped selection honoured even with --yes (the original bug: --yes
     #    used to select ALL candidates, ignoring the piped picks).
@@ -213,6 +433,9 @@ def main() -> int:
     finally:
         shutil.rmtree(tmp, ignore_errors=True)
 
+    # 8-15. Toolbox modes: rebind / recover / pick / doctor
+    toolbox_tests()
+
     print(f"\nsummon tests: {PASS} passed, {FAIL} failed")
     return 1 if FAIL else 0