← back to Crazy News Channel Shadowman

scripts/build-cartoon-batch.sh

195 lines

#!/bin/bash
# build-cartoon-batch.sh — the P24 Political Cartoon Desk's daily batch builder.
#
# NOT YET INSTALLED AS A SCHEDULED JOB. This script + its launchd plist are drafted
# to ~/.claude/yolo-queue/pending-approval/ for Steve's explicit sign-off, per
# CLAUDE.md's hard gate on "money/spend" + "autonomous claude -p execution" — a
# recurring unattended job that spawns paid Claude sessions every day is exactly
# the class that must stay Steve's yes, even after a live "go" on the concept.
#
# What it does when run:
#  1. node scripts/daily-cartoon-gen.mjs   -> picks today's N briefs (default 6),
#     writing cartoons/queue/<date>.json. $0, local, deterministic.
#  2. For each brief still 'queued', spawns ONE autonomous `claude -p` session
#     scoped to that single brief: build a self-contained P24 cartoon strip
#     (3 panels, click-to-advance, Plot Twist button, non-partisan invented
#     satire, 390-1440px responsive, keyboard accessible, reduced-motion safe,
#     zero console errors — same acceptance bar as 20260924-commission-turtles.html),
#     e2e-test it with Playwright, add it to cartoons/manifest.js, and commit.
#     HARD RULE (Steve, verbatim: "always link images cartoons to the real
#     article", codified TK-12158) — the manifest entry's story_id must NOT be
#     null. Every new cartoon must link to a real article, either an existing
#     one in stories-data.js/real-news-data.js or a new stories-data.js entry
#     written for it. See the requirement comment at the top of
#     cartoons/manifest.js. Step 90-98 below enforces this: a built cartoon
#     with story_id: null (or missing) is logged FAIL, not PASS.
#     HARD RULE (TK-12165, 2026-09-24) — the manifest entry's tags array must
#     also be non-empty (inherit the linked story's tags plus at most 1-2
#     cartoon-specific tags, e.g. "editorial cartoon"), and if a NEW
#     stories-data.js entry is written for this brief, IT must carry a
#     non-empty tags array too (3-6 short, lowercase-ish, human-readable
#     tags drawn from the vocabulary already in use there). Enforced the
#     same way as story_id below: a built cartoon with tags: [] or missing
#     is logged FAIL, not PASS.
#  3. Logs the whole run to yolo/cartoon-batch-log.jsonl.
#
# COST: each `claude -p` build is a real paid Anthropic API session. Today's single
# scoped cartoon (commission-turtles) took roughly a dozen tool calls end-to-end
# including one fix-and-retest cycle; a batch of 6/day run unattended, without a
# live contrarian pass on every single one (that would roughly double the cost),
# is the realistic ongoing shape. Steve should treat the first week as a
# cost-calibration period and check yolo/cartoon-batch-log.jsonl + his Anthropic
# usage dashboard before trusting a long-run estimate.
#
# Usage once approved: bash scripts/build-cartoon-batch.sh [--count=6] [--dry-run]
set -uo pipefail
# launchd runs with PATH=/usr/bin:/bin:/usr/sbin:/sbin — node and claude live elsewhere.
export PATH="/opt/homebrew/bin:$HOME/.npm-global/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
command -v node >/dev/null && command -v claude >/dev/null || { echo "$(/bin/date -u +%FT%TZ) FAIL missing node/claude on PATH" >>"$(dirname "$0")/../yolo/cartoon-batch-log.jsonl"; exit 127; }
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
COUNT=6
DRY=0
for a in "$@"; do
  case "$a" in
    --count=*) COUNT="${a#--count=}" ;;
    --dry-run) DRY=1 ;;
  esac
done

TS="$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ)"
DATE="$(/bin/date -u +%Y-%m-%d)"
LOG="$DIR/yolo/cartoon-batch-log.jsonl"
mkdir -p "$DIR/yolo"

node scripts/daily-cartoon-gen.mjs --count="$COUNT" --date="$DATE" || { echo "$TS generator FAILED" >>"$LOG"; exit 1; }

QFILE="$DIR/cartoons/queue/$DATE.json"
[ -f "$QFILE" ] || { echo "$TS no queue file for $DATE" >>"$LOG"; exit 1; }

