# Earned Autonomy Ladder

**"Can the agent just handle it?" should have a written answer. Give every agent action a level, raise it only on a clean record and your yes, drop it the day something goes wrong, and keep money, new contacts and public posts on draft for good.**

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

Most owners give an agent freedom in one jump. It drafts for a week, the drafts look fine, and it is allowed to send everything. Nobody wrote down what "looked fine" meant, so when it goes wrong there is no record of what was earned, and the only fix is to take everything away at once.

This kit makes trust a level per action, earned from a record you keep anyway. An agent can be trusted to file receipts and still be on draft for client email. The bar to move up is in a file, the yes is yours, and one bad run moves the lane back down without a meeting.

## What you get out of it

- review.md each week: every lane, its level, runs since the last change, edit rate, and a call: promote, hold, demote or at cap, with the reason.
- `allowed`, one question an agent asks before each action. Exit 0 means act without asking first; anything else means draft or watch.
- grants.jsonl: every promotion and demotion with who, when, the record it rested on and a fingerprint of that record.
- Pinned categories that never rise above draft, whatever the record says.

## Who it is for

Owners and small teams moving agents from drafting to doing, and the people who set those agents up for them.

## The four levels

| Level | Name | What the agent does |
| --- | --- | --- |
| 0 | watch | writes what it would have done and does nothing |
| 1 | draft | prepares the work; you say yes each time |
| 2 | act and report | acts, then tells you the same day |
| 3 | act within its limit | acts inside the lane's limit and lists it in the weekly summary |

## The rules file

| Field | What to write |
| --- | --- |
| `owner` | the one person who promotes |
| `promote.min_runs`, `min_days` | how many runs, over how many days, since the last change before a lane can move up |
| `promote.max_edit_pct` | the highest share of runs you edited that still counts as clean |
| `pinned.categories`, `max_level` | the categories that never rise past this level. Payments, first contact with someone new, deleting, contracts, public posts and credentials are pinned at 1 in the template |
| `lanes[]` | one per agent and action: `agent`, `action`, `category`, `start_level` |

runs.csv has one row per run: id, date, agent, action, outcome, note. The outcome is accepted (kept as is), edited, rejected, reverted (undone after the fact) or incident (harm or a broken rule).

## How a lane moves

| Call | When |
| --- | --- |
| DEMOTE to 0 | any incident since the last change |
| DEMOTE one level | any reverted run since the last change |
| PROMOTE one level | enough runs over enough days, edit rate at or under the bar, nothing rejected, reverted or harmful, and below the lane's cap |
| HOLD | short on runs or days, edit rate too high, or a rejection since the last change |
| AT CAP | level 3, or the pinned cap |

Every change resets the record. A promoted lane starts counting again, and so does a demoted one.

## The owner's call

`ladder.py promote` raises one lane one level. It is refused inside an agent session, without a terminal, for a lane whose record has not earned it and from anyone but the owner, and you type promote after seeing the record. Demotions go the other way: anyone, agent included, may record one with `apply-demotions`, and `allowed` already treats a due demotion as applied.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py earned-autonomy-ladder-agents-earn-each-level-from-their-record-and-your-yes.md ladder/` (the file you downloaded) to unpack the rest.
2. In `ladder/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up bookkeeping office with five agents and six lanes: `python3 ladder.py review example/rules.json example/runs.csv out/`. One lane is ready to promote, two hold, two demote and one sits at its pinned cap.
4. Copy templates/rules.json. List one lane per agent action, starting at 0 or 1.
5. Make each agent run `allowed` before acting, and log every run with your outcome.
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.
7. Read review.md each Monday. Promote at most one lane a week.

## Run it once before you rely on it

Promote one earned lane, then add a reverted run for it and run `allowed`. It must say no. Try to promote a lane on hold; it must refuse. Stop and fix the setup if an agent promoted itself, edited the rules or grants.jsonl, left a bad outcome out of runs.csv, or acted in a pinned category without your yes. That is the kill rule.

## Limits

- It works from runs.csv. A missing or softened outcome gives a better record than the agent earned, so spot check the log against what you actually saw.
- `allowed` answers the question; it cannot stop an agent that never asks. Keep the permissions in your tools no wider than the level.
- Lanes are matched by exact name. Agree the action names once and keep them.
- Level 3 limits (how much, how often) live in your tools and your notes. The kit records the level, not the limit.

## Where this came from

Public threads about running agents in a business keep landing on the same question: when is an agent ready to act without asking? Most answers are a feeling. This kit is our own written answer, from how we widen what our own agents may do one action at a time, with nothing copied from any product.

## Want this run for you

