# One Front Door

**One offer sentence and one sourced fact sheet. A checker reads every page you have, finds where they disagree, and hands you one reply to settle it all.**

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

Most small businesses tell their story in five places: the site, a directory profile, a marketplace bio, a newsletter about page, a slide deck. Each was written on a different day. One says founded in 2019, another 2021. One quotes last year's price. One still offers a service you dropped. A visitor who sees two of them stops trusting both.

And the front page often fails the simplest test. In ten seconds, can a stranger tell what you do, for whom, and how to start?

## What you get out of it

- A fact sheet you trust: one offer sentence, who it is for, one start step, and every fact with its source and the date you checked it.
- A scan of every page copy you give it, with each mismatch named by page and line.
- One decision sheet with a numbered item per mismatch and a reply line like `1: fix 2: remove 3: hold`. Answer in one message.
- A fix list of exactly the edits you approved, drafted for whoever edits each page. Nothing changes on its own.

## Who it is for

Founders, consultants, studios and small agencies with more than two places where their business is described. Useful before a launch, after a price change, when you add a new profile, or once a month.

Not a copywriter and not an SEO tool. It does not write your offer. It checks that every page says the same true thing.

## The fact sheet

facts.json holds:

- **offer:** one sentence, 25 words or fewer, saying what you do and for whom.
- **offer_anchor:** a short phrase from that sentence. Every front page must show it in its first 80 words.
- **who_for** and **start:** who it is for, and the one first step (a call, a form, a download).
- **front:** which pages are front doors and must pass the ten-second test.
- **facts:** each with a value, a source, a checked date and a pattern that finds that fact on a page. Add `also_ok` for honest variants ("2021" and "since '21").
- **banned:** phrases you never want on a page.

The rule behind it: if a fact has no source you can point to, it does not go on the sheet, and it does not go on a page.

## What the checker catches

| Code | Means |
| --- | --- |
| F01 | the offer is missing, is more than one sentence, runs over 25 words, or the anchor is not from it |
| F02 | no who_for |
| F03 | no start step or no owner |
| F04 | a fact has no value, source, checked date or pattern, or is listed twice |
| F05 | a fact's pattern is not valid or has no ( ) group around the value |
| S01 | a page states a fact with a different value |
| S02 | a page uses a banned phrase |
| S03 | a front page does not show the offer in its first 80 words, or is missing |
| S04 | a front page has no start step |
| R01 to R04 | your reply names an item that does not exist, an option that is not offered, answers an item twice, or has text it cannot read |

Warning: W01 a fact was last checked more than 180 days ago.

## One reply

Every item on the decision sheet comes with its options and a default:

| Finding | Options |
| --- | --- |
| conflicting fact | `fix` the page, `switch` (the page is right, so update the sheet and its source first), `hold` |
| banned phrase | `remove`, `keep` (and drop it from the list), `hold` |
| ten-second test | `add` the line to the first screen, `exempt` the page from the front list, `hold` |

Each item stands alone. Anything you do not answer holds. If a page or the fact sheet changes after the sheet was made, recording a reply is refused, so you never approve a fix for text that has moved.

