# Usage Cap Check

**Know where the month will land before the bill does. One plan with a cap and a rule for going over, a log copied from the provider's usage export, and a one-page sheet the client owner can read and answer in one line.**

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 work is billed by use, and use drifts. A follow-up job starts running twice a day. Someone adds a research task that was never in the plan. Nobody notices until the invoice, and then the conversation is about blame instead of choices.

The fix is boring and it works: agree a cap and a rule before the month starts, count against it every week, and ask the client owner before the cap, not after.

## What you get out of it

- One plan: the monthly cap, alert points, what happens at the cap, and every job that spends with its priority, daily budget and off switch.
- A status line any time: spent so far, the last 7 days' pace, and where the month ends at that pace.
- A one-page usage sheet in plain words, with a numbered decision per problem and a reply line like `1: pause 2: limit 3: add`.
- An action list of only what the client owner approved, each with a done test.

## Who it is for

Providers and small agencies who bill agent or model usage to clients and want no surprises at month end. Owners who pay for that usage and want to see it coming.

Not a billing system. It never pauses a job, changes a limit or touches a bill. It counts what the provider's own export says and records what the client decided.

## The plan

plan.json holds the client, the provider, the client owner (the one person who decides), the month, `cap_usd`, `alert_at` (like `[50, 80, 100]`), the over-cap rule, the log file name, and the jobs.

| Over-cap rule | What it means |
| --- | --- |
| `pause` | nice-to-have jobs pause at the cap; core jobs keep running and the overage is billed at cost |
| `ask` | nothing runs past the cap without the client owner's yes |
| `bill` | work continues and the overage is billed at the written rate, up to `max_over_usd` |

Each job has a `priority` (`core` or `nice`), an optional `daily_usd` budget and an `off_switch`: the exact step that pauses it. The log is a CSV with three columns, `date,job,usd`, copied from the provider's usage export.

## What the checker catches

| Code | Means |
| --- | --- |
| U01 to U05 | the plan is missing the client, provider, owner, month or cap; alert points do not rise; the rule is unknown or a `bill` rule has no rate or limit; a job is unnamed, listed twice or has no priority; a log line cannot be read |
| B01 | at the last 7 days' pace the month ends over the cap, or it is already over; the sheet says by how much and around which day it crosses |
| B02 | a job went over its daily budget on 2 or more of the last 7 days |
| B03 | a job is spending that is not in the plan |
| B04 | a job has no off switch written down, so it cannot be paused on request |

## One reply

| Finding | Options |
| --- | --- |
| B01 heading over | `pause` nice-to-have jobs, `raise` the cap to the projection (rounded up to the next $10), `accept` the written rule, `hold` |
| B02 job over budget | `limit` it at its daily budget, `raise` its daily budget, `hold` |
| B03 unplanned job | `add` it to the plan, `stop` it, `hold` |
| B04 no off switch | `write` one and test it, `retire` the job, `hold` |

