# Thumbnail and Title Review Habit

**A weekly routine for learning from public packaging changes on channels you watch: compare what changed, log it with honest labels, and turn the angles that held into briefs for your own videos. In your words, with your yes.**

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.

## Why this habit

Creators often change a video's title or thumbnail after it goes up. Some changes stick, some get rolled back. Watching which ones stick on channels close to yours is one of the cheapest ways to learn what your audience responds to. The trap is doing it badly: copying a title word for word, treating one change as proof, or pointing a scraper at a site without checking its rules.

This routine keeps the useful part and closes those gaps.

## The routine

**Once a month (10 minutes): pick the watchlist.** Up to 10 public channels that share your audience or format. You approve each one with your name and a revisit date about a month out. Channels without your approval are ignored by the checker.

**Once or twice a day (2 minutes): capture.** For new and recent videos on those channels, record the time, the title, the thumbnail (save the image, or write a short description of it), views and subscribers. Do it by hand or from an official surface. A scraper or browser automation needs its own approval from you after you have read the site's terms; it is not part of this routine.

**Once a week (15 minutes): compare and log.** Run the report. It finds every title or thumbnail change and labels it:

| Label | Means |
| --- | --- |
| observed change | it changed once and has not held long enough to say more |
| likely test | it changed twice within 7 days, or went back to an earlier version |
| confirmed survivor | the new version held 7 or more days across at least 3 captures |

None of these is proof of why it changed, and the log never calls anything a proven A/B test. From the outside you cannot see the creator's numbers.

The report also ranks videos by latest views per subscriber, so you can see which ones are pulling well beyond the channel's size.

**Then brief (15 minutes).** From the survivors and over-performers, your agent drafts briefs: the angle in plain words, a title of your own, a thumbnail concept with your own face, product or space, and which videos it came from. You approve or kill each brief.

## The one rule on copying

Adapt the angle, never the wording, artwork, face or layout.

An angle is the idea underneath: "lead with the habit people want to quit" or "name the common mistake up front". That is fair to learn from. The exact words, the image, a person's face and the layout are someone else's work. The checker enforces part of this: if your title shares 4 or more words in a row with any title in your log, it stops the brief (T09). It also warns if a brief's wording sounds like copying (W02) or claims something is proven (W03).

## What the checker catches

| Code | Stops when |
| --- | --- |
| T01 | a column is missing, or settings.json has no owner |
| T02 | more than 10 channels on the watchlist |
| T03 | a channel was not approved by the owner (it is ignored) |
| T04 | a channel's revisit date has passed (it is ignored until you re-approve) |
| T05 | a channel url is not a public https address |
| T06 | a capture comes from a channel that is not approved |
| T07 | a capture has a bad time or non-numeric views or subscribers |
| T09 | your title shares 4 or more words in a row with a logged title |
| T10 | a brief is approved by anyone but the owner, or has an unknown status |
| T11 | a brief cites a video that is not in your captures |
| T12 | a brief has no angle |

Warnings: W01 more than two capture times a day for one channel, W02 copy wording in a brief, W03 the word "proven" in a brief.

