# Partner Run Sheet

**Reselling agent work under your own brand? Keep one page per client that says who runs it, who owns every account, who approves changes, and what the client keeps if the partner arrangement ends.**

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

An agency sells the work. Another team runs the agents. The client sees one brand. That setup is common and it can be fine, until something breaks at night and nobody knows who answers, or the arrangement ends and the client finds out their phone number belongs to a company they never signed with.

Most of that trouble is decided in advance by a few written answers. This kit makes you write them per client and will not let the page be signed while one is missing.

## What you get out of it

- One run sheet per client: brand shown, who operates, desks and approvers, accounts and owners, support, escalation, data limits, off switch, exit.
- A partner checklist across every client with the open items counted.
- A signature from the agency owner on each clean sheet, which goes stale on its own if that client's entry changes.

## Who it is for

Agencies and consultants who resell or white-label agent work run by a partner, and operators who run agents behind someone else's brand and want the same page on both sides.

Not a contract and not legal advice. It records what your agreement says and points at what it leaves out.

## The file

partners.json names the agency (the brand the client sees), the agency owner, the operator, and each client:

| Field | What to write |
| --- | --- |
| `client_owner` | the one person at the client who decides |
| `told_client` | the sentence the client has actually seen about who runs the work |
| `desks` | each agent desk, what it does, and who approves changes to it |
| `accounts` | every login, workspace and number the agents use, `owned_by` client, agency or operator, and `transfers` if not the client |
| `support` | `first_line` (agency or operator), `hours`, `urgent_reply_hours` |
| `escalation` | a named contact at the agency and at the operator |
| `data_limits` | what the operator may see, keep and must not copy |
| `off_switch` | the exact step that stops every desk, and who can take it |
| `if_partner_exits` | who keeps the desks running and for how long |

## What the checker catches

| Code | Means |
| --- | --- |
| E01 to E05 | the agency, owner or operator is missing; a client is unnamed, listed twice or has no client owner; an account owner or first line is not a known value |
| W01 | the client has not been told in writing who runs the work |
| W02 | an account is not in the client's name and no transfer is written, or no accounts are listed |
| W03 | a desk has no approver, or the approver is the operator rather than the client owner or the agency owner |
| W04 | no first line or hours, an urgent reply time missing or over 24 hours, or fewer than two escalation contacts |
| W05 | no data limits |
| W06 | no off switch |
| W07 | nothing written for the client if the partner arrangement ends |

## Sign-off

`partner.py approve` signs one client's run sheet. It is refused inside an agent session, without a terminal, from anyone but the agency owner in the file, while the sheet has open items, and if that client's entry changed after the sheets were made. `status` marks a signed sheet CHANGED the moment its entry is edited, so a quiet edit cannot ride on an old signature.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py partner-run-sheet-one-page-per-client-when-you-resell-agent-work.md partner/` (the file you downloaded) to unpack the rest.
2. In `partner/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up studio with two clients: `python3 partner.py check example/partners.json`. The bakery is ready to sign; the auto glass shop has 7 open items.
4. Run `python3 partner.py sheets example/partners.json out/`, read out/coastline-auto-glass.md, then at your own terminal sign the clean one: `python3 partner.py approve example/partners.json out/ "Birchwood Bakery"` (type Priya Nand when asked).
5. Copy templates/partners.json, fill in one real client from your agreement, and work the open items down to zero.
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

Fill one live client and hand the run sheet to the operator. If they disagree with any line, the page has done its job: settle it in writing. Then test the off switch for real with both teams watching. Stop and fix the setup if an agent cleared an item by deleting data, or if a sheet shows signed after its entry changed. That is the kill rule.

## Limits

- It checks what is written, not what is true. A disclosure sentence the client never saw passes the check and fails the client.
- 24 hours is the longest urgent reply it accepts. Set your own shorter number in the agreement.
- One agency and one operator per file. Use a file per operator if you work with more than one.
- Not a contract and not legal advice.

## Want this run for you

