# Budget Change Card

**An agent that moves ad budgets on a hunch can spend a week's money by lunch. Make every move fit on one card: the old budget, the new one, the reason, and the numbers from one report. Small moves can go. Big ones wait for your yes.**

Version 1.0, 2026-11-04. 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

Budget moves suggested by agents tend to arrive as a sentence in chat: "raise brand search, it's doing well." Doing well by which report, over which days, from what budget to what? When the numbers come from three dashboards that disagree, nobody can check the case, and when the move turns out wrong, nobody can say who allowed it.

This kit puts each move on a card and checks the card against a single report export. Moves that are small, from an agent you trust, backed by enough data and under a daily cap may go. The rest wait for you, and your yes covers that one card as it read.

## What you get out of it

- cards.md: one card per proposed move, sorted into blocked, needs your yes, and clear, with the reason for each.
- Every card shows old and new budget, the percent change, the reason, the window, spend, conversions and cost per conversion from the report.
- A gate called right before a budget is changed. Exit 0 means go; anything else means stop. A move goes once.
- changelog.jsonl: each yes and each change, with who, when, old and new budget, and a fingerprint of the card.

## Who it is for

Owners and small marketing teams who let an agent suggest or make ad budget changes, and the agencies who set those agents up.

## The rules file

| Field | What to write |
| --- | --- |
| `owner` | the one person who says yes |
| `agents[].name`, `mode` | each agent and its mode: `propose_only` (start here) or `apply_small` |
| `auto_limit_pct` | the biggest move, up or down, an apply_small agent may make without a yes. 0 means every move waits |
| `max_move_pct` | the ceiling. Bigger moves are blocked, even with a yes. Make a big change in steps |
| `daily_increase_cap` | the most budget, in money per day, clear moves may add on one date. Cuts do not count |
| `min_conversions` | increases on fewer conversions than this wait for a yes |
| `max_report_age_days` | the oldest report export a proposal may lean on |
| `tolerance_pct` | how far a proposal's spend or conversions may drift from the report (default 1) |

budgets.csv lists today's daily budget per campaign. report.csv is one export: campaign, start, end, spend, conversions, exported. proposals.csv has one row per move. Dates are YYYY-MM-DD.

## How moves are sorted

| Code | Result | When |
| --- | --- | --- |
| B1 | blocked | the agent is not in the rules |
| B2 | blocked | the campaign is not in budgets.csv |
| B3 | blocked | the old budget on the card is not today's budget |
| B4 | blocked | the new budget is not a real change to a number of 0 or more |
| B5 | blocked | no reason, or one under 20 characters |
| B6 | blocked | the report has no row for that campaign and window |
| B7 | blocked | the card's spend or conversions do not match the report |
| B8 | blocked | the move is over the ceiling |
| H1 | needs yes | the agent may only propose |
| H2 | needs yes | the move is over the auto limit |
| H3 | needs yes | an increase on too few conversions |
| H4 | needs yes | the report is older than you allow |
| H5 | needs yes | the move would pass the daily increase cap |
| | clear | everything else |

A yes cannot clear a blocked card. Fix the proposal or the inputs and scan again.

## The owner's call

