# NAICS Size Map

**Check that you are small under every NAICS code on your record, using the averaging the size rules actually use. Codes near the line get flagged, every set-aside you list gets tied to proof, and nothing changes in SAM.gov until you confirm.**

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 small firms list several NAICS codes and check their size once, the day they register. Then receipts grow, an affiliate appears, or SBA updates a standard, and one code quietly stops fitting. Size is measured per code, over a fixed window, with affiliates added in. A single number from last year's return does not answer it.

The same drift happens with set-asides. A status gets typed into a profile or a capability statement and stays there after the certificate lapses, or before it was ever granted.

## What you get out of it

- map.md: each code on your record with the standard, your average, the share of the standard you use, and SMALL, NEAR THE LINE, NOT SMALL or NO STANDARD.
- A set-aside table with the proof behind each status, its renewal date, and a plain warning where a status cannot be claimed yet.
- bidwatch-naics.json: the codes you are small under, ready for a Bid Watch Sheet profile.
- An owner confirmation tied to the exact figures, so the map shows CHANGED the moment anyone edits them.

## Who it is for

Small firms and solo contractors who sell, or plan to sell, to federal buyers, and the people who keep their records current.

Not legal advice. The tool does the arithmetic; you confirm the figures, the standards and your status with SBA and your own records.

## The inputs

| File | What goes in it |
| --- | --- |
| `firm.json` `owner` | the one person who confirms the map |
| `firm.json` `fiscal_years` | completed fiscal years, oldest first: receipts as filed and weeks (52 for a full year, fewer for a short year) |
| `firm.json` `pay_periods` | period end date and head count for each pay period. Part-time and temporary staff count the same as full-time |
| `firm.json` `affiliates`, `affiliates_checked` | each affiliate's average receipts and employees, and `true` once you have checked, even if the list is empty |
| `firm.json` `naics_in_sam` | the codes on your SAM.gov record |
| `firm.json` `statuses` | each program you list (Small Business, SDVOSB, VOSB, WOSB, HUBZone, 8(a)) with state `certified`, `applied`, `self-represented` or `none`, the proof and the renewal date |
| `codes.csv` | naics, title, basis (`receipts` or `employees`), standard, and the date you copied it from the SBA table |

Receipts standards are written in millions, the way the table writes them: 47 means $47 million.

## How the map is worked out

| Measure | How |
| --- | --- |
| Receipts | the last 5 completed fiscal years divided by 5. Under 5 years, or with a short year in the window: total receipts over total weeks, times 52 |
| Employees | the average head count over pay periods in the 24 completed calendar months before today |
| Affiliates | their averages are added to yours |
| Size | SMALL under the standard, NEAR THE LINE at 90% or more (you can change `near_line_pct`), NOT SMALL above it |
| Flags | a standard copied over a year ago, a code in CODES.csv that is not on your SAM record, and a SAM code with no standard entered |
| Set-asides | SDVOSB or VOSB without SBA certification is marked do not claim; applied is marked pending; a certified status with no renewal date, or a renewal inside 60 days, is flagged |

Being small under a code is one gate. A set-aside also needs the status itself and size under the code on that specific notice.

## The owner's call

`sizemap.py confirm` records that the owner checked the figures against their tax returns and payroll. It is refused inside an agent session, without a terminal, from anyone but the owner in firm.json, and when the inputs changed after the last map. The owner has to type confirm. `status` reads CHANGED as soon as firm.json or codes.csv differ from what was confirmed.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py naics-size-map-check-you-are-small-under-every-code-you-list.md sizemap/` (the file you downloaded) to unpack the rest.
2. In `sizemap/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up firm with made-up standards: `python3 sizemap.py map example/firm.json example/codes.csv out/ --today 2026-11-04`. You get two SMALL, one NEAR THE LINE, one NOT SMALL, one NO STANDARD, an SDVOSB status that cannot be claimed and a HUBZone renewal inside 60 days.
4. Copy templates/firm.json and templates/codes.csv. They fail `check` on purpose until you fill them.
5. Copy each standard from the current SBA table of size standards, with today's date in table_date.
6. Map, read map.md against your returns and payroll, then confirm at your own terminal.
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.

## Run it once before you rely on it

Work one code out by hand from your last five returns and compare it with the map. If they differ, fix the input, not the tool. Stop and fix the setup if an agent confirmed the map, touched SAM.gov, or left out an affiliate or a year to make a code read SMALL. That is the kill rule.

## Limits

- It does arithmetic on the figures you give it. Which receipts count, who is an affiliate and which standard applies are questions for the rules and, when it is close, for SBA or counsel.
- It does not hold the size table. Standards change, so copy them fresh.
- Size for loans and some other SBA programs is measured differently. This map is for contracting.
- 8(a) and other certifications have their own eligibility rules. The map records the status and the proof; it does not judge eligibility.