python3 - "$QFILE" "$DRY" "$LOG" "$TS" "$DIR" <<'PYEOF'
# SEQUENTIAL build, one claude -p at a time (Steve 2026-09-24: "run in order" —
# no parallel sessions hitting Anthropic rate limits). ~8 min each, ~48 min for 6,
# so the plist starts at 5:15 ET to post by 6:30 ET; each build gets a hard
# 15-min timeout. Builders write ONLY their HTML + a meta sidecar; this parent
# alone appends manifest.js / stories-data.js and commits once at the end. Keeps the TK-12158 real-article
# story_id rule and the TK-12165 non-empty tags rule, now checked on the sidecar.
import json, sys, subprocess, os, datetime, re
qfile, dry, log, ts, root = sys.argv[1:6]
q = json.load(open(qfile))
todo = [b for b in q['briefs'] if b.get('status') == 'queued']
tools = "Read Write Edit Glob Grep Bash(node:*) Bash(tk:*) Bash(/Users/macstudio3/Projects/ticket-system/tk:*)"

def build(b):
    meta = os.path.join(root, "cartoons", "queue", b["slug"] + ".meta.json")
    prompt = (
        f"Build one P24 (PANDEMONIUM-24) Cartoon Desk strip, ticket-tracked "
        f"(export TK_AGENT=p24-cartoon-batch; tk new for this brief under project crazy-news-channel). "
        f"TITLE: {b['title']}\nBRIEF: {b['brief']}\nSECTION: {b.get('category','politics')}\n\n"
        f"Follow the exact style, acceptance bar, and file structure of "
        f"~/Projects/crazy-news-channel/cartoons/20260924-commission-turtles.html "
        f"(read it first as the reference implementation): 3-panel click-to-advance SVG strip, "
        f"a Plot Twist button, non-partisan invented satire only (no real people/parties), "
        f"390-1440px responsive, keyboard accessible with visible focus, prefers-reduced-motion "
        f"respected, dark-mode toggle, zero console errors. Save to "
        f"~/Projects/crazy-news-channel/cartoons/{b['slug']}.html. Playwright-test at 1440/1024/390 "
        f"+ reduced-motion + dark (require playwright via ~/Projects/animals/node_modules/playwright, "
        f"launch chromium with channel:'chrome'); keep test scripts/screenshots in /tmp, NOT the repo. "
        f"Fix any real defects found.\n\n"
        f"Then write {meta} as JSON: {{\"blurb\": one sentence, \"story_id\": id, \"tags\": [...], "
        f"\"new_story\": object-or-null}}. HARD RULE — every cartoon links to a real article: story_id "
        f"must be non-null. Reuse an existing id from ~/Projects/crazy-news-channel/stories-data.js or "
        f"real-news-data.js if one covers this topic (new_story: null); otherwise put ONE new satirical "
        f"story in new_story matching the exact schema of an existing window.P24_EXTRA_STORIES entry "
        f"(id, category, categoryLabel, location, byline, image, intervalSec, offsetSec, stageIndex, "
        f"article.dek, article.photoCaption, article.paragraphs, stages, tags) with new_story.id == story_id. "
        f"HARD RULE (TK-12165) — the story's tags must be non-empty (3-6 short lowercase-ish tags reusing "
        f"the shared vocabulary in stories-data.js, e.g. 'city council', 'bureaucracy', 'small town news'), "
        f"and the sidecar tags = that story's tags plus at most 1-2 cartoon tags (e.g. 'editorial cartoon'). "
        f"HARD LIMIT: finish within 12 minutes. Do NOT edit cartoons/manifest.js or stories-data.js and do "
        f"NOT git commit — the batch process registers and commits your work. Do not deploy, push, or "
        f"touch anything else."
    )
    try:
        r = subprocess.run(["claude", "-p", prompt, "--permission-mode", "acceptEdits", "--allowedTools", tools],
                           cwd=root, capture_output=True, text=True, timeout=900)
        rc = r.returncode
    except subprocess.TimeoutExpired:
        rc = 124
    return b, rc, meta

