# Clean Handover

**If your provider stopped tomorrow, could you keep running? One register of every account, export, automation and bill the work touches, a checker that finds each place the answer is no, and one reply to close the gaps.**

Version 1.0, 2026-11-03. By PaperChaseWebb Inc. Free. Works with Grok Bot, Claude Code, Codex and Hermes, or by hand. Python 3.8 or newer, no installs.

## The problem

Agent and automation work piles up quietly. A host signed up on the provider's card. A customer list the client cannot export. A weekly job nobody on the client side knows how to pause. None of it matters until the contract ends, the provider is unreachable, or something breaks at 6 AM. Then it is the only thing that matters.

A good provider wants this list as much as the client does. It is the clearest proof that the client stays because the work is good, not because leaving is hard.

## What you get out of it

- One register of every system the work touches, with who owns it, who can administer it, how to export it, how to switch it off and who answers for it.
- A scan that names every gap by system, in plain words.
- One decision sheet with a numbered item per gap and a reply line like `1: test 2: transfer 3: accept`. Answer in one message.
- A handover plan of only the steps the client approved, each with a done test the client checks themselves.

## Who it is for

Owners who pay for agent or automation work and want to know they are not locked in. Providers and small agencies who want to hand every client a clean exit on day one, and prove it every quarter.

Not a password manager and not a contract. It never holds a secret and it never changes an account. It shows what would strand the client and records what the client decided.

## The register

handover.json holds the client, the provider, the client owner (the one person who decides) and a list of systems. Each system has a kind:

| Kind | What it needs to pass |
| --- | --- |
| account | in the client's name, client has admin, an export method tested in the last 90 days |
| data | the same as an account |
| automation | a runbook file, an off switch and a named client contact |
| schedule | the same as an automation |
| bill | in the client's name, or a written plan to move it |

The rule behind it: a system is handed over only when the client can see it, take it, stop it and pay for it without asking anyone.

## What the checker catches

| Code | Means |
| --- | --- |
| C01 to C04 | the register is missing the client, provider, owner or systems; a name is blank or listed twice; a kind, date or amount cannot be read |
| H01 | the account, data or bill is in the provider's name |
| H02 | the client has no admin access |
| H03 | no export method, an export never tested, or one tested more than 90 days ago |
| H04 | an automation or schedule has no runbook, its runbook file is missing, or it has no off switch |
| H05 | the provider pays a bill with no plan to move it |
| H06 | no one on the client side answers for an automation or schedule |
| R01 to R04 | your reply names an item that does not exist, an option that is not offered, answers an item twice, or has text it cannot read |

## One reply

Every item on the decision sheet comes with its options. Anything you do not answer holds.

| Gap | Options |
| --- | --- |
| H01 provider's name | `transfer`, `accept`, `hold` |
| H02 no client admin | `grant`, `accept`, `hold` |
| H03 export | `test`, `accept`, `hold` |
| H04 runbook or off switch | `write`, `retire`, `hold` |
| H05 provider pays | `transfer`, `cancel`, `hold` |
| H06 no client contact | `name`, `retire`, `hold` |

`accept` is honest, not a failure: the client chose to live with it, and the plan says so with their name and date. Recording a reply is refused inside an agent session, without a terminal, from anyone but the client owner, and if the register or any runbook changed after the sheet was made.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py clean-handover-keep-running-if-your-provider-stops.md handover/` (the file you downloaded) to unpack the rest.
2. In `handover/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up bakery and its automation studio: `python3 handover.py scan example/handover.json --today 2026-11-03`. You should see 9 gaps across 6 systems. The order inbox agent is clean.
4. Run `python3 handover.py sheet example/handover.json out/ --today 2026-11-03` and read out/decisions.md. Then, at your own terminal: `python3 handover.py reply example/handover.json out/ "1: test 2: transfer 3: grant 4: write 5: write 6: name 7: transfer 8: transfer 9: accept"` (type Rosa Delgado when asked) and `python3 handover.py plan example/handover.json out/`.
5. Copy templates/handover.json, list your own systems, and write a runbook for each automation and schedule.
6. Give your agent AGENTS.md. Claude Code and Codex read it from the folder (CLAUDE.md is the same text); for Grok Bot use agents/grok-standing-order.md, and for Hermes load agents/SKILL.md.

## Run it once before you rely on it

Walk the register with both sides in the room and add anything the scan cannot know about. Then try one export and one off switch for real. Stop and fix the setup if a system was missing from the register, if a done test passed on someone's word alone, or if your agent signed in, moved an account or wrote a secret into the folder. That is the kill rule.