## Sources (checked 2026-11-04; verify the current rule)

- 13 CFR 121.104, how annual receipts are calculated, ecfr.gov. Checked 2026-11-04.
- 13 CFR 121.106, how employees are counted, ecfr.gov. Checked 2026-11-04.
- 13 CFR 121.103, affiliation, and 13 CFR 121.201, the size standards table, ecfr.gov and sba.gov. Checked 2026-11-04.
- 13 CFR 128.200(c) and 128.401, veteran certification and offer requirements, ecfr.gov. Checked 2026-11-04.
- FAR 19.1403 and 19.1405, SDVOSB status and set-aside procedures, ecfr.gov. Checked 2026-11-04.
- 13 CFR 124.103, the 8(a) social disadvantage rule revised effective 2026-09-10, federalregister.gov. Checked 2026-11-04.

## Where this came from

Small firms new to federal work keep asking the same question: am I still small under each code I list? Readiness programs sold as done-for-you sprints answer it by hand. This sheet is our own take on that one step, written from the federal rules above, with nothing copied from anyone's program.

## Want this run for you

A size map holds up when it is refreshed on a schedule and confirmed by the owner. If you would rather have agents keep your records current with those checks 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: sizemap.py
~~~~python
#!/usr/bin/env python3
"""sizemap.py: check you are small under every NAICS code you list, and keep the set-asides you claim tied to proof.

  python3 sizemap.py check FIRM.json CODES.csv                 check the inputs
  python3 sizemap.py map FIRM.json CODES.csv OUT/ [--today YYYY-MM-DD]
                                                               write OUT/map.md, OUT/map.json and OUT/bidwatch-naics.json
  python3 sizemap.py confirm FIRM.json CODES.csv OUT/          owner only, at their own terminal
  python3 sizemap.py status FIRM.json CODES.csv OUT/           confirmed, never confirmed, or CHANGED since the owner confirmed

CODES.csv columns: naics, title, basis (receipts or employees), standard, table_date (YYYY-MM-DD).
Copy each standard from the current SBA table of size standards (13 CFR 121.201). Receipts standards are
in millions of dollars (34 means $34 million); employee standards are a head count.
Size math follows 13 CFR 121.104 (receipts) and 121.106 (employees), with affiliates added in.
This tool reads files only. It does not go online and it never changes SAM.gov.
Exit codes: 0 ok, 1 errors, 2 usage, 3 refused (agent session or no terminal), 4 no map yet, 5 owner did not confirm.
Python 3.8 or newer, standard library only. Not legal advice: verify the current rule.
"""
import csv, hashlib, json, os, 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"]
COLS = ["naics", "title", "basis", "standard", "table_date"]
CERT_ONLY = {"SDVOSB": "13 CFR 128.200(c) and FAR 19.1403: SBA certification (VetCert) is needed for SDVOSB set-asides and sole source",
             "VOSB": "13 CFR 128.200(c): SBA certification (VetCert) is needed for VOSB set-asides at VA"}
STATES = ("certified", "applied", "self-represented", "none")


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


def num(x):
    return isinstance(x, (int, float)) and not isinstance(x, bool) and x >= 0


def read_codes(path):
    with open(path, newline="", encoding="utf-8-sig") as f:
        r = csv.DictReader(f)
        miss = [c for c in COLS if c not in (r.fieldnames or [])]
        if miss:
            raise ValueError("CODES.csv is missing column(s): " + ", ".join(miss))
        rows = [{c: (row.get(c) or "").strip() for c in COLS} for row in r]
    errs, seen = [], set()
    for i, row in enumerate(rows, 2):
        n = row["naics"]
        if not (n.isdigit() and len(n) == 6):
            errs.append("line %d naics is not 6 digits" % i)
        elif n in seen:
            errs.append("naics %s is listed twice" % n)
        seen.add(n)
        if row["basis"] not in ("receipts", "employees"):
            errs.append("naics %s basis must be receipts or employees" % n)
        try:
            row["standard"] = float(row["standard"].replace("$", "").replace(",", ""))
            if row["standard"] <= 0:
                raise ValueError
        except ValueError:
            errs.append("naics %s standard must be a positive number" % n)
        try:
            day(row["table_date"])
        except ValueError:
            errs.append("naics %s table_date is not YYYY-MM-DD" % n)
    if not rows:
        errs.append("CODES.csv has no rows")
    if errs:
        raise ValueError("; ".join(errs))
    return rows