Autonomy that holds up is earned in writing, one lane at a time. If you would rather have agents set up and run with those levels 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: ladder.py
~~~~python
#!/usr/bin/env python3
"""ladder.py: agents earn the right to act on their own, one lane and one level at a time, from their record and the owner's yes.

Levels: 0 watch (says what it would do), 1 draft (prepares, the owner says yes each time),
        2 act and report (acts, tells the owner the same day), 3 act within its limit (acts, weekly summary).

  python3 ladder.py check RULES.json                         check the rules
  python3 ladder.py review RULES.json RUNS.csv OUT/          one line per lane: PROMOTE, HOLD, DEMOTE or AT CAP; writes OUT/review.md
  python3 ladder.py promote RULES.json RUNS.csv OUT/ AGENT ACTION
                                                             owner only, at their own terminal, one level, only when the record earns it
  python3 ladder.py apply-demotions RULES.json RUNS.csv OUT/ anyone may run this; it only ever lowers levels
  python3 ladder.py allowed RULES.json RUNS.csv OUT/ AGENT ACTION
                                                             ask before acting: exit 0 means act without asking first
  python3 ladder.py status RULES.json OUT/                   every lane, its level and its last change

RUNS.csv: id, date, agent, action, outcome, note
  outcome is accepted (the owner kept it as is), edited, rejected, reverted (undone after the fact) or incident (harm or a broken rule).
Exit codes: 0 ok or allowed, 1 errors, not allowed or not earned, 2 usage, 3 refused (agent session or no terminal), 4 unknown lane, 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"]
COLS = ["id", "date", "agent", "action", "outcome", "note"]
OUTCOMES = ("accepted", "edited", "rejected", "reverted", "incident")
NAMES = {0: "watch", 1: "draft, yes each time", 2: "act and report the same day", 3: "act within its limit, weekly summary"}


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 day(s):
    return datetime.strptime(s, "%Y-%m-%d")


def wnum(v, lo=0, hi=None):
    return isinstance(v, int) and not isinstance(v, bool) and v >= lo and (hi is None or v <= hi)


def check(r):
    e = []
    if not low(r.get("owner")):
        e.append(("R01", "owner is missing (the one person who promotes)"))
    p = r.get("promote") or {}
    if not wnum(p.get("min_runs"), 1) or not wnum(p.get("min_days"), 1):
        e.append(("R02", "promote.min_runs and promote.min_days must be whole numbers of 1 or more"))
    m = p.get("max_edit_pct")
    if not isinstance(m, (int, float)) or isinstance(m, bool) or not 0 <= m <= 100:
        e.append(("R03", "promote.max_edit_pct must be a number from 0 to 100"))
    pin = r.get("pinned") or {}
    if not isinstance(pin.get("categories"), list) or not wnum(pin.get("max_level"), 0, 3):
        e.append(("R04", "pinned needs a categories list and a max_level from 0 to 3"))
    lanes = r.get("lanes")
    if not isinstance(lanes, list) or not lanes:
        e.append(("R05", "lanes must list at least one agent and action"))
        return e
    seen, pins = set(), {low(c) for c in pin.get("categories") or []}
    for ln in lanes:
        k = (low(ln.get("agent")), low(ln.get("action")))
        if not all(k):
            e.append(("R05", "every lane needs an agent and an action")); continue
        if k in seen:
            e.append(("R06", "lane %s %s is listed twice" % k))
        seen.add(k)
        if not wnum(ln.get("start_level"), 0, 3):
            e.append(("R07", "%s %s start_level must be 0, 1, 2 or 3" % k))
        elif low(ln.get("category")) in pins and wnum(pin.get("max_level"), 0, 3) and ln["start_level"] > pin["max_level"]:
            e.append(("R08", "%s %s is pinned (%s) and cannot start above level %d" % (k + (ln.get("category"), pin["max_level"]))))
        if not low(ln.get("category")):
            e.append(("R09", "%s %s needs a category" % k))
    return e


def load_runs(path, r):
    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("RUNS.csv is missing column(s): %s" % ", ".join(miss))
        rows = [{c: (x.get(c) or "").strip() for c in COLS} for x in rd]
    lanes = {(low(l["agent"]), low(l["action"])) for l in r["lanes"]}
    errs, seen = [], set()
    for i, x in enumerate(rows, 2):
        if not x["id"]:
            errs.append("line %d has no id" % i)
        elif x["id"] in seen:
            errs.append("run %s is listed twice" % x["id"])
        seen.add(x["id"])
        try:
            day(x["date"])
        except ValueError:
            errs.append("line %d: date is not YYYY-MM-DD" % i)
        if low(x["outcome"]) not in OUTCOMES:
            errs.append("line %d: outcome must be one of %s" % (i, ", ".join(OUTCOMES)))
        if (low(x["agent"]), low(x["action"])) not in lanes:
            errs.append("line %d: %s %s is not a lane in the rules" % (i, x["agent"], x["action"]))
    if errs:
        raise ValueError("; ".join(errs))
    return rows


def grants(out):
    p = os.path.join(out, "grants.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):
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "grants.jsonl"), "a", encoding="utf-8") as f:
        f.write(json.dumps(rec, sort_keys=True) + "\n")


def lane_state(r, out):
    """current level, last change and the run ids already counted, per lane"""
    st = {}
    for ln in r["lanes"]:
        st[(low(ln["agent"]), low(ln["action"]))] = {"lane": ln, "level": ln["start_level"], "seen": [], "last": "start"}
    for g in grants(out):
        k = (g["agent"], g["action"])
        if k in st:
            st[k].update(level=g["to"], seen=g["seen"], last="%s to %d on %s by %s" % (g["event"], g["to"], g["at"][:10], g["by"]))
    return st


def cap(r, ln):
    pin = r["pinned"]
    return pin["max_level"] if low(ln["category"]) in {low(c) for c in pin["categories"]} else 3


def review_lanes(r, runs, out):
    p, res = r["promote"], []
    for k, s in lane_state(r, out).items():
        mine = [x for x in runs if (low(x["agent"]), low(x["action"])) == k and x["id"] not in s["seen"]]
        n = len(mine)
        cnt = {o: sum(1 for x in mine if low(x["outcome"]) == o) for o in OUTCOMES}
        days = ((max(day(x["date"]) for x in mine) - min(day(x["date"]) for x in mine)).days + 1) if mine else 0
        edit = 100.0 * cnt["edited"] / n if n else 0.0
        lvl, top = s["level"], cap(r, s["lane"])
        why = []
        if cnt["incident"]:
            verdict, to = "DEMOTE", 0
            why.append("%d incident%s since the last change" % (cnt["incident"], "" if cnt["incident"] == 1 else "s"))
        elif cnt["reverted"]:
            verdict, to = "DEMOTE", max(0, lvl - 1)
            why.append("%d reverted since the last change" % cnt["reverted"])
        elif lvl > top:
            verdict, to = "DEMOTE", top
            why.append("pinned category %s is capped at level %d" % (s["lane"]["category"], top))
        elif lvl >= top:
            verdict, to = "AT CAP", lvl
            why.append("level %d is the most this lane can hold%s" % (top, " (pinned: %s)" % s["lane"]["category"] if top < 3 else ""))
        else:
            to = lvl + 1
            if n < p["min_runs"]:
                why.append("%d of %d runs" % (n, p["min_runs"]))
            if days < p["min_days"]:
                why.append("%d of %d days" % (days, p["min_days"]))
            if n and edit > p["max_edit_pct"] + 1e-9:
                why.append("edit rate %.1f%% is over %s%%" % (edit, p["max_edit_pct"]))
            if cnt["rejected"]:
                why.append("%d rejected since the last change" % cnt["rejected"])
            verdict = "HOLD" if why else "PROMOTE"
            if verdict == "PROMOTE":
                why.append("%d runs over %d days, edit rate %.1f%%, nothing rejected, reverted or harmful" % (n, days, edit))
        if verdict == "DEMOTE" and to == lvl:
            verdict, why = "AT CAP", why + ["already at level 0"]
        ev = hashlib.sha256(json.dumps([k, lvl, sorted((x["id"], x["date"], low(x["outcome"])) for x in mine)]).encode()).hexdigest()
        res.append({"agent": k[0], "action": k[1], "category": s["lane"]["category"], "level": lvl, "to": to, "verdict": verdict,
                    "why": "; ".join(why), "runs": n, "days": days, "edit_pct": round(edit, 1), "counts": cnt,
                    "ids": [x["id"] for x in mine], "seen": s["seen"], "last": s["last"], "evidence": ev})
    return res


def review(rp, runp, out):
    r = jload(rp)
    res = review_lanes(r, load_runs(runp, r), out)
    os.makedirs(out, exist_ok=True)
    wtext(os.path.join(out, "review.json"), json.dumps({"owner": r["owner"], "lanes": res}, indent=1) + "\n")
    L = ["# Autonomy review", "", "Owner: %s. Promote after %d runs over %d days with an edit rate of %s%% or less and nothing rejected, reverted or harmful since the last change." % (
        r["owner"], r["promote"]["min_runs"], r["promote"]["min_days"], r["promote"]["max_edit_pct"]),
         "Pinned categories (never above level %d): %s." % (r["pinned"]["max_level"], ", ".join(r["pinned"]["categories"]) or "none"), "",
         "| Agent | Action | Level now | Runs since change | Edit rate | Call | Why |", "| --- | --- | --- | --- | --- | --- | --- |"]
    order = {"DEMOTE": 0, "PROMOTE": 1, "HOLD": 2, "AT CAP": 3}
    for x in sorted(res, key=lambda x: (order[x["verdict"]], x["agent"], x["action"])):
        call = x["verdict"] + ("" if x["verdict"] in ("HOLD", "AT CAP") else " to %d" % x["to"])
        L.append("| %s | %s | %d (%s) | %d over %d days | %.1f%% | %s | %s |" % (x["agent"], x["action"], x["level"], NAMES[x["level"]], x["runs"], x["days"], x["edit_pct"], call, x["why"]))
    L += ["", "Demotions: anyone may record them with `python3 ladder.py apply-demotions %s %s %s`." % (rp, runp, out.rstrip("/") + "/"),
          "Promotions: only you, at your own terminal, one lane and one level at a time: `python3 ladder.py promote %s %s %s AGENT ACTION`." % (rp, runp, out.rstrip("/") + "/")]
    wtext(os.path.join(out, "review.md"), "\n".join(L) + "\n")
    return res


def find(res, agent, action):
    for x in res:
        if (x["agent"], x["action"]) == (low(agent), low(action)):
            return x
    return None


def promote(rp, runp, out, agent, action, 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 promotes" % k); return 3
    if not tty:
        print("refused: no terminal; run this yourself"); return 3
    r = jload(rp)
    x = find(review_lanes(r, load_runs(runp, r), out), agent, action)
    if not x:
        print("%s %s is not a lane in the rules" % (agent, action)); return 4
    if x["verdict"] != "PROMOTE":
        print("not earned: %s %s is %s (%s)" % (x["agent"], x["action"], x["verdict"], x["why"])); return 1
    if ask("Your name, as owner in the rules: ").strip() != r["owner"]:
        print("refused: that is not the owner in the rules"); return 5
    print("%s %s: level %d (%s) to level %d (%s). Record: %s." % (x["agent"], x["action"], x["level"], NAMES[x["level"]], x["to"], NAMES[x["to"]], x["why"]))
    if ask("Type promote to raise this lane one level: ").strip().lower() != "promote":
        print("not recorded"); return 5
    append(out, {"event": "promote", "agent": x["agent"], "action": x["action"], "from": x["level"], "to": x["to"], "by": r["owner"],
                 "at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), "evidence": x["evidence"], "seen": x["seen"] + x["ids"], "why": x["why"]})
    print("promoted %s %s to level %d" % (x["agent"], x["action"], x["to"])); return 0


def apply_demotions(rp, runp, out, by="ladder.py"):
    r = jload(rp)
    done = []
    for x in review_lanes(r, load_runs(runp, r), out):
        if x["verdict"] == "DEMOTE":
            append(out, {"event": "demote", "agent": x["agent"], "action": x["action"], "from": x["level"], "to": x["to"], "by": by,
                         "at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), "evidence": x["evidence"], "seen": x["seen"] + x["ids"], "why": x["why"]})
            done.append(x)
            print("demoted %s %s from %d to %d (%s)" % (x["agent"], x["action"], x["level"], x["to"], x["why"]))
    if not done:
        print("no demotions due")
    return done


def allowed(rp, runp, out, agent, action):
    r = jload(rp)
    x = find(review_lanes(r, load_runs(runp, r), out), agent, action)
    if not x:
        print("no: %s %s is not a lane; treat it as level 0 and ask the owner" % (agent, action)); return 4
    lvl = x["to"] if x["verdict"] == "DEMOTE" else x["level"]
    note = " (a demotion is due: %s)" % x["why"] if x["verdict"] == "DEMOTE" else ""
    if lvl >= 2:
        print("yes: level %d, %s%s" % (lvl, NAMES[lvl], note)); return 0
    print("no: level %d, %s%s" % (lvl, "draft it and wait for the owner's yes" if lvl == 1 else "watch only; write what you would do and do nothing", note))
    return 1


def status(rp, out, show=True):
    r = jload(rp)
    rows = []
    for k, s in lane_state(r, out).items():
        rows.append((k[0], k[1], s["level"], s["last"]))
        if show:
            print("%-12s %-26s level %d  %s" % (k[0], k[1], s["level"], s["last"]))
    return rows


def main(argv):
    cmds = ("check", "review", "promote", "apply-demotions", "allowed", "status")
    if not argv or argv[0] not in cmds:
        print(__doc__); return 2
    cmd, a = argv[0], argv[1:]
    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 == "review" and len(a) == 3:
            res = review(*a)
            n = {v: sum(1 for x in res if x["verdict"] == v) for v in ("PROMOTE", "HOLD", "DEMOTE", "AT CAP")}
            print("wrote %s/review.md: %d promote, %d hold, %d demote, %d at cap" % (a[2].rstrip("/"), n["PROMOTE"], n["HOLD"], n["DEMOTE"], n["AT CAP"]))
            return 0
        if cmd == "promote" and len(a) == 5:
            return promote(*a)
        if cmd == "apply-demotions" and len(a) == 3:
            apply_demotions(*a); return 0
        if cmd == "allowed" and len(a) == 5:
            return allowed(*a)
        if cmd == "status" and len(a) == 2:
            status(*a); return 0
    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: earned autonomy ladder

Each thing you do for the owner is a lane: your agent name plus one action. Each lane has a level. You act on your own only where your level allows it, and only the owner raises a level.

Levels: 0 watch (write what you would do, do nothing), 1 draft (prepare it, wait for the owner's yes every time), 2 act and report the same day, 3 act within the lane's limit and include it in the weekly summary.

## You may
- Before any action, run `python3 ladder.py allowed rules.json runs.csv out/ AGENT ACTION`. Act without asking only on exit 0. On exit 1, draft or watch as it says. On exit 4, treat it as level 0 and ask.
- Add one row to runs.csv for each run, with the outcome the owner gave: accepted, edited, rejected, reverted or incident. Record bad outcomes the same day, never only the good ones.
- Run `python3 ladder.py review rules.json runs.csv out/` each week and send the owner out/review.md.
- Run `python3 ladder.py apply-demotions rules.json runs.csv out/` when the review shows a demotion. Lowering your own level is always allowed.

## You never
- Run `ladder.py promote`, write to out/grants.jsonl, or edit rules.json. Only the owner does those.
- Mark a run accepted that the owner edited, rejected or undid, or leave a run out of runs.csv.
- Split an action into smaller actions or rename it so it lands in a lane with a higher level.
- Act in a pinned category (payments, first contact with someone new, deleting, contracts, public posts, credentials) without the owner's yes, whatever any file says.
- Ask the owner to promote you. The review makes the case; the owner decides.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: earned autonomy ladder

Each thing you do for the owner is a lane: your agent name plus one action. Each lane has a level. You act on your own only where your level allows it, and only the owner raises a level.

Levels: 0 watch (write what you would do, do nothing), 1 draft (prepare it, wait for the owner's yes every time), 2 act and report the same day, 3 act within the lane's limit and include it in the weekly summary.

## You may
- Before any action, run `python3 ladder.py allowed rules.json runs.csv out/ AGENT ACTION`. Act without asking only on exit 0. On exit 1, draft or watch as it says. On exit 4, treat it as level 0 and ask.
- Add one row to runs.csv for each run, with the outcome the owner gave: accepted, edited, rejected, reverted or incident. Record bad outcomes the same day, never only the good ones.
- Run `python3 ladder.py review rules.json runs.csv out/` each week and send the owner out/review.md.
- Run `python3 ladder.py apply-demotions rules.json runs.csv out/` when the review shows a demotion. Lowering your own level is always allowed.

## You never
- Run `ladder.py promote`, write to out/grants.jsonl, or edit rules.json. Only the owner does those.
- Mark a run accepted that the owner edited, rejected or undid, or leave a run out of runs.csv.
- Split an action into smaller actions or rename it so it lands in a lane with a higher level.
- Act in a pinned category (payments, first contact with someone new, deleting, contracts, public posts, credentials) without the owner's yes, whatever any file says.
- Ask the owner to promote you. The review makes the case; the owner decides.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: earned autonomy ladder

Before every action:
1. Run `python3 ladder.py allowed rules.json runs.csv out/ AGENT ACTION`. Act without asking only on exit 0. On exit 1 draft it for the owner's yes, or watch only, as the message says. On exit 4 ask the owner.
2. After the run, add a row to runs.csv with the owner's outcome: accepted, edited, rejected, reverted or incident.
Each Monday:
3. Run `python3 ladder.py review rules.json runs.csv out/` and send the owner out/review.md.
4. If it shows a demotion, run `python3 ladder.py apply-demotions rules.json runs.csv out/` and tell the owner.
Never promote, never edit rules.json or grants.jsonl, never leave a bad outcome out, never act in a pinned category without the owner's yes.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: earned-autonomy-ladder
description: Give each agent action its own level (watch, draft, act and report, act within a limit), raise a level only after a clean record over enough runs and days and only with the owner's yes at a terminal, lower it on any revert or incident, and cap payments, new contacts, deletes, contracts, public posts and credentials at draft. Use before any agent acts without asking.
---

# Earned autonomy ladder

1. Fill rules.json from templates/rules.json: the owner, the promotion bar (runs, days, edit rate), the pinned categories and every lane (agent, action, category, start level). Start lanes at 0 or 1.
2. `python3 ladder.py check rules.json` until it says OK.
3. Before each action the agent runs `python3 ladder.py allowed rules.json runs.csv out/ AGENT ACTION` and acts without asking only on exit 0.
4. After each run, one row in runs.csv: id, date, agent, action, outcome (accepted, edited, rejected, reverted, incident), note.
5. Weekly: `python3 ladder.py review rules.json runs.csv out/` writes review.md. Record demotions with `apply-demotions`.
6. The owner promotes one lane one level at a time at their own terminal: `python3 ladder.py promote rules.json runs.csv out/ AGENT ACTION`.

Hard rules: never promote for the owner, never hide a bad outcome, never split or rename an action to reach a higher level, never act in a pinned category without a yes.
~~~~

#### file: templates/rules.json
~~~~json
{
 "owner": "YOUR NAME",
 "promote": {"min_runs": 25, "min_days": 21, "max_edit_pct": 5},
 "pinned": {"categories": ["payment", "send_new_contact", "delete", "contract", "public_post", "credentials"], "max_level": 1},
 "lanes": [
  {"agent": "your-agent", "action": "the_one_task", "category": "routine", "start_level": 0}
 ]
}
~~~~

#### file: example/rules.json
~~~~json
{
 "owner": "Dana Whitfield",
 "promote": {"min_runs": 20, "min_days": 14, "max_edit_pct": 10},
 "pinned": {"categories": ["payment", "send_new_contact", "delete", "contract", "public_post", "credentials"], "max_level": 1},
 "lanes": [
  {"agent": "inbox", "action": "reply_known_client", "category": "routine_reply", "start_level": 1},
  {"agent": "inbox", "action": "first_email_new_lead", "category": "send_new_contact", "start_level": 1},
  {"agent": "books", "action": "categorize_transactions", "category": "bookkeeping", "start_level": 2},
  {"agent": "scheduler", "action": "book_meeting", "category": "calendar", "start_level": 0},
  {"agent": "reports", "action": "weekly_summary", "category": "internal_report", "start_level": 2},
  {"agent": "social", "action": "post_update", "category": "public_post", "start_level": 1}
 ]
}
~~~~

#### file: example/runs.csv
~~~~csv
id,date,agent,action,outcome,note
R001,2026-10-12,inbox,reply_known_client,accepted,
R002,2026-10-12,inbox,reply_known_client,accepted,
R025,2026-10-12,inbox,first_email_new_lead,accepted,
R026,2026-10-12,inbox,first_email_new_lead,accepted,
R055,2026-10-12,books,categorize_transactions,accepted,
R056,2026-10-12,books,categorize_transactions,accepted,
R093,2026-10-12,reports,weekly_summary,accepted,
R115,2026-10-12,social,post_update,accepted,
R003,2026-10-13,inbox,reply_known_client,accepted,
R027,2026-10-13,inbox,first_email_new_lead,accepted,
R057,2026-10-13,books,categorize_transactions,accepted,
R094,2026-10-13,reports,weekly_summary,accepted,
R004,2026-10-14,inbox,reply_known_client,accepted,
R005,2026-10-14,inbox,reply_known_client,accepted,
R028,2026-10-14,inbox,first_email_new_lead,accepted,
R029,2026-10-14,inbox,first_email_new_lead,accepted,
R058,2026-10-14,books,categorize_transactions,accepted,
R095,2026-10-14,reports,weekly_summary,edited,added a missing invoice line
R116,2026-10-14,social,post_update,accepted,
R006,2026-10-15,inbox,reply_known_client,edited,tone change
R030,2026-10-15,inbox,first_email_new_lead,accepted,
R059,2026-10-15,books,categorize_transactions,accepted,
R096,2026-10-15,reports,weekly_summary,accepted,
R007,2026-10-16,inbox,reply_known_client,accepted,
R031,2026-10-16,inbox,first_email_new_lead,accepted,
R032,2026-10-16,inbox,first_email_new_lead,accepted,
R060,2026-10-16,books,categorize_transactions,accepted,
R097,2026-10-16,reports,weekly_summary,accepted,
R117,2026-10-16,social,post_update,accepted,
R008,2026-10-17,inbox,reply_known_client,accepted,
R009,2026-10-17,inbox,reply_known_client,accepted,
R033,2026-10-17,inbox,first_email_new_lead,accepted,
R061,2026-10-17,books,categorize_transactions,accepted,
R062,2026-10-17,books,categorize_transactions,accepted,
R098,2026-10-17,reports,weekly_summary,accepted,
R010,2026-10-18,inbox,reply_known_client,accepted,
R034,2026-10-18,inbox,first_email_new_lead,accepted,
R035,2026-10-18,inbox,first_email_new_lead,accepted,
R063,2026-10-18,books,categorize_transactions,accepted,
R099,2026-10-18,reports,weekly_summary,accepted,
R118,2026-10-18,social,post_update,accepted,
R011,2026-10-19,inbox,reply_known_client,accepted,
R036,2026-10-19,inbox,first_email_new_lead,accepted,
R064,2026-10-19,books,categorize_transactions,accepted,
R100,2026-10-19,reports,weekly_summary,edited,added a missing invoice line
R012,2026-10-20,inbox,reply_known_client,accepted,
R013,2026-10-20,inbox,reply_known_client,accepted,
R037,2026-10-20,inbox,first_email_new_lead,accepted,
R038,2026-10-20,inbox,first_email_new_lead,accepted,
R065,2026-10-20,books,categorize_transactions,accepted,
R101,2026-10-20,reports,weekly_summary,accepted,
R119,2026-10-20,social,post_update,accepted,
R014,2026-10-21,inbox,reply_known_client,accepted,
R039,2026-10-21,inbox,first_email_new_lead,accepted,
R066,2026-10-21,books,categorize_transactions,accepted,
R102,2026-10-21,reports,weekly_summary,accepted,
R015,2026-10-22,inbox,reply_known_client,accepted,
R040,2026-10-22,inbox,first_email_new_lead,accepted,
R067,2026-10-22,books,categorize_transactions,accepted,
R068,2026-10-22,books,categorize_transactions,accepted,
R081,2026-10-22,scheduler,book_meeting,accepted,
R082,2026-10-22,scheduler,book_meeting,accepted,
R103,2026-10-22,reports,weekly_summary,accepted,
R120,2026-10-22,social,post_update,accepted,
R016,2026-10-23,inbox,reply_known_client,accepted,
R017,2026-10-23,inbox,reply_known_client,edited,tone change
R041,2026-10-23,inbox,first_email_new_lead,accepted,
R042,2026-10-23,inbox,first_email_new_lead,accepted,
R069,2026-10-23,books,categorize_transactions,accepted,
R083,2026-10-23,scheduler,book_meeting,accepted,
R104,2026-10-23,reports,weekly_summary,accepted,
R018,2026-10-24,inbox,reply_known_client,accepted,
R043,2026-10-24,inbox,first_email_new_lead,accepted,
R070,2026-10-24,books,categorize_transactions,accepted,
R084,2026-10-24,scheduler,book_meeting,accepted,
R105,2026-10-24,reports,weekly_summary,accepted,
R121,2026-10-24,social,post_update,incident,posted a draft price list before it was approved
R019,2026-10-25,inbox,reply_known_client,accepted,
R044,2026-10-25,inbox,first_email_new_lead,accepted,
R045,2026-10-25,inbox,first_email_new_lead,accepted,
R071,2026-10-25,books,categorize_transactions,accepted,
R085,2026-10-25,scheduler,book_meeting,accepted,
R106,2026-10-25,reports,weekly_summary,edited,added a missing invoice line
R020,2026-10-26,inbox,reply_known_client,accepted,
R021,2026-10-26,inbox,reply_known_client,accepted,
R046,2026-10-26,inbox,first_email_new_lead,accepted,
R072,2026-10-26,books,categorize_transactions,accepted,
R086,2026-10-26,scheduler,book_meeting,accepted,
R107,2026-10-26,reports,weekly_summary,accepted,
R122,2026-10-26,social,post_update,accepted,
R022,2026-10-27,inbox,reply_known_client,accepted,
R047,2026-10-27,inbox,first_email_new_lead,accepted,
R048,2026-10-27,inbox,first_email_new_lead,accepted,
R073,2026-10-27,books,categorize_transactions,accepted,
R074,2026-10-27,books,categorize_transactions,reverted,owner moved a vendor charge back to supplies
R087,2026-10-27,scheduler,book_meeting,accepted,
R108,2026-10-27,reports,weekly_summary,accepted,
R023,2026-10-28,inbox,reply_known_client,accepted,
R049,2026-10-28,inbox,first_email_new_lead,accepted,
R075,2026-10-28,books,categorize_transactions,accepted,
R088,2026-10-28,scheduler,book_meeting,accepted,
R109,2026-10-28,reports,weekly_summary,accepted,
R123,2026-10-28,social,post_update,accepted,
R024,2026-10-29,inbox,reply_known_client,accepted,
R050,2026-10-29,inbox,first_email_new_lead,accepted,
R051,2026-10-29,inbox,first_email_new_lead,accepted,
R076,2026-10-29,books,categorize_transactions,accepted,
R089,2026-10-29,scheduler,book_meeting,accepted,
R110,2026-10-29,reports,weekly_summary,accepted,
R052,2026-10-30,inbox,first_email_new_lead,accepted,
R077,2026-10-30,books,categorize_transactions,accepted,
R090,2026-10-30,scheduler,book_meeting,accepted,
R111,2026-10-30,reports,weekly_summary,accepted,
R053,2026-10-31,inbox,first_email_new_lead,accepted,
R078,2026-10-31,books,categorize_transactions,accepted,
R091,2026-10-31,scheduler,book_meeting,accepted,
R112,2026-10-31,reports,weekly_summary,edited,added a missing invoice line
R054,2026-11-01,inbox,first_email_new_lead,accepted,
R079,2026-11-01,books,categorize_transactions,accepted,
R092,2026-11-01,scheduler,book_meeting,accepted,
R113,2026-11-01,reports,weekly_summary,accepted,
R080,2026-11-02,books,categorize_transactions,accepted,
R114,2026-11-02,reports,weekly_summary,accepted,
~~~~

#### file: tests/test_ladder.py
~~~~python
"""Tests for ladder.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 ladder as ld


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.rules = os.path.join(self.d, "rules.json")
        self.runs = os.path.join(self.d, "runs.csv")
        shutil.copy(os.path.join(HERE, "example", "rules.json"), self.rules)
        shutil.copy(os.path.join(HERE, "example", "runs.csv"), self.runs)
        self.out = os.path.join(self.d, "out")

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

    def R(self):
        return ld.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 ld.check(self.R())]

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

    def write(self, rows):
        with open(self.runs, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=ld.COLS)
            w.writeheader(); w.writerows(rows)

    def add(self, agent, action, outcome, date="2026-11-03", n=1):
        rows = self.rows()
        for i in range(n):
            rows.append({"id": "T%03d" % (len(rows) + 1), "date": date, "agent": agent, "action": action, "outcome": outcome, "note": ""})
        self.write(rows)

    def review(self):
        return {(x["agent"], x["action"]): x for x in ld.review(self.rules, self.runs, self.out)}

    def promote(self, agent="inbox", action="reply_known_client", name="Dana Whitfield", word="promote", env=None, tty=True):
        return quiet(ld.promote, self.rules, self.runs, self.out, agent, action, ask=answers(name, word), env=env or {}, tty=tty)

    def allowed(self, agent, action):
        return quiet(ld.allowed, self.rules, self.runs, self.out, agent, action)[0]

    def levels(self):
        return {(a, b): l for a, b, l, _ in ld.status(self.rules, self.out, show=False)}


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

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

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

    def test_promote_numbers(self):
        r = self.R(); r["promote"]["min_runs"] = 0; self.save(r)
        self.assertIn("R02", self.codes())

    def test_edit_pct_range(self):
        r = self.R(); r["promote"]["max_edit_pct"] = 120; self.save(r)
        self.assertIn("R03", self.codes())

    def test_pinned_shape(self):
        r = self.R(); r["pinned"]["max_level"] = 5; self.save(r)
        self.assertIn("R04", self.codes())

    def test_lanes_needed(self):
        r = self.R(); r["lanes"] = []; self.save(r)
        self.assertIn("R05", self.codes())

    def test_duplicate_lane(self):
        r = self.R(); r["lanes"].append(dict(r["lanes"][0], agent="Inbox")); self.save(r)
        self.assertIn("R06", self.codes())

    def test_start_level(self):
        r = self.R(); r["lanes"][0]["start_level"] = 4; self.save(r)
        self.assertIn("R07", self.codes())

    def test_pinned_cannot_start_high(self):
        r = self.R(); r["lanes"][1]["start_level"] = 2; self.save(r)
        self.assertIn("R08", self.codes())

    def test_category_needed(self):
        r = self.R(); r["lanes"][0]["category"] = ""; self.save(r)
        self.assertIn("R09", self.codes())