`budget.py approve` records a yes for one held move. It is refused inside an agent session, without a terminal, for a blocked card and from anyone but the owner, and the owner has to type apply after seeing the campaign, both budgets and the reason. The yes is tied to that row and to today's budget. If either changes afterward, `gate` says no-go and `status` shows CHANGED.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py budget-change-card-every-ad-budget-move-on-one-card-with-the-owners-yes.md budget/` (the file you downloaded) to unpack the rest.
2. In `budget/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up bakery with eight campaigns and eight proposals: `python3 budget.py scan example/rules.json example/budgets.csv example/report.csv example/proposals.csv out/`. Two are clear, four need a yes and two are blocked.
4. Copy templates/rules.json. Keep every agent on propose_only and the auto limit at 0 for the first two weeks.
5. Pick one report and export it each morning to report.csv, with today's budgets in budgets.csv.
6. Make whoever changes budgets, person or agent, call `gate` first and stop on anything but exit 0.
7. 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.
8. After two clean weeks, move one agent to apply_small with a 10 percent auto limit and a low daily cap.

## Run it once before you rely on it

Approve one held move, edit its new budget, and call gate. It must say no-go. Call gate twice on a clear move; the second must say no-go. Stop and fix the setup if an agent recorded a yes, edited the rules, the report or the log, split a move, or changed a budget the gate refused. That is the kill rule.

## Limits

- It never connects to an ad account. It covers the changes that pass through the gate, so remove direct budget access from agents where you can.
- It checks that the numbers match one report, not that the report is right or that the reason is wise. Read the cards.
- Daily budgets only. Lifetime budgets, bids and targeting are out of scope.
- The daily cap counts clear increases by the proposal's created date, in file order.

## Where this came from

Owners connecting agents to ad accounts keep raising the same worry in public threads: an agent that reads a good afternoon as a trend and doubles a budget. This kit is our own answer, written from how we review budget changes for our own campaigns, with nothing copied from any product.

## Want this run for you

Budget agents earn trust one card at a time. If you would rather have agents set up and run with those limits 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: budget.py
~~~~python
#!/usr/bin/env python3
"""budget.py: every ad budget move shows the old budget, the new one, the reason and the numbers from one report, and waits for the owner's yes.

  python3 budget.py check RULES.json                                          check the rules
  python3 budget.py scan RULES.json BUDGETS.csv REPORT.csv PROPOSALS.csv OUT/   one card per proposed move: CLEAR, NEEDS YES or BLOCKED; writes OUT/cards.md
  python3 budget.py approve RULES.json OUT/ MOVE_ID                           owner only, at their own terminal
  python3 budget.py gate RULES.json BUDGETS.csv REPORT.csv PROPOSALS.csv OUT/ MOVE_ID
                                                                              call this right before a budget is changed; exit 0 means go
  python3 budget.py status OUT/                                               clear, waiting, approved, changed, applied, blocked

BUDGETS.csv:   campaign, daily_budget, as_of            (what each campaign is set to today)
REPORT.csv:    campaign, start, end, spend, conversions, exported   (one export, the single source of numbers)
PROPOSALS.csv: id, agent, campaign, old_budget, new_budget, reason, spend, conversions, start, end, created
Dates are YYYY-MM-DD. This tool never touches an ad account. It decides whether a move may be made and logs who said yes.
Exit codes: 0 ok or go, 1 errors or no-go, 2 usage, 3 refused (agent session or no terminal), 4 unknown move, 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import csv, hashlib, json, os, 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"]
BCOLS = ["campaign", "daily_budget", "as_of"]
RCOLS = ["campaign", "start", "end", "spend", "conversions", "exported"]
PCOLS = ["id", "agent", "campaign", "old_budget", "new_budget", "reason", "spend", "conversions", "start", "end", "created"]
MODES = ("propose_only", "apply_small")
MIN_REASON = 20


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


def num(x):
    try:
        v = float(str(x).replace(",", "").strip())
    except (TypeError, ValueError):
        return None
    return v if v == v and v not in (float("inf"), float("-inf")) else None


def isnum(v, lo=0):
    return isinstance(v, (int, float)) and not isinstance(v, bool) and v >= lo


def day(s):
    return datetime.strptime(s, "%Y-%m-%d")


def check(r):
    e = []
    if not low(r.get("owner")):
        e.append(("R01", "owner is missing (the one person who says yes)"))
    ag = r.get("agents")
    if not isinstance(ag, list) or not ag:
        e.append(("R02", "agents must list at least one agent"))
    else:
        seen = set()
        for a in ag:
            n = low(a.get("name"))
            if not n:
                e.append(("R02", "every agent needs a name")); continue
            if n in seen:
                e.append(("R03", "agent %s is listed twice" % n))
            seen.add(n)
            if a.get("mode") not in MODES:
                e.append(("R04", "%s mode must be propose_only or apply_small" % n))
    al, mx = r.get("auto_limit_pct"), r.get("max_move_pct")
    if not isnum(al) or not isnum(mx, 1) or (isnum(al) and isnum(mx) and al > mx):
        e.append(("R05", "auto_limit_pct must be 0 or more and no bigger than max_move_pct (1 or more)"))
    age = r.get("max_report_age_days")
    if not isinstance(age, int) or isinstance(age, bool) or age < 1:
        e.append(("R06", "max_report_age_days must be a whole number of 1 or more"))
    if not isnum(r.get("daily_increase_cap")):
        e.append(("R07", "daily_increase_cap must be a number of 0 or more"))
    mc = r.get("min_conversions")
    if not isinstance(mc, int) or isinstance(mc, bool) or mc < 0:
        e.append(("R08", "min_conversions must be a whole number of 0 or more"))
    if "tolerance_pct" in r and not isnum(r.get("tolerance_pct")):
        e.append(("R09", "tolerance_pct must be a number of 0 or more"))
    return e


def read_csv(path, cols, name, dates=(), key="id"):
    with open(path, newline="", encoding="utf-8-sig") as f:
        rd = csv.DictReader(f)
        miss = [c for c in cols if c not in (rd.fieldnames or [])]
        if miss:
            raise ValueError("%s is missing column(s): %s" % (name, ", ".join(miss)))
        rows = [{c: (row.get(c) or "").strip() for c in cols} for row in rd]
    errs, seen = [], set()
    for i, row in enumerate(rows, 2):
        for c in dates:
            try:
                day(row[c])
            except ValueError:
                errs.append("%s line %d: %s is not YYYY-MM-DD" % (name, i, c))
        if key:
            k = tuple(row[x] for x in key) if isinstance(key, tuple) else row[key]
            if not (k if not isinstance(k, tuple) else all(k)):
                errs.append("%s line %d has no %s" % (name, i, key if isinstance(key, str) else "/".join(key)))
            elif k in seen:
                errs.append("%s lists %s twice" % (name, k if isinstance(k, str) else " ".join(k)))
            seen.add(k)
    if errs:
        raise ValueError("; ".join(errs))
    return rows


def load(bpath, rpath, ppath):
    b = read_csv(bpath, BCOLS, "BUDGETS.csv", ("as_of",), "campaign")
    rep = read_csv(rpath, RCOLS, "REPORT.csv", ("start", "end", "exported"), ("campaign", "start", "end"))
    p = read_csv(ppath, PCOLS, "PROPOSALS.csv", ("start", "end", "created"), "id")
    return b, rep, p


def mhash(row, current):
    return hashlib.sha256(json.dumps([row[c] for c in PCOLS] + [current]).encode("utf-8")).hexdigest()


def close(a, b, tol):
    return abs(a - b) <= max(0.01, abs(b) * tol / 100.0)


def sort_moves(r, budgets, report, props):
    agents = {low(a["name"]): a for a in r["agents"]}
    cur = {low(x["campaign"]): num(x["daily_budget"]) for x in budgets}
    rep = {(low(x["campaign"]), x["start"], x["end"]): x for x in report}
    tol = r.get("tolerance_pct", 1)
    used, res = {}, []
    for p in props:
        why, blocked = [], False
        a = agents.get(low(p["agent"]))
        c = low(p["campaign"])
        old, new = num(p["old_budget"]), num(p["new_budget"])
        current = cur.get(c)
        pct = None
        card = {"report": None, "cpa": None}
        if not a:
            why.append("B1 unknown agent %s" % (p["agent"] or "(blank)")); blocked = True
        if c not in cur:
            why.append("B2 campaign %s is not in BUDGETS.csv" % (p["campaign"] or "(blank)")); blocked = True
        elif current is None or old is None or not close(old, current, 0):
            why.append("B3 old budget %s does not match today's budget %s" % (p["old_budget"] or "(blank)", "?" if current is None else "%.2f" % current)); blocked = True
        if new is None or new < 0 or (old is not None and new == old):
            why.append("B4 new budget %s is not a change to a number of 0 or more" % (p["new_budget"] or "(blank)")); blocked = True
        if len(p["reason"]) < MIN_REASON:
            why.append("B5 reason is missing or under %d characters" % MIN_REASON); blocked = True
        rr = rep.get((c, p["start"], p["end"]))
        if not rr:
            why.append("B6 no report row for %s from %s to %s" % (p["campaign"], p["start"], p["end"])); blocked = True
        else:
            rs, rc, ps, pc = num(rr["spend"]), num(rr["conversions"]), num(p["spend"]), num(p["conversions"])
            card["report"] = rr
            if rs and rc:
                card["cpa"] = rs / rc
            if None in (rs, rc, ps, pc) or not close(ps, rs, tol) or not close(pc, rc, tol):
                why.append("B7 figures do not match the report (proposal spend %s, conversions %s; report %s, %s)" % (p["spend"], p["conversions"], rr["spend"], rr["conversions"])); blocked = True
        if old and new is not None:
            pct = (new - old) / old * 100.0
            if abs(pct) > r["max_move_pct"] + 1e-9:
                why.append("B8 move of %+.1f%% is over the %s%% ceiling" % (pct, r["max_move_pct"])); blocked = True
        if not blocked:
            if a["mode"] == "propose_only":
                why.append("H1 %s may only propose" % a["name"])
            if abs(pct) > r["auto_limit_pct"] + 1e-9:
                why.append("H2 move of %+.1f%% is over the %s%% you allow without a yes" % (pct, r["auto_limit_pct"]))
            if new > old and num(rr["conversions"]) < r["min_conversions"]:
                why.append("H3 an increase on %s conversions; you asked for at least %d" % (rr["conversions"], r["min_conversions"]))
            age = (day(p["created"]) - day(rr["exported"])).days
            if age > r["max_report_age_days"] or age < 0:
                why.append("H4 report exported %s is %d days from the proposal date" % (rr["exported"], age))
            if not why and new > old:
                used[p["created"]] = used.get(p["created"], 0) + (new - old)
                if used[p["created"]] > r["daily_increase_cap"] + 1e-9:
                    used[p["created"]] -= new - old
                    why.append("H5 would take %s past the daily increase cap of %s" % (p["created"], r["daily_increase_cap"]))
        verdict = "BLOCKED" if blocked else ("NEEDS YES" if why else "CLEAR")
        res.append(dict(p, verdict=verdict, why="; ".join(why), pct=None if pct is None else round(pct, 1),
                        cpa=None if card["cpa"] is None else round(card["cpa"], 2),
                        exported=card["report"]["exported"] if card["report"] else "", hash=mhash(p, current)))
    return res


def money(x, cur):
    v = num(x)
    return "%s %.2f" % (cur, v) if v is not None else str(x)


def scan(rp, bp, ip, pp, out):
    r = jload(rp)
    res = sort_moves(r, *load(bp, ip, pp))
    os.makedirs(out, exist_ok=True)
    wtext(os.path.join(out, "queue.json"), json.dumps({"owner": r["owner"], "moves": res}, indent=1) + "\n")
    cur = r.get("currency", "USD")
    n = {v: sum(1 for x in res if x["verdict"] == v) for v in ("CLEAR", "NEEDS YES", "BLOCKED")}
    L = ["# Budget change cards", "",
         "Owner: %s. %d proposed moves: %d clear, %d need your yes, %d blocked." % (r["owner"], len(res), n["CLEAR"], n["NEEDS YES"], n["BLOCKED"]),
         "Numbers come from %s only. No budget has been changed. A move is made only after `budget.py gate` says go." % r.get("report_name", "REPORT.csv"), ""]
    for v, head in (("BLOCKED", "Blocked (fix the proposal; a yes cannot clear these)"), ("NEEDS YES", "Needs your yes"), ("CLEAR", "Clear under your rules")):
        L += ["## " + head, ""]
        xs = [x for x in res if x["verdict"] == v]
        if not xs:
            L += ["- none", ""]
        for x in xs:
            L += ["### %s  %s" % (x["id"], x["campaign"]), "",
                  "| | |", "| --- | --- |",
                  "| Old daily budget | %s |" % money(x["old_budget"], cur),
                  "| New daily budget | %s (%s) |" % (money(x["new_budget"], cur), "?" if x["pct"] is None else "%+.1f%%" % x["pct"]),
                  "| Reason | %s |" % (x["reason"] or "(none)"),
                  "| Window | %s to %s |" % (x["start"], x["end"]),
                  "| Spend, conversions | %s, %s |" % (money(x["spend"], cur), x["conversions"]),
                  "| Cost per conversion (report) | %s |" % ("n/a" if x["cpa"] is None else money(x["cpa"], cur)),
                  "| Report exported | %s |" % (x["exported"] or "no matching row"),
                  "| Proposed by | %s on %s |" % (x["agent"], x["created"]),
                  "| Verdict | %s%s |" % (x["verdict"], (": " + x["why"]) if x["why"] else ""), ""]
    L += ["## Your yes", "", "At your own terminal: `python3 budget.py approve %s %s MOVE_ID`" % (rp, out.rstrip("/") + "/")]
    wtext(os.path.join(out, "cards.md"), "\n".join(L) + "\n")
    return res


def log(out):
    p = os.path.join(out, "changelog.jsonl")
    if not os.path.exists(p):
        return []
    with open(p, encoding="utf-8") as f:
        return [json.loads(x) for x in f if x.strip()]


def append(out, rec):
    with open(os.path.join(out, "changelog.jsonl"), "a", encoding="utf-8") as f:
        f.write(json.dumps(rec, sort_keys=True) + "\n")


def approve(rp, out, mid, 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); only the owner says yes" % k); return 3
    if not tty:
        print("refused: no terminal; run this yourself"); return 3
    qp = os.path.join(out, "queue.json")
    if not os.path.exists(qp):
        print("no scan yet; run scan first"); return 4
    m = [x for x in jload(qp)["moves"] if x["id"] == mid]
    if not m:
        print("%s is not in the last scan" % mid); return 4
    m = m[0]
    if m["verdict"] == "BLOCKED":
        print("refused: %s is blocked (%s); fix it and scan again" % (mid, m["why"])); return 1
    owner = jload(rp)["owner"]
    if ask("Your name, as owner in the rules: ").strip() != owner:
        print("refused: that is not the owner in the rules"); return 5
    print("%s %s: %s to %s (%+.1f%%). Reason: %s" % (mid, m["campaign"], m["old_budget"], m["new_budget"], m["pct"], m["reason"]))
    if ask("Type apply to approve this move as it reads now: ").strip().lower() != "apply":
        print("not recorded"); return 5
    append(out, {"event": "yes", "id": mid, "by": owner, "at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), "hash": m["hash"],
                 "campaign": m["campaign"], "old": m["old_budget"], "new": m["new_budget"]})
    print("yes recorded for %s" % mid); return 0


def gate(rp, bp, ip, pp, out, mid, now=None):
    cur = [x for x in sort_moves(jload(rp), *load(bp, ip, pp)) if x["id"] == mid]
    if not cur:
        print("no-go: %s is not in the proposals" % mid); return 4
    cur = cur[0]
    lg = log(out)
    if any(x["event"] == "applied" and x["id"] == mid for x in lg):
        print("no-go: %s was already applied once" % mid); return 1
    if cur["verdict"] == "BLOCKED":
        print("no-go: %s is blocked (%s)" % (mid, cur["why"])); return 1
    if cur["verdict"] == "NEEDS YES":
        yes = [x for x in lg if x["event"] == "yes" and x["id"] == mid]
        if not yes:
            print("no-go: %s needs the owner's yes (%s)" % (mid, cur["why"])); return 1
        if yes[-1]["hash"] != cur["hash"]:
            print("no-go: %s or today's budget changed after %s said yes; approve again" % (mid, yes[-1]["by"])); return 1
        basis = "yes from %s at %s" % (yes[-1]["by"], yes[-1]["at"])
    else:
        basis = "clear under the rules"
    append(out, {"event": "applied", "id": mid, "at": (now or datetime.now()).strftime("%Y-%m-%dT%H:%M:%S"), "hash": cur["hash"],
                 "basis": basis, "campaign": cur["campaign"], "old": cur["old_budget"], "new": cur["new_budget"]})
    print("go: %s %s to %s (%s)" % (mid, cur["old_budget"], cur["new_budget"], basis)); return 0


def status(out, show=True):
    q = jload(os.path.join(out, "queue.json"))["moves"]
    lg = log(out)
    rows = []
    for m in q:
        app = [x for x in lg if x["event"] == "applied" and x["id"] == m["id"]]
        yes = [x for x in lg if x["event"] == "yes" and x["id"] == m["id"]]
        if app:
            s = "applied at %s (%s)" % (app[-1]["at"], app[-1]["basis"])
        elif m["verdict"] == "BLOCKED":
            s = "BLOCKED: %s" % m["why"]
        elif yes and yes[-1]["hash"] != m["hash"]:
            s = "CHANGED since %s said yes, approve again" % yes[-1]["by"]
        elif yes:
            s = "approved by %s at %s" % (yes[-1]["by"], yes[-1]["at"])
        elif m["verdict"] == "CLEAR":
            s = "clear"
        else:
            s = "waiting for yes: %s" % m["why"]
        rows.append((m["id"], s))
        if show:
            print("%-8s %s" % (m["id"], s))
    return rows


def main(argv):
    if not argv or argv[0] not in ("check", "scan", "approve", "gate", "status"):
        print(__doc__); return 2
    cmd, a = argv[0], argv[1:]
    if cmd == "status" and len(a) == 1:
        status(a[0]); return 0
    if not a:
        print(__doc__); return 2
    errs = check(jload(a[0]))
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("rules %s: %d error(s)" % ("OK" if not errs else "NOT OK", len(errs)))
        return 1 if errs else 0
    try:
        if cmd == "scan" and len(a) == 5:
            res = scan(*a)
            n = [sum(1 for x in res if x["verdict"] == v) for v in ("CLEAR", "NEEDS YES", "BLOCKED")]
            print("wrote %s/cards.md: %d clear, %d need a yes, %d blocked" % (a[4].rstrip("/"), n[0], n[1], n[2]))
            return 0
        if cmd == "approve" and len(a) == 3:
            return approve(a[0], a[1], a[2])
        if cmd == "gate" and len(a) == 6:
            return gate(*a)
    except ValueError as e:
        print("E01 %s" % e); return 1
    print(__doc__); return 2


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: budget change card

You may suggest ad budget moves. The owner decides which ones are made. Nothing in an ad account changes until `budget.py gate` says go.

## You may
- Add one row per proposed move to proposals.csv under your own agent name, with today's budget as old_budget, a reason of at least one full sentence, and the spend and conversions copied from REPORT.csv for the same window.
- Run `python3 budget.py scan rules.json budgets.csv report.csv proposals.csv out/` and send the owner out/cards.md, blocked cards first.
- Call `python3 budget.py gate rules.json budgets.csv report.csv proposals.csv out/ MOVE_ID` right before a budget is changed, and change it only on exit 0, to exactly the new budget on the card.
- Run `python3 budget.py status out/` and tell the owner what is waiting, changed or blocked.

## You never
- Run `budget.py approve`, write to out/changelog.jsonl, or edit rules.json, budgets.csv or report.csv. Only the owner does those.
- Take numbers from anywhere but REPORT.csv, or round them to make a case look better.
- Split one large move into several small ones to stay under the auto limit or the daily cap.
- Change a proposal after the owner said yes and make the move anyway. Edit it, scan again and ask again.
- Change a budget the gate refused, change bids, targeting or ads under the cover of a budget move, or retry a refused move through another tool.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: budget change card

You may suggest ad budget moves. The owner decides which ones are made. Nothing in an ad account changes until `budget.py gate` says go.

## You may
- Add one row per proposed move to proposals.csv under your own agent name, with today's budget as old_budget, a reason of at least one full sentence, and the spend and conversions copied from REPORT.csv for the same window.
- Run `python3 budget.py scan rules.json budgets.csv report.csv proposals.csv out/` and send the owner out/cards.md, blocked cards first.
- Call `python3 budget.py gate rules.json budgets.csv report.csv proposals.csv out/ MOVE_ID` right before a budget is changed, and change it only on exit 0, to exactly the new budget on the card.
- Run `python3 budget.py status out/` and tell the owner what is waiting, changed or blocked.

## You never
- Run `budget.py approve`, write to out/changelog.jsonl, or edit rules.json, budgets.csv or report.csv. Only the owner does those.
- Take numbers from anywhere but REPORT.csv, or round them to make a case look better.
- Split one large move into several small ones to stay under the auto limit or the daily cap.
- Change a proposal after the owner said yes and make the move anyway. Edit it, scan again and ask again.
- Change a budget the gate refused, change bids, targeting or ads under the cover of a budget move, or retry a refused move through another tool.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: budget change card

Each morning after the owner exports the report:
1. Read report.csv and budgets.csv. Add any move you suggest to proposals.csv under your own name, numbers copied from report.csv.
2. Run `python3 budget.py scan rules.json budgets.csv report.csv proposals.csv out/` and send the owner out/cards.md if anything needs a yes or is blocked.
3. Before each budget change, run `python3 budget.py gate rules.json budgets.csv report.csv proposals.csv out/ MOVE_ID`. Change the budget only on exit 0. On any other exit, stop and tell the owner.
4. Run `python3 budget.py status out/` and list what still waits for a yes.
Never approve, never edit rules.json, budgets.csv, report.csv or the log, never split a move, never retry a refused change another way.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: budget-change-card
description: Put every proposed ad budget move on one card (old budget, new budget, reason, and spend and conversions from a single report export), let small moves by trusted agents through under a percent limit and a daily cap, hold the rest for the owner's logged yes, and gate the change itself. Use whenever an agent suggests or makes an ad budget change.
---

# Budget change card

1. Fill rules.json from templates/rules.json: the owner, each agent and its mode, the auto limit, the ceiling, the daily increase cap, minimum conversions and the oldest report you accept.
2. `python3 budget.py check rules.json` until it says OK.
3. Each morning the owner exports one report to report.csv and today's budgets to budgets.csv.
4. Agents add moves to proposals.csv: id, agent, campaign, old_budget, new_budget, reason, spend, conversions, start, end, created.
5. `python3 budget.py scan rules.json budgets.csv report.csv proposals.csv out/` writes cards.md and queue.json.
6. The owner approves held moves at their own terminal: `python3 budget.py approve rules.json out/ MOVE_ID`.
7. Whoever changes the budget calls `python3 budget.py gate ... out/ MOVE_ID` first and changes it only on exit 0.

Hard rules: never approve for the owner, never take numbers from anywhere but the report, never split a move, never change a budget the gate refused, never edit the rules, inputs or log.
~~~~

#### file: templates/rules.json
~~~~json
{
 "owner": "YOUR NAME",
 "currency": "USD",
 "report_name": "the one report every number comes from",
 "auto_limit_pct": 0,
 "max_move_pct": 30,
 "daily_increase_cap": 0,
 "min_conversions": 20,
 "max_report_age_days": 2,
 "tolerance_pct": 1,
 "agents": [
  {"name": "budget-agent", "mode": "propose_only"}
 ]
}
~~~~

#### file: example/rules.json
~~~~json
{
 "owner": "Rosa Delgado",
 "currency": "USD",
 "report_name": "the ad account's campaign report, exported once each morning",
 "auto_limit_pct": 15,
 "max_move_pct": 50,
 "daily_increase_cap": 40,
 "min_conversions": 15,
 "max_report_age_days": 3,
 "tolerance_pct": 1,
 "agents": [
  {"name": "tuner", "mode": "apply_small"},
  {"name": "drafter", "mode": "propose_only"}
 ]
}
~~~~

#### file: example/budgets.csv
~~~~csv
campaign,daily_budget,as_of
search-brand,120,2026-11-04
search-local,250,2026-11-04
display-retarget,80,2026-11-04
social-video,60,2026-11-04
search-catering,40,2026-11-04
display-prospect,90,2026-11-04
shopping-feed,150,2026-11-04
social-static,70,2026-11-04
~~~~

#### file: example/report.csv
~~~~csv
campaign,start,end,spend,conversions,exported
search-brand,2026-10-21,2026-11-03,1610.40,92,2026-11-04
search-local,2026-10-21,2026-11-03,3388.75,141,2026-11-04
display-retarget,2026-10-21,2026-11-03,1050.00,18,2026-11-04
social-video,2026-10-21,2026-11-03,802.10,6,2026-11-04
search-catering,2026-10-21,2026-11-03,512.30,22,2026-11-04
display-prospect,2026-10-21,2026-11-03,1180.00,21,2026-11-04
shopping-feed,2026-10-21,2026-11-03,2210.00,77,2026-11-04
social-static,2026-10-21,2026-11-03,905.20,31,2026-11-04
~~~~

#### file: example/proposals.csv
~~~~csv
id,agent,campaign,old_budget,new_budget,reason,spend,conversions,start,end,created
P-201,tuner,search-brand,120,132,Budget ran out before 3 PM on 9 of 14 days while cost per order held near 17.50.,1610.40,92,2026-10-21,2026-11-03,2026-11-04
P-202,tuner,display-retarget,80,64,Cost per order is 58.33 against 17.50 on search; trim for a week and compare.,1050.00,18,2026-10-21,2026-11-03,2026-11-04
P-203,drafter,search-catering,40,44,Catering orders rose for three weeks running; give the evening hours more room.,512.30,22,2026-10-21,2026-11-03,2026-11-04
P-204,tuner,social-video,60,66,Two video ads have the best click rate this month; give them a little more budget.,802.10,6,2026-10-21,2026-11-03,2026-11-04
P-205,tuner,display-prospect,90,162,New audience looks promising and the budget is spent by noon most days.,1180.00,21,2026-10-21,2026-11-03,2026-11-04
P-206,tuner,shopping-feed,150,165,Feed campaign is the cheapest source of orders after brand search this month.,2810.00,77,2026-10-21,2026-11-03,2026-11-04
P-207,tuner,search-local,250,285,Local search has the most orders and lost an estimated fifth of searches to budget.,3388.75,141,2026-10-21,2026-11-03,2026-11-04
P-208,tuner,social-static,70,63,Cost per order is 29.20 and rising for two weeks; step back and watch.,905.20,31,2026-10-21,2026-11-03,2026-11-04
~~~~

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

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


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()
        for n in ("rules.json", "budgets.csv", "report.csv", "proposals.csv"):
            shutil.copy(os.path.join(HERE, "example", n), os.path.join(self.d, n))
        self.rules, self.b, self.r, self.p = [os.path.join(self.d, n) for n in ("rules.json", "budgets.csv", "report.csv", "proposals.csv")]
        self.out = os.path.join(self.d, "out")

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

    def R(self):
        return bc.jload(self.rules)

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

    def codes(self):
        return [c for c, _ in bc.check(self.R())]

    def rows(self, path):
        with open(path, newline="") as f:
            return list(csv.DictReader(f))

    def write(self, path, rows):
        with open(path, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
            w.writeheader(); w.writerows(rows)

    def edit(self, path, key, val, **ch):
        rows = self.rows(path)
        for x in rows:
            if x[key] == val:
                x.update(ch)
        self.write(path, rows)

    def scan(self):
        return {x["id"]: x for x in bc.scan(self.rules, self.b, self.r, self.p, self.out)}

    def gate(self, mid):
        return quiet(bc.gate, self.rules, self.b, self.r, self.p, self.out, mid)

    def yes(self, mid, name="Rosa Delgado", word="apply", env=None):
        return quiet(bc.approve, self.rules, self.out, mid, ask=answers(name, word), env=env or {}, tty=True)


class Rules(Base):
    def test_example_ok(self):
        self.assertEqual(self.codes(), [])

    def test_template_ok(self):
        self.assertEqual(bc.check(bc.jload(os.path.join(HERE, "templates", "rules.json"))), [])

    def test_owner_needed(self):
        r = self.R(); r["owner"] = " "; self.save(r)
        self.assertIn("R01", self.codes())

    def test_agents_needed(self):
        r = self.R(); r["agents"] = []; self.save(r)
        self.assertIn("R02", self.codes())

    def test_duplicate_agent(self):
        r = self.R(); r["agents"].append({"name": "Tuner", "mode": "propose_only"}); self.save(r)
        self.assertIn("R03", self.codes())

    def test_bad_mode(self):
        r = self.R(); r["agents"][0]["mode"] = "apply_all"; self.save(r)
        self.assertIn("R04", self.codes())

    def test_auto_over_max(self):
        r = self.R(); r["auto_limit_pct"] = 60; self.save(r)
        self.assertIn("R05", self.codes())

    def test_age_whole_number(self):
        r = self.R(); r["max_report_age_days"] = 1.5; self.save(r)
        self.assertIn("R06", self.codes())

    def test_cap_number(self):
        r = self.R(); r["daily_increase_cap"] = "40"; self.save(r)
        self.assertIn("R07", self.codes())

    def test_min_conversions(self):
        r = self.R(); r["min_conversions"] = True; self.save(r)
        self.assertIn("R08", self.codes())

    def test_main_check_exit(self):
        self.assertEqual(quiet(bc.main, ["check", self.rules])[0], 0)
        r = self.R(); r["owner"] = ""; self.save(r)
        self.assertEqual(quiet(bc.main, ["check", self.rules])[0], 1)


class Sorting(Base):
    def test_example_counts(self):
        s = self.scan()
        v = {k: x["verdict"] for k, x in s.items()}
        self.assertEqual(v, {"P-201": "CLEAR", "P-202": "NEEDS YES", "P-203": "NEEDS YES", "P-204": "NEEDS YES",
                             "P-205": "BLOCKED", "P-206": "BLOCKED", "P-207": "NEEDS YES", "P-208": "CLEAR"})

    def test_reason_codes(self):
        s = self.scan()
        for k, c in (("P-202", "H2"), ("P-203", "H1"), ("P-204", "H3"), ("P-205", "B8"), ("P-206", "B7"), ("P-207", "H5")):
            self.assertTrue(s[k]["why"].startswith(c), (k, s[k]["why"]))

    def test_unknown_agent(self):
        self.edit(self.p, "id", "P-201", agent="helper")
        self.assertIn("B1", self.scan()["P-201"]["why"])

    def test_unknown_campaign(self):
        self.edit(self.p, "id", "P-201", campaign="search-new")
        self.assertIn("B2", self.scan()["P-201"]["why"])

    def test_stale_old_budget(self):
        self.edit(self.b, "campaign", "search-brand", daily_budget="125")
        self.assertIn("B3", self.scan()["P-201"]["why"])

    def test_no_change(self):
        self.edit(self.p, "id", "P-201", new_budget="120")
        self.assertIn("B4", self.scan()["P-201"]["why"])

    def test_negative_or_text_budget(self):
        self.edit(self.p, "id", "P-201", new_budget="-5")
        self.assertIn("B4", self.scan()["P-201"]["why"])
        self.edit(self.p, "id", "P-201", new_budget="lots")
        self.assertEqual(self.scan()["P-201"]["verdict"], "BLOCKED")

    def test_short_reason(self):
        self.edit(self.p, "id", "P-201", reason="more sales")
        self.assertIn("B5", self.scan()["P-201"]["why"])

    def test_wrong_window(self):
        self.edit(self.p, "id", "P-201", start="2026-10-20")
        self.assertIn("B6", self.scan()["P-201"]["why"])

    def test_conversions_mismatch(self):
        self.edit(self.p, "id", "P-201", conversions="120")
        self.assertIn("B7", self.scan()["P-201"]["why"])

    def test_small_rounding_ok(self):
        self.edit(self.p, "id", "P-201", spend="1610.00")
        self.assertEqual(self.scan()["P-201"]["verdict"], "CLEAR")

    def test_old_report_holds(self):
        self.edit(self.r, "campaign", "search-brand", exported="2026-10-28")
        self.assertIn("H4", self.scan()["P-201"]["why"])

    def test_report_after_proposal_holds(self):
        self.edit(self.r, "campaign", "search-brand", exported="2026-11-06")
        self.assertIn("H4", self.scan()["P-201"]["why"])

    def test_thin_data_only_for_increases(self):
        self.edit(self.p, "id", "P-204", new_budget="54")
        self.assertEqual(self.scan()["P-204"]["verdict"], "CLEAR")

    def test_decreases_do_not_use_cap(self):
        r = self.R(); r["daily_increase_cap"] = 12; self.save(r)
        s = self.scan()
        self.assertEqual(s["P-201"]["verdict"], "CLEAR")
        self.assertEqual(s["P-208"]["verdict"], "CLEAR")

    def test_cap_is_per_day(self):
        self.edit(self.p, "id", "P-207", created="2026-11-05")
        self.edit(self.r, "campaign", "search-local", exported="2026-11-05")
        self.assertEqual(self.scan()["P-207"]["verdict"], "CLEAR")

    def test_zero_auto_limit_holds_all(self):
        r = self.R(); r["auto_limit_pct"] = 0; self.save(r)
        self.assertNotIn("CLEAR", [x["verdict"] for x in self.scan().values()])

    def test_cards_written(self):
        self.scan()
        with open(os.path.join(self.out, "cards.md")) as f:
            t = f.read()
        self.assertIn("2 clear, 4 need your yes, 2 blocked", t)
        self.assertIn("| Cost per conversion (report) | USD 17.50 |", t)
        self.assertLess(t.index("## Blocked"), t.index("## Needs your yes"))

    def test_missing_column(self):
        rows = self.rows(self.p)
        for x in rows:
            del x["reason"]
        self.write(self.p, rows)
        rc, out = quiet(bc.main, ["scan", self.rules, self.b, self.r, self.p, self.out])
        self.assertEqual(rc, 1); self.assertIn("reason", out)

    def test_duplicate_id(self):
        rows = self.rows(self.p); rows[1]["id"] = "P-201"; self.write(self.p, rows)
        self.assertEqual(quiet(bc.main, ["scan", self.rules, self.b, self.r, self.p, self.out])[0], 1)

    def test_bad_date(self):
        self.edit(self.p, "id", "P-201", created="11/04/2026")
        self.assertEqual(quiet(bc.main, ["scan", self.rules, self.b, self.r, self.p, self.out])[0], 1)


class Approval(Base):
    def setUp(self):
        super().setUp(); self.scan()

    def test_owner_yes_then_gate(self):
        self.assertEqual(self.yes("P-202")[0], 0)
        rc, out = self.gate("P-202")
        self.assertEqual(rc, 0); self.assertIn("yes from Rosa Delgado", out)

    def test_agent_env_refused(self):
        for k in ("CLAUDECODE", "CODEX_SANDBOX", "HERMES_AGENT", "GROK_BOT"):
            self.assertEqual(self.yes("P-202", env={k: "1"})[0], 3)

    def test_no_terminal_refused(self):
        rc, _ = quiet(bc.approve, self.rules, self.out, "P-202", ask=answers("Rosa Delgado", "apply"), env={}, tty=False)
        self.assertEqual(rc, 3)

    def test_wrong_name(self):
        self.assertEqual(self.yes("P-202", name="tuner")[0], 5)

    def test_wrong_word(self):
        self.assertEqual(self.yes("P-202", word="yes")[0], 5)
        self.assertEqual(self.gate("P-202")[0], 1)

    def test_blocked_cannot_be_approved(self):
        self.assertEqual(self.yes("P-205")[0], 1)

    def test_unknown_move(self):
        self.assertEqual(self.yes("P-999")[0], 4)

    def test_clear_goes_without_yes(self):
        rc, out = self.gate("P-201")
        self.assertEqual(rc, 0); self.assertIn("clear under the rules", out)

    def test_needs_yes_no_go(self):
        self.assertEqual(self.gate("P-203")[0], 1)

    def test_blocked_no_go(self):
        self.assertEqual(self.gate("P-206")[0], 1)

    def test_applied_once_only(self):
        self.assertEqual(self.gate("P-201")[0], 0)
        self.assertEqual(self.gate("P-201")[0], 1)

    def test_edit_after_yes_needs_new_yes(self):
        self.yes("P-202")
        self.edit(self.p, "id", "P-202", new_budget="60")
        rc, out = self.gate("P-202")
        self.assertEqual(rc, 1); self.assertIn("changed", out)

    def test_budget_change_after_yes(self):
        self.yes("P-202")
        self.edit(self.b, "campaign", "display-retarget", daily_budget="70")
        self.assertEqual(self.gate("P-202")[0], 1)

    def test_status(self):
        self.yes("P-202"); self.gate("P-201")
        rows = dict(bc.status(self.out, show=False))
        self.assertTrue(rows["P-201"].startswith("applied"))
        self.assertTrue(rows["P-202"].startswith("approved by Rosa Delgado"))
        self.assertTrue(rows["P-205"].startswith("BLOCKED"))
        self.assertTrue(rows["P-208"] == "clear")
        self.assertTrue(rows["P-203"].startswith("waiting"))

    def test_log_records_who_and_hash(self):
        self.yes("P-202")
        rec = bc.log(self.out)[-1]
        self.assertEqual(rec["by"], "Rosa Delgado"); self.assertEqual(len(rec["hash"]), 64)


class Cli(Base):
    def test_usage(self):
        self.assertEqual(quiet(bc.main, [])[0], 2)
        self.assertEqual(quiet(bc.main, ["scan", self.rules])[0], 2)

    def test_scan_cli(self):
        rc, out = quiet(bc.main, ["scan", self.rules, self.b, self.r, self.p, self.out])
        self.assertEqual(rc, 0); self.assertIn("2 clear, 4 need a yes, 2 blocked", out)


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