def check(f, codes_path):
    e = []
    if not str(f.get("owner") or "").strip():
        e.append(("E01", "owner is missing (the one person who confirms the map)"))
    yrs = f.get("fiscal_years")
    if not isinstance(yrs, list) or not yrs or any(not isinstance(y, dict) or not num(y.get("receipts")) or not num(y.get("weeks")) or not y.get("weeks") for y in yrs):
        e.append(("E02", "fiscal_years must list completed years, each with receipts and weeks (52 for a full year)"))
    if f.get("affiliates_checked") is not True:
        e.append(("E03", "affiliates_checked must be true: size counts affiliates too (13 CFR 121.103), so list them or confirm there are none"))
    for a in f.get("affiliates") or []:
        if not isinstance(a, dict) or not str(a.get("name") or "").strip() or not num(a.get("avg_receipts", 0)) or not num(a.get("avg_employees", 0)):
            e.append(("E03", "each affiliate needs a name, avg_receipts and avg_employees")); break
    sam = f.get("naics_in_sam")
    if not isinstance(sam, list) or not sam or any(not str(x).isdigit() or len(str(x)) != 6 for x in sam):
        e.append(("E04", "naics_in_sam must list the 6-digit codes on your SAM.gov record"))
    try:
        rows = read_codes(codes_path)
    except (ValueError, OSError) as x:
        e.append(("E05", str(x))); rows = []
    if any(r["basis"] == "employees" for r in rows) and not f.get("pay_periods"):
        e.append(("E06", "a code uses an employee standard, so pay_periods (period_end, count) are needed"))
    for p in f.get("pay_periods") or []:
        try:
            day(p["period_end"])
            if not num(p["count"]):
                raise ValueError
        except (KeyError, TypeError, ValueError):
            e.append(("E06", "each pay period needs period_end YYYY-MM-DD and a count")); break
    for s in f.get("statuses") or []:
        if not isinstance(s, dict) or not str(s.get("program") or "").strip() or s.get("state") not in STATES:
            e.append(("E07", "each status needs a program and a state: " + ", ".join(STATES))); break
    return e


def avg_receipts(f):
    yrs = f["fiscal_years"][-5:]
    weeks = sum(y["weeks"] for y in yrs)
    total = sum(y["receipts"] for y in yrs)
    if len(yrs) == 5 and all(y["weeks"] >= 52 for y in yrs):
        own, how = total / 5.0, "last 5 completed fiscal years / 5"
    else:
        own, how = total / weeks * 52, "%d fiscal year%s, %d weeks, annualized" % (len(yrs), "" if len(yrs) == 1 else "s", weeks)
    aff = sum(a.get("avg_receipts", 0) for a in f.get("affiliates") or [])
    return own + aff, how + (", plus affiliates" if aff else "")


def avg_employees(f, today):
    first = date(today.year, today.month, 1)
    start = date(first.year - 2, first.month, 1)
    pp = [p for p in f.get("pay_periods") or [] if start <= day(p["period_end"]) < first]
    if not pp:
        return None, "no pay periods in the 24 completed months before %s" % first.isoformat()
    aff = sum(a.get("avg_employees", 0) for a in f.get("affiliates") or [])
    own = sum(p["count"] for p in pp) / float(len(pp))
    return own + aff, "%d pay periods %s to %s%s" % (len(pp), start.isoformat(), (first - timedelta(days=1)).isoformat(), ", plus affiliates" if aff else "")


def fp(*paths):
    h = hashlib.sha256()
    for p in paths:
        with open(p, "rb") as f:
            h.update(f.read())
    return h.hexdigest()


def money(x):
    return "$%s" % format(int(round(x)), ",")