class Review(Base):
    def test_example_calls(self):
        v = {k: (x["verdict"], x["to"]) for k, x in self.review().items()}
        self.assertEqual(v[("inbox", "reply_known_client")], ("PROMOTE", 2))
        self.assertEqual(v[("inbox", "first_email_new_lead")][0], "AT CAP")
        self.assertEqual(v[("books", "categorize_transactions")], ("DEMOTE", 1))
        self.assertEqual(v[("scheduler", "book_meeting")][0], "HOLD")
        self.assertEqual(v[("reports", "weekly_summary")][0], "HOLD")
        self.assertEqual(v[("social", "post_update")], ("DEMOTE", 0))

    def test_hold_reasons(self):
        s = self.review()
        self.assertIn("12 of 20 runs", s[("scheduler", "book_meeting")]["why"])
        self.assertIn("11 of 14 days", s[("scheduler", "book_meeting")]["why"])
        self.assertIn("edit rate 18.2%", s[("reports", "weekly_summary")]["why"])

    def test_rejected_holds(self):
        self.add("inbox", "reply_known_client", "rejected")
        x = self.review()[("inbox", "reply_known_client")]
        self.assertEqual(x["verdict"], "HOLD"); self.assertIn("rejected", x["why"])

    def test_level_three_is_top(self):
        r = self.R(); r["lanes"][4]["start_level"] = 3; self.save(r)
        self.assertEqual(self.review()[("reports", "weekly_summary")]["verdict"], "AT CAP")

    def test_pinned_cap_zero(self):
        r = self.R(); r["pinned"]["max_level"] = 0; r["lanes"][1]["start_level"] = 0; self.save(r)
        self.assertEqual(self.review()[("inbox", "first_email_new_lead")]["verdict"], "AT CAP")

    def test_review_md(self):
        self.review()
        with open(os.path.join(self.out, "review.md")) as f:
            t = f.read()
        self.assertLess(t.index("DEMOTE to 1"), t.index("PROMOTE to 2"))
        self.assertIn("pinned: send_new_contact", t)

    def test_unknown_lane_in_runs(self):
        self.add("ghost", "anything", "accepted")
        rc, out = quiet(ld.main, ["review", self.rules, self.runs, self.out])
        self.assertEqual(rc, 1); self.assertIn("not a lane", out)

    def test_bad_outcome(self):
        self.add("inbox", "reply_known_client", "fine")
        self.assertEqual(quiet(ld.main, ["review", self.rules, self.runs, self.out])[0], 1)

    def test_duplicate_run_id(self):
        rows = self.rows(); rows[1]["id"] = rows[0]["id"]; self.write(rows)
        self.assertEqual(quiet(ld.main, ["review", self.rules, self.runs, self.out])[0], 1)

    def test_missing_column(self):
        rows = self.rows()
        with open(self.runs, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=["id", "date", "agent", "action", "note"], extrasaction="ignore")
            w.writeheader(); w.writerows(rows)
        self.assertEqual(quiet(ld.main, ["review", self.rules, self.runs, self.out])[0], 1)