Partner work holds up when every desk has an owner and an off switch. If you would rather have agents set up and run with those gates in place, that is Managed Ops: our agents work inside the tools you already use, and every change is drafted for your yes. 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: partner.py
~~~~python
#!/usr/bin/env python3
"""partner.py: one run sheet per client when you resell agent work under your own brand.

  python3 partner.py check PARTNERS.json            check the file and list open items per client
  python3 partner.py sheets PARTNERS.json OUT/      write OUT/<client>.md (one run sheet each), OUT/checklist.md and OUT/pending.json
  python3 partner.py approve PARTNERS.json OUT/ CLIENT   agency owner only, at their own terminal: sign one client's run sheet
  python3 partner.py status PARTNERS.json OUT/      which run sheets are signed, open, or changed since sign-off

PARTNERS.json names the agency (the brand the client sees), the agency owner, the operator (the team that runs
the agents behind the agency), and each client: the client owner, what the client is told about who runs the
work, the desks with who approves changes, every account with who owns it (and, if not the client, how it transfers), first-line support and the urgent
reply time, data limits, the off switch and what happens to the client if the partner arrangement ends.
A run sheet can only be signed with no open items, and a signature goes stale if that client's entry changes.
Exit codes: 0 ok, 1 errors or open items, 2 usage, 3 refused (agent session or no terminal),
4 refused (no sheets yet or the client changed since), 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import hashlib, json, os, re, sys
from datetime import 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"]
OWNERS = ("client", "agency", "operator")
FIRST = ("agency", "operator")
MAX_URGENT_HOURS = 24


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 txt(x):
    return str(x or "").strip()


def slug(s):
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "client"


def client_hash(c):
    return hashlib.sha256(json.dumps(c, sort_keys=True).encode("utf-8")).hexdigest()


def errors(p):
    """Structural problems. Any of these stops sheets and approve."""
    e = []
    for k in ("agency", "agency_owner", "operator"):
        if not txt(p.get(k)):
            e.append(("E01", "%s is missing" % k))
    cl = p.get("clients")
    if not isinstance(cl, list) or not cl:
        e.append(("E02", "no clients listed"))
        return e
    seen = set()
    for i, c in enumerate(cl, 1):
        n = txt(c.get("client"))
        if not n:
            e.append(("E02", "client %d has no name" % i))
            continue
        if slug(n) in seen:
            e.append(("E02", "client %s is listed twice" % n))
        seen.add(slug(n))
        if not txt(c.get("client_owner")):
            e.append(("E03", "%s: client_owner is missing (the one person on the client side who decides)" % n))
        for a in c.get("accounts") or []:
            if txt(a.get("owned_by")) not in OWNERS:
                e.append(("E04", "%s: account %s owned_by must be client, agency or operator" % (n, txt(a.get("name")) or "?")))
        sup = c.get("support") or {}
        if sup.get("first_line") and sup.get("first_line") not in FIRST:
            e.append(("E05", "%s: support.first_line must be agency or operator" % n))
    return e


def findings(p, c):
    """Open items for one client. A run sheet is signed only when this list is empty."""
    f = []
    who = {txt(p.get("agency_owner")), txt(c.get("client_owner"))}
    if not txt(c.get("told_client")):
        f.append(("W01", "the client has not been told in writing who runs the work behind %s" % txt(p.get("agency")),
                  "write one plain sentence the client has seen, e.g. in the agreement"))
    for a in c.get("accounts") or []:
        if txt(a.get("owned_by")) != "client" and not txt(a.get("transfers")):
            f.append(("W02", "account %s is owned by the %s, not the client, with no transfer written" % (txt(a.get("name")), txt(a.get("owned_by"))),
                      "move it into the client's name, or write in transfers how and when it moves to them"))
    if not c.get("accounts"):
        f.append(("W02", "no accounts listed", "list every login, workspace and number the agents use"))
    desks = c.get("desks") or []
    if not desks:
        f.append(("W03", "no desks listed", "list each agent desk and what it does"))
    for d in desks:
        if not txt(d.get("approver")):
            f.append(("W03", "desk %s has no named approver for changes" % txt(d.get("name")), "name the person who says yes"))
        elif txt(d.get("approver")) not in who:
            f.append(("W03", "desk %s approver %s is neither the client owner nor the agency owner" % (txt(d.get("name")), txt(d.get("approver"))),
                      "name the client owner or the agency owner; the operator does not approve its own changes"))
    sup = c.get("support") or {}
    h = sup.get("urgent_reply_hours")
    if not sup.get("first_line") or not txt(sup.get("hours")):
        f.append(("W04", "first-line support or support hours not written", "say who the client calls first and when"))
    if not isinstance(h, (int, float)) or isinstance(h, bool) or h <= 0 or h > MAX_URGENT_HOURS:
        f.append(("W04", "urgent reply time missing or over %d hours" % MAX_URGENT_HOURS, "write a number of hours both teams can keep"))
    if len([x for x in c.get("escalation") or [] if txt(x)]) < 2:
        f.append(("W04", "escalation needs a named contact at the agency and at the operator", "two names with a way to reach each"))
    if not txt(c.get("data_limits")):
        f.append(("W05", "data limits not written", "what the operator may see, keep and must not copy"))
    if not txt(c.get("off_switch")):
        f.append(("W06", "no off switch written", "the exact step that stops every desk, and who can take it"))
    if not txt(c.get("if_partner_exits")):
        f.append(("W07", "nothing written for the client if the partner arrangement ends", "who keeps running the desks and for how long"))
    return f


def sheet_md(p, c, f):
    L = ["# Run sheet: %s" % c["client"], "",
         "Brand the client sees: %s. Run behind the scenes by: %s. Agency owner: %s. Client owner: %s." % (p["agency"], p["operator"], p["agency_owner"], c["client_owner"]), "",
         "What the client was told: %s" % (txt(c.get("told_client")) or "NOT WRITTEN"), "", "## Desks", "", "| Desk | What it does | Approves changes |", "| --- | --- | --- |"]
    for d in c.get("desks") or []:
        L.append("| %s | %s | %s |" % (txt(d.get("name")), txt(d.get("does")), txt(d.get("approver")) or "NOBODY"))
    L += ["", "## Accounts", "", "| Account | Owned by |", "| --- | --- |"]
    for a in c.get("accounts") or []:
        L.append("| %s | %s |" % (txt(a.get("name")), txt(a.get("owned_by"))))
    sup = c.get("support") or {}
    L += ["", "## Support", "", "First line: %s. Hours: %s. Urgent reply within %s hours." % (sup.get("first_line") or "?", txt(sup.get("hours")) or "?", sup.get("urgent_reply_hours", "?")),
          "Escalation: %s" % ("; ".join(txt(x) for x in c.get("escalation") or []) or "none"), "",
          "## Limits and exits", "", "Data limits: %s" % (txt(c.get("data_limits")) or "NOT WRITTEN"),
          "", "Off switch: %s" % (txt(c.get("off_switch")) or "NOT WRITTEN"),
          "", "If the arrangement ends: %s" % (txt(c.get("if_partner_exits")) or "NOT WRITTEN"), "", "## Open items", ""]
    if f:
        for i, (code, what, fix) in enumerate(f, 1):
            L.append("%d. %s %s. Fix: %s." % (i, code, what, fix))
    else:
        L.append("None. Ready for the agency owner to sign with partner.py approve.")
    return "\n".join(L) + "\n"


def make_sheets(path, out):
    p = jload(path)
    os.makedirs(out, exist_ok=True)
    pend, rows = {}, []
    for c in p["clients"]:
        f = findings(p, c)
        wtext(os.path.join(out, slug(c["client"]) + ".md"), sheet_md(p, c, f))
        pend[slug(c["client"])] = client_hash(c)
        rows.append((c["client"], len(f)))
    L = ["# Partner checklist: %s" % p["agency"], "", "Operator: %s. Every client needs a run sheet with no open items, signed by %s." % (p["operator"], p["agency_owner"]), "",
         "| Client | Open items | Run sheet |", "| --- | --- | --- |"]
    for n, k in rows:
        L.append("| %s | %d | %s.md |" % (n, k, slug(n)))
    wtext(os.path.join(out, "checklist.md"), "\n".join(L) + "\n")
    wtext(os.path.join(out, "pending.json"), json.dumps(pend, indent=1) + "\n")
    return rows


def find_client(p, name):
    for c in p["clients"]:
        if slug(c["client"]) == slug(name):
            return c
    return None


def approve(path, out, name, 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 agency owner signs" % k)
            return 3
    if not tty:
        print("refused: no terminal; run this yourself")
        return 3
    p = jload(path)
    c = find_client(p, name)
    if c is None:
        print("no client named %s" % name)
        return 2
    pp = os.path.join(out, "pending.json")
    if not os.path.exists(pp) or jload(pp).get(slug(c["client"])) != client_hash(c):
        print("refused: no run sheet for this client, or the entry changed since; run partner.py sheets")
        return 4
    f = findings(p, c)
    if f:
        print("refused: %d open item(s) on this run sheet" % len(f))
        return 1
    if ask("Your name, as agency_owner in the file: ").strip() != p["agency_owner"]:
        print("refused: that is not the agency owner in the file")
        return 5
    if ask("Sign the run sheet for %s? y/n: " % c["client"]).strip().lower() != "y":
        print("not signed")
        return 5
    sp = os.path.join(out, "signed.json")
    s = jload(sp) if os.path.exists(sp) else {}
    s[slug(c["client"])] = {"state": client_hash(c), "signed_by": p["agency_owner"], "signed_at": datetime.now().strftime("%Y-%m-%dT%H:%M")}
    wtext(sp, json.dumps(s, indent=1) + "\n")
    print("signed: %s" % c["client"])
    return 0


def status(path, out):
    p = jload(path)
    sp = os.path.join(out, "signed.json")
    s = jload(sp) if os.path.exists(sp) else {}
    res = []
    for c in p["clients"]:
        k = slug(c["client"])
        if k in s and s[k]["state"] == client_hash(c):
            st = "signed %s by %s" % (s[k]["signed_at"], s[k]["signed_by"])
        elif k in s:
            st = "CHANGED since sign-off; sign again"
        else:
            n = len(findings(p, c))
            st = ("%d open item(s)" % n) if n else "ready to sign"
        res.append((c["client"], st))
        print("%s: %s" % (c["client"], st))
    return res


def main(argv):
    if not argv or argv[0] not in ("check", "sheets", "approve", "status") or len(argv) < 2:
        print(__doc__)
        return 2
    cmd, a = argv[0], argv[1:]
    p = jload(a[0])
    errs = errors(p)
    if errs:
        for c, m in errs:
            print("%s %s" % (c, m))
        print("file NOT OK: %d error(s)" % len(errs))
        return 1
    if cmd == "check":
        total = 0
        for c in p["clients"]:
            f = findings(p, c)
            total += len(f)
            print("%s: %s" % (c["client"], ("%d open item(s)" % len(f)) if f else "ready to sign"))
            for code, what, _ in f:
                print("  %s %s" % (code, what))
        return 1 if total else 0
    if cmd == "sheets" and len(a) == 2:
        rows = make_sheets(a[0], a[1])
        print("wrote %d run sheet(s) and checklist.md to %s; %d open item(s)" % (len(rows), a[1], sum(k for _, k in rows)))
        return 0
    if cmd == "approve" and len(a) == 3:
        return approve(a[0], a[1], a[2])
    if cmd == "status" and len(a) == 2:
        status(a[0], a[1])
        return 0
    print(__doc__)
    return 2


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: partner run sheet

You keep one run sheet per client for an agency that resells agent work run by an operator behind it. You draft and check. The agency owner signs.

## You may
- Fill partners.json from the agreement, the account list and the support plan. Copy what is written; never guess an owner, an hour or a name.
- Run `python3 partner.py check partners.json` and `python3 partner.py sheets partners.json out/`, then send the agency owner out/checklist.md and the run sheets with open items.
- Draft the fix for each open item (a disclosure sentence, a transfer note, an off switch) for the agency owner to take to the client.
- Run `python3 partner.py status partners.json out/` weekly and report any run sheet that changed since sign-off.

## You never
- Run `partner.py approve` or edit out/signed.json. The agency owner signs at their own terminal.
- Clear an open item by deleting a desk, an account or a field. Only a real fix clears it.
- Move an account, change a login, or contact the client or the operator on your own.
- Write a disclosure sentence into partners.json that the client has not actually seen.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: partner run sheet

You keep one run sheet per client for an agency that resells agent work run by an operator behind it. You draft and check. The agency owner signs.

## You may
- Fill partners.json from the agreement, the account list and the support plan. Copy what is written; never guess an owner, an hour or a name.
- Run `python3 partner.py check partners.json` and `python3 partner.py sheets partners.json out/`, then send the agency owner out/checklist.md and the run sheets with open items.
- Draft the fix for each open item (a disclosure sentence, a transfer note, an off switch) for the agency owner to take to the client.
- Run `python3 partner.py status partners.json out/` weekly and report any run sheet that changed since sign-off.

## You never
- Run `partner.py approve` or edit out/signed.json. The agency owner signs at their own terminal.
- Clear an open item by deleting a desk, an account or a field. Only a real fix clears it.
- Move an account, change a login, or contact the client or the operator on your own.
- Write a disclosure sentence into partners.json that the client has not actually seen.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: partner run sheet

Every Monday:
1. Run `python3 partner.py status partners.json out/`.
2. For any client with open items, run `sheets` and send the agency owner that client's run sheet with a drafted fix for each item.
3. For any client marked CHANGED, tell the agency owner what changed and that the run sheet needs a new signature.
4. Never sign a run sheet, never edit out/signed.json, never move an account and never contact a client or the operator yourself.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: partner-run-sheet
description: Keep one run sheet per client when an agency resells agent work that another team operates, check it for the gaps that cause trouble (undisclosed operator, accounts the client does not own, the operator approving its own changes, no off switch, no exit), and have the agency owner sign each clean sheet. Use when setting up or reviewing a white-label or partner arrangement.
---

# Partner run sheet

1. Fill partners.json: agency, agency owner, operator, and per client the client owner, what the client was told, desks with approvers, accounts with owners, support, escalation, data limits, off switch and what happens if the partner arrangement ends.
2. `python3 partner.py check partners.json` lists open items W01 to W07 per client.
3. `python3 partner.py sheets partners.json out/` writes one run sheet per client and checklist.md.
4. Draft fixes. The agency owner signs a clean sheet at their own terminal: `python3 partner.py approve partners.json out/ "Client name"`.
5. `python3 partner.py status partners.json out/` shows signed, open, or changed since sign-off.

Hard rules: never sign, never clear an item by deleting data, never move accounts or contact anyone on your own.
~~~~

#### file: templates/partners.json
~~~~json
{
 "agency": "Your agency name (the brand the client sees)",
 "agency_owner": "Your name",
 "operator": "The team that runs the agents behind you",
 "clients": [
  {
   "client": "Client business name",
   "client_owner": "The one person at the client who decides",
   "told_client": "The sentence the client has seen, in writing, about who runs the work",
   "desks": [
    {"name": "Desk name", "does": "What it does in one line", "approver": "Client owner or agency owner"}
   ],
   "accounts": [
    {"name": "Every login, workspace or number the agents use", "owned_by": "client"}
   ],
   "support": {"first_line": "agency", "hours": "Days and hours", "urgent_reply_hours": 4},
   "escalation": ["Agency contact and how to reach them", "Operator contact and how to reach them"],
   "data_limits": "What the operator may see, keep and must not copy",
   "off_switch": "The exact step that stops every desk, and who can take it",
   "if_partner_exits": "Who keeps the desks running, and for how long"
  }
 ]
}
~~~~

#### file: example/partners.json
~~~~json
{
 "agency": "Lantern Row Studio",
 "agency_owner": "Priya Nand",
 "operator": "Mesa Agent Works",
 "clients": [
  {
   "client": "Birchwood Bakery",
   "client_owner": "Ines Varga",
   "told_client": "Section 4 of the agreement: Lantern Row Studio sets up and supervises the agents; Mesa Agent Works runs them day to day under Lantern Row's rules.",
   "desks": [
    {"name": "Order inbox", "does": "sorts catering requests and drafts replies for Ines to send", "approver": "Ines Varga"},
    {"name": "Review replies", "does": "drafts replies to public reviews", "approver": "Ines Varga"}
   ],
   "accounts": [
    {"name": "Bakery Google Workspace", "owned_by": "client"},
    {"name": "Review platform login", "owned_by": "client"}
   ],
   "support": {"first_line": "agency", "hours": "Mon to Sat, 7 AM to 3 PM", "urgent_reply_hours": 4},
   "escalation": ["Priya Nand, Lantern Row, by text", "Mesa on-call, by the shared support inbox"],
   "data_limits": "Mesa may read the order inbox and review pages only; no copies outside the bakery's workspace; customer details are not used to train anything.",
   "off_switch": "Ines or Priya turns off the two desks in the agent console; Mesa confirms within one hour.",
   "if_partner_exits": "Mesa keeps both desks running for 30 days while Lantern Row moves them; every account is already in the bakery's name."
  },
  {
   "client": "Coastline Auto Glass",
   "client_owner": "Raymond Teo",
   "told_client": "",
   "desks": [
    {"name": "Quote follow-up", "does": "sends a reminder two days after a quote", "approver": "Mesa Agent Works"},
    {"name": "Booking calls", "does": "answers after-hours calls and books slots", "approver": "Raymond Teo"}
   ],
   "accounts": [
    {"name": "Phone number for after-hours line", "owned_by": "operator"},
    {"name": "Shop calendar", "owned_by": "client"}
   ],
   "support": {"first_line": "operator", "hours": "every day, 8 AM to 8 PM", "urgent_reply_hours": 48},
   "escalation": ["Mesa on-call"],
   "data_limits": "Mesa sees call recordings and the calendar only.",
   "off_switch": "",
   "if_partner_exits": ""
  }
 ]
}
~~~~

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

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


def rd(p, mode="r"):
    with open(p, mode) as f:
        return f.read()


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


def answers(*xs):
    it = iter(xs)
    return lambda prompt="": next(it)


class Base(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.f = os.path.join(self.d, "partners.json")
        shutil.copy(os.path.join(HERE, "example", "partners.json"), self.f)
        self.out = os.path.join(self.d, "out")

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

    def P(self):
        return pr.jload(self.f)

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

    def codes(self, p, i):
        return [c for c, _, _ in pr.findings(p, p["clients"][i])]

    def sign(self, client="Birchwood Bakery", name="Priya Nand", yes="y", env=None, tty=True):
        return quiet(pr.approve, self.f, self.out, client, ask=answers(name, yes), env=env or {}, tty=tty)


class TestCheck(Base):
    def test_example_has_no_errors(self):
        self.assertEqual(pr.errors(self.P()), [])

    def test_bakery_ready(self):
        self.assertEqual(self.codes(self.P(), 0), [])

    def test_auto_glass_seven_items(self):
        self.assertEqual(self.codes(self.P(), 1), ["W01", "W02", "W03", "W04", "W04", "W06", "W07"])

    def test_missing_agency_owner(self):
        p = self.P(); p["agency_owner"] = ""
        self.assertIn("E01", [c for c, _ in pr.errors(p)])

    def test_no_clients(self):
        p = self.P(); p["clients"] = []
        self.assertIn("E02", [c for c, _ in pr.errors(p)])

    def test_duplicate_client(self):
        p = self.P(); p["clients"].append(dict(p["clients"][0]))
        self.assertIn("E02", [c for c, _ in pr.errors(p)])

    def test_missing_client_owner(self):
        p = self.P(); p["clients"][0]["client_owner"] = ""
        self.assertIn("E03", [c for c, _ in pr.errors(p)])

    def test_bad_owner_value(self):
        p = self.P(); p["clients"][0]["accounts"][0]["owned_by"] = "someone"
        self.assertIn("E04", [c for c, _ in pr.errors(p)])

    def test_bad_first_line(self):
        p = self.P(); p["clients"][0]["support"]["first_line"] = "client"
        self.assertIn("E05", [c for c, _ in pr.errors(p)])

    def test_transfer_note_clears_account_item(self):
        p = self.P(); p["clients"][1]["accounts"][0]["transfers"] = "Ported to the shop's carrier account within 14 days of a request."
        self.assertNotIn("W02", self.codes(p, 1))

    def test_operator_cannot_approve(self):
        p = self.P(); p["clients"][0]["desks"][0]["approver"] = "Mesa Agent Works"
        self.assertEqual(self.codes(p, 0), ["W03"])

    def test_agency_owner_may_approve(self):
        p = self.P(); p["clients"][0]["desks"][0]["approver"] = "Priya Nand"
        self.assertEqual(self.codes(p, 0), [])

    def test_no_accounts_flagged(self):
        p = self.P(); p["clients"][0]["accounts"] = []
        self.assertEqual(self.codes(p, 0), ["W02"])

    def test_urgent_hours_bool_rejected(self):
        p = self.P(); p["clients"][0]["support"]["urgent_reply_hours"] = True
        self.assertEqual(self.codes(p, 0), ["W04"])

    def test_check_cli_exit(self):
        rc, out = quiet(pr.main, ["check", self.f])
        self.assertEqual(rc, 1)
        self.assertIn("Birchwood Bakery: ready to sign", out)


class TestSheets(Base):
    def test_sheets_written(self):
        rc, _ = quiet(pr.main, ["sheets", self.f, self.out])
        self.assertEqual(rc, 0)
        for n in ("birchwood-bakery.md", "coastline-auto-glass.md", "checklist.md", "pending.json"):
            self.assertTrue(os.path.exists(os.path.join(self.out, n)), n)

    def test_sheet_lists_numbered_items(self):
        quiet(pr.main, ["sheets", self.f, self.out])
        t = rd(os.path.join(self.out, "coastline-auto-glass.md"))
        self.assertIn("7. W07", t)
        self.assertIn("NOT WRITTEN", t)

    def test_input_never_modified(self):
        before = rd(self.f, "rb")
        quiet(pr.main, ["sheets", self.f, self.out])
        self.sign()
        quiet(pr.main, ["status", self.f, self.out])
        self.assertEqual(before, rd(self.f, "rb"))


class TestApprove(Base):
    def setUp(self):
        super().setUp()
        quiet(pr.main, ["sheets", self.f, self.out])

    def test_owner_signs_ready_client(self):
        rc, _ = self.sign()
        self.assertEqual(rc, 0)
        self.assertIn("birchwood-bakery", pr.jload(os.path.join(self.out, "signed.json")))

    def test_refused_in_agent_session(self):
        for k in pr.AGENT_ENV:
            rc, _ = self.sign(env={k: "1"})
            self.assertEqual(rc, 3, k)

    def test_refused_without_terminal(self):
        self.assertEqual(self.sign(tty=False)[0], 3)

    def test_wrong_name(self):
        self.assertEqual(self.sign(name="Mesa Agent Works")[0], 5)

    def test_said_no(self):
        self.assertEqual(self.sign(yes="n")[0], 5)
        self.assertFalse(os.path.exists(os.path.join(self.out, "signed.json")))

    def test_open_items_refused(self):
        self.assertEqual(self.sign(client="Coastline Auto Glass")[0], 1)

    def test_changed_after_sheet_refused(self):
        p = self.P(); p["clients"][0]["off_switch"] = "Ines turns it off."; self.save(p)
        self.assertEqual(self.sign()[0], 4)

    def test_no_sheets_refused(self):
        shutil.rmtree(self.out)
        self.assertEqual(self.sign()[0], 4)

    def test_unknown_client(self):
        self.assertEqual(self.sign(client="Nobody Ltd")[0], 2)

    def test_status_goes_stale_after_edit(self):
        self.sign()
        p = self.P(); p["clients"][0]["data_limits"] = "Anything goes."; self.save(p)
        res = dict(quiet(pr.status, self.f, self.out)[0])
        self.assertIn("CHANGED", res["Birchwood Bakery"])
        self.assertIn("7 open", res["Coastline Auto Glass"])

    def test_status_signed(self):
        self.sign()
        res = dict(quiet(pr.status, self.f, self.out)[0])
        self.assertTrue(res["Birchwood Bakery"].startswith("signed"))


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