if dry == "1":
    for b in todo: print(f"[DRY RUN] would build: {b['id']} — {b['title']}")
    sys.exit(0)

results = [build(b) for b in todo]

def load_js_array(path, var):
    head, arr = open(path).read().split(f"window.{var} = ", 1)
    return head, json.loads(arr.rstrip().rstrip(";"))

def save_js_array(path, var, head, data, indent):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        f.write(head + f"window.{var} = " + json.dumps(data, indent=indent, ensure_ascii=False) + ";\n")
    os.replace(tmp, path)

man_path = os.path.join(root, "cartoons", "manifest.js")
sd_path = os.path.join(root, "stories-data.js")
m_head, entries = load_js_array(man_path, "P24_CARTOONS")
s_head, stories = load_js_array(sd_path, "P24_EXTRA_STORIES")
try: real_news = open(os.path.join(root, "real-news-data.js")).read()
except OSError: real_news = ""
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
added, new_stories = [], 0
for b, rc, meta_path in results:
    fname = b["slug"] + ".html"
    has_file = os.path.isfile(os.path.join(root, "cartoons", fname))
    try: meta = json.load(open(meta_path))
    except Exception: meta = {}
    sid, tags, ns = meta.get("story_id"), meta.get("tags") or [], meta.get("new_story")
    story_ok = False
    if sid and ns:
        story_ok = ns.get("id") == sid and bool(ns.get("tags")) and not any(s.get("id") == sid for s in stories)
    elif sid:
        story_ok = any(s.get("id") == sid for s in stories) or f'"{sid}"' in real_news
    ok = (rc == 0 and has_file and bool(meta.get("blurb")) and story_ok and bool(tags)
          and not any(e.get("file") == fname for e in entries))
    if ok:
        if ns: stories.append(ns); new_stories += 1
        sec = b.get("category", "politics")
        entries.append({"id": b["id"], "title": b["title"], "file": fname, "created_at": now,
                        "category": "Political Cartoon" if sec == "politics" else "Editorial Cartoon",
                        "section": sec, "blurb": meta["blurb"], "thumb": None,
                        "story_id": sid, "tags": tags})
        added.append(fname)
    with open(log, "a") as f:
        f.write(json.dumps({"ts": ts, "id": b["id"], "title": b["title"], "exit": rc,
                            "file": has_file, "blurb": bool(meta.get("blurb")), "story_id": story_ok,
                            "tags": bool(tags), "ok": ok, "verdict": "PASS" if ok else "FAIL"}) + "\n")
    b['status'] = 'built' if ok else 'failed'
if added:
    save_js_array(man_path, "P24_CARTOONS", m_head, entries, 2)
    paths = ["cartoons/manifest.js"] + ["cartoons/" + a for a in added]
    if new_stories:
        save_js_array(sd_path, "P24_EXTRA_STORIES", s_head, stories, 1)
        paths.append("stories-data.js")
    subprocess.run(["git", "add", "--"] + paths, cwd=root)
    subprocess.run(["git", "-c", "user.email=steve@designerwallcoverings.com", "commit", "-q", "-m",
                    f"P24 daily cartoons {q['date']}: {len(added)} built + registered"], cwd=root)
json.dump(q, open(qfile, 'w'), indent=2)
sys.exit(1 if any(b.get('status') == 'failed' for b in q['briefs']) else 0)
PYEOF
RC=$?
echo "$TS batch complete (count=$COUNT dry=$DRY rc=$RC)" >>"$LOG"

# TK-12191: post today's newly built cartoons as PUBLIC "Satire:" YouTube Shorts on the
# All News Daily channel (one per cartoon, with the linked article). Capped at 5 because
# the YouTube Data API default quota is ~6 uploads/day and the daily news Short needs one.
# The shorts ledger makes this idempotent, so re-runs never double-post.
if [ "$DRY" = "0" ]; then
  node scripts/cartoon-shorts/make-cartoon-shorts.mjs --date="$DATE" --limit=5 >>"$DIR/yolo/cartoon-shorts-wrapper.log" 2>&1 \
    || echo "$TS cartoon-shorts FAILED (see yolo/cartoon-shorts-wrapper.log)" >>"$LOG"
fi
exit $RC