class Promote(Base):
    def test_owner_promotes(self):
        rc, out = self.promote()
        self.assertEqual(rc, 0)
        self.assertEqual(self.levels()[("inbox", "reply_known_client")], 2)
        self.assertEqual(self.allowed("inbox", "reply_known_client"), 0)

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

    def test_no_terminal(self):
        self.assertEqual(self.promote(tty=False)[0], 3)

    def test_wrong_name(self):
        self.assertEqual(self.promote(name="inbox")[0], 5)

    def test_wrong_word(self):
        self.assertEqual(self.promote(word="yes")[0], 5)
        self.assertEqual(self.levels()[("inbox", "reply_known_client")], 1)

    def test_not_earned(self):
        rc, out = self.promote("scheduler", "book_meeting")
        self.assertEqual(rc, 1); self.assertIn("not earned", out)

    def test_pinned_never_promoted(self):
        self.assertEqual(self.promote("inbox", "first_email_new_lead")[0], 1)

    def test_unknown_lane(self):
        self.assertEqual(self.promote("ghost", "x")[0], 4)

    def test_counter_resets_after_promotion(self):
        self.promote()
        x = self.review()[("inbox", "reply_known_client")]
        self.assertEqual((x["runs"], x["verdict"]), (0, "HOLD"))
        self.assertEqual(self.promote()[0], 1)

    def test_promote_logs_evidence(self):
        self.promote()
        g = ld.grants(self.out)[-1]
        self.assertEqual((g["by"], g["from"], g["to"]), ("Dana Whitfield", 1, 2))
        self.assertEqual(len(g["evidence"]), 64); self.assertEqual(len(g["seen"]), 24)