def build(fpath, cpath, today):
    f = jload(fpath)
    rows = read_codes(cpath)
    near = f.get("near_line_pct", 90)
    rec, rhow = avg_receipts(f)
    emp, ehow = avg_employees(f, today)
    sam = [str(x) for x in f["naics_in_sam"]]
    out = []
    for r in rows:
        flags = []
        if r["basis"] == "receipts":
            std = r["standard"] * 1e6
            mine, shown, sstd = rec, money(rec), "%s (%sM)" % (money(std), ("%g" % r["standard"]))
        else:
            std = r["standard"]
            mine, shown, sstd = emp, ("%.1f" % emp) if emp is not None else "no data", "%g employees" % std
        if mine is None:
            verdict, pct = "UNKNOWN", None
            flags.append(ehow)
        else:
            pct = 100.0 * mine / std
            verdict = "NOT SMALL" if mine > std else ("NEAR THE LINE" if pct >= near else "SMALL")
        if (today - day(r["table_date"])).days > 365:
            flags.append("standard copied over a year ago, check the current table")
        if r["naics"] not in sam:
            flags.append("not on your SAM record")
        out.append(dict(naics=r["naics"], title=r["title"], basis=r["basis"], standard=sstd, yours=shown,
                        pct=None if pct is None else round(pct, 1), verdict=verdict, flags=flags))
    for n in sam:
        if n not in [r["naics"] for r in rows]:
            out.append(dict(naics=n, title="", basis="", standard="not entered", yours="", pct=None,
                            verdict="NO STANDARD", flags=["on your SAM record but missing from CODES.csv"]))
    st = []
    for s in f.get("statuses") or []:
        prog, state = str(s["program"]).strip(), s["state"]
        note = ""
        if prog.upper() in CERT_ONLY and state != "certified":
            note = "do not claim on a set-aside: " + CERT_ONLY[prog.upper()]
        elif state == "certified" and not s.get("renewal_due"):
            note = "add renewal_due so the date cannot slip"
        elif state == "certified" and day(s["renewal_due"]) <= today + timedelta(days=60):
            note = "renewal due %s, inside 60 days" % s["renewal_due"]
        elif state == "applied":
            note = "pending: not claimable until the certifying office approves"
        st.append(dict(program=prog, state=state, proof=s.get("proof", ""), renewal_due=s.get("renewal_due", ""), note=note))
    return f, dict(today=today.isoformat(), owner=f["owner"], receipts=round(rec, 2), receipts_how=rhow,
                   employees=None if emp is None else round(emp, 2), employees_how=ehow, codes=out, statuses=st)


def write_map(fpath, cpath, out, today):
    f, m = build(fpath, cpath, today)
    os.makedirs(out, exist_ok=True)
    m["inputs"] = fp(fpath, cpath)
    wtext(os.path.join(out, "map.json"), json.dumps(m, indent=1) + "\n")
    small = [c["naics"] for c in m["codes"] if c["verdict"] in ("SMALL", "NEAR THE LINE")]
    wtext(os.path.join(out, "bidwatch-naics.json"), json.dumps({"naics": small}, indent=1) + "\n")
    L = ["# NAICS size map, %s" % m["today"], "",
         "Owner: %s. Average receipts %s (%s). Average employees %s (%s)." % (
             m["owner"], money(m["receipts"]), m["receipts_how"], "n/a" if m["employees"] is None else m["employees"], m["employees_how"]),
         "Nothing here has changed SAM.gov. The owner confirms this map before anyone updates a representation.", "",
         "## Size by code", "", "| NAICS | Title | Standard | Yours | Used | Size | Notes |", "| --- | --- | --- | --- | --- | --- | --- |"]
    for c in m["codes"]:
        L.append("| %s | %s | %s | %s | %s | %s | %s |" % (c["naics"], c["title"] or "-", c["standard"], c["yours"] or "-",
                 "-" if c["pct"] is None else "%s%%" % c["pct"], c["verdict"], "; ".join(c["flags"]) or "-"))
    L += ["", "## Set-asides you list", ""]
    if m["statuses"]:
        L += ["| Program | State | Proof | Renewal | Note |", "| --- | --- | --- | --- | --- |"]
        for s in m["statuses"]:
            L.append("| %s | %s | %s | %s | %s |" % (s["program"], s["state"], s["proof"] or "-", s["renewal_due"] or "-", s["note"] or "-"))
    else:
        L.append("None listed.")
    L += ["", "## Next", "",
          "- Small under a code is not enough for a set-aside: you must also hold the status, and be small under the code on that notice.",
          "- bidwatch-naics.json lists the codes where you are small, ready for a Bid Watch Sheet profile.",
          "- Owner, at your own terminal: `python3 sizemap.py confirm %s %s %s`" % (fpath, cpath, out.rstrip("/") + "/"),
          "- Re-run when a fiscal year closes, a pay period is added, an affiliate changes or SBA updates the table."]
    wtext(os.path.join(out, "map.md"), "\n".join(L) + "\n")
    return m