## Set up (10 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py thumbnail-title-review-habit.md habit/` to unpack the rest.
2. In `habit/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example week: `cd example && python3 ../review_log.py report --settings settings.json --watchlist watchlist.csv --snapshots snapshots.csv --briefs briefs.csv`. You should see one confirmed survivor, one likely test and one observed change.
4. Copy `templates/` into your own folder, put your name in settings.json as `owner`, and add your first channels to watchlist.csv with your approval.
5. Give your agent AGENTS.md. Claude Code and Codex read it from the folder; paste it into Grok Bot's instructions or Hermes' context.

For Grok Bot or Hermes on a schedule, the standing order is short: capture at 9:00 and 18:00 from approved channels only; on Sunday run the report and draft up to three briefs; tell the owner the log is ready. Never approve a brief.

## Run one trial week first

Run the routine for one week before you rely on it. Stop and fix the setup if any capture came from an unapproved channel, if a brief reused a creator's words, or if the agent marked anything approved. That is the kill rule.

## Limits

- It reads only the CSVs you give it. It does not fetch anything from the internet.
- Saved thumbnail images are compared by their bytes, so a re-saved copy of the same picture can look like a change. Descriptions are compared as text.
- Seven days and three captures are defaults in settings.json, not facts about any platform.
- Views per subscriber is a rough signal. Older videos and channels with many inactive subscribers skew it.

## Want this run for you

This routine is one small part of how we run content operations with agents and approval gates. If you want the whole system set up around your channels, see our services: PAOM Agent Orchestration (Starting at $2,500 setup + $750/mo, Growth Fleet), Full Build (Scoped quote) or Governed Operations (Premium, Scoped quote). Write to mgmt@paperchasewebb.com.

Planning Instagram the same way? A full Instagram Manager Workflow with Owner Yes is coming soon.

## License

Free for personal and commercial use on your own channels. Do not resell or republish the routine.

## 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: review_log.py
~~~~python
#!/usr/bin/env python3
"""Thumbnail and title review habit: compare public packaging changes, log them, brief your own.

  python3 review_log.py check  --settings settings.json --watchlist watchlist.csv --snapshots snapshots.csv [--briefs briefs.csv]
  python3 review_log.py report --settings settings.json --watchlist watchlist.csv --snapshots snapshots.csv [--briefs briefs.csv] [--out log.md]

Read-only on your CSVs. It never fetches anything from the internet: you (or an approved,
terms-checked tool) record what you saw in snapshots.csv. Labels are "observed change",
"likely test" and "confirmed survivor". It never calls anything a proven A/B test, because from
the outside you cannot see why a creator changed a title.
Exit 0 when there are no errors, 1 when there are errors, 2 for bad input.
"""
import argparse, csv, datetime as dt, hashlib, json, os, re, sys

WATCH_COLS = ["channel", "url", "why_watch", "approved_by", "approved_on", "revisit_by"]
SNAP_COLS = ["captured_at", "channel", "video_id", "title", "thumb", "views", "subscribers"]
BRIEF_COLS = ["id", "angle", "our_title", "thumb_concept", "source_video_ids", "drafted_by", "approved_by", "status"]
DEFAULTS = {"owner": "", "agents": [], "max_channels": 10, "survivor_days": 7, "min_captures": 3,
            "max_captures_per_day": 2, "overlap_words": 4, "today": ""}
COPY_WORDS = re.compile(r"\b(same (thumbnail|layout|face|font)|copy|replicate|recreate|clone|their face|exact)\b", re.I)
WORD = re.compile(r"[a-z0-9']+")


def load_settings(path):
    s = dict(DEFAULTS)
    with open(path, encoding="utf-8") as f:
        s.update(json.load(f))
    s["agents"] = [a.strip().lower() for a in s.get("agents", []) if a.strip()]
    s["today_date"] = dt.date.fromisoformat(s["today"]) if s.get("today") else dt.date.today()
    return s


def read_csv(path, need):
    with open(path, newline="", encoding="utf-8-sig") as f:
        r = csv.DictReader(f)
        miss = [c for c in need if c not in (r.fieldnames or [])]
        rows = [{k: (v or "").strip() for k, v in row.items() if k} for row in r]
    return miss, rows


def when(text):
    try:
        return dt.datetime.fromisoformat(text.replace(" ", "T", 1))
    except ValueError:
        return None


def thumb_key(base, thumb):
    """A saved image file is compared by its bytes; anything else by its text (a note or a hash)."""
    p = os.path.join(base, thumb)
    if thumb and os.path.isfile(p):
        with open(p, "rb") as f:
            return "sha:" + hashlib.sha256(f.read()).hexdigest()[:16]
    return "note:" + thumb.lower()


def words(text):
    return WORD.findall(text.lower().replace("\u2019", "'"))


def shared_run(a, b, n):
    """First run of n or more consecutive words that a and b share, or ''."""
    wa, wb = words(a), words(b)
    grams = {tuple(wb[i:i + n]) for i in range(len(wb) - n + 1)}
    for i in range(len(wa) - n + 1):
        if tuple(wa[i:i + n]) in grams:
            return " ".join(wa[i:i + n])
    return ""


def videos(snaps, base):
    """Group captures by video, oldest first, with a version key per capture."""
    out = {}
    for r in snaps:
        t = when(r["captured_at"])
        if not t:
            continue
        out.setdefault((r["channel"], r["video_id"]), []).append(
            dict(r, _t=t, _v=(r["title"], thumb_key(base, r["thumb"]))))
    for caps in out.values():
        caps.sort(key=lambda c: c["_t"])
    return out


def label(caps, s):
    """Return (label, changes). A change is (time, what, old_title, new_title)."""
    changes, seen = [], [caps[0]["_v"]]
    reverted = False
    for prev, cur in zip(caps, caps[1:]):
        if cur["_v"] != prev["_v"]:
            what = " and ".join(x for x, a, b in (("title", prev["_v"][0], cur["_v"][0]), ("thumbnail", prev["_v"][1], cur["_v"][1])) if a != b)
            changes.append((cur["_t"], what, prev["title"], cur["title"]))
            if cur["_v"] in seen:
                reverted = True
            seen.append(cur["_v"])
    if not changes:
        return "no change", changes
    if reverted:
        return "likely test (reverted)", changes
    window = dt.timedelta(days=s["survivor_days"])
    if len(changes) >= 2 and changes[-1][0] - changes[-2][0] < window:
        return "likely test", changes
    last = changes[-1][0]
    held = [c for c in caps if c["_t"] >= last]
    if caps[-1]["_t"] - last >= window and len(held) >= s["min_captures"]:
        return "confirmed survivor", changes
    return "observed change", changes


def check(s, watch_path, snap_path, brief_path=None):
    errors, warnings = [], []
    E = lambda c, w, m: errors.append((c, w, m))
    W = lambda c, w, m: warnings.append((c, w, m))
    owner = s["owner"].strip().lower()
    if not owner or owner in s["agents"]:
        E("T01", "settings", "settings.json needs an owner who is not also listed as an agent")
    miss, watch = read_csv(watch_path, WATCH_COLS)
    if miss:
        E("T01", "watchlist", "missing columns: " + ", ".join(miss))
        return errors, warnings, [], [], []
    approved = set()
    if len(watch) > s["max_channels"]:
        E("T02", "watchlist", f"{len(watch)} channels; keep it to {s['max_channels']}")
    for w in watch:
        ch = w["channel"]
        if w["approved_by"].lower() != owner or not w["approved_on"]:
            E("T03", ch, "channel is not approved by the owner; it is ignored until it is")
            continue
        try:
            due = dt.date.fromisoformat(w["revisit_by"])
        except ValueError:
            E("T04", ch, "revisit_by must be a date (monthly is the habit)")
            continue
        if due < s["today_date"]:
            E("T04", ch, f"revisit was due {due}; the owner re-approves or drops this channel")
            continue
        if not w["url"].startswith("https://"):
            E("T05", ch, "url must be the channel's public https address")
        approved.add(ch)
    miss, snaps = read_csv(snap_path, SNAP_COLS)
    if miss:
        E("T01", "snapshots", "missing columns: " + ", ".join(miss))
        return errors, warnings, watch, [], []
    kept, per_day = [], {}
    for r in snaps:
        key = r["channel"] + "/" + r["video_id"]
        if r["channel"] not in approved:
            E("T06", key, "capture from a channel that is not on the approved watchlist; delete it")
            continue
        t = when(r["captured_at"])
        if not t or not r["views"].replace(",", "").isdigit() or not r["subscribers"].replace(",", "").isdigit():
            E("T07", key, f"bad row at '{r['captured_at']}': needs a date and time and whole-number views and subscribers")
            continue
        per_day.setdefault((r["channel"], t.date()), set()).add(t.strftime("%H:%M"))
        kept.append(r)
    for (ch, day), times in sorted(per_day.items()):
        if len(times) > s["max_captures_per_day"]:
            W("W01", ch, f"{len(times)} capture times on {day}; the habit is once or twice a day")
    briefs = []
    if brief_path:
        miss, briefs = read_csv(brief_path, BRIEF_COLS)
        if miss:
            E("T01", "briefs", "missing columns: " + ", ".join(miss))
            return errors, warnings, watch, kept, []
        titles = {(r["channel"] + "/" + r["video_id"]): r["title"] for r in kept}
        all_titles = {r["title"] for r in kept}
        vids = {r["video_id"] for r in kept}
        for b in briefs:
            bid = b["id"] or "?"
            if not b["angle"]:
                E("T12", bid, "a brief needs the angle in your own words")
            for t in all_titles:
                run = shared_run(b["our_title"], t, s["overlap_words"])
                if run:
                    E("T09", bid, f"our title shares '{run}' with a logged title; rewrite it in your own words")
                    break
            for v in [x.strip() for x in b["source_video_ids"].split(";") if x.strip()]:
                if v not in vids:
                    E("T11", bid, f"source video {v} is not in snapshots.csv")
            if b["status"] not in ("draft", "approved", "killed"):
                E("T10", bid, "status must be draft, approved or killed")
            if b["status"] == "approved":
                ab = b["approved_by"].lower()
                if ab != owner:
                    E("T10", bid, "only the owner approves a brief")
            if COPY_WORDS.search(b["thumb_concept"] + " " + b["angle"]):
                W("W02", bid, "brief wording suggests copying; adapt the angle, never the wording, artwork, face or layout")
            if re.search(r"\bproven\b", b["angle"] + " " + b["thumb_concept"], re.I):
                W("W03", bid, "say observed or likely test, not proven")
    return errors, warnings, watch, kept, briefs


def report(s, base, watch, kept, briefs, errors, warnings):
    vids = videos(kept, base)
    lines = [f"# Packaging review log, week ending {s['today_date']}", "",
             "Public titles and thumbnails only. Labels: observed change (one change, not yet held), "
             f"likely test (changed twice within {s['survivor_days']} days or reverted), confirmed survivor "
             f"(held {s['survivor_days']}+ days across {s['min_captures']}+ captures). Never proof of why it changed.", ""]
    lines += [f"Watchlist: {len(watch)} channels. Captures: {len(kept)}. Errors: {len(errors)}. Warnings: {len(warnings)}.", ""]
    rows = []
    for (ch, vid), caps in vids.items():
        lab, changes = label(caps, s)
        for t, what, old, new in changes:
            rows.append((t, ch, vid, what, old, new, lab))
    lines += ["## Changes seen", ""]
    if rows:
        lines += ["| when | channel | video | changed | title before | title after | label |", "| --- | --- | --- | --- | --- | --- | --- |"]
        for t, ch, vid, what, old, new, lab in sorted(rows):
            lines.append(f"| {t:%Y-%m-%d %H:%M} | {ch} | {vid} | {what} | {old} | {new if new != old else '(same)'} | {lab} |")
    else:
        lines.append("No changes this week.")
    lines += ["", "## Over-performers (latest views per subscriber)", ""]
    ranked = []
    for (ch, vid), caps in vids.items():
        c = caps[-1]
        subs = int(c["subscribers"].replace(",", "")) or 1
        ranked.append((int(c["views"].replace(",", "")) / subs, ch, vid, c["title"]))
    for ratio, ch, vid, title in sorted(ranked, reverse=True)[:10]:
        lines.append(f"- {ratio:.2f}x | {ch} | {vid} | {title}")
    lines += ["", "## Briefs", ""]
    for b in briefs or []:
        lines.append(f"- {b['id']} [{b['status']}] angle: {b['angle']} | our title: {b['our_title']}")
    if not briefs:
        lines.append("No briefs this week.")
    lines += ["", "## Open findings", ""]
    for c, w, m in errors + warnings:
        lines.append(f"- {c} [{w}] {m}")
    if not errors and not warnings:
        lines.append("None.")
    lines += ["", "Adapt the angle, never the wording, artwork, face or layout.", ""]
    return "\n".join(lines)


def main(argv=None):
    ap = argparse.ArgumentParser(description="Thumbnail and title review habit")
    ap.add_argument("cmd", choices=["check", "report"])
    ap.add_argument("--settings", required=True)
    ap.add_argument("--watchlist", required=True)
    ap.add_argument("--snapshots", required=True)
    ap.add_argument("--briefs")
    ap.add_argument("--out")
    a = ap.parse_args(argv)
    try:
        s = load_settings(a.settings)
        errors, warnings, watch, kept, briefs = check(s, a.watchlist, a.snapshots, a.briefs)
    except (OSError, ValueError, KeyError) as x:
        print("cannot read input:", x)
        return 2
    if a.cmd == "check":
        for c, w, m in errors:
            print(f"ERROR {c} [{w}] {m}")
        for c, w, m in warnings:
            print(f"warn  {c} [{w}] {m}")
        print(f"{len(watch)} channels, {len(kept)} captures, {len(errors)} errors, {len(warnings)} warnings")
    else:
        text = report(s, os.path.dirname(os.path.abspath(a.snapshots)), watch, kept, briefs, errors, warnings)
        if a.out:
            with open(a.out, "w", encoding="utf-8") as f:
                f.write(text)
        else:
            print(text)
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())
~~~~

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: thumbnail and title review habit

You keep the weekly packaging log for the owner. You compare, label and brief. The owner approves.

## You may
- Add capture rows to snapshots.csv from channels on the approved watchlist only, using what is publicly visible: title, thumbnail (saved image or a short description), views, subscribers.
- Run `python3 review_log.py check ...` and `python3 review_log.py report ... --out log.md` and summarize the result in plain language.
- Draft briefs in briefs.csv with status `draft` and `drafted_by` set to your name: the angle in your own words, our title, our thumbnail concept, and the source video ids.

## You never
- Add a channel to the watchlist or fill in `approved_by`. The owner does both.
- Collect from a channel that is not approved, log in anywhere, or use a scraper or browser automation the owner has not approved after checking the site's terms.
- Reuse another creator's wording, artwork, face, font or layout. Adapt the angle only. If review_log.py reports T09, rewrite the title.
- Call a change a proven test. Use the three labels: observed change, likely test, confirmed survivor.
- Set a brief to `approved`, publish anything, or change the owner's channel.
~~~~

#### file: templates/settings.json
~~~~json
{
  "owner": "Your Name",
  "agents": [
    "grok-bot",
    "claude-code",
    "codex",
    "hermes"
  ],
  "max_channels": 10,
  "survivor_days": 7,
  "min_captures": 3,
  "max_captures_per_day": 2,
  "overlap_words": 4,
  "today": ""
}
~~~~

#### file: templates/watchlist.csv
~~~~csv
channel,url,why_watch,approved_by,approved_on,revisit_by
~~~~

#### file: templates/snapshots.csv
~~~~csv
captured_at,channel,video_id,title,thumb,views,subscribers
~~~~

#### file: templates/briefs.csv
~~~~csv
id,angle,our_title,thumb_concept,source_video_ids,drafted_by,approved_by,status
~~~~

#### file: example/settings.json
~~~~json
{
  "owner": "Dana Reyes",
  "agents": ["grok-bot", "claude-code", "codex", "hermes"],
  "max_channels": 10,
  "survivor_days": 7,
  "min_captures": 3,
  "max_captures_per_day": 2,
  "overlap_words": 4,
  "today": "2026-11-09"
}
~~~~

#### file: example/watchlist.csv
~~~~csv
channel,url,why_watch,approved_by,approved_on,revisit_by
Northbound Kitchen,https://www.youtube.com/@northbound-kitchen-example,Same audience as our recipe videos,Dana Reyes,2026-11-01,2026-12-01
Quiet Garage,https://www.youtube.com/@quiet-garage-example,Tutorial format we want to learn from,Dana Reyes,2026-11-01,2026-12-01
Small Room Studio,https://www.youtube.com/@small-room-studio-example,Similar channel size to ours,Dana Reyes,2026-11-01,2026-12-01
~~~~

#### file: example/snapshots.csv
~~~~csv
captured_at,channel,video_id,title,thumb,views,subscribers
2026-11-01 18:00,Northbound Kitchen,nk-101,Weeknight dumplings without a stand mixer,"bowl of dumplings, hands folding",3900,38000
2026-11-02 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",6000,38000
2026-11-02 09:00,Northbound Kitchen,nk-102,Five sauces from one jar of miso,five small bowls in a row,2300,38000
2026-11-02 09:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,"open outlet box, red arrow",1500,12500
2026-11-02 18:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",6100,38000
2026-11-03 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",6900,38000
2026-11-03 09:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,"open outlet box, red arrow",1800,12500
2026-11-04 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",7800,38000
2026-11-04 09:00,Quiet Garage,qg-207,The outlet mistake I see in every garage,"open outlet box, red arrow",2100,12500
2026-11-05 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",8700,38000
2026-11-05 09:00,Northbound Kitchen,nk-102,Five sauces from one jar of miso,five small bowls in a row,2600,38000
2026-11-05 09:00,Quiet Garage,qg-207,The outlet mistake I see in every garage,"open outlet box, red arrow",2400,12500
2026-11-05 09:00,Small Room Studio,sr-033,Treating a small room for under a weekend,"desk with two monitors, plain",2750,8200
2026-11-06 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",9600,38000
2026-11-06 09:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,"open outlet box, red arrow",2700,12500
2026-11-07 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",10500,38000
2026-11-07 09:00,Small Room Studio,sr-033,Treating a small room for under a weekend,"close-up face, bold yellow text",3250,8200
2026-11-08 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",11400,38000
2026-11-08 09:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,"open outlet box, red arrow",3300,12500
2026-11-08 09:00,Small Room Studio,sr-033,Treating a small room for under a weekend,"close-up face, bold yellow text",3500,8200
2026-11-09 09:00,Northbound Kitchen,nk-101,I stopped buying frozen dumplings,"bowl of dumplings, hands folding",12300,38000
2026-11-09 09:00,Northbound Kitchen,nk-102,Five sauces from one jar of miso,five small bowls in a row,3000,38000
2026-11-09 09:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,"open outlet box, red arrow",3600,12500
2026-11-09 09:00,Small Room Studio,sr-033,Treating a small room for under a weekend,"close-up face, bold yellow text",3750,8200
~~~~

#### file: example/briefs.csv
~~~~csv
id,angle,our_title,thumb_concept,source_video_ids,drafted_by,approved_by,status
B1,"Lead with the habit people want to quit, not the technique","Why we quit store-bought pie crust","Our own hands, flour on a dark counter, one word of text",nk-101,grok-bot,Dana Reyes,approved
B2,"Name the common mistake up front","The glaze mistake that ruins a first firing","Cracked test tile next to a clean one, our studio light",qg-207,grok-bot,,draft
~~~~

#### file: tests/test_review_log.py
~~~~python
import contextlib, io, json, os, shutil, sys, tempfile, unittest

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import review_log as rl


class Habit(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.w = os.path.join(self.d, "ex")
        shutil.copytree(os.path.join(ROOT, "example"), self.w)
        self.p = {k: os.path.join(self.w, k + ".csv") for k in ("watchlist", "snapshots", "briefs")}
        self.s = rl.load_settings(os.path.join(self.w, "settings.json"))

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

    def append(self, name, line):
        with open(self.p[name], "a") as f:
            f.write(line + "\n")

    def replace(self, name, old, new):
        t = open(self.p[name]).read()
        with open(self.p[name], "w") as f:
            f.write(t.replace(old, new))

    def run_check(self):
        e, w, watch, kept, briefs = rl.check(self.s, self.p["watchlist"], self.p["snapshots"], self.p["briefs"])
        return [c for c, _, _ in e], [c for c, _, _ in w], watch, kept, briefs

    def labels(self):
        _, _, _, kept, _ = self.run_check()
        return {k[1]: rl.label(v, self.s)[0] for k, v in rl.videos(kept, self.w).items()}

    def test_example_clean(self):
        e, w = self.run_check()[:2]
        self.assertEqual((e, w), ([], []))

    def test_labels(self):
        lab = self.labels()
        self.assertEqual(lab["nk-101"], "confirmed survivor")
        self.assertEqual(lab["qg-207"], "likely test (reverted)")
        self.assertEqual(lab["sr-033"], "observed change")
        self.assertEqual(lab["nk-102"], "no change")

    def test_two_quick_changes_are_a_likely_test(self):
        self.append("snapshots", "2026-11-09 18:00,Small Room Studio,sr-033,Treating a small room for under a weekend,desk photo with a red circle,4000,8200")
        self.assertEqual(self.labels()["sr-033"], "likely test")

    def test_unapproved_channel_ignored(self):
        self.append("watchlist", "Loud Channel,https://www.youtube.com/@loud-example,curious,grok-bot,2026-11-02,2026-12-01")
        self.append("snapshots", "2026-11-05 09:00,Loud Channel,lc-1,Some title here,thumb,100,1000")
        e = self.run_check()[0]
        self.assertIn("T03", e)
        self.assertIn("T06", e)

    def test_too_many_channels(self):
        for n in range(8):
            self.append("watchlist", f"Extra {n},https://www.youtube.com/@x{n},why,Dana Reyes,2026-11-01,2026-12-01")
        self.assertIn("T02", self.run_check()[0])

    def test_revisit_due(self):
        self.replace("watchlist", "Small Room Studio,https://www.youtube.com/@small-room-studio-example,Similar channel size to ours,Dana Reyes,2026-11-01,2026-12-01",
                     "Small Room Studio,https://www.youtube.com/@small-room-studio-example,Similar channel size to ours,Dana Reyes,2026-10-01,2026-11-01")
        e = self.run_check()[0]
        self.assertIn("T04", e)
        self.assertIn("T06", e)

    def test_bad_numbers(self):
        self.append("snapshots", "2026-11-09 10:00,Quiet Garage,qg-207,Wiring a garage outlet the right way,open outlet box,lots,12500")
        self.assertIn("T07", self.run_check()[0])

    def test_capture_too_often_warns(self):
        for h in ("10:00", "11:00"):
            self.append("snapshots", f"2026-11-09 {h},Quiet Garage,qg-207,Wiring a garage outlet the right way,\"open outlet box, red arrow\",3600,12500")
        self.assertIn("W01", self.run_check()[1])

    def test_copied_title_blocked(self):
        self.append("briefs", "B3,Quit angle,Why I stopped buying frozen dumplings too,our own photo,nk-101,grok-bot,,draft")
        self.assertIn("T09", self.run_check()[0])

    def test_three_shared_words_allowed(self):
        self.append("briefs", "B3,Mistake angle,The outlet mistake nobody warns you about,our own photo,qg-207,grok-bot,,draft")
        self.assertNotIn("T09", self.run_check()[0])

    def test_agent_cannot_approve_brief(self):
        self.replace("briefs", "grok-bot,,draft", "grok-bot,grok-bot,approved")
        self.assertIn("T10", self.run_check()[0])

    def test_unknown_source_video(self):
        self.append("briefs", "B3,Angle,Our own title words,our photo,zz-999,grok-bot,,draft")
        self.assertIn("T11", self.run_check()[0])

    def test_copy_wording_warns(self):
        self.append("briefs", "B3,Proven hook,Our own title words,same thumbnail as theirs,nk-101,grok-bot,,draft")
        w = self.run_check()[1]
        self.assertIn("W02", w)
        self.assertIn("W03", w)

    def test_thumbnail_file_compared_by_bytes(self):
        img = os.path.join(self.w, "t.png")
        with open(img, "wb") as f:
            f.write(b"one")
        a = rl.thumb_key(self.w, "t.png")
        with open(img, "wb") as f:
            f.write(b"two")
        self.assertNotEqual(a, rl.thumb_key(self.w, "t.png"))

    def test_report_and_exit_codes(self):
        out = os.path.join(self.d, "log.md")
        args = ["report", "--settings", os.path.join(self.w, "settings.json"), "--watchlist", self.p["watchlist"],
                "--snapshots", self.p["snapshots"], "--briefs", self.p["briefs"], "--out", out]
        with contextlib.redirect_stdout(io.StringIO()):
            self.assertEqual(rl.main(args), 0)
        text = open(out).read()
        self.assertIn("confirmed survivor", text)
        self.assertNotIn("proven", text.lower())
        self.append("briefs", "B3,,x,y,nk-101,grok-bot,,draft")
        with contextlib.redirect_stdout(io.StringIO()):
            self.assertEqual(rl.main(["check"] + args[1:-2]), 1)


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