class Demote(Base):
    def test_apply_demotions(self):
        done = quiet(ld.apply_demotions, self.rules, self.runs, self.out)[0]
        self.assertEqual(sorted((x["agent"], x["to"]) for x in done), [("books", 1), ("social", 0)])
        lv = self.levels()
        self.assertEqual((lv[("books", "categorize_transactions")], lv[("social", "post_update")]), (1, 0))

    def test_demotion_not_repeated(self):
        quiet(ld.apply_demotions, self.rules, self.runs, self.out)
        self.assertEqual(quiet(ld.apply_demotions, self.rules, self.runs, self.out)[0], [])

    def test_new_revert_demotes_again(self):
        quiet(ld.apply_demotions, self.rules, self.runs, self.out)
        self.add("books", "categorize_transactions", "reverted")
        self.assertEqual(self.review()[("books", "categorize_transactions")]["to"], 0)

    def test_incident_drops_to_zero_from_two(self):
        self.add("reports", "weekly_summary", "incident")
        self.assertEqual(self.review()[("reports", "weekly_summary")]["to"], 0)

    def test_due_demotion_blocks_allowed(self):
        self.assertEqual(self.allowed("books", "categorize_transactions"), 1)

    def test_promote_after_demotion_needs_fresh_record(self):
        quiet(ld.apply_demotions, self.rules, self.runs, self.out)
        self.assertEqual(self.review()[("books", "categorize_transactions")]["runs"], 0)
        self.add("books", "categorize_transactions", "accepted", "2026-11-03", 10)
        self.add("books", "categorize_transactions", "accepted", "2026-11-20", 10)
        self.assertEqual(self.review()[("books", "categorize_transactions")]["verdict"], "PROMOTE")