## Set up (15 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py one-front-door-ten-second-offer-test-and-fact-check.md frontdoor/` (the file you downloaded) to unpack the rest.
2. In `frontdoor/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up salon automation studio with three pages: `python3 frontdoor.py scan example/facts.json example/surfaces`. You should see 7 findings: a price, a call length and a founding year that disagree, two banned phrases, and a directory profile that fails the ten-second test. The home page is clean.
4. Run `python3 frontdoor.py sheet example/facts.json example/surfaces out/` and read out/decisions.md. Then, at your own terminal: `python3 frontdoor.py reply example/facts.json example/surfaces out/ "1: fix 2: fix 3: remove 4: remove 5: add 6: add 7: switch"` and `python3 frontdoor.py fixlist example/facts.json example/surfaces out/`.
5. Copy templates/facts.json, fill it in from sources you can point to, and save copies of your own pages into a folder.
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

Run one scan on your real pages and read every finding yourself. Stop and fix the setup if a pattern matched the wrong thing, if a true variant was flagged (add it to `also_ok`), or if your agent edited a page or the fact sheet. That is the kill rule.

## Limits

- It reads saved copies. It does not fetch pages, so refresh the copies before each scan.
- Patterns find what you tell them to find. A fact phrased in a way your pattern does not cover is not checked.
- The ten-second test is a text check on the first 80 words. It does not judge design, images or how fast a page loads.
- Banned phrases match plain text, not meaning.

## Want this run for you

Keeping every page true is one part of a business that runs on agents with approval gates. If you would rather have it set up and run for you, with the fact sheet kept current and every fix drafted for your yes, that is Managed Ops: our agents work inside the tools you already use. Managed Ops starts at $5,000/mo with $2,500 onboarding and a 3-month minimum. Growth Fleet, starting at $2,500 setup + $750/mo, covers a smaller set of agents. Book a call on the paperchasewebb.ai homepage. Every price is a starting price.

## License

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

## Appendix: the files

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

#### file: frontdoor.py
~~~~python
#!/usr/bin/env python3
"""frontdoor.py: one offer, one fact sheet, every page in agreement. You decide every fix.

  python3 frontdoor.py check FACTS.json [--today D]        check the fact sheet itself
  python3 frontdoor.py scan FACTS.json PAGES/ [--today D]  ten-second test and fact check on every page
  python3 frontdoor.py sheet FACTS.json PAGES/ OUT/        write OUT/decisions.md and OUT/pending.json
  python3 frontdoor.py parse OUT/pending.json "REPLY"      check a reply is complete and valid (writes nothing)
  python3 frontdoor.py reply FACTS.json PAGES/ OUT/ "REPLY"  owner only, at their own terminal: record the answers
  python3 frontdoor.py fixlist FACTS.json PAGES/ OUT/      write OUT/fixlist.md, the edits you approved, as drafts

PAGES/ holds copies of your pages as .html, .htm, .md or .txt. Nothing here edits a page or touches a live site.
Exit codes: 0 ok, 1 findings or errors, 2 usage, 3 refused (agent session or no terminal),
4 refused (stale or missing sheet or decisions), 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import hashlib, html, json, os, re, 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"]
EXTS = (".html", ".htm", ".md", ".txt")
OPTIONS = {"S01": ["fix", "switch", "hold"], "S02": ["remove", "keep", "hold"],
           "S03": ["add", "exempt", "hold"], "S04": ["add", "exempt", "hold"]}
MEANING = {"fix": "change the page to the fact sheet value", "switch": "the page is right; update the fact sheet and its source first",
           "remove": "take the phrase out", "keep": "keep it and drop it from the banned list",
           "add": "draft the missing line into the first screen", "exempt": "this page is not a front door; drop it from the list",
           "hold": "leave the page as it is for now"}


def jload(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def wtext(p, t):
    with open(p, "w", encoding="utf-8") as f:
        f.write(t)


def to_date(s):
    try:
        return datetime.strptime(str(s), "%Y-%m-%d").date()
    except ValueError:
        return None


def visible_lines(raw, name):
    """Page text as a list of lines a reader would see. HTML: drop head, script and style; block tags break lines."""
    if name.lower().endswith((".html", ".htm")):
        raw = re.sub(r"(?is)<(head|script|style|noscript|svg)\b.*?</\1>", "\n", raw)
        raw = re.sub(r"(?i)<br\s*/?>|</?(p|div|h[1-6]|li|ul|ol|section|header|footer|main|nav|article|tr|td|a|button)\b[^>]*>", "\n", raw)
        raw = html.unescape(re.sub(r"<[^>]+>", " ", raw))
    out = []
    for l in raw.splitlines():
        l = re.sub(r"\s+", " ", l).strip()
        if l:
            out.append(l)
    return out


def pages(folder):
    res = {}
    for root, _, files in os.walk(folder):
        for f in sorted(files):
            if f.lower().endswith(EXTS):
                p = os.path.join(root, f)
                with open(p, encoding="utf-8", errors="replace") as fh:
                    res[os.path.relpath(p, folder).replace(os.sep, "/")] = visible_lines(fh.read(), f)
    return res


def norm(s):
    return re.sub(r"[^a-z0-9$]+", " ", s.lower()).strip()


def check_facts(facts, today=None):
    """Return (errors, warnings) as (code, message)."""
    today = today or date.today()
    errs, warns = [], []
    offer = (facts.get("offer") or "").strip()
    words = len(offer.split())
    if not offer:
        errs.append(("F01", "no offer sentence"))
    else:
        if len(re.findall(r"[.!?](\s|$)", offer)) > 1:
            errs.append(("F01", "the offer must be one sentence"))
        if words > 25:
            errs.append(("F01", "the offer is %d words; 25 or fewer reads in ten seconds" % words))
        anchor = facts.get("offer_anchor", "")
        if not anchor or norm(anchor) not in norm(offer):
            errs.append(("F01", "offer_anchor must be a phrase taken from the offer sentence"))
    if not (facts.get("who_for") or "").strip():
        errs.append(("F02", "no who_for: say who it is for in a few words"))
    st = facts.get("start") or {}
    if not st.get("label"):
        errs.append(("F03", "no start step: the one thing a visitor does first"))
    if not facts.get("owner"):
        errs.append(("F03", "no owner: the person who decides fixes"))
    seen = set()
    for f in facts.get("facts", []):
        k = f.get("key", "?")
        if k in seen:
            errs.append(("F04", "fact listed twice: %s" % k))
        seen.add(k)
        for need in ("value", "source", "checked", "pattern"):
            if not f.get(need):
                errs.append(("F04", "fact '%s' has no %s" % (k, need)))
        d = to_date(f.get("checked", ""))
        if f.get("checked") and not d:
            errs.append(("F04", "fact '%s' checked date is not YYYY-MM-DD" % k))
        elif d and (today - d).days > int(facts.get("max_fact_age_days", 180)):
            warns.append(("W01", "fact '%s' was last checked %s; check the source again" % (k, d)))
        if f.get("pattern"):
            try:
                rx = re.compile(f["pattern"])
                if rx.groups < 1:
                    errs.append(("F05", "fact '%s' pattern needs one ( ) group around the value" % k))
            except re.error as e:
                errs.append(("F05", "fact '%s' pattern is not valid: %s" % (k, e)))
    return errs, warns


def scan(facts, folder):
    """Return findings as dicts with code, page, line, text and details. Assumes check_facts passed."""
    found = []
    ps = pages(folder)
    for name in facts.get("front", []):
        if name not in ps:
            found.append({"code": "S03", "page": name, "line": 0, "text": "", "detail": "front page listed but not found"})
    for name, lines in sorted(ps.items()):
        for f in facts.get("facts", []):
            rx = re.compile(f["pattern"])
            ok = [norm(v) for v in [f["value"]] + list(f.get("also_ok", []))]
            for n, l in enumerate(lines, 1):
                for m in rx.finditer(l):
                    if norm(m.group(1)) not in ok:
                        found.append({"code": "S01", "page": name, "line": n, "text": l, "fact": f["key"],
                                      "found": m.group(1), "canonical": f["value"], "source": f["source"],
                                      "checked": f["checked"]})
        for phrase in facts.get("banned", []):
            for n, l in enumerate(lines, 1):
                if phrase.lower() in l.lower():
                    found.append({"code": "S02", "page": name, "line": n, "text": l, "phrase": phrase})
        if name in facts.get("front", []):
            first = norm(" ".join(" ".join(lines).split()[:int(facts.get("ten_second_words", 80))]))
            if norm(facts["offer_anchor"]) not in first:
                found.append({"code": "S03", "page": name, "line": 1, "text": (lines or [""])[0],
                              "detail": "the offer (\"%s\") is not in the first %d words" % (facts["offer_anchor"], int(facts.get("ten_second_words", 80)))})
            if norm(facts["start"].get("anchor") or facts["start"]["label"]) not in norm(" ".join(lines)):
                found.append({"code": "S04", "page": name, "line": 1, "text": (lines or [""])[0],
                              "detail": "no start step (\"%s\")" % facts["start"]["label"]})
    return found


def describe(f):
    if f["code"] == "S01":
        return "%s line %d says %s for %s. The fact sheet says %s (%s, checked %s)." % (
            f["page"], f["line"], f["found"], f["fact"], f["canonical"], f["source"], f["checked"])
    if f["code"] == "S02":
        return "%s line %d uses the banned phrase \"%s\"." % (f["page"], f["line"], f["phrase"])
    return "%s fails the ten-second test: %s." % (f["page"], f["detail"])


def state_hash(facts_path, folder):
    h = hashlib.sha256()
    with open(facts_path, "rb") as fh:
        h.update(fh.read())
    for name, lines in sorted(pages(folder).items()):
        h.update(name.encode() + b"\0" + "\n".join(lines).encode("utf-8") + b"\0")
    return h.hexdigest()


def make_sheet(facts_path, folder, out):
    facts = jload(facts_path)
    items = []
    for f in scan(facts, folder):
        it = dict(f)
        it["n"] = len(items) + 1
        it["options"] = OPTIONS[f["code"]]
        it["ask"] = describe(f)
        items.append(it)
    os.makedirs(out, exist_ok=True)
    lines = ["# Front door decisions for %s" % facts.get("business", ""), "",
             "Owner: %s. Generated %s. Nothing changes until you reply. Each item stands alone; answering one does not block another." % (
                 facts["owner"], datetime.now().strftime("%Y-%m-%d %H:%M")), "",
             "Offer on the fact sheet: %s" % facts["offer"], ""]
    if not items:
        lines.append("No decisions needed. Every page matches the fact sheet.")
    for it in items:
        lines += ["## %d. %s" % (it["n"], it["ask"]), ""]
        for o in it["options"]:
            lines.append("- `%s`: %s" % (o, MEANING[o]))
        lines += ["", "Until you answer: hold.", ""]
    if items:
        lines += ["## Reply with one line", "", "~~~text", " ".join("%d: ___" % it["n"] for it in items), "~~~", ""]
    wtext(os.path.join(out, "decisions.md"), "\n".join(lines))
    wtext(os.path.join(out, "pending.json"), json.dumps({"state": state_hash(facts_path, folder), "items": items}, indent=1) + "\n")
    return items


def parse_reply(items, reply):
    """Return (answers {n: option}, errors). Unanswered items are held."""
    errs, ans = [], {}
    by_n = {it["n"]: it for it in items}
    rest = reply
    for m in re.finditer(r"(\d+)\s*[:.)=-]\s*([A-Za-z]+)", reply):
        n, opt = int(m.group(1)), m.group(2).lower()
        rest = rest.replace(m.group(0), " ", 1)
        if n not in by_n:
            errs.append(("R01", "there is no item %d" % n))
        elif opt not in by_n[n]["options"]:
            errs.append(("R02", "item %d: '%s' is not an option (%s)" % (n, opt, ", ".join(by_n[n]["options"]))))
        elif n in ans:
            errs.append(("R03", "item %d answered twice" % n))
        else:
            ans[n] = opt
    if re.sub(r"[\s,;]+", "", rest):
        errs.append(("R04", "could not read: %s" % re.sub(r"\s+", " ", rest).strip()[:60]))
    for n in by_n:
        ans.setdefault(n, "hold")
    return ans, errs


def record_reply(facts_path, folder, out, reply, ask=input, env=None, tty=None):
    env = os.environ if env is None else env
    tty = sys.stdin.isatty() if tty is None else tty
    for k in AGENT_ENV:
        if env.get(k):
            print("refused: agent session detected (%s); the owner records decisions" % k)
            return 3
    if not tty:
        print("refused: no terminal; run this yourself")
        return 3
    pp = os.path.join(out, "pending.json")
    if not os.path.exists(pp):
        print("refused: no decision sheet; run frontdoor.py sheet")
        return 4
    pending = jload(pp)
    if pending["state"] != state_hash(facts_path, folder):
        print("refused: the fact sheet or a page changed since the sheet was made; run frontdoor.py sheet again")
        return 4
    ans, errs = parse_reply(pending["items"], reply)
    if errs:
        for c, m in errs:
            print("%s %s" % (c, m))
        return 1
    for it in pending["items"]:
        print("%d. %s -> %s" % (it["n"], it["ask"], ans[it["n"]]))
    facts = jload(facts_path)
    if ask("Your name, as in the fact sheet: ").strip() != facts["owner"]:
        print("refused: that is not the owner in the fact sheet")
        return 5
    if ask("Record these decisions? y/n: ").strip().lower() != "y":
        print("not recorded")
        return 5
    rec = {"state": pending["state"], "decided_by": facts["owner"], "decided_at": datetime.now().strftime("%Y-%m-%dT%H:%M"),
           "answers": {str(k): v for k, v in sorted(ans.items())}}
    wtext(os.path.join(out, "decisions.json"), json.dumps(rec, indent=1) + "\n")
    print("recorded. Next: python3 frontdoor.py fixlist %s %s %s" % (facts_path, folder, out))
    return 0


def fixlist(facts_path, folder, out):
    dp, pp = os.path.join(out, "decisions.json"), os.path.join(out, "pending.json")
    if not (os.path.exists(dp) and os.path.exists(pp)):
        print("refused: no recorded decisions; the owner runs frontdoor.py reply")
        return 4
    dec, pending = jload(dp), jload(pp)
    if dec["state"] != pending["state"] or dec["state"] != state_hash(facts_path, folder):
        print("refused: pages or the fact sheet changed after the owner decided; make a new sheet")
        return 4
    facts = jload(facts_path)
    edits, held, sheet = {}, [], []
    for it in pending["items"]:
        a = dec["answers"][str(it["n"])]
        if a == "hold":
            held.append("- %d. %s" % (it["n"], it["ask"]))
            continue
        if a == "fix":
            line = "line %d: change \"%s\" to \"%s\"\n  now: %s\n  draft: %s" % (
                it["line"], it["found"], it["canonical"], it["text"], it["text"].replace(it["found"], it["canonical"]))
        elif a == "remove":
            line = "line %d: take out \"%s\"\n  now: %s" % (it["line"], it["phrase"], it["text"])
        elif a == "add":
            line = "first screen: add \"%s\" and the start step \"%s\"" % (facts["offer"], facts["start"]["label"]) if it["code"] == "S03" \
                else "add the start step \"%s\" near the top" % facts["start"]["label"]
        elif a == "switch":
            sheet.append("- %s: set the value to %s, update its source and checked date, then scan again" % (it["fact"], it["found"]))
            continue
        elif a == "keep":
            sheet.append("- remove \"%s\" from banned" % it["phrase"])
            continue
        else:
            sheet.append("- remove %s from front" % it["page"])
            continue
        edits.setdefault(it["page"], []).append(line)
    lines = ["# Approved front door edits for %s" % facts.get("business", ""), "",
             "Decided by %s on %s. These are drafts for whoever edits each page. Publish only after the owner sees the edited page." % (
                 dec["decided_by"], dec["decided_at"][:10]), ""]
    for page in sorted(edits):
        lines += ["## %s" % page, ""] + ["- " + e for e in edits[page]] + [""]
    if sheet:
        lines += ["## Fact sheet changes (owner)", ""] + sheet + [""]
    if held:
        lines += ["## Held", ""] + held + [""]
    wtext(os.path.join(out, "fixlist.md"), "\n".join(lines))
    print("wrote %s/fixlist.md: %d page(s) to edit, %d held" % (out, len(edits), len(held)))
    return 0


def main(argv):
    today = None
    if "--today" in argv:
        i = argv.index("--today")
        today = to_date(argv[i + 1]) if i + 1 < len(argv) else None
        if not today:
            print("--today needs YYYY-MM-DD")
            return 2
        argv = argv[:i] + argv[i + 2:]
    if not argv:
        print(__doc__)
        return 2
    cmd, a = argv[0], argv[1:]
    if cmd == "parse" and len(a) == 2:
        ans, errs = parse_reply(jload(a[0])["items"], a[1])
        for c, m in errs:
            print("%s %s" % (c, m))
        held = [str(n) for n, v in sorted(ans.items()) if v == "hold"]
        print("reply %s; held: %s" % ("OK" if not errs else "NOT OK", ", ".join(held) or "none"))
        return 1 if errs else 0
    if cmd not in ("check", "scan", "sheet", "reply", "fixlist") or not a:
        print(__doc__)
        return 2
    facts = jload(a[0])
    errs, warns = check_facts(facts, today)
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        for c, m in warns:
            print("warning %s %s" % (c, m))
        print("fact sheet %s: %d errors, %d warning(s)" % ("OK" if not errs else "NOT OK", len(errs), len(warns)))
        if errs or len(a) != 1:
            return 1 if errs else 2
        return 0
    if cmd == "scan" and len(a) == 2:
        found = scan(facts, a[1])
        for f in found:
            print("%s %s" % (f["code"], describe(f)))
        print("front door %s: %d finding(s) across %d page(s)" % ("OK" if not found else "NOT OK", len(found), len(pages(a[1]))))
        return 1 if found else 0
    if cmd == "sheet" and len(a) == 3:
        items = make_sheet(a[0], a[1], a[2])
        print("wrote %s/decisions.md with %d decision(s)" % (a[2], len(items)))
        return 0
    if cmd == "reply" and len(a) == 4:
        return record_reply(a[0], a[1], a[2], a[3])
    if cmd == "fixlist" and len(a) == 3:
        return fixlist(a[0], a[1], a[2])
    print(__doc__)
    return 2


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

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: one front door

You keep the owner's pages in line with one offer and one fact sheet. You scan, explain and draft. The owner decides.

## You may
- Save copies of the owner's pages (site, profiles, bios, newsletter about pages) into pages/ as .html, .md or .txt.
- Run `python3 frontdoor.py check facts.json`, `scan facts.json pages/` and `sheet facts.json pages/ out/`, then send the owner out/decisions.md with the one-line reply template.
- Run `python3 frontdoor.py parse out/pending.json "REPLY"` on the owner's reply and tell them if anything is unclear or missing.
- After the owner has recorded decisions, run `fixlist` and draft the edits in out/fixlist.md for review.

## You never
- Run `frontdoor.py reply` or write decisions.json. The owner records decisions at their own terminal.
- Edit facts.json. A new fact, value, source or banned phrase comes from the owner.
- Change a live page, publish, or post. Drafts go to the owner first.
- Add a claim the fact sheet does not hold: no client counts, results, rankings or years that are not on the sheet with a source.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: one front door

You keep the owner's pages in line with one offer and one fact sheet. You scan, explain and draft. The owner decides.

## You may
- Save copies of the owner's pages (site, profiles, bios, newsletter about pages) into pages/ as .html, .md or .txt.
- Run `python3 frontdoor.py check facts.json`, `scan facts.json pages/` and `sheet facts.json pages/ out/`, then send the owner out/decisions.md with the one-line reply template.
- Run `python3 frontdoor.py parse out/pending.json "REPLY"` on the owner's reply and tell them if anything is unclear or missing.
- After the owner has recorded decisions, run `fixlist` and draft the edits in out/fixlist.md for review.

## You never
- Run `frontdoor.py reply` or write decisions.json. The owner records decisions at their own terminal.
- Edit facts.json. A new fact, value, source or banned phrase comes from the owner.
- Change a live page, publish, or post. Drafts go to the owner first.
- Add a claim the fact sheet does not hold: no client counts, results, rankings or years that are not on the sheet with a source.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: one front door

Once a month, or when the owner changes an offer or a price:
1. Refresh the copies in pages/ from the owner's public pages. Read only; never log in to change them.
2. Run `python3 frontdoor.py scan facts.json pages/`. If it reports 0 findings, tell the owner in one line and stop.
3. Otherwise run `python3 frontdoor.py sheet facts.json pages/ out/` and send the owner out/decisions.md with the reply line.
4. When the owner replies, run `parse` and tell them if any item was unclear. The owner records it with `reply` at their terminal.
5. After that, run `fixlist` and draft each edit for the owner to review. Publish nothing.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: one-front-door
description: Check every page against one offer sentence and one sourced fact sheet, then turn mismatches into a one-reply decision sheet for the owner. Use before a launch, after a price change, or monthly.
---

# One front door

1. Fact sheet: facts.json holds the offer (one sentence, 25 words or fewer), who it is for, the start step, sourced facts with a pattern each, and banned phrases. `python3 frontdoor.py check facts.json`.
2. Scan: `python3 frontdoor.py scan facts.json pages/`. S01 conflicting fact, S02 banned phrase, S03 front page fails the ten-second test, S04 no start step.
3. Sheet: `python3 frontdoor.py sheet facts.json pages/ out/`. Send out/decisions.md. Unanswered items hold.
4. Reply: the owner only, at their own terminal: `python3 frontdoor.py reply facts.json pages/ out/ "1: fix 2: remove"`.
5. Fix list: `python3 frontdoor.py fixlist facts.json pages/ out/`. Drafts only; the owner sees each edited page before it goes live.

Hard rules: never edit facts.json or a live page; never add a claim the fact sheet does not hold.
~~~~

#### file: templates/facts.json
~~~~json
{
  "owner": "YOUR NAME",
  "business": "YOUR BUSINESS",
  "offer": "YOUR BUSINESS does WHAT for WHOM, and WHY THEY CAN TRUST IT.",
  "offer_anchor": "WHAT for WHOM",
  "who_for": "WHO IT IS FOR",
  "start": {
    "label": "THE ONE FIRST STEP",
    "anchor": "FIRST STEP"
  },
  "ten_second_words": 80,
  "max_fact_age_days": 180,
  "front": [
    "home.html"
  ],
  "facts": [
    {
      "key": "founded",
      "value": "YYYY",
      "source": "WHERE YOU CHECKED IT",
      "checked": "YYYY-MM-DD",
      "pattern": "(?i)(?:since|founded in|started in)\\s+(\\d{4})"
    },
    {
      "key": "starting price",
      "value": "$0",
      "source": "YOUR RATE CARD",
      "checked": "YYYY-MM-DD",
      "pattern": "(?i)(?:starts at|from)\\s+(\\$\\d{1,3}(?:,\\d{3})*)"
    }
  ],
  "banned": [
    "guaranteed results",
    "world-class"
  ]
}
~~~~

#### file: example/facts.json
~~~~json
{
  "owner": "Mina Ortiz",
  "business": "Kestrel Studio",
  "offer": "Kestrel Studio sets up booking and follow-up agents for independent salons, and the owner keeps every login.",
  "offer_anchor": "booking and follow-up agents for independent salons",
  "who_for": "independent salon owners",
  "start": {"label": "Book a 20-minute fit call", "anchor": "fit call"},
  "ten_second_words": 80,
  "max_fact_age_days": 180,
  "front": ["home.html", "directory-profile.md"],
  "facts": [
    {"key": "founded", "value": "2021", "source": "state business filing", "checked": "2026-10-12",
     "pattern": "(?i)(?:since|founded in|started in)\\s+(\\d{4})"},
    {"key": "setup price", "value": "$1,200", "source": "rate card v3", "checked": "2026-10-30",
     "pattern": "(?i)setup (?:starts at|from|is)\\s+(\\$\\d{1,3}(?:,\\d{3})*)"},
    {"key": "call length", "value": "20", "source": "booking settings", "checked": "2026-10-30",
     "pattern": "(?i)(\\d+)-minute (?:fit |intro )?call"}
  ],
  "banned": ["guaranteed results", "world-class", "#1 "]
}
~~~~

#### file: example/surfaces/home.html
~~~~html
<!doctype html>
<html><head><title>Kestrel Studio</title><style>body{font-family:serif}</style></head>
<body>
<header><nav><a href="/">Kestrel Studio</a></nav></header>
<main>
<h1>Booking and follow-up agents for independent salons.</h1>
<p>We set them up inside the tools you already use, and you keep every login.</p>
<a class="cta" href="/book">Book a 20-minute fit call</a>
<section><h2>What it costs</h2><p>Setup starts at $1,200. Monthly care is optional.</p></section>
<footer><p>Kestrel Studio, building since 2021.</p></footer>
</main>
</body></html>
~~~~

#### file: example/surfaces/directory-profile.md
~~~~markdown
# Kestrel Studio

We are a world-class automation studio helping beauty businesses grow.

Since 2021 we have built tools for small teams. Setup from $900, and every client gets guaranteed results.

Want to talk? Book a 30-minute intro call.
~~~~

#### file: example/surfaces/newsletter-about.txt
~~~~text
About this newsletter

Kestrel Studio has helped salons with booking since 2019. Every issue covers one booking or follow-up habit you can use this week.
~~~~

#### file: tests/test_frontdoor.py
~~~~python
"""Tests for frontdoor.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 frontdoor as fd

TODAY = date(2026, 11, 3)
ALL = "1: fix 2: fix 3: remove 4: remove 5: add 6: add 7: switch"


def rd(p):
    with open(p) as f:
        return f.read()


def wr(p, t):
    with open(p, "w") as f:
        f.write(t)


def quiet(fn, *a, **k):
    with contextlib.redirect_stdout(io.StringIO()) as buf:
        rc = fn(*a, **k)
    return rc, buf.getvalue()


class Base(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.w = os.path.join(self.d, "ex")
        shutil.copytree(os.path.join(HERE, "example"), self.w)
        self.facts = os.path.join(self.w, "facts.json")
        self.pages = os.path.join(self.w, "surfaces")
        self.out = os.path.join(self.d, "out")

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

    def F(self):
        return json.loads(rd(self.facts))

    def setf(self, **kw):
        d = self.F()
        d.update(kw)
        wr(self.facts, json.dumps(d))

    def fcodes(self, facts=None):
        return [c for c, _ in fd.check_facts(facts or self.F(), TODAY)[0]]

    def scodes(self):
        return [f["code"] for f in fd.scan(self.F(), self.pages)]

    def page(self, name, text):
        wr(os.path.join(self.pages, name), text)

    def record(self, reply=ALL, answers=("Mina Ortiz", "y"), env=None, tty=True):
        it = iter(answers)
        return quiet(fd.record_reply, self.facts, self.pages, self.out, reply, ask=lambda _: next(it), env=env or {}, tty=tty)


class Facts(Base):
    def test_example_sheet_is_clean(self):
        self.assertEqual(fd.check_facts(self.F(), TODAY), ([], []))

    def test_template_fails_until_filled(self):
        self.assertIn("F04", self.fcodes(json.loads(rd(os.path.join(HERE, "templates", "facts.json")))))

    def test_f01_offer_rules(self):
        self.assertIn("F01", self.fcodes(dict(self.F(), offer="")))
        self.assertIn("F01", self.fcodes(dict(self.F(), offer="We do salons. We also do spas.")))
        self.assertIn("F01", self.fcodes(dict(self.F(), offer=" ".join(["word"] * 26) + ".")))
        self.assertIn("F01", self.fcodes(dict(self.F(), offer_anchor="something else")))

    def test_f02_f03_who_start_owner(self):
        self.assertIn("F02", self.fcodes(dict(self.F(), who_for="")))
        self.assertIn("F03", self.fcodes(dict(self.F(), start={})))
        self.assertIn("F03", self.fcodes(dict(self.F(), owner="")))

    def test_f04_fact_needs_value_source_date(self):
        d = self.F()
        d["facts"][0]["source"] = ""
        d["facts"][1]["checked"] = "last month"
        d["facts"].append(dict(d["facts"][2]))
        msgs = " ".join(m for _, m in fd.check_facts(d, TODAY)[0])
        self.assertIn("no source", msgs)
        self.assertIn("not YYYY-MM-DD", msgs)
        self.assertIn("listed twice", msgs)

    def test_f05_bad_pattern(self):
        d = self.F()
        d["facts"][0]["pattern"] = "since \\d{4}"
        self.assertIn("F05", self.fcodes(d))
        d["facts"][0]["pattern"] = "since (\\d{4}"
        self.assertIn("F05", self.fcodes(d))

    def test_w01_old_check(self):
        d = self.F()
        d["facts"][0]["checked"] = "2025-01-01"
        self.assertEqual(fd.check_facts(d, TODAY)[1][0][0], "W01")


class Scan(Base):
    def test_example_findings(self):
        f = fd.scan(self.F(), self.pages)
        self.assertEqual(sorted(x["code"] for x in f), ["S01", "S01", "S01", "S02", "S02", "S03", "S04"])
        self.assertFalse([x for x in f if x["page"] == "home.html"])
        s01 = [x for x in f if x["code"] == "S01"]
        self.assertEqual({x["found"] for x in s01}, {"$900", "30", "2019"})

    def test_html_head_and_script_ignored(self):
        self.page("home.html", rd(os.path.join(self.pages, "home.html")).replace(
            "<title>Kestrel Studio</title>", "<title>Since 2015</title><script>var a='setup from $5';</script>"))
        self.assertFalse([x for x in fd.scan(self.F(), self.pages) if x["page"] == "home.html"])

    def test_also_ok_variants(self):
        d = self.F()
        d["facts"][1]["also_ok"] = ["$900"]
        wr(self.facts, json.dumps(d))
        self.assertNotIn("$900", [x.get("found") for x in fd.scan(self.F(), self.pages)])

    def test_s03_offer_must_be_early(self):
        txt = "Kestrel Studio\n\n" + "filler " * 90 + "\nbooking and follow-up agents for independent salons. Book a 20-minute fit call."
        self.page("directory-profile.md", txt)
        codes = [x["code"] for x in fd.scan(self.F(), self.pages) if x["page"] == "directory-profile.md"]
        self.assertIn("S03", codes)
        self.assertNotIn("S04", codes)

    def test_missing_front_page(self):
        os.remove(os.path.join(self.pages, "home.html"))
        f = [x for x in fd.scan(self.F(), self.pages) if x["page"] == "home.html"]
        self.assertEqual(f[0]["code"], "S03")

    def test_banned_is_case_insensitive(self):
        self.page("extra.txt", "Our GUARANTEED RESULTS speak.")
        self.assertIn("S02", [x["code"] for x in fd.scan(self.F(), self.pages) if x["page"] == "extra.txt"])

    def test_other_file_types_ignored(self):
        self.page("notes.csv", "since 1999")
        self.assertFalse([x for x in fd.scan(self.F(), self.pages) if x["page"] == "notes.csv"])


class Sheet(Base):
    def test_sheet_and_reply_template(self):
        items = fd.make_sheet(self.facts, self.pages, self.out)
        self.assertEqual(len(items), 7)
        t = rd(os.path.join(self.out, "decisions.md"))
        self.assertIn("1: ___ 2: ___ 3: ___ 4: ___ 5: ___ 6: ___ 7: ___", t)
        self.assertIn("Nothing changes until you reply", t)

    def test_clean_pages_need_no_decisions(self):
        for n in ("directory-profile.md", "newsletter-about.txt"):
            os.remove(os.path.join(self.pages, n))
        self.setf(front=["home.html"])
        self.assertEqual(fd.make_sheet(self.facts, self.pages, self.out), [])
        self.assertIn("No decisions needed", rd(os.path.join(self.out, "decisions.md")))

    def test_parse_reply(self):
        items = fd.make_sheet(self.facts, self.pages, self.out)
        ans, errs = fd.parse_reply(items, "1: fix, 2) fix 3. remove")
        self.assertEqual(errs, [])
        self.assertEqual(ans[1], "fix")
        self.assertEqual(ans[7], "hold")
        codes = [c for c, _ in fd.parse_reply(items, "9: fix 1: remove 2: fix 2: hold hello")[1]]
        self.assertEqual(sorted(codes), ["R01", "R02", "R03", "R04"])

    def test_parse_cli(self):
        fd.make_sheet(self.facts, self.pages, self.out)
        pj = os.path.join(self.out, "pending.json")
        self.assertEqual(quiet(fd.main, ["parse", pj, ALL])[0], 0)
        self.assertEqual(quiet(fd.main, ["parse", pj, "1: maybe"])[0], 1)


class Reply(Base):
    def test_refused_in_agent_session_and_without_terminal(self):
        fd.make_sheet(self.facts, self.pages, self.out)
        for k in ("CLAUDECODE", "CODEX_SANDBOX", "HERMES_AGENT", "GROK_BOT"):
            self.assertEqual(self.record(env={k: "1"})[0], 3)
        self.assertEqual(self.record(tty=False)[0], 3)
        self.assertFalse(os.path.exists(os.path.join(self.out, "decisions.json")))

    def test_refused_without_sheet_or_when_stale(self):
        self.assertEqual(self.record()[0], 4)
        fd.make_sheet(self.facts, self.pages, self.out)
        self.page("newsletter-about.txt", "changed")
        self.assertEqual(self.record()[0], 4)

    def test_bad_reply_wrong_owner_or_no(self):
        fd.make_sheet(self.facts, self.pages, self.out)
        self.assertEqual(self.record("1: maybe")[0], 1)
        self.assertEqual(self.record(answers=("Someone", "y"))[0], 5)
        self.assertEqual(self.record(answers=("Mina Ortiz", "n"))[0], 5)
        self.assertFalse(os.path.exists(os.path.join(self.out, "decisions.json")))

    def test_full_flow_to_fixlist(self):
        fd.make_sheet(self.facts, self.pages, self.out)
        self.assertEqual(quiet(fd.fixlist, self.facts, self.pages, self.out)[0], 4)
        rc, out = self.record("1: fix 2: fix 3: remove 4: remove 5: add 6: exempt 7: switch")
        self.assertEqual(rc, 0, out)
        rc, out = quiet(fd.fixlist, self.facts, self.pages, self.out)
        self.assertEqual(rc, 0, out)
        t = rd(os.path.join(self.out, "fixlist.md"))
        self.assertIn("Setup from $1,200", t)
        self.assertIn("take out \"guaranteed results\"", t)
        self.assertIn("founded: set the value to 2019", t)
        self.assertIn("remove directory-profile.md from front", t)
        self.assertNotIn("newsletter-about.txt\n", t.split("## Fact sheet")[0])

    def test_fixlist_lists_held_and_refuses_after_page_edit(self):
        fd.make_sheet(self.facts, self.pages, self.out)
        self.assertEqual(self.record("1: fix")[0], 0)
        quiet(fd.fixlist, self.facts, self.pages, self.out)
        t = rd(os.path.join(self.out, "fixlist.md"))
        self.assertIn("## Held", t)
        self.assertIn("- 7. ", t)
        self.page("home.html", "edited")
        self.assertEqual(quiet(fd.fixlist, self.facts, self.pages, self.out)[0], 4)

    def test_pages_are_never_modified(self):
        before = {n: rd(os.path.join(self.pages, n)) for n in os.listdir(self.pages)}
        fd.make_sheet(self.facts, self.pages, self.out)
        self.record(ALL)
        quiet(fd.fixlist, self.facts, self.pages, self.out)
        self.assertEqual(before, {n: rd(os.path.join(self.pages, n)) for n in os.listdir(self.pages)})


class Cli(Base):
    def test_exit_codes(self):
        self.assertEqual(quiet(fd.main, ["check", self.facts, "--today", "2026-11-03"])[0], 0)
        self.assertEqual(quiet(fd.main, ["scan", self.facts, self.pages, "--today", "2026-11-03"])[0], 1)
        self.assertEqual(quiet(fd.main, ["check", self.facts, "--today", "x"])[0], 2)
        self.assertEqual(quiet(fd.main, [])[0], 2)
        self.assertEqual(quiet(fd.main, ["nope", self.facts])[0], 2)
        self.setf(offer="")
        self.assertEqual(quiet(fd.main, ["scan", self.facts, self.pages])[0], 1)
        self.assertEqual(quiet(fd.main, ["sheet", self.facts, self.pages, self.out])[0], 1)


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