Anything not answered holds, and the plan's rule applies when the cap is reached. Recording a reply is refused inside an agent session, without a terminal, from anyone but the client owner, and if the plan or the log 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 usage-cap-check-see-the-month-coming-before-the-bill.md usage/` (the file you downloaded) to unpack the rest.
2. In `usage/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up plumbing company and its automation provider: `python3 usagecap.py status example/plan.json --today 2026-11-14`. You should see $171.90 spent of a $300 cap, a month-end pace near $409, and 4 findings.
4. Run `python3 usagecap.py sheet example/plan.json out/ --today 2026-11-14` and read out/usage-sheet.md. Then, at your own terminal: `python3 usagecap.py reply example/plan.json out/ "1: pause 2: limit 3: add 4: write"` (type Dana Okafor when asked) and `python3 usagecap.py plan example/plan.json out/`.
5. Copy templates/plan.json and templates/usage.csv, fill in your own cap and jobs, and agree the over-cap rule with the client before the month starts.
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 past month from real usage exports and compare the status line with the invoice that month. Then try one off switch for real. Stop and fix the setup if the totals do not match the invoice, if a log row was estimated instead of copied, or if your agent paused a job, raised the cap or edited the plan to clear a finding. That is the kill rule.

## Limits

- It knows only what the log says. Copy rows from the provider's own export; a missing row is missing spend.
- The month-end figure is the last 7 days' pace carried forward. A busy week early in the month will overstate it; a quiet one will understate it.
- One currency, US dollars. Convert before you log if you are billed in another.
- It does not read any provider's API. That keeps it safe to hand to an agent, and it means a person or agent copies the export.
- Not accounting or legal advice, and not a substitute for the usage terms in your contract.

## Want this run for you

Keeping usage inside an agreed cap 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 usage counted weekly and every change 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: usagecap.py
~~~~python
#!/usr/bin/env python3
"""usagecap.py: see a month's agent usage coming before the bill does. The client owner decides what happens near the cap.

  python3 usagecap.py check PLAN.json [--today D]           check the plan and the usage log
  python3 usagecap.py status PLAN.json [--today D]          spend to date, pace, projected month total, findings
  python3 usagecap.py sheet PLAN.json OUT/ [--today D]      write OUT/usage-sheet.md (one page for the client) and OUT/pending.json
  python3 usagecap.py parse OUT/pending.json "REPLY"        check a reply is complete and valid (writes nothing)
  python3 usagecap.py reply PLAN.json OUT/ "REPLY"          client owner only, at their own terminal: record answers
  python3 usagecap.py plan PLAN.json OUT/                   write OUT/usage-actions.md, the approved steps with a done test

PLAN.json holds the monthly cap, alert points, the rule for going over, and every job that spends.
The usage log is a CSV (date,job,usd) next to PLAN.json, named in "log". Nothing here pauses a job,
changes a limit or touches a bill. Amounts are in US dollars; use the provider's own usage export.
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 calendar, csv, hashlib, json, os, re, sys
from datetime import date, datetime, timedelta

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"]
RULES = {"pause": "nice-to-have jobs pause at the cap; core jobs keep running and the overage is billed at cost",
         "ask": "nothing runs past the cap without the client owner's yes",
         "bill": "work continues past the cap and the overage is billed as written, up to the stated limit"}
OPTIONS = {"B01": ["pause", "raise", "accept", "hold"], "B02": ["limit", "raise", "hold"],
           "B03": ["add", "stop", "hold"], "B04": ["write", "retire", "hold"]}
MEANING = {"pause": "pause every nice-to-have job until the 1st of next month",
           "raise": "raise the budget to the projected amount, rounded up to the next $10, in writing",
           "accept": "keep going and follow the overage rule exactly as written in the plan",
           "limit": "the provider sets a hard daily limit on that job at its planned daily budget",
           "add": "add the job to the plan with a priority, a daily budget and an off switch",
           "stop": "switch the job off until it is added to the plan",
           "write": "the provider writes down the off switch and tests it once",
           "retire": "turn the job off and remove it from the plan",
           "hold": "no decision yet; the rule in the plan applies when the cap is reached"}
DONE = {"pause": "status shows no new spend from nice-to-have jobs after the pause date",
        "raise": "the plan shows the new number and the client owner confirmed it in writing",
        "accept": "the overage rule in the plan is unchanged and the client owner's name is on the decision",
        "limit": "the job's daily spend stays at or under its daily budget for 7 days",
        "add": "the job appears in the plan with a priority, daily budget and off switch, and check passes",
        "stop": "status shows no new spend from that job",
        "write": "the client reads the off switch and uses it once without help",
        "retire": "the job no longer spends and nothing else depended on it"}
B02_RAISE = "raise that job's daily budget to its highest day this week, in writing"


def meaning(code, opt):
    return B02_RAISE if (code, opt) == ("B02", "raise") else MEANING[opt]


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).strip(), "%Y-%m-%d").date()
    except ValueError:
        return None


def money(x):
    return "$%s" % format(round(x, 2), ",.2f").replace(".00", "")


def month_bounds(m):
    try:
        y, mo = [int(x) for x in str(m).split("-")]
        return date(y, mo, 1), date(y, mo, calendar.monthrange(y, mo)[1])
    except (ValueError, TypeError):
        return None, None


def log_path(plan_path, plan):
    return os.path.join(os.path.dirname(os.path.abspath(plan_path)), plan.get("log") or "usage.csv")


def read_log(path):
    """Return (rows, errors). rows are (date, job, usd)."""
    rows, errs = [], []
    if not os.path.isfile(path):
        return rows, [("U05", "usage log %s not found" % os.path.basename(path))]
    with open(path, encoding="utf-8", newline="") as f:
        r = csv.DictReader(f)
        if not r.fieldnames or [h.strip().lower() for h in r.fieldnames][:3] != ["date", "job", "usd"]:
            return rows, [("U05", "usage log header must be date,job,usd")]
        for i, row in enumerate(r, 2):
            d = to_date(row.get("date", ""))
            job = str(row.get("job") or "").strip()
            try:
                usd = float(row.get("usd", ""))
            except ValueError:
                usd = None
            if not d or not job or usd is None or usd < 0:
                errs.append(("U05", "usage log line %d cannot be read: %s" % (i, ",".join(str(v) for v in row.values()))))
                continue
            rows.append((d, job, usd))
    return rows, errs


def check_plan(plan, rows_errs=None):
    errs = []
    for k in ("client", "provider", "client_owner"):
        if not str(plan.get(k) or "").strip():
            errs.append(("U01", "no %s" % k))
    a, _ = month_bounds(plan.get("month"))
    if not a:
        errs.append(("U01", "month must be YYYY-MM"))
    cap = plan.get("cap_usd")
    if not isinstance(cap, (int, float)) or isinstance(cap, bool) or cap <= 0:
        errs.append(("U01", "cap_usd must be a number above 0"))
    al = plan.get("alert_at")
    if not isinstance(al, list) or not al or any(not isinstance(x, int) or isinstance(x, bool) or not 1 <= x <= 200 for x in al) or al != sorted(set(al)):
        errs.append(("U02", "alert_at must be rising whole percents between 1 and 200, like [50, 80, 100]"))
    ru = plan.get("over_cap") or {}
    if ru.get("rule") not in RULES:
        errs.append(("U03", "over_cap.rule must be one of %s" % ", ".join(RULES)))
    elif ru["rule"] == "bill":
        mx = ru.get("max_over_usd")
        if not str(ru.get("rate") or "").strip() or not isinstance(mx, (int, float)) or isinstance(mx, bool) or mx <= 0:
            errs.append(("U03", "a 'bill' rule needs a written rate and a max_over_usd above 0"))
    jobs = plan.get("jobs")
    if not isinstance(jobs, list) or not jobs:
        errs.append(("U04", "no jobs: list every job that spends"))
    else:
        seen = set()
        for i, j in enumerate(jobs, 1):
            n = str(j.get("name") or "").strip()
            if not n:
                errs.append(("U04", "job %d has no name" % i))
                continue
            if n.lower() in seen:
                errs.append(("U04", "job listed twice: %s" % n))
            seen.add(n.lower())
            if j.get("priority") not in ("core", "nice"):
                errs.append(("U04", "'%s' priority must be core or nice" % n))
            b = j.get("daily_usd")
            if b is not None and (not isinstance(b, (int, float)) or isinstance(b, bool) or b <= 0):
                errs.append(("U04", "'%s' daily_usd must be a number above 0" % n))
    for e in rows_errs or []:
        errs.append(e)
    return errs


def analyse(plan, rows, today):
    """Return a dict with totals, projection and findings. Assumes check passed."""
    first, last = month_bounds(plan["month"])
    if today < first:
        today = first
    end = min(today, last)
    rows = [r for r in rows if first <= r[0] <= end]
    spent = sum(r[2] for r in rows)
    cap = float(plan["cap_usd"])
    by_job = {}
    for d, j, u in rows:
        by_job[j] = by_job.get(j, 0) + u
    days_in, days_done = (last - first).days + 1, (end - first).days + 1
    win_start = max(first, end - timedelta(days=6))
    win_days = (end - win_start).days + 1
    pace = sum(u for d, j, u in rows if d >= win_start) / win_days
    projected = spent + pace * (days_in - days_done)
    cross = None
    if spent >= cap:
        cross = min((d for d in sorted({r[0] for r in rows}) if sum(u for dd, j, u in rows if dd <= d) >= cap), default=end)
    elif pace > 0 and projected > cap:
        cross = end + timedelta(days=int(-(-(cap - spent) // pace)))
    alerts = [p for p in plan["alert_at"] if spent >= cap * p / 100.0]
    planned = {j["name"].lower(): j for j in plan["jobs"]}
    f = []
    if projected > cap:
        if spent >= cap:
            det = "already %s over the %s cap on %s; at this pace the month ends near %s" % (money(spent - cap), money(cap), cross, money(projected))
        else:
            det = "at this pace the month ends near %s against a %s cap, about %s over, crossing it around %s" % (
                money(projected), money(cap), money(projected - cap), cross)
        f.append({"code": "B01", "subject": "Monthly cap", "detail": det})
    for j in plan["jobs"]:
        b = j.get("daily_usd")
        if b:
            days = {}
            for d, jj, u in rows:
                if jj.lower() == j["name"].lower() and d >= win_start:
                    days[d] = days.get(d, 0) + u
            over = sorted(d for d, u in days.items() if u > b + 1e-9)
            if len(over) >= 2:
                f.append({"code": "B02", "subject": j["name"], "detail": "over its %s daily budget on %d of the last %d days (%s)" % (
                    money(b), len(over), win_days, ", ".join(str(d) for d in over))})
    for name in sorted(set(j for _, j, _ in rows if j.lower() not in planned)):
        f.append({"code": "B03", "subject": name, "detail": "spent %s this month but is not in the plan" % money(by_job[name])})
    for j in plan["jobs"]:
        if not str(j.get("off_switch") or "").strip():
            f.append({"code": "B04", "subject": j["name"], "detail": "no off switch written down, so it cannot be paused on request"})
    return {"spent": spent, "cap": cap, "pace": pace, "projected": projected, "cross": cross, "alerts": alerts,
            "by_job": by_job, "as_of": end, "days_done": days_done, "days_in": days_in, "findings": f}


def load(plan_path):
    plan = jload(plan_path)
    rows, rerrs = read_log(log_path(plan_path, plan))
    return plan, rows, check_plan(plan, rerrs)


def state_hash(plan_path):
    h = hashlib.sha256()
    with open(plan_path, "rb") as fh:
        h.update(fh.read())
    lp = log_path(plan_path, jload(plan_path))
    if os.path.isfile(lp):
        with open(lp, "rb") as fh:
            h.update(b"\0" + fh.read())
    return h.hexdigest()


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


def make_sheet(plan_path, out, today=None):
    plan, rows, _ = load(plan_path)
    a = analyse(plan, rows, today or date.today())
    items = []
    for f in a["findings"]:
        it = dict(f)
        it["n"] = len(items) + 1
        it["options"] = OPTIONS[f["code"]]
        it["ask"] = describe(f)
        items.append(it)
    ru = plan["over_cap"]
    rule = RULES[ru["rule"]] + ((" (%s, at most %s over)" % (ru["rate"], money(ru["max_over_usd"]))) if ru["rule"] == "bill" else "")
    pr = {j["name"].lower(): j["priority"] for j in plan["jobs"]}
    lines = ["# Usage this month: %s, %s" % (plan["client"], plan["month"]), "",
             "For %s from %s. Figures through %s (day %d of %d). Generated %s." % (
                 plan["client_owner"], plan["provider"], a["as_of"], a["days_done"], a["days_in"], datetime.now().strftime("%Y-%m-%d %H:%M")), "",
             "| | |", "| --- | --- |",
             "| Monthly cap | %s |" % money(a["cap"]),
             "| Spent so far | %s (%d%% of the cap) |" % (money(a["spent"]), round(100 * a["spent"] / a["cap"])),
             "| Pace, last 7 days | %s a day |" % money(a["pace"]),
             "| Month end at this pace | %s |" % money(a["projected"]),
             "| Alert points passed | %s |" % (", ".join("%d%%" % p for p in a["alerts"]) or "none"),
             "| When the cap is reached | %s |" % rule, "",
             "## By job", "", "| Job | Priority | Spent |", "| --- | --- | --- |"]
    for j, u in sorted(a["by_job"].items(), key=lambda x: -x[1]):
        lines.append("| %s | %s | %s |" % (j, pr.get(j.lower(), "not in plan"), money(u)))
    lines.append("")
    if not items:
        lines.append("Nothing needs a decision. The month is on pace to stay under the cap and every job is planned and can be paused.")
    else:
        lines += ["## Decisions", "", "Nothing changes until you reply. Each item stands alone.", ""]
        for it in items:
            lines += ["### %d. %s" % (it["n"], it["ask"]), ""]
            for o in it["options"]:
                lines.append("- `%s`: %s" % (o, meaning(it["code"], o)))
            lines += ["", "Until you answer: hold.", ""]
        lines += ["## Reply with one line", "", "~~~text", " ".join("%d: ___" % it["n"] for it in items), "~~~", ""]
    os.makedirs(out, exist_ok=True)
    wtext(os.path.join(out, "usage-sheet.md"), "\n".join(lines))
    wtext(os.path.join(out, "pending.json"), json.dumps({"state": state_hash(plan_path), "projected": round(a["projected"], 2), "items": items}, indent=1) + "\n")
    return items


def parse_reply(items, reply):
    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(plan_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 usage sheet; run usagecap.py sheet")
        return 4
    pending = jload(pp)
    if pending["state"] != state_hash(plan_path):
        print("refused: the plan or the usage log changed since the sheet was made; run usagecap.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"]]))
    plan = jload(plan_path)
    if ask("Your name, as client_owner in the plan: ").strip() != plan["client_owner"]:
        print("refused: that is not the client owner in the plan")
        return 5
    if ask("Record these decisions? y/n: ").strip().lower() != "y":
        print("not recorded")
        return 5
    rec = {"state": pending["state"], "decided_by": plan["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 usagecap.py plan %s %s" % (plan_path, out))
    return 0


def plan_steps(plan_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 usagecap.py reply")
        return 4
    dec, pending = jload(dp), jload(pp)
    if dec["state"] != pending["state"] or dec["state"] != state_hash(plan_path):
        print("refused: the plan or the usage log changed after the client decided; make a new sheet")
        return 4
    plan = jload(plan_path)
    todo, kept, 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":
            kept.append("- %d. %s Keep going under the written rule. Decided by %s on %s." % (it["n"], it["ask"], dec["decided_by"], dec["decided_at"][:10]))
        else:
            what = meaning(it["code"], a)
            if a == "raise" and it["code"] == "B01":
                what = "raise the monthly cap to %s, in writing" % money(-(-pending["projected"] // 10) * 10)
            done = "the plan shows the new daily budget and the client owner confirmed it in writing" if (it["code"], a) == ("B02", "raise") else DONE[a]
            todo.append("- [ ] %d. %s: %s.\n  Done when %s." % (it["n"], it["subject"], what, done))
    lines = ["# Usage actions: %s, %s" % (plan["client"], plan["month"]), "",
             "Decided by %s on %s. The provider makes each change; the client checks the done test." % (dec["decided_by"], dec["decided_at"][:10]), ""]
    if todo:
        lines += ["## Steps", ""] + todo + [""]
    if kept:
        lines += ["## Kept as written", ""] + kept + [""]
    if held:
        lines += ["## Held (the plan's rule applies at the cap)", ""] + held + [""]
    if not (todo or kept or held):
        lines += ["Nothing to do this time.", ""]
    lines += ["## Next", "", "Run usagecap.py status again in a week, or the day any alert point is passed.", ""]
    wtext(os.path.join(out, "usage-actions.md"), "\n".join(lines))
    print("wrote %s/usage-actions.md: %d step(s), %d kept, %d held" % (out, len(todo), len(kept), 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:]
    today = today or date.today()
    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", "status", "sheet", "reply", "plan") or not a:
        print(__doc__)
        return 2
    plan, rows, errs = load(a[0])
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("plan %s: %d error(s), %d job(s), %d log line(s)" % ("OK" if not errs else "NOT OK", len(errs), len(plan.get("jobs") or []), len(rows)))
        return 1 if errs else 0
    if cmd == "status" and len(a) == 1:
        r = analyse(plan, rows, today)
        print("%s %s: spent %s of %s through %s; pace %s/day; month end near %s" % (
            plan["client"], plan["month"], money(r["spent"]), money(r["cap"]), r["as_of"], money(r["pace"]), money(r["projected"])))
        if r["alerts"]:
            print("alert points passed: %s" % ", ".join("%d%%" % p for p in r["alerts"]))
        for f in r["findings"]:
            print("%s %s" % (f["code"], describe(f)))
        print("usage %s: %d finding(s)" % ("ON TRACK" if not r["findings"] else "NEEDS A DECISION", len(r["findings"])))
        return 1 if r["findings"] else 0
    if cmd == "sheet" and len(a) == 2:
        items = make_sheet(a[0], a[1], today)
        print("wrote %s/usage-sheet.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_steps(a[0], a[1])
    print(__doc__)
    return 2


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: usage cap check

You keep a client's monthly agent usage readable and inside the cap they agreed to. You count, project and draft. The client owner decides.

## You may
- Add rows to the usage log (date,job,usd) from the provider's own usage export. Copy the numbers; never estimate a row.
- Run `python3 usagecap.py check plan.json`, `status plan.json` and `sheet plan.json out/`, then send the client owner out/usage-sheet.md with the one-line reply template.
- Run `python3 usagecap.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 plan.json out/` and hand the provider out/usage-actions.md.
- Draft changes to plan.json (a new job, a new daily budget) for the provider and client to review.

## You never
- Run `usagecap.py reply` or write decisions.json. The client owner records decisions at their own terminal.
- Pause or stop a job, change a limit, raise the cap or touch a bill. People do that after the owner decides.
- Edit plan.json to make a finding go away, or delete or change a log row.
- Promise the client a month-end number. The projection is a pace, not a quote.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: usage cap check

You keep a client's monthly agent usage readable and inside the cap they agreed to. You count, project and draft. The client owner decides.

## You may
- Add rows to the usage log (date,job,usd) from the provider's own usage export. Copy the numbers; never estimate a row.
- Run `python3 usagecap.py check plan.json`, `status plan.json` and `sheet plan.json out/`, then send the client owner out/usage-sheet.md with the one-line reply template.
- Run `python3 usagecap.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 plan.json out/` and hand the provider out/usage-actions.md.
- Draft changes to plan.json (a new job, a new daily budget) for the provider and client to review.

## You never
- Run `usagecap.py reply` or write decisions.json. The client owner records decisions at their own terminal.
- Pause or stop a job, change a limit, raise the cap or touch a bill. People do that after the owner decides.
- Edit plan.json to make a finding go away, or delete or change a log row.
- Promise the client a month-end number. The projection is a pace, not a quote.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: usage cap check

Every Monday for each active client, and the same day any alert point is passed:
1. Append last week's rows to the usage log from the provider's usage export. Copy, never estimate.
2. Run `python3 usagecap.py status plan.json`. If it says ON TRACK, send the client owner one line with spent, cap and month-end pace, and stop.
3. Otherwise run `python3 usagecap.py sheet plan.json out/` and send the client owner out/usage-sheet.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 out/usage-actions.md to the provider. Never pause a job, change a limit or touch a bill yourself.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: usage-cap-check
description: Track a client's monthly agent or model usage against a written cap, project the month end from the last 7 days, and turn anything heading over into a one-reply decision sheet for the client owner. Use weekly and whenever an alert point is passed.
---

# Usage cap check

1. Plan: plan.json holds the cap, alert points, the over-cap rule (pause, ask or bill) and every job that spends, each with a priority, a daily budget and an off switch. `python3 usagecap.py check plan.json`.
2. Log: append date,job,usd rows to the usage log from the provider's usage export. Never estimate.
3. Status: `python3 usagecap.py status plan.json`. B01 month heading over the cap, B02 a job over its daily budget on 2 or more of the last 7 days, B03 spend from a job not in the plan, B04 a job with no off switch.
4. Sheet: `python3 usagecap.py sheet plan.json out/`. Send out/usage-sheet.md. Unanswered items hold.
5. Reply: the client owner only, at their own terminal: `python3 usagecap.py reply plan.json out/ "1: pause 2: limit"`.
6. Plan: `python3 usagecap.py plan plan.json out/`. Each step has a done test.

Hard rules: never pause a job, change a limit, raise the cap or touch a bill yourself; never edit the plan or the log to clear a finding.
~~~~

#### file: templates/plan.json
~~~~json
{
 "client": "",
 "provider": "",
 "client_owner": "",
 "month": "YYYY-MM",
 "cap_usd": 0,
 "alert_at": [50, 80, 100],
 "over_cap": {"rule": "ask", "rate": "", "max_over_usd": 0},
 "log": "usage.csv",
 "jobs": [
  {"name": "", "priority": "core", "daily_usd": 0, "off_switch": ""}
 ]
}
~~~~

#### file: templates/usage.csv
~~~~text
date,job,usd
~~~~

#### file: example/plan.json
~~~~json
{
 "client": "Northgate Plumbing",
 "provider": "Brightside Automation",
 "client_owner": "Dana Okafor",
 "month": "2026-11",
 "cap_usd": 300,
 "alert_at": [50, 80, 100],
 "over_cap": {"rule": "ask"},
 "log": "usage.csv",
 "note": "Fictional example. Every name and number here is made up.",
 "jobs": [
  {"name": "Missed call text-back", "priority": "core", "daily_usd": 6, "off_switch": "Pause 'callback' in the scheduler; texts stop within a minute"},
  {"name": "Quote follow-up drafts", "priority": "core", "daily_usd": 5, "off_switch": "Set QUOTE_FOLLOWUP=off in the job settings"},
  {"name": "Review reply drafts", "priority": "nice", "daily_usd": 3, "off_switch": "Pause 'reviews' in the scheduler"},
  {"name": "Weekly owner report", "priority": "nice", "off_switch": ""}
 ]
}
~~~~

#### file: example/usage.csv
~~~~text
date,job,usd
2026-11-01,Missed call text-back,4.2
2026-11-01,Quote follow-up drafts,3.1
2026-11-01,Review reply drafts,1.2
2026-11-02,Missed call text-back,5.1
2026-11-02,Quote follow-up drafts,3.4
2026-11-02,Review reply drafts,1.4
2026-11-03,Missed call text-back,4.8
2026-11-03,Quote follow-up drafts,2.9
2026-11-03,Review reply drafts,0.9
2026-11-04,Missed call text-back,5.6
2026-11-04,Quote follow-up drafts,3.8
2026-11-04,Review reply drafts,1.6
2026-11-05,Missed call text-back,7.4
2026-11-05,Quote follow-up drafts,4.1
2026-11-05,Review reply drafts,1.1
2026-11-06,Missed call text-back,3.9
2026-11-06,Quote follow-up drafts,2.2
2026-11-06,Review reply drafts,0.8
2026-11-07,Missed call text-back,4.4
2026-11-07,Quote follow-up drafts,2.6
2026-11-07,Review reply drafts,1.3
2026-11-07,Weekly owner report,2.4
2026-11-08,Missed call text-back,5.2
2026-11-08,Quote follow-up drafts,3.5
2026-11-08,Review reply drafts,1.5
2026-11-09,Missed call text-back,6.8
2026-11-09,Quote follow-up drafts,4.0
2026-11-09,Review reply drafts,1.2
2026-11-09,Lead research,3.6
2026-11-10,Missed call text-back,7.1
2026-11-10,Quote follow-up drafts,3.7
2026-11-10,Review reply drafts,1.0
2026-11-10,Lead research,4.1
2026-11-11,Missed call text-back,5.0
2026-11-11,Quote follow-up drafts,3.3
2026-11-11,Review reply drafts,1.4
2026-11-11,Lead research,3.8
2026-11-12,Missed call text-back,4.6
2026-11-12,Quote follow-up drafts,3.9
2026-11-12,Review reply drafts,1.1
2026-11-12,Lead research,4.4
2026-11-13,Missed call text-back,5.3
2026-11-13,Quote follow-up drafts,4.4
2026-11-13,Review reply drafts,1.7
2026-11-13,Lead research,4.0
2026-11-14,Missed call text-back,6.5
2026-11-14,Quote follow-up drafts,4.2
2026-11-14,Review reply drafts,1.3
2026-11-14,Weekly owner report,2.4
2026-11-14,Lead research,4.7
~~~~

#### file: tests/test_usagecap.py
~~~~python
"""Tests for usagecap.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 usagecap as uc

TODAY = date(2026, 11, 14)
ALL = "1: pause 2: limit 3: add 4: write"


def rd(p):
    with open(p, encoding="utf-8") 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()


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.plan = os.path.join(self.w, "plan.json")
        self.log = os.path.join(self.w, "usage.csv")
        self.out = os.path.join(self.d, "out")

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

    def P(self):
        with open(self.plan) as f:
            return json.load(f)

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

    def errs(self, p=None):
        rows, re_ = uc.read_log(self.log)
        return [c for c, _ in uc.check_plan(p or self.P(), re_)]

    def A(self, today=TODAY):
        p, rows, _ = uc.load(self.plan)
        return uc.analyse(p, rows, today)

    def codes(self, today=TODAY):
        return [f["code"] for f in self.A(today)["findings"]]

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

    def record(self, reply=ALL, answers=("Dana Okafor", "y"), env=None, tty=True):
        it = iter(answers)
        return quiet(uc.record_reply, self.plan, self.out, reply, ask=lambda _: next(it), env=env or {}, tty=tty)


class Plan(Base):
    def test_example_is_valid(self):
        self.assertEqual(self.errs(), [])

    def test_missing_owner(self):
        p = self.P(); p["client_owner"] = ""
        self.assertIn("U01", self.errs(p))

    def test_bad_cap(self):
        p = self.P(); p["cap_usd"] = 0
        self.assertIn("U01", self.errs(p))

    def test_alerts_must_rise(self):
        p = self.P(); p["alert_at"] = [80, 50]
        self.assertIn("U02", self.errs(p))

    def test_unknown_rule(self):
        p = self.P(); p["over_cap"] = {"rule": "whatever"}
        self.assertIn("U03", self.errs(p))

    def test_bill_rule_needs_rate_and_limit(self):
        p = self.P(); p["over_cap"] = {"rule": "bill", "rate": "", "max_over_usd": 0}
        self.assertIn("U03", self.errs(p))
        p["over_cap"] = {"rule": "bill", "rate": "at cost plus 10%", "max_over_usd": 100}
        self.assertNotIn("U03", self.errs(p))

    def test_duplicate_job_and_priority(self):
        p = self.P(); p["jobs"].append(dict(p["jobs"][0])); p["jobs"][1]["priority"] = "urgent"
        self.assertEqual(self.errs(p).count("U04"), 2)

    def test_bad_log_line(self):
        with open(self.log, "a") as f:
            f.write("2026-11-31,Missed call text-back,abc\n")
        self.assertIn("U05", self.errs())

    def test_bad_log_header(self):
        with open(self.log, "w") as f:
            f.write("day,what,amount\n")
        self.assertIn("U05", self.errs())


class Status(Base):
    def test_example_numbers(self):
        a = self.A()
        self.assertAlmostEqual(a["spent"], 171.9, places=2)
        self.assertEqual(a["alerts"], [50])
        self.assertEqual(a["cross"], date(2026, 11, 23))
        self.assertGreater(a["projected"], 400)

    def test_example_findings(self):
        self.assertEqual(self.codes(), ["B01", "B02", "B03", "B04"])

    def test_on_track_month(self):
        p = self.P(); p["cap_usd"] = 1000; p["jobs"][3]["off_switch"] = "Pause 'report'"
        p["jobs"].append({"name": "Lead research", "priority": "nice", "off_switch": "Pause 'leads'"})
        p["jobs"][0]["daily_usd"] = 10
        self.save(p)
        self.assertEqual(self.codes(), [])

    def test_already_over_cap(self):
        p = self.P(); p["cap_usd"] = 100; self.save(p)
        f = [x for x in self.A()["findings"] if x["code"] == "B01"][0]
        self.assertIn("already", f["detail"])

    def test_ignores_other_months_and_future_rows(self):
        with open(self.log, "a") as f:
            f.write("2026-10-31,Missed call text-back,500\n2026-11-20,Missed call text-back,500\n")
        self.assertAlmostEqual(self.A()["spent"], 171.9, places=2)

    def test_cli_status_exit_codes(self):
        rc, out = quiet(uc.main, ["status", self.plan, "--today", "2026-11-14"])
        self.assertEqual(rc, 1)
        self.assertIn("NEEDS A DECISION", out)


class Reply(Base):
    def test_sheet_lists_every_finding(self):
        items = self.sheet()
        self.assertEqual(len(items), 4)
        t = rd(os.path.join(self.out, "usage-sheet.md"))
        self.assertIn("1: ___ 2: ___ 3: ___ 4: ___", t)
        self.assertIn("$300", t)

    def test_parse_errors(self):
        items = self.sheet()
        _, e = uc.parse_reply(items, "1: pause 9: add 2: shrug 3: add 3: stop maybe")
        self.assertEqual(sorted(c for c, _ in e), ["R01", "R02", "R03", "R04"])

    def test_unanswered_holds(self):
        items = self.sheet()
        ans, e = uc.parse_reply(items, "1: pause")
        self.assertEqual(e, [])
        self.assertEqual(ans[2], "hold")

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

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

    def test_refused_wrong_name(self):
        self.sheet()
        self.assertEqual(self.record(answers=("Someone Else", "y"))[0], 5)

    def test_refused_after_log_changes(self):
        self.sheet()
        with open(self.log, "a") as f:
            f.write("2026-11-14,Missed call text-back,1\n")
        self.assertEqual(self.record()[0], 4)

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

    def test_record_and_plan(self):
        self.sheet()
        rc, _ = self.record()
        self.assertEqual(rc, 0)
        rc, _ = quiet(uc.plan_steps, self.plan, self.out)
        self.assertEqual(rc, 0)
        t = rd(os.path.join(self.out, "usage-actions.md"))
        self.assertIn("pause every nice-to-have job", t)
        self.assertEqual(t.count("- [ ]"), 4)

    def test_raise_names_the_new_cap(self):
        self.sheet()
        self.assertEqual(self.record("1: raise 2: raise 3: stop 4: retire")[0], 0)
        quiet(uc.plan_steps, self.plan, self.out)
        t = rd(os.path.join(self.out, "usage-actions.md"))
        self.assertIn("raise the monthly cap to $410", t)
        self.assertIn("daily budget to its highest day", t)

    def test_accept_is_kept_not_a_step(self):
        self.sheet()
        self.record("1: accept")
        quiet(uc.plan_steps, self.plan, self.out)
        t = rd(os.path.join(self.out, "usage-actions.md"))
        self.assertIn("Kept as written", t)
        self.assertIn("Held", t)

    def test_plan_refused_after_plan_edit(self):
        self.sheet(); self.record()
        p = self.P(); p["cap_usd"] = 999; self.save(p)
        self.assertEqual(quiet(uc.plan_steps, self.plan, self.out)[0], 4)

    def test_never_writes_plan_or_log(self):
        before = rd(self.plan) + rd(self.log)
        self.sheet(); self.record(); quiet(uc.plan_steps, self.plan, self.out)
        self.assertEqual(before, rd(self.plan) + rd(self.log))


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