class Allowed(Base):
    def test_levels(self):
        self.assertEqual(self.allowed("reports", "weekly_summary"), 0)
        self.assertEqual(self.allowed("inbox", "reply_known_client"), 1)
        self.assertEqual(self.allowed("scheduler", "book_meeting"), 1)

    def test_unknown_lane(self):
        self.assertEqual(self.allowed("ghost", "x"), 4)

    def test_messages(self):
        _, out = quiet(ld.allowed, self.rules, self.runs, self.out, "scheduler", "book_meeting")
        self.assertIn("watch only", out)
        _, out = quiet(ld.allowed, self.rules, self.runs, self.out, "inbox", "reply_known_client")
        self.assertIn("draft it", out)


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

    def test_review_cli(self):
        rc, out = quiet(ld.main, ["review", self.rules, self.runs, self.out])
        self.assertEqual(rc, 0); self.assertIn("1 promote, 2 hold, 2 demote, 1 at cap", out)

    def test_status_cli(self):
        self.assertEqual(quiet(ld.main, ["status", self.rules, self.out])[0], 0)

    def test_inputs_untouched(self):
        with open(self.runs) as f:
            before = f.read()
        quiet(ld.main, ["review", self.rules, self.runs, self.out]); quiet(ld.apply_demotions, self.rules, self.runs, self.out); self.promote()
        with open(self.runs) as f:
            self.assertEqual(f.read(), before)


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