# Bid Watch Sheet

**Score this week's contract notices against your own profile. Clear misses get passed with a reason, the rest get ranked, and nobody replies to a buyer until you say pursue.**

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

A weekly search turns up dozens of notices. Most are wrong for you on one hard fact: the code is not yours, the set-aside is one you do not hold, it closes in four days, or it is in a state you do not work in. Reading each one to find that out eats the week, and an eager agent will happily draft replies to all of them.

This kit applies your hard facts first, scores what is left, and stops at a list. The owner says pursue or pass on each notice, and only then does drafting start.

## What you get out of it

- watch.md: the notices that cleared every gate, ranked, with the reason for each score, and the ones passed with the gate that stopped them.
- A pursue or pass record per notice, made by the owner, that flags itself when the notice is amended.
- A Monday routine your agent can run without being able to answer for you.

## Who it is for

Small firms and solo contractors who check government opportunities every week, and the people who run that check for them.

Not legal or contracting advice. You confirm your own eligibility, and you read the full notice before you decide.

## The profile

| Field | What to write |
| --- | --- |
| `owner` | the one person who says pursue |
| `naics` | your 6-digit codes. A notice in the same 4-digit family still clears the gate but scores lower |
| `set_asides_held` | only what you actually hold, written the way notices write it (SDVOSB, 8(a), WOSB, HUBZone, Small Business). Empty if none |
| `states` | `"any"` or a list such as `["HI", "CA"]` |
| `min_days_to_due` | the shortest runway you will work with |
| `max_value` | optional. Notices above it lose 20 points rather than being dropped |
| `keywords`, `exclude_words` | words in the title that raise a score, and words that rule a notice out |

notices.csv has one row per notice: id, title, agency, naics, set_aside, due (YYYY-MM-DD), value, place, link. Export or paste it from the search you already use. The tool reads the file and never goes online.

## Gates and score

| Gate | Passes the notice when |
| --- | --- |
| G1 | the NAICS code is not yours or in the same 4-digit family |
| G2 | it is set aside for a program you do not hold |
| G3 | it is past due or closes inside your minimum runway |
| G4 | the title or agency has an excluded word |
| G5 | the place is outside your states |

What clears every gate gets up to 100: NAICS exact 30 or family 15, your set-aside 25 or open competition 10, 10 per keyword up to 30, and up to 15 for runway (30 days or more is full). The score sorts the list. It is not a reason to bid.

## The owner's call

`bidwatch.py decide` records pursue or pass for one notice. It is refused inside an agent session, without a terminal and from anyone but the owner in the profile, and the owner has to type the choice. `status` marks a decision CHANGED when the notice row changes after it was made (an amended due date, a new set-aside), and marks undecided notices DUE SOON inside 7 days.

