← back to Crazy News Channel Shadowman
P24 cartoon batch: 6:00 ET start (3:00 PT), build all briefs in parallel to post by 6:30 ET; parent registers manifest/stories + commits (keeps story_id + tags rules)
7b23cb12c9e4328fe7be80bf1d01fa3ba2b3b73c · 2026-09-24 15:24:27 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Files touched
M scripts/build-cartoon-batch.sh
Diff
commit 7b23cb12c9e4328fe7be80bf1d01fa3ba2b3b73c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 15:24:27 2026 -0700
P24 cartoon batch: 6:00 ET start (3:00 PT), build all briefs in parallel to post by 6:30 ET; parent registers manifest/stories + commits (keeps story_id + tags rules)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
scripts/build-cartoon-batch.sh | 168 ++++++++++++++++++++++-------------------
1 file changed, 90 insertions(+), 78 deletions(-)
diff --git a/scripts/build-cartoon-batch.sh b/scripts/build-cartoon-batch.sh
index 60b9852..31c65ef 100644
--- a/scripts/build-cartoon-batch.sh
+++ b/scripts/build-cartoon-batch.sh
@@ -68,16 +68,25 @@ 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'
-import json, sys, subprocess, os
+# PARALLEL build (2026-09-24, Steve: start 6:00 ET, finish + post by 6:30 ET).
+# Sequential took ~48 min for 6; all briefs now build concurrently with a hard
+# 25-min timeout. Builders write ONLY their HTML + a meta sidecar; this parent
+# alone appends manifest.js / stories-data.js and commits, so parallel sessions
+# can't race on shared files or git's index.lock. 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
+from concurrent.futures import ThreadPoolExecutor
qfile, dry, log, ts, root = sys.argv[1:6]
q = json.load(open(qfile))
-for b in q['briefs']:
- if b.get('status') != 'queued':
- continue
+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) Political Cartoon Desk strip, ticket-tracked "
+ 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']}\n\n"
+ 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, "
@@ -86,87 +95,90 @@ for b in q['briefs']:
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'). Fix any real defects found, then add an entry to "
- f"~/Projects/crazy-news-channel/cartoons/manifest.js (as a new object appended to the window.P24_CARTOONS array: id, title, file, created_at ISO now, "
- f"category 'Political Cartoon', blurb, thumb:null, tags) and commit with author "
- f"steve@designerwallcoverings.com. HARD RULE — Steve's standing instruction is 'always link "
- f"images cartoons to the real article': the manifest entry's story_id field must be a real, "
- f"non-null id. If no existing entry in ~/Projects/crazy-news-channel/stories-data.js or "
- f"real-news-data.js already covers this brief's topic, write ONE new satirical story object "
- f"matching the exact schema of an existing window.P24_EXTRA_STORIES entry (id, category, "
- f"categoryLabel, location, byline, image, intervalSec, offsetSec, stageIndex, article.dek, "
- f"article.photoCaption, article.paragraphs, stages, tags) and append it to stories-data.js, then set "
- f"this cartoon's story_id to that new id. Never leave story_id: null. HARD RULE (TK-12165) — "
- f"every post carries tags: the new (or reused) story object's tags array must be non-empty (3-6 "
- f"short, lowercase-ish, human-readable tags reusing the shared vocabulary already in "
- f"stories-data.js where it fits, e.g. 'city council', 'bureaucracy', 'parking', 'small town "
- f"news' — so tags actually recur across posts), and this cartoon's own manifest.js entry must "
- f"set tags to that story's tags PLUS at most 1-2 cartoon-specific tags (e.g. 'editorial "
- f"cartoon', and 'paul conrad style' only if drawn in that manner). Never leave tags: [] or "
- f"omit the field on either object. Do not deploy, push, or touch anything outside this repo."
+ 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 20 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."
)
- if dry == "1":
- print(f"[DRY RUN] would build: {b['id']} — {b['title']}")
- continue
- print(f"Building {b['id']} — {b['title']} ...")
- tools = "Read Write Edit Glob Grep Bash(node:*) Bash(git add:*) Bash(git commit:*) Bash(git status:*) Bash(git diff:*) Bash(tk:*) Bash(/Users/macstudio3/Projects/ticket-system/tk:*)"
try:
r = subprocess.run(["claude", "-p", prompt, "--permission-mode", "acceptEdits", "--allowedTools", tools],
- cwd=root, capture_output=True, text=True, timeout=1800)
+ cwd=root, capture_output=True, text=True, timeout=1500)
rc = r.returncode
except subprocess.TimeoutExpired:
rc = 124
- # exit 0 is not proof: require the file on disk AND a manifest entry for it.
- out = os.path.join(root, "cartoons", b["slug"] + ".html")
- man = open(os.path.join(root, "cartoons", "manifest.js")).read()
- has_file, in_manifest = os.path.isfile(out), (b["slug"] + ".html") in man
- # HARD RULE check (TK-12158): a cartoon with story_id: null is not a valid
- # end state (Steve, verbatim: "always link images cartoons to the real
- # article"). Cheap substring check, not a full JSON parse — find this
- # entry's object body between its "file" key and the object's closing
- # " }" line, and require a non-null "story_id" inside it.
- has_story_id = False
- # HARD RULE (TK-12165): a cartoon with tags: [] (or missing) is not a
- # valid end state either — same substring-check approach as story_id
- # above (this is a cheap FAIL gate, not a full JSON parse). A non-empty
- # tags array always contains at least one quoted string between the
- # brackets, so "tags": [] (empty) or a missing key both fail the regex.
- has_tags = False
- story_tags_ok = False
- marker = f'"file": "{b["slug"]}.html"'
- mi = man.find(marker)
- if mi != -1:
- obj_end = man.find("\n }", mi)
- obj_body = man[mi: obj_end if obj_end != -1 else mi + 2000]
- m = __import__("re").search(r'"story_id"\s*:\s*"([^"]+)"', obj_body)
- has_story_id = bool(m)
- tm = __import__("re").search(r'"tags"\s*:\s*\[\s*"[^\]]*\]', obj_body)
- has_tags = bool(tm)
- # If a NEW story object was appended to stories-data.js for this
- # brief, it must also carry non-empty tags — check the story's own
- # object body there the same way (find its id, then its own tags
- # field before the next top-level "},\n {" or the array's closing).
- if has_story_id:
- story_id_val = m.group(1)
- try:
- sd = open(os.path.join(root, "stories-data.js")).read()
- sid_marker = f'"id": "{story_id_val}"'
- si = sd.find(sid_marker)
- if si != -1:
- s_end = sd.find('\n }', si)
- s_body = sd[si: s_end if s_end != -1 else si + 4000]
- story_tags_ok = bool(__import__("re").search(r'"tags"\s*:\s*\[\s*"', s_body))
- else:
- story_tags_ok = True # story_id resolves to real-news-data.js instead — out of this script's scope, don't false-FAIL
- except FileNotFoundError:
- story_tags_ok = True
- ok = rc == 0 and has_file and in_manifest and has_story_id and has_tags and story_tags_ok
+ return b, rc, meta
+
+if dry == "1":
+ for b in todo: print(f"[DRY RUN] would build: {b['id']} — {b['title']}")
+ sys.exit(0)
+
+with ThreadPoolExecutor(max_workers=len(todo) or 1) as ex:
+ results = list(ex.map(build, 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, "manifest": in_manifest, "story_id": has_story_id,
- "tags": has_tags, "story_tags": story_tags_ok,
- "ok": ok, "verdict": "PASS" if ok else "FAIL"}) + "\n")
+ "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
← 2a7fdfb auto-data-snapshot: 2026-09-24T15:23:04 (2 data files) — scr
·
back to Crazy News Channel Shadowman
·
P24 cartoon batch: build one at a time (no parallel rate-lim bd41b0b →