## Limits

- It knows only what the register says. A system nobody listed is not checked, so the walk-through matters more than the scan.
- It checks that a runbook file exists, not that it is good. Read each one and try its off switch.
- It does not sign in anywhere, so it cannot confirm who owns an account or who can reach it. The done tests are how people confirm it.
- It is not legal advice and does not replace the contract's exit terms.

## Want this run for you

A clean handover is one part of a business that runs on agents with approval gates. If you would rather have it set up and run for you, with the register kept current and every step drafted for your yes, that is Managed Ops: our agents work inside the tools you already use. Managed Ops starts at $5,000/mo with $2,500 onboarding and a 3-month minimum. Growth Fleet, starting at $2,500 setup + $750/mo, covers a smaller set of agents. Book a call on the paperchasewebb.ai homepage. Every price is a starting price.

## License

Free for personal and commercial use inside your own business. Do not resell or republish the kit.

## Appendix: the files

#### file: extract_from_pack.py
~~~~python
#!/usr/bin/env python3
"""Unpack every '#### file: PATH' block from pack.md (or FREE-SAMPLE.md) into a folder."""
import os, re, sys
src, dst = sys.argv[1], sys.argv[2]
t = open(src, encoding="utf-8").read()
n = 0
for m in re.finditer(r"^#### file: (\S+)\n~~~~[a-z]*\n(.*?)\n~~~~$", t, re.S | re.M):
    p = os.path.join(dst, m.group(1))
    os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
    open(p, "w", encoding="utf-8").write(m.group(2) + "\n")
    n += 1
print(n, "files")
~~~~