## Set up (10 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py bid-watch-sheet-score-this-weeks-notices-before-anyone-replies.md bidwatch/` (the file you downloaded) to unpack the rest.
2. In `bidwatch/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up firm and eight made-up notices: `python3 bidwatch.py scan example/profile.json example/notices.csv out/ --today 2026-11-03`. Three reach the watch list; five are passed, one per gate.
4. Copy templates/profile.json and fill it with your real codes and the set-asides you hold.
5. Put one real week of notices in notices.csv, scan, and read watch.md against the notices themselves.
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

Scan a week you already read by hand. If a notice you would have pursued was passed, fix the profile, not the notice. Stop and fix the setup if an agent recorded a decision, contacted a buyer, or added a set-aside you do not hold. That is the kill rule.

## Limits

- It reads the fields you give it. A wrong due date or code in notices.csv gives a wrong answer.
- Keywords match the title only. Read the full notice before you decide.
- The place gate reads the last word of the place as a state code.
- One profile per file. A firm with two lines of work can keep two profiles.

## Want this run for you

A bid watch holds up when the scan is routine and the decision stays with you. If you would rather have agents set up and run with those gates in place, that is Managed Ops: our agents work inside the tools you already use, and every change is drafted for your yes. Managed Ops starts at $5,000/mo with $2,500 onboarding and a 3-month minimum. Growth Fleet, starting at $2,500 setup + $750/mo, covers a smaller set of agents. Book a call on the paperchasewebb.ai homepage. Every price is a starting price.

## License

Free for personal and commercial use inside your own business. Do not resell or republish the kit.

## Appendix: the files

#### file: extract_from_pack.py
~~~~python
#!/usr/bin/env python3
"""Unpack every '#### file: PATH' block from pack.md (or FREE-SAMPLE.md) into a folder."""
import os, re, sys
src, dst = sys.argv[1], sys.argv[2]
t = open(src, encoding="utf-8").read()
n = 0
for m in re.finditer(r"^#### file: (\S+)\n~~~~[a-z]*\n(.*?)\n~~~~$", t, re.S | re.M):
    p = os.path.join(dst, m.group(1))
    os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
    open(p, "w", encoding="utf-8").write(m.group(2) + "\n")
    n += 1
print(n, "files")
~~~~

#### file: bidwatch.py
~~~~python
#!/usr/bin/env python3
"""bidwatch.py: score this week's contract notices against your profile, and let only the owner say pursue.

  python3 bidwatch.py check PROFILE.json                          check the profile
  python3 bidwatch.py scan PROFILE.json NOTICES.csv OUT/ [--today YYYY-MM-DD]
                                                                  gate and score every notice; write OUT/watch.md, OUT/watch.csv, OUT/scan.json
  python3 bidwatch.py decide PROFILE.json OUT/ NOTICE_ID pursue|pass   owner only, at their own terminal
  python3 bidwatch.py status OUT/ [--today YYYY-MM-DD]            decided, undecided, due soon, and changed since the decision

NOTICES.csv columns: id, title, agency, naics, set_aside, due (YYYY-MM-DD), value, place, link.
Export or paste them from the search you already use. This tool reads a file; it does not go online.
Gates (any one sends a notice to the pass list): G1 NAICS not yours, G2 a set-aside you do not hold,
G3 due too soon or past, G4 an excluded word, G5 a place outside your states.
Notices that clear every gate get a fit score out of 100 and wait for the owner's pursue or pass.
Exit codes: 0 ok, 1 errors, 2 usage, 3 refused (agent session or no terminal), 4 not in the last scan, 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import csv, hashlib, json, os, sys
from datetime import date, 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", "title", "agency", "naics", "set_aside", "due", "value", "place", "link"]
OPEN = {"", "none", "n/a", "full and open", "unrestricted"}


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(xs):
    return [str(x).strip().lower() for x in xs or [] if str(x).strip()]


def check(p):
    e = []
    if not str(p.get("owner") or "").strip():
        e.append(("E01", "owner is missing (the one person who says pursue)"))
    n = p.get("naics")
    if not isinstance(n, list) or not n or any(not str(x).isdigit() or len(str(x)) != 6 for x in n):
        e.append(("E02", "naics must be a list of 6-digit codes"))
    if not isinstance(p.get("set_asides_held"), list):
        e.append(("E03", "set_asides_held must be a list (empty if you hold none)"))
    d = p.get("min_days_to_due")
    if not isinstance(d, int) or isinstance(d, bool) or d < 1:
        e.append(("E04", "min_days_to_due must be a whole number of days, 1 or more"))
    s = p.get("states")
    if s != "any" and not (isinstance(s, list) and s):
        e.append(("E05", "states must be \"any\" or a list such as [\"HI\", \"CA\"]"))
    return e


def read_notices(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("NOTICES.csv is missing column(s): " + ", ".join(miss))
        rows = [{c: (row.get(c) or "").strip() for c in COLS} for row in r]
    seen, errs = set(), []
    for i, row in enumerate(rows, 2):
        if not row["id"]:
            errs.append("line %d has no id" % i)
        elif row["id"] in seen:
            errs.append("id %s is listed twice" % row["id"])
        seen.add(row["id"])
        try:
            datetime.strptime(row["due"], "%Y-%m-%d")
        except ValueError:
            errs.append("id %s due date is not YYYY-MM-DD" % (row["id"] or "line %d" % i))
    if errs:
        raise ValueError("; ".join(errs))
    return rows


def rhash(row):
    return hashlib.sha256(json.dumps([row[c] for c in COLS]).encode("utf-8")).hexdigest()


def days_left(row, today):
    return (datetime.strptime(row["due"], "%Y-%m-%d").date() - today).days


def gates(p, row, today):
    g = []
    mine = [str(x) for x in p["naics"]]
    code = row["naics"][:6]
    if code not in mine and code[:4] not in [m[:4] for m in mine]:
        g.append(("G1", "NAICS %s is not yours" % (code or "blank")))
    sa = row["set_aside"].lower()
    if sa not in OPEN and sa not in low(p["set_asides_held"]):
        g.append(("G2", "set-aside %s, which you do not hold" % row["set_aside"]))
    dl = days_left(row, today)
    if dl < p["min_days_to_due"]:
        g.append(("G3", "past due" if dl < 0 else "due in %d day%s, under your %d" % (dl, "" if dl == 1 else "s", p["min_days_to_due"])))
    text = (row["title"] + " " + row["agency"]).lower()
    hit = [w for w in low(p.get("exclude_words")) if w in text]
    if hit:
        g.append(("G4", "excluded word: %s" % ", ".join(hit)))
    if p["states"] != "any" and row["place"]:
        st = row["place"].replace(",", " ").split()[-1].upper()
        if st not in [s.upper() for s in p["states"]]:
            g.append(("G5", "place %s is outside your states" % row["place"]))
    return g


def score(p, row, today):
    s, why = 0, []
    mine = [str(x) for x in p["naics"]]
    if row["naics"][:6] in mine:
        s += 30; why.append("NAICS exact 30")
    else:
        s += 15; why.append("NAICS family 15")
    sa = row["set_aside"].lower()
    if sa in low(p["set_asides_held"]):
        s += 25; why.append("your set-aside 25")
    else:
        s += 10; why.append("open competition 10")
    kw = [w for w in low(p.get("keywords")) if w in row["title"].lower()]
    k = min(30, 10 * len(kw))
    s += k
    if kw:
        why.append("keywords %s %d" % ("/".join(kw), k))
    dl = days_left(row, today)
    r = 15 if dl >= 30 else int(15 * dl / 30)
    s += r; why.append("runway %dd %d" % (dl, r))
    mv = p.get("max_value")
    try:
        v = float(row["value"].replace("$", "").replace(",", "")) if row["value"] else None
    except ValueError:
        v = None
    if mv and v is not None and v > mv:
        s -= 20; why.append("over your max value -20")
    return max(0, min(100, s)), "; ".join(why)


def scan(ppath, npath, out, today):
    p = jload(ppath)
    rows = read_notices(npath)
    os.makedirs(out, exist_ok=True)
    res = []
    for r in rows:
        g = gates(p, r, today)
        if g:
            res.append(dict(r, verdict="pass", score="", why="; ".join("%s %s" % x for x in g), hash=rhash(r)))
        else:
            sc, why = score(p, r, today)
            res.append(dict(r, verdict="watch", score=sc, why=why, hash=rhash(r)))
    res.sort(key=lambda x: (x["verdict"] != "watch", -(x["score"] or 0), x["due"]))
    with open(os.path.join(out, "watch.csv"), "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=COLS + ["verdict", "score", "why"], extrasaction="ignore")
        w.writeheader(); w.writerows(res)
    wtext(os.path.join(out, "scan.json"), json.dumps({"owner": p["owner"], "today": today.isoformat(), "notices": res}, indent=1) + "\n")
    watch = [x for x in res if x["verdict"] == "watch"]
    L = ["# Bid watch, %s" % today.isoformat(), "", "Owner: %s. %d notices read, %d cleared every gate, %d passed on a gate." % (p["owner"], len(res), len(watch), len(res) - len(watch)),
         "Nothing has been answered. Each notice below waits for your pursue or pass.", "", "## Watch list", "",
         "| Score | Due | Notice | Agency | Why |", "| --- | --- | --- | --- | --- |"]
    for x in watch:
        L.append("| %s | %s | %s %s | %s | %s |" % (x["score"], x["due"], x["id"], x["title"], x["agency"], x["why"]))
    if not watch:
        L.append("| - | - | nothing cleared the gates this week | - | - |")
    L += ["", "## Passed on a gate", ""]
    for x in res:
        if x["verdict"] == "pass":
            L.append("- %s %s: %s" % (x["id"], x["title"], x["why"]))
    L += ["", "## Your decision", "", "At your own terminal: `python3 bidwatch.py decide %s %s NOTICE_ID pursue` (or pass)." % (ppath, out.rstrip("/") + "/"),
          "A score is a sort order, not a reason to bid. Read the full notice before you say pursue."]
    wtext(os.path.join(out, "watch.md"), "\n".join(L) + "\n")
    return res


def decisions(out):
    p = os.path.join(out, "decisions.json")
    return jload(p) if os.path.exists(p) else {}


def decide(ppath, out, nid, choice, 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
    if choice not in ("pursue", "pass"):
        print("say pursue or pass"); return 2
    for k in AGENT_ENV:
        if env.get(k):
            print("refused: agent session detected (%s); only the owner decides" % k); return 3
    if not tty:
        print("refused: no terminal; run this yourself"); return 3
    p = jload(ppath)
    sp = os.path.join(out, "scan.json")
    if not os.path.exists(sp):
        print("no scan yet; run scan first"); return 4
    n = [x for x in jload(sp)["notices"] if x["id"] == nid]
    if not n:
        print("%s is not in the last scan" % nid); return 4
    n = n[0]
    if choice == "pursue" and n["verdict"] == "pass":
        print("note: %s failed a gate (%s)" % (nid, n["why"]))
    if ask("Your name, as owner in the profile: ").strip() != p["owner"]:
        print("refused: that is not the owner in the profile"); return 5
    if ask("%s %s on %s, due %s. Type %s to confirm: " % (choice.upper(), nid, n["title"], n["due"], choice)).strip().lower() != choice:
        print("not recorded"); return 5
    d = decisions(out)
    d[nid] = {"choice": choice, "by": p["owner"], "at": datetime.now().strftime("%Y-%m-%dT%H:%M"), "hash": n["hash"], "due": n["due"], "title": n["title"]}
    wtext(os.path.join(out, "decisions.json"), json.dumps(d, indent=1, sort_keys=True) + "\n")
    print("recorded: %s %s" % (choice, nid)); return 0


def status(out, today, show=True):
    sc = jload(os.path.join(out, "scan.json"))
    d = decisions(out)
    rows = []
    for n in sc["notices"]:
        x = d.get(n["id"])
        if x:
            st = "CHANGED since %s, decide again" % x["choice"] if x["hash"] != n["hash"] else x["choice"]
        elif n["verdict"] == "watch":
            dl = days_left(n, today)
            st = "undecided, DUE SOON (%d days)" % dl if dl <= 7 else "undecided"
        else:
            continue
        rows.append((n["id"], st))
        if show:
            print("%-14s %s" % (n["id"], st))
    return rows


def parse_today(a):
    if "--today" in a:
        i = a.index("--today")
        t = datetime.strptime(a[i + 1], "%Y-%m-%d").date()
        return a[:i] + a[i + 2:], t
    return a, date.today()


def main(argv):
    if not argv or argv[0] not in ("check", "scan", "decide", "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 cmd == "status" and len(a) == 1:
        status(a[0], today); return 0
    if not a:
        print(__doc__); return 2
    errs = check(jload(a[0]))
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("profile %s: %d error(s)" % ("OK" if not errs else "NOT OK", len(errs)))
        return 1 if errs else 0
    if cmd == "scan" and len(a) == 3:
        try:
            res = scan(a[0], a[1], a[2], today)
        except ValueError as e:
            print("E06 %s" % e); return 1
        w = sum(1 for x in res if x["verdict"] == "watch")
        print("wrote %s/watch.md: %d notices, %d on the watch list, %d passed on a gate" % (a[2].rstrip("/"), len(res), w, len(res) - w))
        return 0
    if cmd == "decide" and len(a) == 4:
        return decide(a[0], a[1], a[2], a[3])
    print(__doc__); return 2


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: bid watch sheet

You run the weekly bid watch for the owner. You read and score; the owner decides; nobody replies to a buyer until the owner says pursue.

## You may
- Keep profile.json current with what the owner tells you: NAICS codes, set-asides they actually hold, states, the shortest runway they will work with, keywords and exclude words.
- Put this week's notices into notices.csv from the search the owner already uses, copying fields exactly. Never invent a due date, a value or a set-aside.
- Run `python3 bidwatch.py scan profile.json notices.csv out/` and send the owner out/watch.md.
- For a notice the owner marked pursue in out/decisions.json, draft questions, a capability summary or a reply for the owner to review.
- Run `python3 bidwatch.py status out/` and flag anything DUE SOON or CHANGED.

## You never
- Run `bidwatch.py decide` or edit out/decisions.json. Only the owner says pursue or pass.
- Contact a buyer, submit anything, register for anything or send a draft. Drafts go to the owner.
- Add a set-aside to set_asides_held that the owner has not confirmed they hold.
- Loosen a gate (more NAICS codes, fewer exclude words, a shorter runway) to make the watch list look fuller.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: bid watch sheet

You run the weekly bid watch for the owner. You read and score; the owner decides; nobody replies to a buyer until the owner says pursue.

## You may
- Keep profile.json current with what the owner tells you: NAICS codes, set-asides they actually hold, states, the shortest runway they will work with, keywords and exclude words.
- Put this week's notices into notices.csv from the search the owner already uses, copying fields exactly. Never invent a due date, a value or a set-aside.
- Run `python3 bidwatch.py scan profile.json notices.csv out/` and send the owner out/watch.md.
- For a notice the owner marked pursue in out/decisions.json, draft questions, a capability summary or a reply for the owner to review.
- Run `python3 bidwatch.py status out/` and flag anything DUE SOON or CHANGED.

## You never
- Run `bidwatch.py decide` or edit out/decisions.json. Only the owner says pursue or pass.
- Contact a buyer, submit anything, register for anything or send a draft. Drafts go to the owner.
- Add a set-aside to set_asides_held that the owner has not confirmed they hold.
- Loosen a gate (more NAICS codes, fewer exclude words, a shorter runway) to make the watch list look fuller.
~~~~

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

Every Monday:
1. Update notices.csv with the week's notices from the owner's usual search. Copy fields exactly.
2. Run `python3 bidwatch.py scan profile.json notices.csv out/` and send the owner out/watch.md with the top scores and anything due within 14 days.
3. Run `python3 bidwatch.py status out/`. Flag DUE SOON and CHANGED items to the owner.
4. Draft only for notices the owner marked pursue. Never run decide, never edit decisions.json, never contact a buyer and never submit anything.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: bid-watch-sheet
description: Score a week of government contract notices against the owner's profile (NAICS, set-asides held, states, runway, keywords), pass the ones that fail a hard gate with the reason, rank the rest, and wait for the owner's pursue or pass before any reply is drafted. Use for a weekly bid scan.
---

# Bid watch sheet

1. Fill profile.json from templates/profile.json with the owner.
2. Put the week's notices in notices.csv: id, title, agency, naics, set_aside, due, value, place, link.
3. `python3 bidwatch.py scan profile.json notices.csv out/` writes watch.md, watch.csv and scan.json.
4. The owner decides at their own terminal: `python3 bidwatch.py decide profile.json out/ NOTICE_ID pursue` (or pass).
5. `python3 bidwatch.py status out/` shows decided, undecided, due soon, and changed since the decision.

Hard rules: never decide for the owner, never contact a buyer or submit anything, never claim a set-aside the owner does not hold, never loosen a gate to fill the list.
~~~~

#### file: templates/profile.json
~~~~json
{
 "owner": "Your name",
 "naics": ["541511", "561210"],
 "set_asides_held": [],
 "states": "any",
 "min_days_to_due": 10,
 "max_value": 250000,
 "keywords": ["automation", "software", "data"],
 "exclude_words": ["construction", "brand name only"]
}
~~~~

#### file: example/profile.json
~~~~json
{
 "owner": "Dana Okafor",
 "naics": ["541511", "561210", "561110"],
 "set_asides_held": ["SDVOSB", "Small Business"],
 "states": ["HI", "CA", "WA"],
 "min_days_to_due": 10,
 "max_value": 250000,
 "keywords": ["automation", "workflow", "data", "help desk"],
 "exclude_words": ["construction", "brand name only", "on-site 24/7"]
}
~~~~

#### file: example/notices.csv
~~~~csv
id,title,agency,naics,set_aside,due,value,place,link
N-1001,Workflow automation for permit intake,Example County Public Works,541511,SDVOSB,2026-12-10,180000,"Honolulu, HI",https://example.com/n-1001
N-1002,Data dashboard and reporting support,Example State Library,561210,Small Business,2026-11-28,95000,"Sacramento, CA",https://example.com/n-1002
N-1003,Help desk software renewal,Example Harbor Authority,561110,,2026-12-01,60000,"Tacoma, WA",https://example.com/n-1003
N-1004,Parking structure repair,Example Transit District,236220,Small Business,2026-12-15,900000,"Honolulu, HI",https://example.com/n-1004
N-1005,Records scanning,Example Courts Office,541511,8(a),2026-12-05,70000,"Fresno, CA",https://example.com/n-1005
N-1006,Workflow tool licenses brand name only,Example Parks Office,541511,,2026-12-20,40000,"Seattle, WA",https://example.com/n-1006
N-1007,Data migration support,Example Water Board,561210,Small Business,2026-11-08,120000,"San Diego, CA",https://example.com/n-1007
N-1008,Automation pilot for intake forms,Example Health Office,541511,,2026-12-18,80000,"Austin, TX",https://example.com/n-1008
~~~~

#### file: tests/test_bidwatch.py
~~~~python
"""Tests for bidwatch.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
from datetime import date

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

TODAY = date(2026, 11, 3)


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)


def rd(p):
    with open(p, encoding="utf-8") as f:
        return f.read()


class Base(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.prof = os.path.join(self.d, "profile.json")
        self.notes = os.path.join(self.d, "notices.csv")
        shutil.copy(os.path.join(HERE, "example", "profile.json"), self.prof)
        shutil.copy(os.path.join(HERE, "example", "notices.csv"), self.notes)
        self.out = os.path.join(self.d, "out")

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

    def P(self):
        return bw.jload(self.prof)

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

    def scan(self):
        return {x["id"]: x for x in bw.scan(self.prof, self.notes, self.out, TODAY)}

    def edit_notice(self, nid, col, val):
        with open(self.notes, newline="", encoding="utf-8") as f:
            rows = list(csv.DictReader(f))
        for r in rows:
            if r["id"] == nid:
                r[col] = val
        with open(self.notes, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=bw.COLS); w.writeheader(); w.writerows(rows)

    def decide(self, nid="N-1001", choice="pursue", name="Dana Okafor", typed=None, env=None, tty=True):
        return quiet(bw.decide, self.prof, self.out, nid, choice, ask=answers(name, typed or choice), env=env or {}, tty=tty)


class TestCheck(Base):
    def test_example_ok(self):
        self.assertEqual(bw.check(self.P()), [])

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

    def test_no_owner(self):
        p = self.P(); p["owner"] = " "
        self.assertIn("E01", [c for c, _ in bw.check(p)])

    def test_bad_naics(self):
        p = self.P(); p["naics"] = ["5415"]
        self.assertIn("E02", [c for c, _ in bw.check(p)])

    def test_bad_days(self):
        for v in (0, True, "10"):
            p = self.P(); p["min_days_to_due"] = v
            self.assertIn("E04", [c for c, _ in bw.check(p)], v)

    def test_bad_states(self):
        p = self.P(); p["states"] = []
        self.assertIn("E05", [c for c, _ in bw.check(p)])


class TestScan(Base):
    def test_example_counts(self):
        r = self.scan()
        self.assertEqual(sorted(k for k, v in r.items() if v["verdict"] == "watch"), ["N-1001", "N-1002", "N-1003"])

    def test_each_gate(self):
        r = self.scan()
        self.assertTrue(r["N-1004"]["why"].startswith("G1"))
        self.assertTrue(r["N-1005"]["why"].startswith("G2"))
        self.assertTrue(r["N-1007"]["why"].startswith("G3"))
        self.assertTrue(r["N-1006"]["why"].startswith("G4"))
        self.assertTrue(r["N-1008"]["why"].startswith("G5"))

    def test_scores_sorted(self):
        res = bw.scan(self.prof, self.notes, self.out, TODAY)
        sc = [x["score"] for x in res if x["verdict"] == "watch"]
        self.assertEqual(sc, sorted(sc, reverse=True))
        self.assertEqual(sc[0], 90)

    def test_past_due(self):
        self.edit_notice("N-1001", "due", "2026-11-01")
        self.assertIn("past due", self.scan()["N-1001"]["why"])

    def test_naics_family_scores_less(self):
        self.edit_notice("N-1001", "naics", "541513")
        r = self.scan()
        self.assertEqual(r["N-1001"]["verdict"], "watch")
        self.assertIn("NAICS family 15", r["N-1001"]["why"])

    def test_over_max_value(self):
        self.edit_notice("N-1001", "value", "$400,000")
        self.assertIn("over your max value", self.scan()["N-1001"]["why"])

    def test_states_any(self):
        p = self.P(); p["states"] = "any"; self.save(p)
        self.assertEqual(self.scan()["N-1008"]["verdict"], "watch")

    def test_duplicate_id_rejected(self):
        with open(self.notes, "a", encoding="utf-8") as f:
            f.write("N-1001,Again,X,541511,,2026-12-10,1,\"Hilo, HI\",x\n")
        rc, out = quiet(bw.main, ["scan", self.prof, self.notes, self.out, "--today", "2026-11-03"])
        self.assertEqual(rc, 1)
        self.assertIn("listed twice", out)

    def test_bad_date_rejected(self):
        self.edit_notice("N-1002", "due", "11/28/2026")
        self.assertEqual(quiet(bw.main, ["scan", self.prof, self.notes, self.out])[0], 1)

    def test_missing_column(self):
        with open(self.notes, "w", encoding="utf-8") as f:
            f.write("id,title\nN-1,x\n")
        rc, out = quiet(bw.main, ["scan", self.prof, self.notes, self.out])
        self.assertEqual(rc, 1)
        self.assertIn("missing column", out)

    def test_watch_md(self):
        self.scan()
        t = rd(os.path.join(self.out, "watch.md"))
        self.assertIn("Nothing has been answered", t)
        self.assertIn("N-1001", t)

    def test_cli_scan(self):
        rc, out = quiet(bw.main, ["scan", self.prof, self.notes, self.out, "--today", "2026-11-03"])
        self.assertEqual(rc, 0)
        self.assertIn("3 on the watch list", out)


class TestDecide(Base):
    def setUp(self):
        super().setUp()
        self.scan()

    def test_owner_pursues(self):
        self.assertEqual(self.decide()[0], 0)
        self.assertEqual(bw.jload(os.path.join(self.out, "decisions.json"))["N-1001"]["choice"], "pursue")

    def test_refused_in_agent_session(self):
        for k in bw.AGENT_ENV:
            self.assertEqual(self.decide(env={k: "1"})[0], 3, k)
        self.assertFalse(os.path.exists(os.path.join(self.out, "decisions.json")))

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

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

    def test_must_type_choice(self):
        self.assertEqual(self.decide(typed="yes")[0], 5)

    def test_unknown_notice(self):
        self.assertEqual(self.decide(nid="N-9999")[0], 4)

    def test_bad_choice(self):
        self.assertEqual(self.decide(choice="maybe")[0], 2)


class TestStatus(Base):
    def test_due_soon_and_changed(self):
        self.scan()
        self.decide()
        st = dict(bw.status(self.out, date(2026, 11, 22), show=False))
        self.assertEqual(st["N-1001"], "pursue")
        self.assertIn("DUE SOON", st["N-1002"])
        self.edit_notice("N-1001", "due", "2026-12-12")
        self.scan()
        st = dict(bw.status(self.out, TODAY, show=False))
        self.assertIn("CHANGED", st["N-1001"])


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