def confirm(fpath, cpath, out, 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 confirms" % k); return 3
    if not tty:
        print("refused: no terminal; run this yourself"); return 3
    mp = os.path.join(out, "map.json")
    if not os.path.exists(mp):
        print("no map yet; run map first"); return 4
    m = jload(mp)
    if m["inputs"] != fp(fpath, cpath):
        print("the inputs changed since the map was written; run map again"); return 4
    owner = jload(fpath)["owner"]
    if ask("Your name, as owner in FIRM.json: ").strip() != owner:
        print("refused: that is not the owner in FIRM.json"); return 5
    bad = [c["naics"] for c in m["codes"] if c["verdict"] != "SMALL"]
    if bad:
        print("note: not plainly small under %s" % ", ".join(bad))
    if ask("I checked these figures against my tax returns and payroll. Type confirm: ").strip().lower() != "confirm":
        print("not recorded"); return 5
    rec = {"by": owner, "at": datetime.now().strftime("%Y-%m-%dT%H:%M"), "inputs": m["inputs"], "map_date": m["today"]}
    wtext(os.path.join(out, "confirmed.json"), json.dumps(rec, indent=1) + "\n")
    print("confirmed by %s" % owner); return 0


def status(fpath, cpath, out, show=True):
    cp = os.path.join(out, "confirmed.json")
    if not os.path.exists(cp):
        s = "never confirmed"
    else:
        c = jload(cp)
        s = "confirmed by %s on %s" % (c["by"], c["at"]) if c["inputs"] == fp(fpath, cpath) else "CHANGED since %s confirmed on %s, map and confirm again" % (c["by"], c["at"])
    if show:
        print(s)
    return s


def parse_today(a):
    if "--today" in a:
        i = a.index("--today")
        return a[:i] + a[i + 2:], day(a[i + 1])
    return a, date.today()


def main(argv):
    if not argv or argv[0] not in ("check", "map", "confirm", "status"):
        print(__doc__); return 2
    cmd, a = argv[0], argv[1:]
    try:
        a, today = parse_today(a)
    except (ValueError, IndexError):
        print("--today needs YYYY-MM-DD"); return 2
    if len(a) < 2:
        print(__doc__); return 2
    errs = check(jload(a[0]), a[1])
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("inputs %s: %d error(s)" % ("OK" if not errs else "NOT OK", len(errs)))
        return 1 if errs else 0
    if len(a) != 3:
        print(__doc__); return 2
    if cmd == "map":
        m = write_map(a[0], a[1], a[2], today)
        n = {}
        for c in m["codes"]:
            n[c["verdict"]] = n.get(c["verdict"], 0) + 1
        print("wrote %s/map.md: %s" % (a[2].rstrip("/"), ", ".join("%d %s" % (v, k) for k, v in sorted(n.items()))))
        return 0
    if cmd == "confirm":
        return confirm(a[0], a[1], a[2])
    status(a[0], a[1], a[2]); return 0


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: NAICS size map

You keep the owner's size map current. You copy figures and run the map; the owner confirms it; nobody changes SAM.gov or claims a status from it until the owner has confirmed.

## You may
- Keep firm.json current with figures the owner gives you: completed fiscal year receipts as filed, pay period head counts, affiliates, the codes on the SAM record and the set-asides with their proof and renewal dates.
- Copy each size standard into codes.csv from the current SBA table of size standards, with the date you copied it. Never guess a standard.
- Run `python3 sizemap.py map firm.json codes.csv out/` and send the owner out/map.md, calling out NOT SMALL, NEAR THE LINE, NO STANDARD and any set-aside note.
- Run `python3 sizemap.py status firm.json codes.csv out/` and flag CHANGED or never confirmed.
- Copy out/bidwatch-naics.json into a Bid Watch Sheet profile once the owner has confirmed.

## You never
- Run `sizemap.py confirm` or write out/confirmed.json. Only the owner confirms.
- Log in to SAM.gov, change a representation, apply for a certification or submit anything.
- Round receipts down, leave out an affiliate or drop a pay period to make a code read SMALL.
- List a set-aside as certified without the certificate, or claim SDVOSB or VOSB on a set-aside without SBA certification.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: NAICS size map

You keep the owner's size map current. You copy figures and run the map; the owner confirms it; nobody changes SAM.gov or claims a status from it until the owner has confirmed.

## You may
- Keep firm.json current with figures the owner gives you: completed fiscal year receipts as filed, pay period head counts, affiliates, the codes on the SAM record and the set-asides with their proof and renewal dates.
- Copy each size standard into codes.csv from the current SBA table of size standards, with the date you copied it. Never guess a standard.
- Run `python3 sizemap.py map firm.json codes.csv out/` and send the owner out/map.md, calling out NOT SMALL, NEAR THE LINE, NO STANDARD and any set-aside note.
- Run `python3 sizemap.py status firm.json codes.csv out/` and flag CHANGED or never confirmed.
- Copy out/bidwatch-naics.json into a Bid Watch Sheet profile once the owner has confirmed.

## You never
- Run `sizemap.py confirm` or write out/confirmed.json. Only the owner confirms.
- Log in to SAM.gov, change a representation, apply for a certification or submit anything.
- Round receipts down, leave out an affiliate or drop a pay period to make a code read SMALL.
- List a set-aside as certified without the certificate, or claim SDVOSB or VOSB on a set-aside without SBA certification.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: NAICS size map

On the first business day of each month, and whenever a fiscal year closes:
1. Update firm.json with the figures the owner sends. Copy, do not estimate.
2. Check each standard in codes.csv against the current SBA table and update table_date when you check.
3. Run `python3 sizemap.py map firm.json codes.csv out/` and send the owner out/map.md with anything NOT SMALL, NEAR THE LINE, NO STANDARD or a renewal inside 60 days.
4. Run `python3 sizemap.py status firm.json codes.csv out/` and ask the owner to confirm if it says CHANGED or never confirmed.
Never run confirm, never touch SAM.gov, never apply or submit anything, never claim a status the owner does not hold.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: naics-size-map
description: Check that a firm is small under every NAICS code on its SAM record using SBA averaging rules (5 completed fiscal years of receipts, 24 months of pay periods, affiliates added), flag codes near the line, and tie each set-aside the firm lists to proof and a renewal date, then wait for the owner to confirm. Use monthly and when a fiscal year closes.
---

# NAICS size map

1. Fill firm.json from templates/firm.json with the owner, and codes.csv from the current SBA table of size standards.
2. `python3 sizemap.py check firm.json codes.csv` until it says OK.
3. `python3 sizemap.py map firm.json codes.csv out/` writes map.md, map.json and bidwatch-naics.json.
4. The owner confirms at their own terminal: `python3 sizemap.py confirm firm.json codes.csv out/`.
5. `python3 sizemap.py status firm.json codes.csv out/` shows confirmed, never confirmed, or CHANGED.

Hard rules: never confirm for the owner, never change SAM.gov or apply for anything, never shade a figure to stay small, never claim a set-aside without proof.
~~~~

#### file: templates/firm.json
~~~~json
{
 "owner": "Your name",
 "firm": "Your firm",
 "fiscal_years": [
  {
   "year": 2021,
   "receipts": 0,
   "weeks": 52
  },
  {
   "year": 2022,
   "receipts": 0,
   "weeks": 52
  },
  {
   "year": 2023,
   "receipts": 0,
   "weeks": 52
  },
  {
   "year": 2024,
   "receipts": 0,
   "weeks": 52
  },
  {
   "year": 2025,
   "receipts": 0,
   "weeks": 52
  }
 ],
 "pay_periods": [
  {
   "period_end": "2026-10-31",
   "count": 0
  }
 ],
 "affiliates_checked": false,
 "affiliates": [],
 "naics_in_sam": [
  "000000"
 ],
 "near_line_pct": 90,
 "statuses": [
  {
   "program": "Small Business",
   "state": "self-represented",
   "proof": "",
   "renewal_due": ""
  }
 ]
}
~~~~

#### file: templates/codes.csv
~~~~csv
naics,title,basis,standard,table_date
000000,Copy the title from the SBA table,receipts,0,2026-01-01
~~~~

#### file: example/firm.json
~~~~json
{
 "owner": "Dana Okafor",
 "firm": "Harbor Line Services (fictional)",
 "fiscal_years": [
  {
   "year": 2021,
   "receipts": 10600000,
   "weeks": 52
  },
  {
   "year": 2022,
   "receipts": 11900000,
   "weeks": 52
  },
  {
   "year": 2023,
   "receipts": 13300000,
   "weeks": 52
  },
  {
   "year": 2024,
   "receipts": 14100000,
   "weeks": 52
  },
  {
   "year": 2025,
   "receipts": 15700000,
   "weeks": 52
  }
 ],
 "pay_periods": [
  {
   "period_end": "2024-11-28",
   "count": 31
  },
  {
   "period_end": "2024-12-28",
   "count": 32
  },
  {
   "period_end": "2025-01-28",
   "count": 32
  },
  {
   "period_end": "2025-02-28",
   "count": 33
  },
  {
   "period_end": "2025-03-28",
   "count": 34
  },
  {
   "period_end": "2025-04-28",
   "count": 34
  },
  {
   "period_end": "2025-05-28",
   "count": 35
  },
  {
   "period_end": "2025-06-28",
   "count": 36
  },
  {
   "period_end": "2025-07-28",
   "count": 36
  },
  {
   "period_end": "2025-08-28",
   "count": 37
  },
  {
   "period_end": "2025-09-28",
   "count": 38
  },
  {
   "period_end": "2025-10-28",
   "count": 38
  },
  {
   "period_end": "2025-11-28",
   "count": 39
  },
  {
   "period_end": "2025-12-28",
   "count": 40
  },
  {
   "period_end": "2026-01-28",
   "count": 40
  },
  {
   "period_end": "2026-02-28",
   "count": 41
  },
  {
   "period_end": "2026-03-28",
   "count": 42
  },
  {
   "period_end": "2026-04-28",
   "count": 42
  },
  {
   "period_end": "2026-05-28",
   "count": 43
  },
  {
   "period_end": "2026-06-28",
   "count": 44
  },
  {
   "period_end": "2026-07-28",
   "count": 44
  },
  {
   "period_end": "2026-08-28",
   "count": 45
  },
  {
   "period_end": "2026-09-28",
   "count": 46
  },
  {
   "period_end": "2026-10-28",
   "count": 46
  }
 ],
 "affiliates_checked": true,
 "affiliates": [],
 "naics_in_sam": [
  "561210",
  "561720",
  "561110",
  "332710",
  "238220"
 ],
 "near_line_pct": 90,
 "statuses": [
  {
   "program": "SDVOSB",
   "state": "self-represented",
   "proof": "",
   "renewal_due": ""
  },
  {
   "program": "HUBZone",
   "state": "certified",
   "proof": "SBA certification letter, saved in /proof",
   "renewal_due": "2026-12-15"
  },
  {
   "program": "Small Business",
   "state": "self-represented",
   "proof": "this map, confirmed by the owner",
   "renewal_due": ""
  }
 ]
}
~~~~

#### file: example/codes.csv
~~~~csv
naics,title,basis,standard,table_date
561210,Facilities support services,receipts,47,2026-03-01
561720,Janitorial services,receipts,14,2026-03-01
561110,Office administrative services,receipts,12.5,2026-03-01
332710,Machine shops,employees,500,2025-06-01
~~~~

#### file: tests/test_sizemap.py
~~~~python
"""Tests for sizemap.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 sizemap as sm

TODAY = date(2026, 11, 4)


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

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

    def F(self):
        return sm.jload(self.firm)

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

    def codes_text(self, t):
        with open(self.codes, "w") as h:
            h.write(t)

    def run_map(self):
        return sm.write_map(self.firm, self.codes, self.out, TODAY)

    def by(self):
        return {c["naics"]: c for c in self.run_map()["codes"]}

    def confirm(self, name="Dana Okafor", typed="confirm", env=None, tty=True):
        return quiet(sm.confirm, self.firm, self.codes, self.out, ask=answers(name, typed), env=env or {}, tty=tty)


class TestCheck(Base):
    def test_example_is_clean(self):
        self.assertEqual(sm.check(self.F(), self.codes), [])

    def test_template_must_be_filled(self):
        codes = [c for c, _ in sm.check(sm.jload(os.path.join(HERE, "templates", "firm.json")), os.path.join(HERE, "templates", "codes.csv"))]
        self.assertIn("E03", codes)
        self.assertIn("E05", codes)

    def test_owner_required(self):
        f = self.F(); f["owner"] = " "; self.save(f)
        self.assertIn("E01", [c for c, _ in sm.check(self.F(), self.codes)])

    def test_years_required(self):
        f = self.F(); f["fiscal_years"] = []; self.save(f)
        self.assertIn("E02", [c for c, _ in sm.check(self.F(), self.codes)])

    def test_affiliates_must_be_checked(self):
        f = self.F(); f["affiliates_checked"] = False; self.save(f)
        self.assertIn("E03", [c for c, _ in sm.check(self.F(), self.codes)])

    def test_sam_codes_shape(self):
        f = self.F(); f["naics_in_sam"] = ["5612"]; self.save(f)
        self.assertIn("E04", [c for c, _ in sm.check(self.F(), self.codes)])

    def test_bad_codes_file(self):
        self.codes_text("naics,title,basis,standard,table_date\n561210,X,revenue,47,2026-03-01\n561210,X,receipts,47,2026-03-01\n")
        e = dict(sm.check(self.F(), self.codes))
        self.assertIn("basis", e["E05"]); self.assertIn("twice", e["E05"])

    def test_employee_basis_needs_payroll(self):
        f = self.F(); f["pay_periods"] = []; self.save(f)
        self.assertIn("E06", [c for c, _ in sm.check(self.F(), self.codes)])

    def test_status_state_checked(self):
        f = self.F(); f["statuses"][0]["state"] = "pending"; self.save(f)
        self.assertIn("E07", [c for c, _ in sm.check(self.F(), self.codes)])


class TestSize(Base):
    def test_five_year_average(self):
        r, how = sm.avg_receipts(self.F())
        self.assertEqual(r, 13120000)
        self.assertIn("/ 5", how)

    def test_uses_last_five_only(self):
        f = self.F(); f["fiscal_years"].insert(0, {"year": 2020, "receipts": 90000000, "weeks": 52})
        self.assertEqual(sm.avg_receipts(f)[0], 13120000)

    def test_young_firm_is_annualized(self):
        f = self.F(); f["fiscal_years"] = [{"year": 2025, "receipts": 1000000, "weeks": 26}, {"year": 2026, "receipts": 3000000, "weeks": 52}]
        self.assertAlmostEqual(sm.avg_receipts(f)[0], 4000000 / 78.0 * 52)

    def test_affiliates_added(self):
        f = self.F(); f["affiliates"] = [{"name": "Sister Co", "avg_receipts": 2000000, "avg_employees": 10}]
        self.assertEqual(sm.avg_receipts(f)[0], 15120000)
        self.assertAlmostEqual(sm.avg_employees(f, TODAY)[0], sm.avg_employees(self.F(), TODAY)[0] + 10)

    def test_employees_window_is_24_completed_months(self):
        f = self.F(); f["pay_periods"].append({"period_end": "2026-11-02", "count": 900})
        f["pay_periods"].append({"period_end": "2024-10-28", "count": 900})
        e, how = sm.avg_employees(f, TODAY)
        self.assertLess(e, 50)
        self.assertIn("24 pay periods", how)

    def test_verdicts(self):
        b = self.by()
        self.assertEqual(b["561210"]["verdict"], "SMALL")
        self.assertEqual(b["561720"]["verdict"], "NEAR THE LINE")
        self.assertEqual(b["561110"]["verdict"], "NOT SMALL")
        self.assertEqual(b["332710"]["verdict"], "SMALL")
        self.assertEqual(b["238220"]["verdict"], "NO STANDARD")

    def test_old_table_flagged(self):
        self.assertTrue(any("over a year" in x for x in self.by()["332710"]["flags"]))

    def test_code_not_on_sam_flagged(self):
        f = self.F(); f["naics_in_sam"].remove("561110"); self.save(f)
        self.assertIn("not on your SAM record", self.by()["561110"]["flags"])

    def test_bidwatch_export_skips_not_small(self):
        self.run_map()
        n = sm.jload(os.path.join(self.out, "bidwatch-naics.json"))["naics"]
        self.assertEqual(n, ["561210", "561720", "332710"])

    def test_map_md_written(self):
        self.run_map()
        with open(os.path.join(self.out, "map.md")) as h:
            t = h.read()
        self.assertIn("NOT SMALL", t); self.assertIn("sizemap.py confirm", t)


class TestStatuses(Base):
    def st(self):
        return {s["program"]: s["note"] for s in self.run_map()["statuses"]}

    def test_sdvosb_needs_certification(self):
        self.assertIn("do not claim", self.st()["SDVOSB"])

    def test_certified_sdvosb_is_fine(self):
        f = self.F(); f["statuses"][0].update(state="certified", renewal_due="2028-01-01"); self.save(f)
        self.assertEqual(self.st()["SDVOSB"], "")

    def test_renewal_inside_60_days(self):
        self.assertIn("inside 60 days", self.st()["HUBZone"])

    def test_applied_is_not_claimable(self):
        f = self.F(); f["statuses"][1]["state"] = "applied"; self.save(f)
        self.assertIn("pending", self.st()["HUBZone"])


class TestConfirm(Base):
    def test_owner_confirms_and_status(self):
        self.run_map()
        rc, _ = self.confirm()
        self.assertEqual(rc, 0)
        self.assertIn("confirmed by Dana Okafor", sm.status(self.firm, self.codes, self.out, show=False))

    def test_refused_in_agent_session(self):
        self.run_map()
        self.assertEqual(self.confirm(env={"CLAUDECODE": "1"})[0], 3)

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

    def test_wrong_name_refused(self):
        self.run_map()
        self.assertEqual(self.confirm(name="An Agent")[0], 5)

    def test_must_type_confirm(self):
        self.run_map()
        self.assertEqual(self.confirm(typed="yes")[0], 5)
        self.assertFalse(os.path.exists(os.path.join(self.out, "confirmed.json")))

    def test_no_map_yet(self):
        self.assertEqual(self.confirm()[0], 4)

    def test_stale_map_refused(self):
        self.run_map()
        f = self.F(); f["fiscal_years"][-1]["receipts"] = 12000000; self.save(f)
        self.assertEqual(self.confirm()[0], 4)

    def test_change_after_confirm_flags(self):
        self.run_map(); self.confirm()
        f = self.F(); f["fiscal_years"][-1]["receipts"] = 12000000; self.save(f)
        self.assertTrue(sm.status(self.firm, self.codes, self.out, show=False).startswith("CHANGED"))


class TestCli(Base):
    def test_usage(self):
        self.assertEqual(quiet(sm.main, [])[0], 2)

    def test_check_cli(self):
        self.assertEqual(quiet(sm.main, ["check", self.firm, self.codes])[0], 0)

    def test_map_cli(self):
        rc, out = quiet(sm.main, ["map", self.firm, self.codes, self.out, "--today", "2026-11-04"])
        self.assertEqual(rc, 0); self.assertIn("1 NOT SMALL", out)


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