#### file: handover.py
~~~~python
#!/usr/bin/env python3
"""handover.py: prove a client can keep running if the provider leaves tomorrow. The client decides every step.

  python3 handover.py check HANDOVER.json [--today D]     check the register itself
  python3 handover.py scan HANDOVER.json [--today D]      find every gap that would strand the client
  python3 handover.py sheet HANDOVER.json OUT/ [--today D]  write OUT/decisions.md and OUT/pending.json
  python3 handover.py parse OUT/pending.json "REPLY"      check a reply is complete and valid (writes nothing)
  python3 handover.py reply HANDOVER.json OUT/ "REPLY"    client owner only, at their own terminal: record answers
  python3 handover.py plan HANDOVER.json OUT/             write OUT/handover-plan.md, the approved steps with a done test

HANDOVER.json lists every account, data store, automation, schedule and bill the work touches.
Runbook paths are relative to the folder HANDOVER.json sits in. Nothing here logs in, moves an account or changes a bill.
Exit codes: 0 ok, 1 findings or errors, 2 usage, 3 refused (agent session or no terminal),
4 refused (stale or missing sheet or decisions), 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import hashlib, json, os, re, sys
from datetime import date, datetime

AGENT_ENV = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED",
             "CODEX_MANAGED_BY_NPM", "HERMES_AGENT", "HERMES_SESSION_ID", "GROK_BOT", "CURSOR_AGENT"]
KINDS = ("account", "data", "automation", "schedule", "bill")
OPTIONS = {"H01": ["transfer", "accept", "hold"], "H02": ["grant", "accept", "hold"],
           "H03": ["test", "accept", "hold"], "H04": ["write", "retire", "hold"],
           "H05": ["transfer", "cancel", "hold"], "H06": ["name", "retire", "hold"]}
MEANING = {"transfer": "move it into the client's name or onto the client's card",
           "accept": "leave it as it is; the client accepts the risk and it is noted in the plan",
           "grant": "give the client owner admin access",
           "test": "run a real export, open it, and date the test",
           "write": "the provider writes the missing runbook or off switch",
           "retire": "turn it off and remove it before handover",
           "cancel": "cancel the bill before handover",
           "name": "name a person on the client side who answers for it",
           "hold": "no decision yet; it stays a gap"}
DONE = {"H01": "the account shows the client as owner and the provider as a removable user",
        "H02": "the client owner signs in and reaches the admin settings without help",
        "H03": "an export file was opened by the client and the tested date is updated",
        "H04": "the client reads the runbook and turns it off and on once without help",
        "H05": "the next invoice is addressed to the client or the bill is gone",
        "H06": "client_contact is filled in and that person has read the runbook",
        "retire": "it no longer runs and nothing else depended on it",
        "cancel": "the bill is cancelled and nothing else depended on it"}


def jload(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def wtext(p, t):
    with open(p, "w", encoding="utf-8") as f:
        f.write(t)


def to_date(s):
    try:
        return datetime.strptime(str(s), "%Y-%m-%d").date()
    except ValueError:
        return None


def check_register(reg):
    """Return errors as (code, message)."""
    errs = []
    for k in ("client", "provider", "client_owner"):
        if not str(reg.get(k) or "").strip():
            errs.append(("C01", "no %s" % k))
    systems = reg.get("systems")
    if not isinstance(systems, list) or not systems:
        errs.append(("C01", "no systems: list every account, data store, automation, schedule and bill"))
        return errs
    seen = set()
    for i, s in enumerate(systems, 1):
        n = str(s.get("name") or "").strip()
        if not n:
            errs.append(("C02", "system %d has no name" % i))
            continue
        if n.lower() in seen:
            errs.append(("C02", "system listed twice: %s" % n))
        seen.add(n.lower())
        if s.get("kind") not in KINDS:
            errs.append(("C02", "'%s' kind must be one of %s" % (n, ", ".join(KINDS))))
        t = (s.get("export") or {}).get("tested")
        if t and not to_date(t):
            errs.append(("C03", "'%s' export tested date is not YYYY-MM-DD" % n))
        if s.get("kind") == "bill":
            amt = (s.get("billing") or {}).get("monthly_usd")
            if not isinstance(amt, (int, float)) or amt < 0:
                errs.append(("C04", "'%s' billing.monthly_usd must be a number" % n))
    return errs


def scan(reg, base, today=None):
    """Return findings as dicts. Assumes check_register passed."""
    today = today or date.today()
    age = int(reg.get("max_export_age_days", 90))
    out = []

    def add(code, s, detail):
        out.append({"code": code, "system": s["name"], "kind": s["kind"], "detail": detail})

    for s in reg["systems"]:
        k = s["kind"]
        if k in ("account", "data", "bill") and s.get("account_owner", "client") != "client":
            add("H01", s, "in the %s's name, not the client's" % s.get("account_owner"))
        if k in ("account", "data") and "client" not in (s.get("admin_access") or []):
            add("H02", s, "the client has no admin access")
        if k in ("account", "data"):
            ex = s.get("export") or {}
            d = to_date(ex.get("tested", ""))
            if not ex.get("how"):
                add("H03", s, "no export method written down")
            elif not d:
                add("H03", s, "export never tested")
            elif (today - d).days > age:
                add("H03", s, "export last tested %s, more than %d days ago" % (d, age))
        if k in ("automation", "schedule"):
            rb = s.get("runbook")
            if not rb:
                add("H04", s, "no runbook")
            elif not os.path.isfile(os.path.join(base, rb)):
                add("H04", s, "runbook %s is listed but the file is missing" % rb)
            if not str(s.get("off_switch") or "").strip():
                add("H04", s, "no off switch written down")
            if not str(s.get("client_contact") or "").strip():
                add("H06", s, "no one on the client side answers for it")
        if k == "bill":
            b = s.get("billing") or {}
            if b.get("payer", "client") != "client" and not str(b.get("transfer") or "").strip():
                add("H05", s, "the %s pays $%s a month with no plan to move it" % (b.get("payer"), b.get("monthly_usd")))
    return out


def describe(f):
    return "%s (%s): %s." % (f["system"], f["kind"], f["detail"])


def state_hash(reg_path):
    h = hashlib.sha256()
    with open(reg_path, "rb") as fh:
        h.update(fh.read())
    base = os.path.dirname(os.path.abspath(reg_path))
    for s in jload(reg_path).get("systems", []):
        rb = s.get("runbook")
        if rb and os.path.isfile(os.path.join(base, rb)):
            with open(os.path.join(base, rb), "rb") as fh:
                h.update(rb.encode() + b"\0" + fh.read() + b"\0")
    return h.hexdigest()


def make_sheet(reg_path, out, today=None):
    reg = jload(reg_path)
    items = []
    for f in scan(reg, os.path.dirname(os.path.abspath(reg_path)), today):
        it = dict(f)
        it["n"] = len(items) + 1
        it["options"] = OPTIONS[f["code"]]
        it["ask"] = describe(f)
        items.append(it)
    os.makedirs(out, exist_ok=True)
    lines = ["# Handover decisions: %s from %s" % (reg["client"], reg["provider"]), "",
             "For %s. Generated %s. Nothing moves until you reply. Each item stands alone." % (
                 reg["client_owner"], datetime.now().strftime("%Y-%m-%d %H:%M")), "",
             "The test: if %s stopped tomorrow, could %s keep running? Each item below is a place where the answer is no." % (
                 reg["provider"], reg["client"]), ""]
    if not items:
        lines.append("No gaps found. Every system is in the client's name, exportable, documented and owned.")
    for it in items:
        lines += ["## %d. %s" % (it["n"], it["ask"]), ""]
        for o in it["options"]:
            lines.append("- `%s`: %s" % (o, MEANING[o]))
        lines += ["", "Until you answer: hold.", ""]
    if items:
        lines += ["## Reply with one line", "", "~~~text", " ".join("%d: ___" % it["n"] for it in items), "~~~", ""]
    wtext(os.path.join(out, "decisions.md"), "\n".join(lines))
    wtext(os.path.join(out, "pending.json"), json.dumps({"state": state_hash(reg_path), "items": items}, indent=1) + "\n")
    return items


def parse_reply(items, reply):
    """Return (answers {n: option}, errors). Unanswered items hold."""
    errs, ans = [], {}
    by_n = {it["n"]: it for it in items}
    rest = reply
    for m in re.finditer(r"(\d+)\s*[:.)=-]\s*([A-Za-z]+)", reply):
        n, opt = int(m.group(1)), m.group(2).lower()
        rest = rest.replace(m.group(0), " ", 1)
        if n not in by_n:
            errs.append(("R01", "there is no item %d" % n))
        elif opt not in by_n[n]["options"]:
            errs.append(("R02", "item %d: '%s' is not an option (%s)" % (n, opt, ", ".join(by_n[n]["options"]))))
        elif n in ans:
            errs.append(("R03", "item %d answered twice" % n))
        else:
            ans[n] = opt
    if re.sub(r"[\s,;]+", "", rest):
        errs.append(("R04", "could not read: %s" % re.sub(r"\s+", " ", rest).strip()[:60]))
    for n in by_n:
        ans.setdefault(n, "hold")
    return ans, errs


def record_reply(reg_path, out, reply, ask=input, env=None, tty=None):
    env = os.environ if env is None else env
    tty = sys.stdin.isatty() if tty is None else tty
    for k in AGENT_ENV:
        if env.get(k):
            print("refused: agent session detected (%s); the client owner records decisions" % k)
            return 3
    if not tty:
        print("refused: no terminal; run this yourself")
        return 3
    pp = os.path.join(out, "pending.json")
    if not os.path.exists(pp):
        print("refused: no decision sheet; run handover.py sheet")
        return 4
    pending = jload(pp)
    if pending["state"] != state_hash(reg_path):
        print("refused: the register or a runbook changed since the sheet was made; run handover.py sheet again")
        return 4
    ans, errs = parse_reply(pending["items"], reply)
    if errs:
        for c, m in errs:
            print("%s %s" % (c, m))
        return 1
    for it in pending["items"]:
        print("%d. %s -> %s" % (it["n"], it["ask"], ans[it["n"]]))
    reg = jload(reg_path)
    if ask("Your name, as client_owner in the register: ").strip() != reg["client_owner"]:
        print("refused: that is not the client owner in the register")
        return 5
    if ask("Record these decisions? y/n: ").strip().lower() != "y":
        print("not recorded")
        return 5
    rec = {"state": pending["state"], "decided_by": reg["client_owner"], "decided_at": datetime.now().strftime("%Y-%m-%dT%H:%M"),
           "answers": {str(k): v for k, v in sorted(ans.items())}}
    wtext(os.path.join(out, "decisions.json"), json.dumps(rec, indent=1) + "\n")
    print("recorded. Next: python3 handover.py plan %s %s" % (reg_path, out))
    return 0


def plan(reg_path, out):
    dp, pp = os.path.join(out, "decisions.json"), os.path.join(out, "pending.json")
    if not (os.path.exists(dp) and os.path.exists(pp)):
        print("refused: no recorded decisions; the client owner runs handover.py reply")
        return 4
    dec, pending = jload(dp), jload(pp)
    if dec["state"] != pending["state"] or dec["state"] != state_hash(reg_path):
        print("refused: the register or a runbook changed after the client decided; make a new sheet")
        return 4
    reg = jload(reg_path)
    todo, accepted, held = [], [], []
    for it in pending["items"]:
        a = dec["answers"][str(it["n"])]
        if a == "hold":
            held.append("- %d. %s" % (it["n"], it["ask"]))
        elif a == "accept":
            accepted.append("- %d. %s Accepted by %s on %s." % (it["n"], it["ask"], dec["decided_by"], dec["decided_at"][:10]))
        else:
            done = DONE.get(a) or DONE[it["code"]]
            todo.append("- [ ] %d. %s: %s.\n  Done when %s." % (it["n"], it["system"], MEANING[a], done))
    ready = not held and not todo
    lines = ["# Handover plan: %s from %s" % (reg["client"], reg["provider"]), "",
             "Decided by %s on %s. The provider does each step with the client watching; the client checks the done test." % (
                 dec["decided_by"], dec["decided_at"][:10]), "",
             "Status: %s" % ("READY. Nothing is left to move." if ready else "NOT READY. %d step(s) to do, %d held." % (len(todo), len(held))), ""]
    if todo:
        lines += ["## Steps", ""] + todo + [""]
    if accepted:
        lines += ["## Accepted as is", ""] + accepted + [""]
    if held:
        lines += ["## Held (still gaps)", ""] + held + [""]
    lines += ["## Last step", "", "When every step is done, run handover.py scan again. Handover is complete when it reports 0 gaps or only accepted ones.", ""]
    wtext(os.path.join(out, "handover-plan.md"), "\n".join(lines))
    print("wrote %s/handover-plan.md: %d step(s), %d accepted, %d held" % (out, len(todo), len(accepted), len(held)))
    return 0


def main(argv):
    today = None
    if "--today" in argv:
        i = argv.index("--today")
        today = to_date(argv[i + 1]) if i + 1 < len(argv) else None
        if not today:
            print("--today needs YYYY-MM-DD")
            return 2
        argv = argv[:i] + argv[i + 2:]
    if not argv:
        print(__doc__)
        return 2
    cmd, a = argv[0], argv[1:]
    if cmd == "parse" and len(a) == 2:
        ans, errs = parse_reply(jload(a[0])["items"], a[1])
        for c, m in errs:
            print("%s %s" % (c, m))
        held = [str(n) for n, v in sorted(ans.items()) if v == "hold"]
        print("reply %s; held: %s" % ("OK" if not errs else "NOT OK", ", ".join(held) or "none"))
        return 1 if errs else 0
    if cmd not in ("check", "scan", "sheet", "reply", "plan") or not a:
        print(__doc__)
        return 2
    reg = jload(a[0])
    errs = check_register(reg)
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("register %s: %d error(s), %d system(s)" % ("OK" if not errs else "NOT OK", len(errs), len(reg.get("systems") or [])))
        return 1 if errs else 0
    base = os.path.dirname(os.path.abspath(a[0]))
    if cmd == "scan" and len(a) == 1:
        found = scan(reg, base, today)
        for f in found:
            print("%s %s" % (f["code"], describe(f)))
        print("handover %s: %d gap(s) across %d system(s)" % ("READY" if not found else "NOT READY", len(found), len(reg["systems"])))
        return 1 if found else 0
    if cmd == "sheet" and len(a) == 2:
        items = make_sheet(a[0], a[1], today)
        print("wrote %s/decisions.md with %d decision(s)" % (a[1], len(items)))
        return 0
    if cmd == "reply" and len(a) == 3:
        return record_reply(a[0], a[1], a[2])
    if cmd == "plan" and len(a) == 2:
        return plan(a[0], a[1])
    print(__doc__)
    return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
~~~~

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: clean handover

You help prove a client can keep running if the provider stops tomorrow. You list, check and draft. The client owner decides.

## You may
- Draft handover.json from what the provider and client tell you: every account, data store, automation, schedule and bill the work touches.
- Draft a runbook in runbooks/ for each automation and schedule: what it does, where it runs, the off switch, what to do if it breaks.
- Run `python3 handover.py check handover.json`, `scan handover.json` and `sheet handover.json out/`, then send the client owner out/decisions.md with the one-line reply template.
- Run `python3 handover.py parse out/pending.json "REPLY"` on the owner's reply and say if anything is unclear or missing.
- After the owner has recorded decisions, run `plan handover.json out/` and walk the provider through each step.

## You never
- Run `handover.py reply` or write decisions.json. The client owner records decisions at their own terminal.
- Sign in to an account, move an account to a new owner, change a password, cancel a bill or turn an automation off. People do that, with the client watching.
- Copy a password, key or token into any file in this folder. The register names where access lives, never the secret itself.
- Mark a gap closed because someone said it was done. Update the register only after the done test is met, then scan again.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: clean handover

You help prove a client can keep running if the provider stops tomorrow. You list, check and draft. The client owner decides.

## You may
- Draft handover.json from what the provider and client tell you: every account, data store, automation, schedule and bill the work touches.
- Draft a runbook in runbooks/ for each automation and schedule: what it does, where it runs, the off switch, what to do if it breaks.
- Run `python3 handover.py check handover.json`, `scan handover.json` and `sheet handover.json out/`, then send the client owner out/decisions.md with the one-line reply template.
- Run `python3 handover.py parse out/pending.json "REPLY"` on the owner's reply and say if anything is unclear or missing.
- After the owner has recorded decisions, run `plan handover.json out/` and walk the provider through each step.

## You never
- Run `handover.py reply` or write decisions.json. The client owner records decisions at their own terminal.
- Sign in to an account, move an account to a new owner, change a password, cancel a bill or turn an automation off. People do that, with the client watching.
- Copy a password, key or token into any file in this folder. The register names where access lives, never the secret itself.
- Mark a gap closed because someone said it was done. Update the register only after the done test is met, then scan again.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: clean handover

Once a quarter for every active client, and two weeks before any contract ends:
1. Ask the provider and client what changed: new accounts, automations, schedules or bills. Draft the changes into handover.json and runbooks/ for their review.
2. Run `python3 handover.py scan handover.json`. If it reports 0 gaps, tell the client owner in one line and stop.
3. Otherwise run `python3 handover.py sheet handover.json out/` and send the client owner out/decisions.md with the reply line.
4. When the owner replies, run `parse` and say if any item was unclear. The owner records it with `reply` at their own terminal.
5. Then run `plan` and send the steps to the provider. Never sign in, move an account or cancel a bill yourself.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: clean-handover
description: Check that a client could keep running if their agent or automation provider stopped tomorrow, then turn every gap into a one-reply decision sheet for the client owner. Use at onboarding, quarterly, and before any contract ends.
---

# Clean handover

1. Register: handover.json lists every account, data store, automation, schedule and bill, with owner, admin access, export, runbook, off switch, client contact and payer. `python3 handover.py check handover.json`.
2. Scan: `python3 handover.py scan handover.json`. H01 not in the client's name, H02 no client admin, H03 export missing or stale, H04 no runbook or off switch, H05 provider pays with no plan, H06 no client contact.
3. Sheet: `python3 handover.py sheet handover.json out/`. Send out/decisions.md. Unanswered items hold.
4. Reply: the client owner only, at their own terminal: `python3 handover.py reply handover.json out/ "1: test 2: transfer"`.
5. Plan: `python3 handover.py plan handover.json out/`. Each step has a done test the client checks.

Hard rules: never sign in, move an account, cancel a bill or switch anything off; never write a secret into the folder.
~~~~

#### file: templates/handover.json
~~~~json
{
 "client": "",
 "provider": "",
 "client_owner": "",
 "max_export_age_days": 90,
 "systems": [
  {"name": "", "kind": "account", "account_owner": "client", "admin_access": ["client"], "export": {"how": "", "tested": ""}},
  {"name": "", "kind": "data", "account_owner": "client", "admin_access": ["client"], "export": {"how": "", "tested": ""}},
  {"name": "", "kind": "automation", "runbook": "runbooks/NAME.md", "off_switch": "", "client_contact": ""},
  {"name": "", "kind": "schedule", "runbook": "runbooks/NAME.md", "off_switch": "", "client_contact": ""},
  {"name": "", "kind": "bill", "account_owner": "client", "billing": {"payer": "client", "monthly_usd": 0, "transfer": ""}}
 ]
}
~~~~

#### file: example/handover.json
~~~~json
{
 "client": "Harbor Lane Bakery",
 "provider": "Tidewell Studio",
 "client_owner": "Rosa Delgado",
 "max_export_age_days": 90,
 "note": "Fictional example. Every name here is made up.",
 "systems": [
  {"name": "Order inbox agent", "kind": "automation", "runbook": "runbooks/order-inbox.md",
   "off_switch": "Pause the 'order-inbox' job in the scheduler; replies stop within one minute", "client_contact": "Rosa Delgado"},
  {"name": "Customer list", "kind": "data", "account_owner": "client", "admin_access": ["client", "provider"],
   "export": {"how": "Settings > Export > CSV", "tested": "2026-06-01"}},
  {"name": "Website host", "kind": "account", "account_owner": "provider", "admin_access": ["provider"],
   "export": {"how": "Download site backup from the host dashboard", "tested": "2026-10-20"}},
  {"name": "Weekly specials email", "kind": "schedule", "runbook": "runbooks/weekly-specials.md", "off_switch": "", "client_contact": ""},
  {"name": "Model API bill", "kind": "bill", "account_owner": "provider",
   "billing": {"payer": "provider", "monthly_usd": 38, "transfer": ""}},
  {"name": "Booking calendar", "kind": "account", "account_owner": "client", "admin_access": ["client"], "export": {}}
 ]
}
~~~~

#### file: example/runbooks/order-inbox.md
~~~~markdown
# Order inbox agent

What it does: reads the orders inbox every five minutes, drafts a reply with pickup time and total, and waits for staff to press send.
Where it runs: the scheduler on the bakery's own account, job name order-inbox.
Off switch: pause the order-inbox job. Replies stop within one minute. Orders still arrive in the inbox.
If it breaks: pause it, answer orders by hand, then read the last 20 lines of the job log.
Owner on the client side: Rosa Delgado.
~~~~

#### file: tests/test_handover.py
~~~~python
"""Tests for handover.py. Run from the pack folder: python3 -m unittest discover -s tests -p 'test_*.py'"""
import contextlib, io, json, os, shutil, sys, tempfile, unittest
from datetime import date

HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
import handover as ho

TODAY = date(2026, 11, 3)
ALL = "1: test 2: transfer 3: grant 4: write 5: write 6: name 7: transfer 8: transfer 9: accept"


def quiet(fn, *a, **k):
    with contextlib.redirect_stdout(io.StringIO()) as buf:
        rc = fn(*a, **k)
    return rc, buf.getvalue()


class Base(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.w = os.path.join(self.d, "ex")
        shutil.copytree(os.path.join(HERE, "example"), self.w)
        self.reg = os.path.join(self.w, "handover.json")
        self.out = os.path.join(self.d, "out")

    def tearDown(self):
        shutil.rmtree(self.d)

    def R(self):
        with open(self.reg) as f:
            return json.load(f)

    def save(self, r):
        with open(self.reg, "w") as f:
            json.dump(r, f)

    def codes(self, r=None):
        return [f["code"] for f in ho.scan(r or self.R(), self.w, TODAY)]

    def sheet(self):
        with contextlib.redirect_stdout(io.StringIO()):
            return ho.make_sheet(self.reg, self.out, TODAY)

    def record(self, reply=ALL, answers=("Rosa Delgado", "y"), env=None, tty=True):
        it = iter(answers)
        return quiet(ho.record_reply, self.reg, self.out, reply, ask=lambda _: next(it), env=env or {}, tty=tty)


class Register(Base):
    def test_example_is_valid(self):
        self.assertEqual(ho.check_register(self.R()), [])

    def test_missing_owner(self):
        r = self.R(); r["client_owner"] = ""
        self.assertIn("C01", [c for c, _ in ho.check_register(r)])

    def test_no_systems(self):
        r = self.R(); r["systems"] = []
        self.assertIn("C01", [c for c, _ in ho.check_register(r)])

    def test_duplicate_and_bad_kind(self):
        r = self.R(); r["systems"].append(dict(r["systems"][0])); r["systems"][1]["kind"] = "server"
        cs = [c for c, _ in ho.check_register(r)]
        self.assertEqual(cs.count("C02"), 2)

    def test_bad_date_and_amount(self):
        r = self.R(); r["systems"][1]["export"]["tested"] = "June"; r["systems"][4]["billing"]["monthly_usd"] = "38"
        cs = [c for c, _ in ho.check_register(r)]
        self.assertIn("C03", cs); self.assertIn("C04", cs)


class Scan(Base):
    def test_example_gaps(self):
        self.assertEqual(self.codes(), ["H03", "H01", "H02", "H04", "H04", "H06", "H01", "H05", "H03"])

    def test_order_inbox_is_clean(self):
        self.assertNotIn("Order inbox agent", [f["system"] for f in ho.scan(self.R(), self.w, TODAY)])

    def test_fresh_export_passes(self):
        r = self.R(); r["systems"][1]["export"]["tested"] = "2026-10-01"
        self.assertEqual(self.codes(r).count("H03"), 1)

    def test_export_age_setting(self):
        r = self.R(); r["max_export_age_days"] = 200
        self.assertEqual(self.codes(r).count("H03"), 1)

    def test_runbook_file_counts(self):
        with open(os.path.join(self.w, "runbooks", "weekly-specials.md"), "w") as f:
            f.write("runbook")
        self.assertEqual(self.codes().count("H04"), 1)

    def test_bill_with_transfer_plan(self):
        r = self.R(); r["systems"][4]["billing"]["transfer"] = "Move to the bakery card on 2026-12-01"
        self.assertNotIn("H05", self.codes(r))

    def test_fixed_register_is_ready(self):
        r = self.R()
        r["systems"][1]["export"]["tested"] = "2026-10-30"
        r["systems"][2].update(account_owner="client", admin_access=["client"])
        r["systems"][3].update(runbook="runbooks/order-inbox.md", off_switch="Pause job", client_contact="Rosa Delgado")
        r["systems"][4].update(account_owner="client"); r["systems"][4]["billing"]["payer"] = "client"
        r["systems"][5]["export"] = {"how": "Export ICS", "tested": "2026-11-02"}
        self.assertEqual(self.codes(r), [])

    def test_cli_scan_exit_code(self):
        rc, out = quiet(ho.main, ["scan", self.reg, "--today", "2026-11-03"])
        self.assertEqual(rc, 1); self.assertIn("9 gap(s)", out)


class Reply(Base):
    def test_sheet_numbers_every_gap(self):
        items = self.sheet()
        self.assertEqual([i["n"] for i in items], list(range(1, 10)))
        with open(os.path.join(self.out, "decisions.md")) as f:
            self.assertIn("1: ___ 2: ___", f.read())

    def test_parse_ok_and_holds(self):
        ans, errs = ho.parse_reply(self.sheet(), "1: test 3: grant")
        self.assertEqual(errs, []); self.assertEqual(ans[2], "hold")

    def test_parse_errors(self):
        items = self.sheet()
        cs = [c for c, _ in ho.parse_reply(items, "12: test 1: fix 2: transfer 2: accept huh")[1]]
        self.assertEqual(sorted(cs), ["R01", "R02", "R03", "R04"])

    def test_agent_session_refused(self):
        self.sheet()
        for k in ("CLAUDECODE", "CODEX_SANDBOX", "HERMES_AGENT", "GROK_BOT"):
            self.assertEqual(self.record(env={k: "1"})[0], 3)
        self.assertFalse(os.path.exists(os.path.join(self.out, "decisions.json")))

    def test_no_terminal_refused(self):
        self.sheet()
        self.assertEqual(self.record(tty=False)[0], 3)

    def test_no_sheet_refused(self):
        self.assertEqual(self.record()[0], 4)

    def test_wrong_name_refused(self):
        self.sheet()
        self.assertEqual(self.record(answers=("Tidewell Studio", "y"))[0], 5)

    def test_not_confirmed(self):
        self.sheet()
        self.assertEqual(self.record(answers=("Rosa Delgado", "n"))[0], 5)

    def test_stale_after_register_edit(self):
        self.sheet()
        r = self.R(); r["provider"] = "Someone Else"; self.save(r)
        self.assertEqual(self.record()[0], 4)

    def test_stale_after_runbook_edit(self):
        self.sheet()
        with open(os.path.join(self.w, "runbooks", "order-inbox.md"), "a") as f:
            f.write("\nchanged")
        self.assertEqual(self.record()[0], 4)


class Plan(Base):
    def test_plan_before_decisions_refused(self):
        self.sheet()
        self.assertEqual(quiet(ho.plan, self.reg, self.out)[0], 4)

    def test_plan_lists_steps_and_accepts(self):
        self.sheet(); self.assertEqual(self.record()[0], 0)
        rc, _ = quiet(ho.plan, self.reg, self.out)
        self.assertEqual(rc, 0)
        with open(os.path.join(self.out, "handover-plan.md")) as f:
            t = f.read()
        self.assertIn("NOT READY. 8 step(s) to do, 0 held", t)
        self.assertIn("Accepted by Rosa Delgado", t)
        self.assertIn("Done when", t)

    def test_plan_ready_when_all_accepted(self):
        self.sheet()
        self.record(reply=" ".join("%d: accept" % n for n in (1, 2, 3, 9)) + " 4: retire 5: retire 6: retire 7: accept 8: cancel")
        quiet(ho.plan, self.reg, self.out)
        with open(os.path.join(self.out, "handover-plan.md")) as f:
            t = f.read()
        self.assertIn("NOT READY. 4 step(s)", t)
        self.assertIn("no longer runs", t)

    def test_plan_stale_after_edit(self):
        self.sheet(); self.record()
        r = self.R(); r["client"] = "Other"; self.save(r)
        self.assertEqual(quiet(ho.plan, self.reg, self.out)[0], 4)

    def test_tool_never_edits_register(self):
        before = open(self.reg).read()
        self.sheet(); self.record(); quiet(ho.plan, self.reg, self.out)
        self.assertEqual(open(self.reg).read(), before)


if __name__ == "__main__":
    unittest.main()
~~~~
