comic-director
Phase 2/3 of a movie — bake + cross-model-verify a movie from an authored comic.json. Per frame: render the content-SVG blueprint, bake via the agent mcp__codex__codex sidecar, gate with a 3-reviewer cross-model panel (narrative [currently the codex CLI] ‖ Gemini visual ‖ Codex v
Install
npx skills add https://github.com/wanshuiyin/ARIS-Movie-Director/tree/main/skills/comic-director
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wanshuiyin-aris-movie-director@llmmart
git clone https://github.com/wanshuiyin/ARIS-Movie-Director.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wanshuiyin/aris-movie-director collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
comic-director — the Audited Spiral + Release (Phase 2/3)
The middle + right of Figure 1: take an authored comic.json (produced by the
comic-author skill, Phase 1) and turn it into baked frames + a clickable
viewer, through the audited spiral. Per frame: render a deterministic content-SVG blueprint → bake a
pixel-art frame (agent mcp__codex__codex sidecar) → a 3-reviewer cross-model panel_gate → keep / retry → page assembly →
projection. The movie twin of method-figure's render half. (Image-based today; video-based is next.)
Input contract — comic.json (authored upstream)
The contract boundary is comic.json + the assets it references (content-SVG blueprints, identity refs,
ART_BIBLE.md) — authored by comic-author. This skill is render + verify:
- It never invents a number or a cast — faithfulness is a deterministic token-diff over reviewers who never see the expected values; identity is judged against the ref the author supplied (cast-aware).
- It writes back only
image_path/active_attempt_id/wiki_node_id(+ the wiki trace) on KEEP. - The generator family (Codex image_gen) can NOT self-acquit. A KEEP needs the token-diff clean and the cross-model visual panel (Gemini + Codex) to pass. The calling agent (Claude) gives the final structural sign-off.
Full IR spec: ../../docs/comic-json.md /
../../schemas/comic.schema.json.
Run it (scripts/run_comic.py — agent-driven bake, CLI gates)
A pure-stdlib faithful port of packages/core/spiral_engine.js's movie branch. The cross-model gates
(Gemini + Codex vision) still shell the gemini / codex CLIs directly. The BAKE is performed by the
skill agent via the mcp__codex__codex MCP tool through --bake-mode=agent (the default): the core
emits a deterministic bake-request sidecar (prompt + absolute ref paths + exact out_path), the agent calls
mcp__codex__codex (sandbox: workspace-write, model: gpt-5.5, config: {model_reasoning_effort: xhigh, include_image_gen_tool: true},
cwd: <project>), and the core verifies the explicit out_path. (codex exec is RETIRED for real bakes — it
forced a hand-drawn SVG/struct+zlib fallback; --bake-mode=exec RAISES if it ever reaches a real bake. See
docs/spiral-runtime.md.)
python3 skills/comic-director/scripts/run_comic.py \
--project examples/comic_m3_audit --page P02_b08 --panels S12,S13,S14,S15 \
[--bake-lang zh] [--finalize] [--dry-run] [--skip-assembly] \
[--gemini-cmd "python3 cli/gemini_agy_shim.py"] [--gemini-family google]
--dry-runvalidates + prints each frame's concrete bake prompt (no image_gen) — run this first; it's also howcomic-authorconfirms Phase 1 is correct.--finalizerebuilds the single-file viewer (packages/viewer/build_comic.py) only if the run is shippable (nothing escalated/throttled/flagged-for-human; assembly accepted).--gemini-cmd(default: the legacygeminiCLI) — the argv prefix shelled for the google-family reviewer slot (the--model auto-gemini-3 -p <prompt>tail is appended). With the legacy CLI deprecated upstream (IneligibleTierError, 2026-07), pass--gemini-cmd "python3 cli/gemini_agy_shim.py"— the shipped shim translates the old interface to Antigravity'sagyand pins a Gemini model (Antigravity also serves Claude/GPT-OSS; the slot must stay google-family for cross-model quorum honesty).--gemini-family(defaultgoogle) — the provenance family recorded in the wiki for whatever--gemini-cmdactually routes to. It must stay non-openai(the visual quorum needs a second family); record honestly, never re-label.- Needs the
codexCLI + a google-family reviewer (legacygeminiCLI, or Antigravity via--gemini-cmd+ the shim) on PATH + headless Chrome (to rasterize the content-SVG blueprint). Reusesskills/method-figure/scripts/pickup_image.py.
It prints a JSON run-report (kept, flagged_for_human, needs_human, shippable, throttled,
escalated, assembly, attempts_per_panel). Engine semantics it ports (caps 4/panel + 6/run, the
deterministic verdict, anatomy single-vote veto, throttle → fresh-run): ../../docs/spiral-runtime.md.
Bake seam contract (agent ↔ core) — --bake-mode=agent
The bake is a synchronous sidecar handshake (contract-v2 §1–§4). The core never returns/exits early on a
bake; only the bake primitive moved off codex exec onto mcp__codex__codex:
- (A) core writes
<out>.bakereq.json={prompt_text, content_png, identity_ref, out_path, model:"gpt-5.5", config:{model_reasoning_effort:"xhigh", include_image_gen_tool:true}, sandbox:"workspace-write", cwd, created_at, min_bytes:500000, aspect, request_id:"<uuid4 hex>"}(prompt_textALREADY contains the absolute ref + out paths — the schema has no-i;configcarries bothmodel_reasoning_effort:"xhigh"ANDinclude_image_gen_tool:true— without the latter codex won't fire its native image tool;request_idis a per-bake uuid4-hex nonce the core mints — see (D)+(E)), pre-deleting any staleout_path+ status. - (B) the agent calls
mcp__codex__codexwith EXACTLY those params. - (C) codex writes a native PNG to
out_path. - (D) the agent writes
<out>.bakestatus.json={status:"ok"|"fail", failure_kind:"throttle"|"other", mcp_output:"<bounded raw codex tool output>", mcp_error, request_id:"<verbatim from bakereq>"}.mcp_outputis MANDATORY on BOTH ok and fail — the core feeds this status file topickup --transcript, and the HARD-VETO scans it forimport struct/zlib.compress/<svg>/matplotlib/… markers. Anokstatus with no raw output makes the HARD-VETO inert (a hand-drawn struct+zlib/SVG that still emits a ≥500KB sig-valid PNG would slip through) — so always embed the bounded raw tool result.request_idis EQUALLY MANDATORY on BOTH ok and fail — copy it VERBATIM from<out>.bakereq.jsoninto the bakestatus;pickup_image.py --out-existing --request-idfail-closes the bake if the statusrequest_idis missing or ≠ the bakereq nonce (a stale/foreign status at the same path can't be silently honored). - (E) the core verifies via
pickup_image.py --out-existingon the EXPLICITout_path(PNG sig + IHDR dims- size > 500000 + aspect band +
mtime >= created_at), HARD-VETOES the markers in the transcript (= the status file'smcp_output), and (via--request-id) fail-closes if the status file'srequest_idis absent or ≠ the bakereq nonce — but all ONLY after it has readstatus:"ok". Empty/timeout/any-non-ok = fail-closed (no pickup). NO fallback tocodex exec/PIL/SVG; effort is ALWAYSxhigh; sandbox ALWAYSworkspace-write.
- size > 500000 + aspect band +
Who runs --bake-mode=agent (the agent-wrapper SOP) — REQUIRED for the default mode to function
The comic-director skill agent is the bake fulfiller. Concretely:
- Launch the orchestrator in the BACKGROUND:
python3 skills/comic-director/scripts/run_comic.py --project … --page … --panels … --bake-mode agent(it blocks on each bake by polling<out>.bakestatus.json). - Loop until the orchestrator prints its final run-report JSON / exits:
- watch the project's
panels/dir for a new*.bakereq.json; - read it; call
mcp__codex__codexwith exactly its{prompt: <prompt_text>, model:"gpt-5.5", config:{include_image_gen_tool:true, model_reasoning_effort:"xhigh"}, sandbox:"workspace-write", cwd:<cwd>}(codex writes the native PNG to the sidecar'sout_path). TheconfigMUST carry bothinclude_image_gen_tool:trueANDmodel_reasoning_effort:"xhigh": withoutinclude_image_gen_toolCodex will not fire its native image tool (it falls back to descriptive text / an SVG renderer), andxhighis the required reasoning tier; - then read
request_idfrom<out>.bakereq.jsonand write<out>.bakestatus.jsoncarrying the status, a bounded rawmcp_output, AND thatrequest_idVERBATIM —mcp_outputso the core's HARD-VETO can scan it, andrequest_idbecausepickup_image.py --out-existing --request-idfail-closes the bake if the statusrequest_idis missing or mismatched (it must be written on BOTH ok and fail). Live-verified pitfall: if themcp__codex__codexreply TEXT is EMPTY (an image-only reply), serialize the RAW response object intomcp_output— never write an empty string (pickup fail-closes a blankmcp_outputon the empty-transcript guard):{"status":"ok","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}on success, or{"status":"fail","failure_kind":"throttle","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}on a 429 /MODEL_CAPACITY_EXHAUSTED/ overloaded error (otherwise{"status":"fail","failure_kind":"other","mcp_output":"<raw>","mcp_error":"<raw>","request_id":"<verbatim from bakereq>"}).
- watch the project's
- Stop when the orchestrator exits. (Without this wrapper, every bake polls to
--bake-timeoutand returnsgeneration_failedwithfailure_kind="other"— fail-closed, not a hang, and never a falsethrottle.)
--bake-mode=execis the legacy/CI non-image path and RAISES if it ever reaches a real bake — never use it to produce a panel.
Hard do / don't
- Do author
comic.jsonfirst withcomic-author; copyexamples/comic_m3_audit/comic.jsonas the shape. - Do
--dry-runfirst; confirm every baked figure-panel carries ascii-tokenizableexpected_literals(else the fail-closed gate refuses to run). - Don't run two bakes at once. The default
--bake-mode=agentwrites each native PNG to its explicit per-panelout_pathand has no/tmp/aris_imagegen.lock(that code lock is GONE in agent mode — the agent wrapper itself serializes themcp__codex__codexcalls). The real race surface: concurrent runs on the same project/page collide on the per-panel.bakereq.json/.bakestatus.jsonsidecars + the sharedout_path— so keep ONE runner per project/page. (The global generated-images dir that could cross-pollinate concurrent bakes is a hazard of the LEGACY--bake-mode=execpath ONLY, retired for real bakes.) - Don't resume after an image_gen throttle — launch a FRESH run for the remaining panels.
- Don't treat a
needs_human/flagged panel as shippable.
Protocols (governance contracts this skill honors)
reviewer-independence— reviewers get file paths only; the executor (Codex image_gen) ≠ the visual judges (Gemini + Codex vision).acceptance-gate— the loop can DRIVE but can't ACQUIT: a KEEP needs the deterministic token-diff clean and Gemini+Codex visual pass; the generator family never self-acquits; Claude gives the final structural sign-off.artifact-integrity— the model that bakes a frame does not judge its own faithfulness; numbers are verified, never originated.review-tracing— every reviewer's prompt+response is saved (trace.jsonl) so each verdict is auditable.reviewer-routing— Codexgpt-5.5xhigh; Geminiauto-gemini-3; never downgrade the reviewer tier.
Files (aris-movie-director)
-
scripts
-
run_comic.py 57.9 KB
#!/usr/bin/env python3 """run_comic.py — stdlib subprocess port of the COMIC branch of packages/core/spiral_engine.js. FAITHFUL PORT NOTICE Stdlib + subprocess port of spiral_engine.js. Two distinct seams: the GATES shell the `codex` and `gemini` CLIs directly (the runtime's reviewer agent() calls become subprocess calls), but the BAKE is the agent's `mcp__codex__codex` sidecar — generate_panel emits a bake request and polls <out>.bakestatus.json that the agent wrapper writes (exec self-baking hand-draws a non-native fallback, so the exec path RAISES before any bake). `panel_verdict()` and `assembly_verdict()` are ported LINE-FOR-LINE from the engine — the gate is the contract; do not paraphrase it. Mirrors skills/method-figure/scripts/run_spiral.py (shared sh/_Fail/extract_json/sha/log + the same agent-MCP sidecar bake seam) and reuses pickup_image.py. Per panel: render condition.content_svg -> blueprint PNG -> agent mcp__codex__codex sidecar bake (blueprint + identity ref) -> 3 cross-model reviewers (CC narrative ‖ Gemini visual ‖ Codex visual) -> deterministic panel_verdict -> write wiki nodes -> on KEEP update comic.json image_path/active_attempt_id -> page assembly_gate (cross-frame repair) -> optional viewer build. Caps: 4 bakes/panel + 6 assembly rollbacks. Usage: python3 run_comic.py --project examples/comic_m3_audit --page P02_b08 --panels S12,S13,S14,S15 [--bake-lang zh] [--max-total 4] [--max-rollbacks 6] [--finalize] [--dry-run] [--skip-assembly] The generator family (the agent mcp__codex__codex sidecar bake) can NOT self-acquit: faithfulness is the deterministic token-diff over Gemini+Codex blind transcriptions. The calling agent (Claude) gives the final structural sign-off; this orchestrator prints a run-report JSON and never claims that acquittal for itself. """ import argparse, hashlib, json, os, re, shlex, shutil, subprocess, sys, time from datetime import datetime, timezone HERE = os.path.dirname(os.path.abspath(__file__)) ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") PATH_RE = re.compile(r"^/[A-Za-z0-9._/-]+$") REL_SVG_RE = re.compile(r"^[\w][\w./-]*\.svg$") REL_PNG_RE = re.compile(r"^[\w][\w./-]*\.png$") def find_chrome(): """No hardcoded Chrome path: $ARIS_CHROME > macOS app path > PATH lookup (the cli/preflight.py candidates). Hard error if none found — a missing rasterizer is an environment fault, not a per-panel retry.""" for c in (os.environ.get("ARIS_CHROME"), "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"): if c and os.path.exists(c): return c for c in ("google-chrome", "chromium", "chromium-browser", "chrome"): p = shutil.which(c) if p: return p sys.exit("no headless Chrome/Chromium found — set $ARIS_CHROME to a Chrome binary or install one of " "google-chrome/chromium/chromium-browser/chrome (same candidates as cli/preflight.py)") # contract-v2 §0a: import the SHARED bake primitives from pickup_image.py (the single source of truth — NEVER # re-define them here; an inlined copy re-introduces the cross-engine drift this rewire exists to kill). The # pickup path is the same rsplit recipe derive_paths() uses for paths["PICKUP"]. sys.path.insert(0, HERE.rsplit("/skills/", 1)[0] + "/skills/method-figure/scripts") from pickup_image import build_bake_prompt, emit_bake_request, await_bake_status, _status_path # noqa: E402 # ── shared infra (copied verbatim from run_spiral.py) ── def log(msg): print(f"[run_comic {time.strftime('%H:%M:%S')}] {msg}", flush=True) # B-NONCE: forward the per-bake nonce to the pickup verifier via --request-id so it can reject a stale/foreign bake # correlated to the WRONG request. The shared pickup_image.py is the single source of truth (contract-v2 §0a) and is # OWNED BY ANOTHER TASK — we must not edit it. Its emit_bake_request RETURNS a uuid4-hex request_id (stamped into the # bakereq payload) and its --out-existing verifier fail-closes when the bakestatus request_id ≠ --request-id. The # shipped pickup genuinely supports --request-id, so we forward it UNCONDITIONALLY (mirroring run_spiral.py line 333): # (1) capture the returned id, falling back to the request's own marker (the created_at epoch used by --created-at) # only if a future/older emit ever returns falsy, and (2) always append --request-id to the pickup subprocess. A # prior one-shot `--help` probe gated the flag and could be poisoned by a single transient sh() failure (it cached # False and silently DROPPED the nonce for the whole run) — removed; the flag is part of the contract pickup ships. class _Fail: def __init__(self, msg): self.returncode = 1; self.stdout = ""; self.stderr = msg def sh(cmd, timeout, **kw): try: return subprocess.run(cmd, timeout=timeout, capture_output=True, text=True, **kw) except subprocess.TimeoutExpired as e: return _Fail(f"timeout after {timeout}s: {e}") except Exception as e: return _Fail(f"subprocess error: {e}") def sha(path): return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16] if os.path.exists(path) else "" def now_utc(): """created_at for EVERY wiki node write — real wall-clock UTC, never a hardcoded stamp (provenance must be honest).""" return datetime.now(timezone.utc).isoformat(timespec="seconds") # clamp every reviewer score to a finite 0..5 (the engine's SCORE schema bound). The pure-subprocess port has # NO schema validation, so a stray 50 could KEEP and a numeric string could crash min(); out-of-range/non-numeric # → None (fail-closed: the verdict's completeness check then forces a retry). Numeric strings ("4") → 4.0. SCORE_KEYS = ("identity_consistency", "style_consistency", "composition_readability", "artifact_severity", "baked_text_quality", "narrative_beat_fidelity", "composition_story", "reading_order", "page_rhythm", "cross_panel_identity", "cross_panel_style", "text_fits_safezone", "baked_text_legibility") def clamp_scores(rv): if not isinstance(rv, dict): return rv for k in SCORE_KEYS: if k in rv: try: f = float(rv[k]) except (TypeError, ValueError): rv[k] = None; continue rv[k] = f if 0 <= f <= 5 else None return rv def extract_json(text, required_key=None): """pull the first JSON object that (optionally) has required_key — uses a real JSON decoder so braces inside string values (e.g. observed_literals, repair text) don't break parsing; skips prose preambles.""" dec = json.JSONDecoder() s = text.find("{") while s != -1: try: j, _ = dec.raw_decode(text[s:]) if isinstance(j, dict) and (required_key is None or required_key in j): return j except Exception: pass s = text.find("{", s + 1) return None # ── paths + ids (ported from engine 15-48) ── def derive_paths(project, repo): PROJ = os.path.abspath(project) REPO = os.path.abspath(repo) if repo else re.sub(r"/examples/[^/]+/?$", "", PROJ) if not PATH_RE.match(PROJ): sys.exit(f"unsafe --project (abs path, no spaces/metachars, {PATH_RE.pattern}): {PROJ}") if not PATH_RE.match(REPO): sys.exit(f"unsafe --repo: {REPO}") # canonical identity: movie.project.json identity_ref (resolved project-relative) wins over the historical # constant; per-panel cfg.identity_ref still overrides BOTH at bake/review time (precedence unchanged). # HARDENED: project-relative ONLY (no absolute paths, no ".." traversal) and the resolved file must EXIST — # a phantom/escaping identity_ref logs a warning and falls back to the default canon (never bake a phantom). canon = PROJ + "/assets/duo_canonical_ref_v001.png" try: ir = (json.load(open(PROJ + "/movie.project.json", encoding="utf-8")) or {}).get("identity_ref") if isinstance(ir, str) and ir: if ir.startswith("/") or ".." in ir: log(f"⚠ movie.project.json identity_ref REJECTED (absolute/traversal — project-relative only): {ir!r} → default canon") elif not os.path.isfile(PROJ + "/" + ir): log(f"⚠ movie.project.json identity_ref not found on disk: {PROJ + '/' + ir} → default canon") else: canon = PROJ + "/" + ir except (OSError, ValueError): pass # runtime node-id suffix: derived from comic.json comic_id, slugified to node_schema's [a-z0-9_-] charset; # the historical "_aris_comic_v1" literal ONLY when comic_id is absent/unreadable. suffix = "_aris_comic_v1" try: cid = str((json.load(open(PROJ + "/comic.json", encoding="utf-8")) or {}).get("comic_id") or "") slug = re.sub(r"[^a-z0-9_-]+", "_", cid.lower()).strip("_-") if slug: suffix = "_" + slug except (OSError, ValueError): pass return { "PROJ": PROJ, "REPO": REPO, "PANELS": PROJ + "/panels", "NODES": PROJ + "/wiki/nodes", "CANON": canon, "BIBLE": PROJ + "/ART_BIBLE.md", "COMICJSON": PROJ + "/comic.json", "SUFFIX": suffix, "PICKUP": HERE.rsplit("/skills/", 1)[0] + "/skills/method-figure/scripts/pickup_image.py", "BUILD": REPO + "/packages/viewer/build_comic.py", } def validate_ids(page, panel_ids): if not ID_RE.match(page): sys.exit(f"unsafe page id: {page!r}") for p in panel_ids: if not ID_RE.match(p): sys.exit(f"unsafe panel id: {p!r}") def rel_img(paths, p): if not p: return "" return p[len(paths["PROJ"]) + 1:] if p.startswith(paths["PROJ"] + "/") else p def abs_img(paths, p): if not p: return "" return p if p.startswith("/") else paths["PROJ"] + "/" + p # ── conditions: read comic.json directly (no agent round-trip; the sandbox-fs reason is gone) ── def loc_text(x, lang): if x is None: return "" if isinstance(x, str): return x if isinstance(x, dict): return x.get(lang) or x.get("en") or x.get("zh") or "" return str(x) def load_conditions(comic, panel_ids, bake_lang): panels = comic.get("panels", {}) out = {} for pid in panel_ids: p = panels.get(pid, {}) or {} c = p.get("condition", {}) or {} bubbles = [] for b in p.get("bubbles", []) or []: t = loc_text(b.get("text"), bake_lang) if t and t.strip(): bubbles.append(f"{b.get('speaker','')} says: {t}") chibi = c.get("chibi_action", {}) or {} out[pid] = { "text_mode": p.get("text_mode") or comic.get("defaults", {}).get("text_mode") or "html", "content_svg": c.get("content_svg"), "world": c.get("world", ""), "scene": c.get("scene", ""), "identity_ref": c.get("identity_ref"), "identity_desc": c.get("identity_desc", ""), "characters": c.get("characters", ""), "chibi_executor": chibi.get("executor", ""), "chibi_reviewer": chibi.get("reviewer", ""), "bubbles_all": bubbles, "expected_literals": c.get("expected_literals", []) or [], } return out def cfg_usable(c): # FAIL-CLOSED: EVERY panel needs a content_svg blueprint (engine contract — null is rejected); a baked figure # ALSO needs ascii-tokenizable expected_literals (engine 346-348). (was: an html panel with no svg slipped through.) if not c or not c.get("text_mode"): return False if not c.get("content_svg"): return False if c["text_mode"] != "baked": return True exp = c.get("expected_literals") return isinstance(exp, list) and len(exp) > 0 and all( isinstance(e, str) and re.findall(r"[a-z0-9+._-]+", e.lower()) for e in exp) # ── deterministic verdicts — PORTED LINE-FOR-LINE from spiral_engine.js (do not paraphrase) ── def _num(x, d=0): return x if isinstance(x, (int, float)) else d def panel_verdict(cc, gem, cdx, cfg): cc, gem, cdx, cfg = cc or {}, gem or {}, cdx or {}, cfg or {} mode = cfg.get("text_mode") or "html" if cc.get("timed_out") or gem.get("timed_out") or cdx.get("timed_out"): return {"v": "retry_panel", "reason": "a reviewer timed out — fail-safe retry", "invariant": ""} vis_core = lambda r: [r.get("identity_consistency"), r.get("style_consistency"), r.get("composition_readability"), r.get("artifact_severity")] if not all(all(x is not None for x in vis_core(r)) for r in (gem, cdx)): return {"v": "retry_panel", "reason": "a visual reviewer returned incomplete core scores (fail-closed)", "invariant": ""} baked = mode == "baked" narr = min(_num(cc.get("narrative_beat_fidelity"), 0), _num(cc.get("composition_story"), 5)) idents = [r.get("identity_consistency") for r in (gem, cdx) if r.get("identity_consistency") is not None] styles = [r.get("style_consistency") for r in (gem, cdx) if r.get("style_consistency") is not None] comps = [r.get("composition_readability") for r in (gem, cdx) if r.get("composition_readability") is not None] min_ident = min(idents) if idents else 0 min_style, max_style = (min(styles), max(styles)) if styles else (0, 0) min_comp, max_comp = (min(comps), max(comps)) if comps else (0, 0) gem_art, cdx_art = _num(gem.get("artifact_severity")), _num(cdx.get("artifact_severity")) artifact_bad = (gem_art >= 4 and cdx_art >= 3) or (cdx_art >= 4 and gem_art >= 3) disagree = abs(_num(gem.get("identity_consistency")) - _num(cdx.get("identity_consistency"))) style_ok = min_style >= 4 or (min_style >= 3 and max_style >= 4) comp_ok = min_comp >= 4 or (min_comp >= 3 and max_comp >= 4) repair = cdx.get("failure_mode_positive_invariant") or gem.get("failure_mode_positive_invariant") or "" anatomy_defect = gem.get("anatomy_defect_present") is True or cdx.get("anatomy_defect_present") is True if baked: btqs = [gem.get("baked_text_quality"), cdx.get("baked_text_quality")] both_btq = all(x is not None for x in btqs) min_btq = min(btqs) if both_btq else 0 corruption = gem.get("content_corruption_present") is True or cdx.get("content_corruption_present") is True def sets(arr): if not isinstance(arr, list): return None cset, fset = set(), set() for s in arr: if not isinstance(s, str): continue t = s.lower() for x in re.findall(r"[a-z0-9+._:|/-]+", t): cset.add(x) # COARSE: keep : | / so timestamps/versions/paths stay WHOLE for x in re.findall(r"[a-z0-9]+", t): fset.add(x) joined = re.sub(r"\s+", " ", " ".join(s.lower() for s in arr if isinstance(s, str))) return (cset, fset, joined) gem_s, cdx_s = sets(gem.get("observed_literals")), sets(cdx.get("observed_literals")) reviewers_ok = gem_s is not None and cdx_s is not None structured = lambda e: bool(re.search(r"[0-9:]", e)) # number / timestamp / version / code → EXACT coarse token only def hit(e, S): e = e.lower().strip() if " " in e: return e in S[2] # multi-word phrase → ordered substring (no token reshuffle) if structured(e): return e in S[0] # "+6.25"/"t-24:00:01" must NOT satisfy "+6.2"/"t-24:00:00" if e in S[0]: return True parts = re.findall(r"[a-z0-9]+", e) return len(parts) > 0 and all(p in S[1] for p in parts) _el = cfg.get("expected_literals") exp = [e for e in _el if isinstance(e, str)] if isinstance(_el, list) else [] missing = [e for e in exp if not (hit(e, gem_s) and hit(e, cdx_s))] if reviewers_ok else exp figure_ungated = bool(cfg.get("content_svg")) and len(exp) == 0 tokens_ok = reviewers_ok and not figure_ungated and len(missing) == 0 text_ok = both_btq and min_btq >= 4 and not corruption and tokens_ok text_reason = f"btq={min_btq if both_btq else 'NO'} corruption={corruption} reviewersOk={reviewers_ok} missing={missing}{' UNGATED' if figure_ungated else ''}" else: safezone = gem.get("safezone_present") is True or cdx.get("safezone_present") is True stray = gem.get("stray_text_present") is True or cdx.get("stray_text_present") is True text_ok = safezone and not stray text_reason = f"safezone={safezone} stray={stray}" reason = (f"[{mode}] narr={narr} identity={min_ident} style={min_style}/{max_style} comp={min_comp}/{max_comp} " f"art(g/c)={gem_art}/{cdx_art} {text_reason} anatomyDefect={anatomy_defect} disagreeId={disagree}") if narr >= 4 and min_ident >= 4 and style_ok and comp_ok and not artifact_bad and text_ok and not anatomy_defect and disagree < 2: return {"v": "keep", "reason": "KEEP — " + reason, "invariant": ""} return {"v": "retry_panel", "reason": "RETRY — " + reason, "invariant": repair} def assembly_verdict(cc, gem, mode="html"): cc, gem = cc or {}, gem or {} text_dim = _num(gem.get("baked_text_legibility")) if mode == "baked" else _num(gem.get("text_fits_safezone")) dims = [_num(cc.get("reading_order")), _num(cc.get("page_rhythm")), _num(gem.get("cross_panel_identity")), _num(gem.get("cross_panel_style")), text_dim] min_dim, ssum = min(dims), sum(dims) drift = [x for x in (gem.get("drift_panels") or []) if isinstance(x, str)] if min_dim < 2 or ssum < 16: return {"v": "rollback", "reason": f"[{mode}] assembly weak (min={min_dim} sum={ssum}/25)", "drift_panels": drift, "point_of_divergence": (drift[0] if drift else None)} return {"v": "accept", "reason": f"[{mode}] assembly ok (min={min_dim} sum={ssum}/25)", "drift_panels": [], "point_of_divergence": None} # ── generation: render content_svg -> blueprint PNG -> agent mcp__codex__codex sidecar bake -> pickup ── def get_bake_plan(args=None): """CANONICAL bake plan (contract bakereq/v1) — the SINGLE source: emit_bake_request's model/config/sandbox payload is built FROM this dict, and the p0_proof cert binds to bake_plan_digest(get_bake_plan(args)) (_p0_clean; run_p0_proof.py mints against get_bake_plan(<its own argparse namespace>) — its --min-bytes/ --bake-timeout defaults MUST stay in sync with parse_args here). Change a knob here and any prior cert fail-closes — never edit the emit payload independently; a non-default run needs a matching-flag cert.""" return {"contract": "bakereq/v1", "model": "gpt-5.5", "effort": "xhigh", "include_image_gen_tool": True, "sandbox": "workspace-write", "min_bytes": getattr(args, "min_bytes", 500000), "aspect": "1280x720", "bake_timeout": getattr(args, "bake_timeout", 600)} def bake_prompt_body(cfg, bake_lang, invariants): id_desc = cfg.get("identity_desc") or "the CANONICAL DUO — blue executor: brown hair, NO beard; green reviewer: dark hair, beard" chars = cfg.get("characters") or " ".join(x for x in [ f"executor action: {cfg['chibi_executor']}." if cfg.get("chibi_executor") else "", f"reviewer action: {cfg['chibi_reviewer']}." if cfg.get("chibi_reviewer") else ""] if x) bubbles = [b for b in (cfg.get("bubbles_all") or []) if isinstance(b, str) and b.strip()] if bubbles: bubble_line = f"Bake these speech bubbles as clean readable comic lettering ({bake_lang}): " + " || ".join(bubbles) else: bubble_line = "This panel has NO speech bubbles — bake NO dialogue text (the technical figure's text is the only text)." repair = [i for i in (invariants or []) if i] lines = [ f"WORLD = {cfg.get('world')} → apply the ART_BIBLE §0.5 two-world lighting (warm-lab = warm Edison-glow human world; dark-cyber = dark screen-lit digital world).", f"SCENE: {cfg.get('scene')}", f"Render the character(s) from reference image #2 EXACTLY as depicted there — {id_desc}. {chars}", "Integrate the technical figure from reference image #1 onto a board/screen/dashboard in the scene. Reproduce its numbers, labels and code EXACTLY as they appear in image #1 — do NOT alter, re-spell, round, or invent any number or token.", bubble_line, "ONE coherent flat pixel-art comic panel. No watermark. No UI text beyond the integrated figure and the bubbles (if any).", ("REPAIR CONSTRAINTS (earlier attempts failed for these reasons — honor every one): " + " | ".join(repair)) if repair else "", ] return "\n".join(x for x in lines if x) def generate_panel(paths, pid, cfg, ai, invariants, args, bakelog): # bakelog: exec-legacy / unused on the live agent seam aTag = "a" + str(ai).zfill(2) out = f"{paths['PANELS']}/{pid}_panel_{aTag}.png" cdir = f"{paths['PANELS']}/_content_refs" cpng = f"{cdir}/{pid}_{aTag}.png" svg_rel = cfg.get("content_svg") or "" if not REL_SVG_RE.match(svg_rel) or ".." in svg_rel: return {"status": "generation_failed", "image_path": "", "gen_failed_reason": f"unsafe/empty content_svg: {svg_rel!r}"} id_rel = cfg.get("identity_ref") or "" if id_rel and (not REL_PNG_RE.match(id_rel) or ".." in id_rel): return {"status": "generation_failed", "image_path": "", "gen_failed_reason": f"unsafe identity_ref: {id_rel!r}"} id_ref_abs = f"{paths['PROJ']}/{id_rel}" if id_rel else paths["CANON"] # FAIL-CLOSED (mirror run_spiral.py's `if a.identity and not os.path.exists(a.identity): sys.exit(2)`): when an # identity ref is CONFIGURED (id_rel set), the resolved file MUST exist on disk before it is embedded (as a # literal path) in the bake prompt — a missing/typoed identity_ref would otherwise silently desync the agent's # write target from our pickup probe AND waste a metered bake. Skip BEFORE emitting the bakereq via the file's # existing generation_failed path. (The implicit CANON fallback is NOT existence-gated here, matching run_spiral, # which only existence-checks the explicit --identity, never its defaults.) if id_rel and not os.path.exists(id_ref_abs): return {"status": "generation_failed", "image_path": "", "failure_kind": "other", "gen_failed_reason": f"identity_ref not found on disk: {id_ref_abs}"} os.makedirs(cdir, exist_ok=True) # STEP 1 — render the content blueprint SVG -> persistent PNG (deterministic; no lock needed) rc = sh([find_chrome(), "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=2", "--window-size=1280,720", f"--screenshot={cpng}", f"file://{paths['PROJ']}/{svg_rel}"], 45) if not os.path.exists(cpng) or os.path.getsize(cpng) < 1000: return {"status": "generation_failed", "image_path": "", "gen_failed_reason": f"content_svg render failed (need headless Chrome): {getattr(rc,'stderr','')[:200]}"} # STEP 2 — assemble prompt = ART_BIBLE + body, then BAKE via the AGENT (mcp__codex__codex) over the sidecar # seam. R4: the exec path NEVER bakes (codex exec hand-draws a non-native fallback) — it RAISES. R3: in agent # mode the core does NOT hold /tmp/aris_imagegen.lock (the agent wrapper is the sole serializer; the # foreground core blocking on the agent that must service the sidecar would deadlock). if args.bake_mode != "agent": raise RuntimeError("exec bake retired — it hand-draws a non-native fallback; use --bake-mode=agent") bible = open(paths["BIBLE"], encoding="utf-8").read() if os.path.exists(paths["BIBLE"]) else "" body = (f"Paste these style rules first:\n{bible}\n\nThen render this panel:\n" if bible else "") + bake_prompt_body(cfg, args.bake_lang, invariants) # paths+out_path are LITERAL in prompt_text (the mcp__codex__codex schema has no -i image param) prompt_text = build_bake_prompt(body, content_png_abs=cpng, identity_ref_abs=id_ref_abs, out_path_abs=out) created_at = time.time() # A: include_image_gen_tool MUST sit in the config payload next to model_reasoning_effort — without it the agent # wrapper will not hand the native image tool to codex (repo contract) and the bake never fires. Every knob below # comes FROM get_bake_plan(args) (the p0-bound single source) — never inline a model/effort/sandbox here. plan = get_bake_plan(args) aw, ah = (int(x) for x in plan["aspect"].split("x")) request_id = emit_bake_request(out, {"prompt_text": prompt_text, "out_path": out, "content_png": cpng, "identity_ref": id_ref_abs, "model": plan["model"], "config": {"model_reasoning_effort": plan["effort"], "include_image_gen_tool": plan["include_image_gen_tool"]}, "sandbox": plan["sandbox"], "cwd": paths["PROJ"], "created_at": created_at, "min_bytes": plan["min_bytes"], "aspect": aw / ah}) # B: capture the request_id RETURNED by emit_bake_request (a uuid4 hex it stamps into the bakereq + returns) and # forward it to the pickup verifier below. Fall back to the request's own marker (created_at) only if it's falsy. if not request_id: request_id = str(created_at) status = await_bake_status(out, plan["bake_timeout"]) # polls <out>.bakestatus.json (agent wrapper writes it); {} on timeout # C (BLOCKER-defense): harden against a non-dict status (None/list/str from a future wrapper or a corrupt # status file) so every status.get(...) below is total — a non-dict here would crash the whole panel loop. if not isinstance(status, dict): status = {} # FAIL-CLOSED: ONLY status=="ok" may proceed to verify. Empty/timeout/missing-wrapper/any-non-ok => no pickup, # no chance of accepting a stale/foreign PNG already at out_path. A timeout is "other", NOT "throttle" # (only an explicit wrapper failure_kind=="throttle" is a throttle — consistent with run_spiral.py). if status.get("status") != "ok": kind = "throttle" if status.get("failure_kind") == "throttle" else "other" reason = "agent bake timed out (no <out>.bakestatus.json — is the agent wrapper running?)" if not status \ else f"agent bake not ok [{kind}]: {str(status.get('mcp_error') or status.get('status'))[:200]}" return {"status": "generation_failed", "image_path": "", "failure_kind": kind, "gen_failed_reason": reason} # HARD-VETO PRECONDITION (contract-v2 §4): status==ok ALONE is not enough — the HARD-VETO scans mcp_output # (the full codex transcript, where a hand-draw leaves struct/zlib/PIL/SVG traces) for non-native fallbacks. # An ok status with a missing/empty mcp_output would make the veto INERT (nothing to scan) and could wave a # foreign/hand-drawn PNG through. Fail-closed exactly like a bake failure BEFORE the pickup call. NB: a bare # str() coercion lets a JSON null slip past (str(None)=="None" is truthy) — require a real non-empty str. m = status.get("mcp_output") if not isinstance(m, str) or not m.strip(): return {"status": "generation_failed", "image_path": "", "failure_kind": "other", "gen_failed_reason": "agent status ok but mcp_output missing/empty/non-string — HARD-VETO inert, fail-closed"} # verify the EXPLICIT out_path; HARD-VETO scans the transcript (the status file's raw mcp_output, contract-v2 §4) # B: forward the per-bake nonce UNCONDITIONALLY (mirror run_spiral.py line 333) so the verifier can reject a bake # correlated to the WRONG request. The shipped pickup supports --request-id (contract-v2 §0a); no probe is needed # (a probe could be poisoned by a transient sh() failure and silently drop the nonce for the entire run). pk = sh(["python3", paths["PICKUP"], "--out-existing", "--out", out, "--min-bytes", str(plan["min_bytes"]), "--aspect", str(aw / ah), "--created-at", str(created_at), "--request-id", str(request_id), "--transcript", _status_path(out)], 60) if pk.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > args.min_bytes: pj = extract_json(pk.stdout) or {} return {"status": "panel_ready", "image_path": out, "bytes": os.path.getsize(out), "image_sha256": pj.get("sha256") or sha(out), "text_mode": cfg.get("text_mode")} return {"status": "generation_failed", "image_path": "", "failure_kind": "other", "gen_failed_reason": f"no native image (pickup rc={pk.returncode}): {(pk.stderr or '')[:200]}"} def is_throttle(gen, reason=""): if not gen: return False if gen.get("failure_kind") == "throttle": return True return not gen.get("failure_kind") and bool(re.search(r"rate|limit|throttl|server|unavailable|429|quota", reason or "", re.I)) # ── the 3 cross-model reviewers (ported engine 274-316) ── def _vis_json(baked): if baked: return ('{"identity_consistency":n,"style_consistency":n,"composition_readability":n,"artifact_severity":n,' '"baked_text_quality":n,"observed_literals":["verbatim tokens you can READ — never guess"],' '"content_corruption_present":false,"character_hand_count":["blue:2","green:2"],"anatomy_defect_present":false,' '"failure_mode_positive_invariant":""}') return ('{"identity_consistency":n,"style_consistency":n,"composition_readability":n,"artifact_severity":n,' '"safezone_present":false,"stray_text_present":false,"character_hand_count":["blue:2","green:2"],' '"anatomy_defect_present":false,"failure_mode_positive_invariant":""}') ANATOMY = (' If the panel has characters, ENUMERATE each one\'s visible hands and set anatomy_defect_present=true on any ' 'wrong count (!=2)/third/floating/merged hand (single-vote veto).') def _id_desc(cfg): return cfg.get("identity_desc") or "blue executor: brown hair, NO beard; green reviewer: dark hair, beard" def review_cc(paths, pid, cfg, img, args): # CC scores STORY; it may glance at the actual panel to judge composition_story (engine 284). p = (f'You are the CC NARRATIVE reviewer in ARIS comic panel_gate for {pid} (mode={cfg.get("text_mode")}). ' f'Judge STORY only — you may glance at the attached panel image but score narrative, not pixels. ' f'Read panel {pid} from "{paths["COMICJSON"]}": its .condition.scene, .bubbles, .caption, and the page beat. ' f'Score 0-5: narrative_beat_fidelity, composition_story. Output ONLY one JSON line: ' '{"narrative_beat_fidelity":n,"composition_story":n,"timed_out":false}') r = sh(["codex", "exec", p, "-i", img, "--sandbox", "read-only", "-c", f"model_reasoning_effort={args.review_effort}", "--skip-git-repo-check"], args.review_timeout) return extract_json((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or ""), required_key="narrative_beat_fidelity") or {"timed_out": True} def review_gemini(paths, pid, img, idref, id_desc, baked, args): shape = _vis_json(baked) p = (f"@{img} @{idref} @{paths['BIBLE']} Review this ARIS comic panel ({pid}). Image #1 is the panel; image #2 is the " f"identity reference ({id_desc}) — judge each character against it. Honor ART_BIBLE. Score 0-5. baked={baked}: if baked, " f"observed_literals = verbatim transcription of every number/label/code you can READ (never guess); content_corruption_present = " f"any garbled/illegal token.{ANATOMY} Output ONLY one JSON line: {shape}") r = sh(shlex.split(args.gemini_cmd) + ["--model", "auto-gemini-3", "-p", p], args.review_timeout) return extract_json((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or ""), required_key="identity_consistency") or {"timed_out": True} def review_codex(paths, pid, img, idref, id_desc, bible_excerpt, baked, args): # NOTE: codex `-i` is --image; do NOT pass ART_BIBLE.md (a .md) via -i. Inline the style rules as text instead; # attach only the two images (panel + identity ref). shape = _vis_json(baked) p = (f"Review this ARIS comic panel ({pid}). Image #1 is the panel; image #2 is the identity reference " f"({id_desc}) — judge each character against it. Score 0-5. baked={baked}: if baked, observed_literals = verbatim " f"transcription of numbers/labels/code you can READ (never guess); content_corruption_present = garbled tokens.{ANATOMY} " f"Style rules to honor:\n{bible_excerpt}\nOutput ONLY one JSON line: {shape}") r = sh(["codex", "exec", p, "-i", img, "-i", idref, "--sandbox", "read-only", "-c", f"model_reasoning_effort={args.review_effort}", "--skip-git-repo-check"], args.review_timeout) return extract_json((getattr(r, "stdout", "") or "") + (getattr(r, "stderr", "") or ""), required_key="identity_consistency") or {"timed_out": True} def panel_gate(paths, pid, gen, cfg, ai, args): mode = cfg.get("text_mode") or gen.get("text_mode") or "html" baked = mode == "baked" img = abs_img(paths, gen["image_path"]) id_rel = cfg.get("identity_ref") if cfg.get("identity_ref") and REL_PNG_RE.match(cfg.get("identity_ref") or "") and ".." not in (cfg.get("identity_ref") or "") else "" idref = f"{paths['PROJ']}/{id_rel}" if id_rel else paths["CANON"] id_desc = _id_desc(cfg) bible = (open(paths["BIBLE"], encoding="utf-8").read()[:2500] if os.path.exists(paths["BIBLE"]) else "") cc = clamp_scores(review_cc(paths, pid, cfg, img, args)) gem = clamp_scores(review_gemini(paths, pid, img, idref, id_desc, baked, args)) cdx = clamp_scores(review_codex(paths, pid, img, idref, id_desc, bible, baked, args)) return {"cc": cc, "gem": gem, "cdx": cdx, "verdict": panel_verdict(cc, gem, cdx, cfg)} # ── persistence: wiki nodes + comic.json (reimplemented as direct file writes; engine 311-330) ── def add_edges(paths, edges): """Append runtime edges to wiki/edges.jsonl (dedup by (src,dst,type)) so validate_wiki sees resolved attempt_of / reviews / decides / failure_of provenance for what run_comic actually wrote.""" ep = paths["PROJ"] + "/wiki/edges.jsonl" existing = set() if os.path.exists(ep): for line in open(ep, encoding="utf-8"): line = line.strip() if not line: continue try: e = json.loads(line); existing.add((e.get("src"), e.get("dst"), e.get("type"))) except Exception: pass with open(ep, "a", encoding="utf-8") as f: for s, d, t in edges: if (s, d, t) not in existing: f.write(json.dumps({"src": s, "dst": d, "type": t}, ensure_ascii=False) + "\n"); existing.add((s, d, t)) def write_wiki(paths, pid, gen, gate, ai, gemini_family): os.makedirs(paths["NODES"], exist_ok=True) sl = pid.lower(); aTag = "a" + str(ai).zfill(2); v = gate["verdict"]["v"] ts = now_utc() panel_node = f"panel:{sl}{paths['SUFFIX']}"; attempt_node = f"attempt:{sl}_{aTag}" def w(name, obj): json.dump(obj, open(f"{paths['NODES']}/{name}.json", "w"), ensure_ascii=False, indent=1) w(f"panel_attempt_{sl}_{aTag}", {"node_id": attempt_node, "node_type": "panel_attempt", "status": "under_review", "title": f"{pid} panel attempt {ai}", "created_at": ts, "payload": {"source_panel_id": panel_node, "image_path": rel_img(paths, gen["image_path"]), "attempt_index": ai, "model": "codex_image_gen", "is_baked_duo": True}}) edges = [(attempt_node, panel_node, "attempt_of")] for rv, who in ((gate["cc"], "cc"), (gate["gem"], "gemini"), (gate["cdx"], "codex")): rnode = f"review:panel_{sl}_{aTag}_{who}" # schema-valid review payload: target_node_id/reviewer/gate_kind are REQUIRED (validate_wiki); keep the raw # reviewer scores too. (explicit keys last so a malformed reviewer JSON can't clobber the required fields.) w(f"review_panel_{sl}_{aTag}_{who}", {"node_id": rnode, "node_type": "review", "status": "final", "title": f"{who} review {pid} a{ai}", "created_at": ts, "payload": {**(rv if isinstance(rv, dict) else {"raw": rv}), "target_node_id": attempt_node, "reviewer": who, "gate_kind": "panel"}}) edges.append((rnode, attempt_node, "reviews")) dnode = f"decision:panel_{sl}_{aTag}" w(f"decision_panel_{sl}_{aTag}", {"node_id": dnode, "node_type": "decision", "status": "final", "title": f"panel_gate {pid} a{ai} → {v}", "created_at": ts, "payload": {"gate_kind": "panel", "target_node_id": attempt_node, "verdict": v, "reviewer_families": {"cc": "openai", "gemini": gemini_family, "codex": "openai"}, # honest provenance: the narrative "cc" slot CURRENTLY shells the codex CLI (an OpenAI model; a configurable --narrative-reviewer is planned) — cross-family acquittal vs the Claude author still holds via gemini + codex. gemini_family = --gemini-family (whatever --gemini-cmd actually routes to; must stay non-openai) "reasoning": gate["verdict"]["reason"][:300], "repair_instruction": gate["verdict"].get("invariant", "")}}) edges.append((dnode, attempt_node, "decides")) if v != "keep": fnode = f"fail:{sl}_{aTag}" w(f"fail_{sl}_{aTag}", {"node_id": fnode, "node_type": "failure_mode", "status": "active", "created_at": ts, "payload": {"active": True, "affected_shot_ids": [pid], "layer": "panel_visual", "repair_pattern": gate["verdict"].get("invariant", "")}}) edges.append((fnode, attempt_node, "failure_of")) add_edges(paths, edges) def update_comic_json(paths, comic, pid, gen, ai): aTag = "a" + str(ai).zfill(2) panel = comic.setdefault("panels", {}).setdefault(pid, {}) panel["image_path"] = rel_img(paths, gen["image_path"]) panel["active_attempt_id"] = f"{pid}_{aTag}" panel["wiki_node_id"] = f"panel:{pid.lower()}{paths['SUFFIX']}" json.dump(comic, open(paths["COMICJSON"], "w"), ensure_ascii=False, indent=2) # ── assembly_gate + cross-frame repair (ported engine 388-464) ── def run_assembly(paths, page, kept, cfg_map, page_mode, args): ids = ",".join(k["pid"] for k in kept) listimgs = " ".join(f"@{abs_img(paths, k['image_path'])}" for k in kept) cc_p = (f'You are the CC reviewer for ARIS comic PAGE assembly_gate (page {page}). Kept panels: {ids}. ' f'Read the page narration/beat from "{paths["COMICJSON"]}". Score 0-5. Output ONLY one JSON line: ' '{"reading_order":n,"page_rhythm":n}') cc_r = sh(["codex", "exec", cc_p, "--sandbox", "read-only", "-c", f"model_reasoning_effort={args.review_effort}", "--skip-git-repo-check"], args.review_timeout) cc = clamp_scores(extract_json((getattr(cc_r, "stdout", "") or "") + (getattr(cc_r, "stderr", "") or ""), required_key="reading_order") or {"timed_out": True}) refs = [] for k in kept: r = (cfg_map.get(k["pid"]) or {}).get("identity_ref") refs.append(f"{paths['PROJ']}/{r}" if r and REL_PNG_RE.match(r) and ".." not in r else paths["CANON"]) refs = list(dict.fromkeys(refs))[:3] text_key = '"baked_text_legibility":n' if page_mode == "baked" else '"text_fits_safezone":n' # CAST-AWARE (engine 405-414): tell the reviewer each panel's INTENDED cast so a designed cast/world # difference (disjoint casts, warm vs cyber world) is never mis-flagged as drift. cast_lines = " ".join(f"{k['pid']}=「{((cfg_map.get(k['pid']) or {}).get('characters') or (cfg_map.get(k['pid']) or {}).get('identity_desc') or 'the duo')[:90]}」" for k in kept) gp = (" ".join(f"@{r}" for r in refs) + f" These side-by-side comic panels [{ids}]: {listimgs} . Intended cast per panel: {cast_lines} . " "Judge cross-panel DRIFT (same character across the panels where they appear + vs the ref = identity — a panel simply " "NOT containing a character is design, NEVER a penalty; same pixel style = style — warm vs cyber world is design, not drift). " "Output ONLY one JSON line: " '{"cross_panel_identity":n,"cross_panel_style":n,' + text_key + ',"drift_panels":["id of a panel with a real identity break or style collapse, else omit"]}') g_r = sh(shlex.split(args.gemini_cmd) + ["--model", "auto-gemini-3", "-p", gp], args.review_timeout) gem = clamp_scores(extract_json((getattr(g_r, "stdout", "") or "") + (getattr(g_r, "stderr", "") or ""), required_key="cross_panel_identity") or {"timed_out": True}) return {"cc": cc, "gem": gem, "verdict": assembly_verdict(cc, gem, page_mode)} # ── main ── def parse_args(): ap = argparse.ArgumentParser(description="standalone comic spiral (faithful subprocess port of spiral_engine.js)") ap.add_argument("--project", required=True, help="project/example dir (has comic.json, panels/, wiki/)") ap.add_argument("--repo", default="", help="repo root (default: strip /examples/<name> from --project)") ap.add_argument("--page", required=True, help="page id") ap.add_argument("--panels", required=True, help="comma-separated panel ids, e.g. S12,S13,S14,S15") ap.add_argument("--bake-lang", default="zh", help="bubble language baked into the image (zh proven clean)") ap.add_argument("--max-total", type=int, default=4); ap.add_argument("--max-rollbacks", type=int, default=6) ap.add_argument("--strike-escalate", type=int, default=2) # a panel drifting ≥N assembly rounds → author-layer rewrite ap.add_argument("--finalize", action="store_true"); ap.add_argument("--dry-run", action="store_true") ap.add_argument("--skip-assembly", action="store_true") ap.add_argument("--skip-p0-proof", action="store_true", help="bake without a clean decision:p0_proof_* node (UNAUDITED; forces the run non-shippable)") ap.add_argument("--bake-timeout", type=int, default=600); ap.add_argument("--review-timeout", type=int, default=300) ap.add_argument("--review-effort", default="xhigh") # bake effort is get_bake_plan's p0-bound knob, not a flag ap.add_argument("--gemini-cmd", default="gemini", help='command for the SECOND visual reviewer — the legacy gemini CLI, or e.g. "python3 cli/gemini_agy_shim.py" ' "for Antigravity; MUST route to a google-family model (shlex-split; the " '["--model","auto-gemini-3","-p",<prompt>] tail is appended unchanged)') ap.add_argument("--gemini-family", default="google", help="provenance family recorded for that reviewer in decision-node reviewer_families — must differ from " "the codex reviewers' family (openai) or the cross-model quorum provenance is dishonest") ap.add_argument("--min-bytes", type=int, default=500000) ap.add_argument("--bake-mode", choices=["agent", "exec"], default="agent", help="agent = real bake via the mcp__codex__codex sidecar seam (default); exec = legacy/CI non-image path that RAISES if it reaches a real bake (exec hand-draws a non-native fallback)") return ap.parse_args() def main(): args = parse_args() panel_ids = [s.strip() for s in args.panels.split(",") if s.strip()] paths = derive_paths(args.project, args.repo) validate_ids(args.page, panel_ids) if not os.path.exists(paths["COMICJSON"]): sys.exit(f"comic.json missing: {paths['COMICJSON']}") comic = json.load(open(paths["COMICJSON"], encoding="utf-8")) # FAIL-CLOSED (dry-run included): --page must be a real pages[].id and every --panels id must belong to that # page's panel_ids. A strict SUBSET is allowed — throttle-resume runs the remainder of a page. page_ids = [p.get("id") for p in (comic.get("pages") or []) if isinstance(p, dict)] page_def = next((p for p in (comic.get("pages") or []) if isinstance(p, dict) and p.get("id") == args.page), None) if page_def is None: print(f"FAIL-CLOSED: --page {args.page!r} is not a pages[].id in {paths['COMICJSON']} (pages: {page_ids})", file=sys.stderr) sys.exit(2) page_panel_ids = [x for x in (page_def.get("panel_ids") or []) if isinstance(x, str)] rogue = [p for p in panel_ids if p not in page_panel_ids] if rogue: print(f"FAIL-CLOSED: --panels {rogue} not in page {args.page!r}'s panel_ids {page_panel_ids} " f"(a subset IS allowed — throttle-resume runs a remainder)", file=sys.stderr) sys.exit(2) cfg_map = load_conditions(comic, panel_ids, args.bake_lang) missing = [p for p in panel_ids if not cfg_usable(cfg_map.get(p))] if missing: sys.exit(f"FAIL-CLOSED: unusable cfg for {missing} (missing text_mode, missing content_svg blueprint, or a baked figure with no ASCII-tokenizable expected_literals) — refusing to run an ungated gate") if args.dry_run: log(f"dry-run: {len(panel_ids)} panels, all cfg-usable. Printing bake prompts (NO image_gen):") for pid in panel_ids: print(f"\n===== BAKE PROMPT {pid} ({cfg_map[pid]['text_mode']}) =====") print(bake_prompt_body(cfg_map[pid], args.bake_lang, [])) print(f" refs: content_svg={cfg_map[pid]['content_svg']} identity_ref={cfg_map[pid].get('identity_ref') or '(canonical duo)'}") print(f" expected_literals={cfg_map[pid]['expected_literals']}") return # P0-PROOF PREFLIGHT — no metered image credit before the zero-credit proof gate cleared: require a clean # decision:p0_proof_* node in the wiki, OR an explicit --skip-p0-proof ack (which forces the run non-shippable). # The agent bake seam is SYNCHRONOUS and main() never re-enters, so _p0_clean()/p0_skipped is evaluated EXACTLY # ONCE per run and stays valid across every panel + assembly bake — no --p0-proof-clean resume flag is needed. def _p0_clean(): # P0 BINDING (fail-closed): a p0_proof cert acquits ONLY the exact inputs it audited — require the node's # payload.comic_sha == sha256(CURRENT comic.json bytes) AND payload.bake_plan_sha == the canonical digest of # get_bake_plan(args). A node missing either digest, or mismatching, is a stale/foreign cert → REJECTED. try: from pickup_image import bake_plan_digest # lazy: module load / --dry-run never need the p0 digest except ImportError: sys.exit("FAIL-CLOSED: pickup_image.bake_plan_digest missing — cannot verify the p0_proof cert binding; " "update skills/method-figure/scripts/pickup_image.py, then re-run the p0 gate via " "skills/comic-cross-layer-gate/scripts/run_p0_proof.py") comic_sha = hashlib.sha256(open(paths["COMICJSON"], "rb").read()).hexdigest() plan_sha = bake_plan_digest(get_bake_plan(args)) nd = paths["NODES"] if not os.path.isdir(nd): return False for fn in os.listdir(nd): if not fn.endswith(".json") or "p0" not in fn.lower(): continue try: d = json.load(open(os.path.join(nd, fn), encoding="utf-8")) except Exception: continue pl = d.get("payload") or {} node_status = str(d.get("status", "")).lower() pl_status = str(pl.get("status", "")).lower() verdict = str(pl.get("verdict", "")).lower() # D (verdict drift): comic-cross-layer-gate's p0_proof advance emits verdict "advance" and FLIPS the # node status to "locked" (SKILL.md "FLIP target status → locked (advance)"; verified node-level, the # repo's universal locked-signal). The old check demanded status=="final" + a verdict set without # "advance"/"locked" → it rejected EVERY real pass and blocked ALL production baking. Accept: # • verdict ∈ the original clean set PLUS "advance"/"locked" # • OR a "locked" status — the gate's own advance signal — at the node level (where the gate flips it) # OR in the payload (the instruction's literal wording; covers emitters that mirror it there). status_ok = node_status in ("final", "locked") or pl_status == "locked" verdict_ok = verdict in ("pass", "clean", "approve", "keep", "ship", "proceed", "advance", "locked") \ or pl_status == "locked" if d.get("node_type") == "decision" and status_ok \ and str(d.get("node_id", "")).startswith("decision:p0_proof") \ and str(pl.get("gate_kind", "")).lower() == "p0_proof" \ and verdict_ok: if pl.get("comic_sha") == comic_sha and pl.get("bake_plan_sha") == plan_sha: # CONSUMER CHECKS (fail-closed): a real cert (run_p0_proof.py mint) always carries the # counted cross-model quorum (BOTH openai + google) and the target it acquits — a # hand-built/partial payload without them is NOT a certificate, even with correct digests. quorum, tgt = pl.get("reviewer_quorum"), pl.get("target_node_id") if isinstance(quorum, list) and {"openai", "google"} <= {str(q).lower() for q in quorum} \ and isinstance(tgt, str) and tgt.strip(): return True log(f"✗ p0_proof cert {fn} REJECTED: payload.reviewer_quorum must be a list with BOTH " f"'openai' and 'google' and payload.target_node_id a non-empty string — hand-built/" f"partial cert; mint via skills/comic-cross-layer-gate/scripts/run_p0_proof.py") continue log(f"✗ p0_proof cert {fn} REJECTED: comic_sha/bake_plan_sha missing or stale — the cert does not " f"bind the CURRENT comic.json + bake plan; re-run the p0 gate via " f"skills/comic-cross-layer-gate/scripts/run_p0_proof.py") return False p0_skipped = False if not _p0_clean(): if not args.skip_p0_proof: sys.exit("FAIL-CLOSED: no clean decision:p0_proof_* node in the wiki — run the zero-credit p0_proof gate " "(comic-cross-layer-gate <comic> --gate p0_proof) BEFORE spending image credit, " "or pass --skip-p0-proof to bake UNAUDITED (forces the run non-shippable).") p0_skipped = True log("⚠ --skip-p0-proof: baking WITHOUT a p0_proof gate node → run is UNAUDITED and will be marked non-shippable") kept, flagged, total_by, pending = [], [], {}, {} throttled, escalated = False, None for pid in panel_ids: if throttled or escalated: break cfg = cfg_map[pid]; done = False; last_gen = None while not done and total_by.get(pid, 0) < args.max_total: total_by[pid] = total_by.get(pid, 0) + 1; ai = total_by[pid] inv = list(pending.get(pid, [])) log(f"▶ {pid} attempt {ai} [{cfg['text_mode']}]" + (f" +{len(inv)} repair" if inv else "")) bakelog = f"{paths['PANELS']}/{pid}_a{ai}.bakelog.txt" gen = generate_panel(paths, pid, cfg, ai, inv, args, bakelog) if gen.get("status") != "panel_ready" or not gen.get("image_path") or gen.get("bytes", 0) <= args.min_bytes: reason = gen.get("gen_failed_reason", "no image") log(f" ✗ gen failed [{gen.get('failure_kind','?')}]: {reason}") if is_throttle(gen, reason): throttled = True escalated = {"pid": pid, "why": "image_gen throttled mid-run", "resume_allowed": False, "fresh_run_required": True, "resume_from_panel": pid, "how_to_resume": f"wait for cooldown, then launch a FRESH run for the remaining panels starting at {pid}; do NOT reuse cached state"} break continue last_gen = gen gate = panel_gate(paths, pid, gen, cfg, ai, args) write_wiki(paths, pid, gen, gate, ai, args.gemini_family) log(f" panel_gate {pid}#{ai}: {gate['verdict']['v']} — {gate['verdict']['reason']}") if gate["verdict"]["v"] == "keep": update_comic_json(paths, comic, pid, gen, ai) kept.append({"pid": pid, "image_path": gen["image_path"], "image_sha256": gen.get("image_sha256"), "attempt": ai, "needs_human": False}); done = True; break if gate["verdict"].get("invariant"): pending[pid] = pending.get(pid, []) + [gate["verdict"]["invariant"]] if throttled: break if not done: if last_gen: kept.append({"pid": pid, "image_path": last_gen["image_path"], "image_sha256": last_gen.get("image_sha256"), "attempt": total_by[pid], "needs_human": True}); flagged.append(pid) log(f" ⚑ {pid} exhausted {args.max_total} attempts → best-so-far flagged for HUMAN, continuing") else: escalated = escalated or {"pid": pid, "why": "no panel ever generated (image_gen down?)"}; break asm = None drift_strikes, rewrite_storyboard = {}, [] # strike-escalation + KEEP≠final author-layer bounce-backs page_mode = "baked" if any((cfg_map.get(k["pid"]) or {}).get("text_mode") == "baked" for k in kept) else "html" if not escalated and len(kept) >= 2 and not args.skip_assembly: asm_round = 0 while True: asm = run_assembly(paths, args.page, kept, cfg_map, page_mode, args) d = asm["verdict"].get("drift_panels", []) log(f" assembly_gate {args.page} (round {asm_round}): {asm['verdict']['v']} — {asm['verdict']['reason']}" + (f" drift={d}" if d else "")) if asm["verdict"]["v"] == "accept": break if asm_round >= args.max_rollbacks: flagged.append(f"{args.page}:assembly"); break drifters = [p for p in d if any(k["pid"] == p for k in kept)] if not drifters: flagged.append(f"{args.page}:assembly"); break # STRIKE-ESCALATION: a panel that keeps drifting is a storyboard/blueprint problem → author-layer # rewrite, not more bakes. (Comic = independent panels; the escalation unit is the single panel.) for pid in drifters: drift_strikes[pid] = drift_strikes.get(pid, 0) + 1 for pid in [p for p in drifters if drift_strikes[p] >= args.strike_escalate]: if pid not in rewrite_storyboard: rewrite_storyboard.append(pid) if pid not in flagged: flagged.append(pid) log(f" ⚑ {pid} drifted {drift_strikes[pid]}× (≥{args.strike_escalate}) → ESCALATE to author layer (rewrite_storyboard_from {pid}); a re-bake can't fix a spec problem") rebakeable = [p for p in drifters if drift_strikes[p] < args.strike_escalate] if not rebakeable: log(" ⚑ all remaining drifters escalated to an author-layer rewrite → stop"); break log(f" ⤺ cross-frame repair round {asm_round + 1}: re-bake {rebakeable} (point_of_divergence={asm['verdict'].get('point_of_divergence') or rebakeable[0]})") any_fixed = False for pid in rebakeable: if throttled: break if total_by.get(pid, 0) >= args.max_total + args.max_rollbacks: if pid not in flagged: flagged.append(pid) continue ai = total_by.get(pid, 0) + 1; total_by[pid] = ai inv = [f"CROSS-PANEL CONSISTENCY REPAIR: this panel drifted from page {args.page} — match the canonical duo identity AND the page's flat pixel-art style EXACTLY. Assembly: {asm['verdict']['reason']}"] + pending.get(pid, []) bakelog = f"{paths['PANELS']}/{pid}_a{ai}.bakelog.txt" gen = generate_panel(paths, pid, cfg_map[pid], ai, inv, args, bakelog) if gen.get("status") != "panel_ready" or gen.get("bytes", 0) <= args.min_bytes: if is_throttle(gen, gen.get("gen_failed_reason", "")): throttled = True; escalated = {"pid": pid, "why": "throttled during cross-frame repair"} continue gate = panel_gate(paths, pid, gen, cfg_map[pid], ai, args) write_wiki(paths, pid, gen, gate, ai, args.gemini_family) if gate["verdict"]["v"] == "keep": idx = next((i for i, k in enumerate(kept) if k["pid"] == pid), -1) if idx >= 0: kept[idx] = {"pid": pid, "image_path": gen["image_path"], "image_sha256": gen.get("image_sha256"), "attempt": ai, "needs_human": False} update_comic_json(paths, comic, pid, gen, ai); any_fixed = True asm_round += 1 if throttled or not any_fixed: if not throttled and not any_fixed: flagged.append(f"{args.page}:assembly") # throttle breaks before human-flag (engine 461-462) break assembly_skipped = args.skip_assembly and len(kept) >= 2 # multi-panel page with assembly_gate skipped = UNCERTIFIED if assembly_skipped: flagged.append(f"{args.page}:assembly-skipped") log(f" ⚑ assembly_gate SKIPPED on a {len(kept)}-panel page → cross-panel drift never checked; page NOT shippable (diagnostic run only)") any_needs_human = any(k["needs_human"] for k in kept) or len(flagged) > 0 shippable = (not escalated) and (not throttled) and (not any_needs_human) and (not p0_skipped) and (asm["verdict"]["v"] == "accept" if asm else len(kept) >= 1) # STAGED ACCEPTANCE (KEEP≠final): panel_gate KEEP = panel_accepted; page_accepted only when assembly accepts (skipped ⇒ never). page_accepted = False if assembly_skipped else ((asm["verdict"]["v"] == "accept") if asm else len(kept) >= 1) for k in kept: k["acceptance_stage"] = "page_accepted" if (page_accepted and not k["needs_human"] and k["pid"] not in flagged) else "panel_accepted" finalize_failed = False if args.finalize and shippable: if not os.path.isfile(paths["BUILD"]): # a missing builder must NEVER read as a successful finalize finalize_failed = True log(f"finalize FAILED (viewer builder missing): {paths['BUILD']}") else: log("finalize: building viewer") br = sh(["python3", paths["BUILD"], paths["PROJ"]], 120) if br.returncode != 0: # a broken viewer build must NEVER read as a successful finalize finalize_failed = True log(f"finalize FAILED (viewer build rc!=0): rc={br.returncode} {(br.stderr or '')[:200]}") report = {"page": args.page, "panel_ids": panel_ids, "kept": kept, "flagged_for_human": flagged, "needs_human": any_needs_human, "shippable": shippable, "assembly_skipped": assembly_skipped, "p0_skipped": p0_skipped, "throttled": throttled, "escalated": escalated, "assembly": (asm["verdict"] if asm else None), "rewrite_storyboard": rewrite_storyboard, "attempts_per_panel": total_by, "finalize": bool(args.finalize and shippable and not finalize_failed)} print("\n" + json.dumps(report, ensure_ascii=False, indent=2)) if escalated or throttled: sys.exit(2) if any_needs_human: sys.exit(3) if finalize_failed: sys.exit(4) if __name__ == "__main__": main()
-
-
SKILL.md 12 KB
--- name: comic-director description: Phase 2/3 of a movie — bake + cross-model-verify a movie from an authored comic.json. Per frame: render the content-SVG blueprint, bake via the agent mcp__codex__codex sidecar, gate with a 3-reviewer cross-model panel (narrative [currently the codex CLI] ‖ Gemini visual ‖ Codex visual, deterministic fuse), keep/retry/assemble, write the wiki trace, project to the viewer. The movie-side twin of method-figure's render half. (Author the comic.json first with the comic-author skill.) --- # comic-director — the Audited Spiral + Release (Phase 2/3) The **middle + right of Figure 1**: take an authored `comic.json` (produced by the [`comic-author`](../comic-author/SKILL.md) skill, Phase 1) and turn it into baked frames + a clickable viewer, through the audited spiral. Per frame: render a deterministic content-SVG blueprint → bake a pixel-art frame (agent `mcp__codex__codex` sidecar) → a 3-reviewer cross-model `panel_gate` → keep / retry → page assembly → projection. The movie twin of `method-figure`'s render half. (Image-based today; video-based is next.) ## Input contract — `comic.json` (authored upstream) The contract boundary is **`comic.json` + the assets it references** (content-SVG blueprints, identity refs, `ART_BIBLE.md`) — authored by [`comic-author`](../comic-author/SKILL.md). This skill is **render + verify**: - It **never invents** a number or a cast — faithfulness is a deterministic token-diff over reviewers who never see the expected values; identity is judged against the ref the author supplied (cast-aware). - It writes back **only** `image_path` / `active_attempt_id` / `wiki_node_id` (+ the wiki trace) on KEEP. - **The generator family (Codex image_gen) can NOT self-acquit.** A KEEP needs the token-diff clean **and** the cross-model visual panel (Gemini + Codex) to pass. The calling agent (Claude) gives the final structural sign-off. Full IR spec: [`../../docs/comic-json.md`](../../docs/comic-json.md) / [`../../schemas/comic.schema.json`](../../schemas/comic.schema.json). ## Run it (`scripts/run_comic.py` — agent-driven bake, CLI gates) A pure-stdlib **faithful port** of `packages/core/spiral_engine.js`'s movie branch. The cross-model **gates** (Gemini + Codex vision) still shell the `gemini` / `codex` CLIs directly. The **BAKE** is performed by the skill **agent** via the **`mcp__codex__codex`** MCP tool through `--bake-mode=agent` (the default): the core emits a deterministic bake-request sidecar (prompt + absolute ref paths + exact out_path), the agent calls `mcp__codex__codex` (`sandbox: workspace-write`, `model: gpt-5.5`, `config: {model_reasoning_effort: xhigh, include_image_gen_tool: true}`, `cwd: <project>`), and the core verifies the explicit out_path. (`codex exec` is RETIRED for real bakes — it forced a hand-drawn SVG/struct+zlib fallback; `--bake-mode=exec` RAISES if it ever reaches a real bake. See `docs/spiral-runtime.md`.) ```bash python3 skills/comic-director/scripts/run_comic.py \ --project examples/comic_m3_audit --page P02_b08 --panels S12,S13,S14,S15 \ [--bake-lang zh] [--finalize] [--dry-run] [--skip-assembly] \ [--gemini-cmd "python3 cli/gemini_agy_shim.py"] [--gemini-family google] ``` - `--dry-run` validates + prints each frame's concrete bake prompt (no image_gen) — **run this first**; it's also how `comic-author` confirms Phase 1 is correct. - `--finalize` rebuilds the single-file viewer (`packages/viewer/build_comic.py`) **only if the run is shippable** (nothing escalated/throttled/flagged-for-human; assembly accepted). - `--gemini-cmd` (default: the legacy `gemini` CLI) — the argv prefix shelled for the google-family reviewer slot (the `--model auto-gemini-3 -p <prompt>` tail is appended). With the legacy CLI deprecated upstream (`IneligibleTierError`, 2026-07), pass `--gemini-cmd "python3 cli/gemini_agy_shim.py"` — the shipped shim translates the old interface to Antigravity's `agy` and **pins a Gemini model** (Antigravity also serves Claude/GPT-OSS; the slot must stay google-family for cross-model quorum honesty). - `--gemini-family` (default `google`) — the provenance family recorded in the wiki for whatever `--gemini-cmd` actually routes to. It must stay **non-`openai`** (the visual quorum needs a second family); record honestly, never re-label. - Needs the **`codex` CLI** + a **google-family reviewer** (legacy `gemini` CLI, or Antigravity via `--gemini-cmd` + the shim) on PATH + headless Chrome (to rasterize the content-SVG blueprint). Reuses `skills/method-figure/scripts/pickup_image.py`. It prints a JSON run-report (`kept`, `flagged_for_human`, `needs_human`, `shippable`, `throttled`, `escalated`, `assembly`, `attempts_per_panel`). Engine semantics it ports (caps 4/panel + 6/run, the deterministic verdict, anatomy single-vote veto, throttle → fresh-run): [`../../docs/spiral-runtime.md`](../../docs/spiral-runtime.md). ## Bake seam contract (agent ↔ core) — `--bake-mode=agent` The bake is a **synchronous sidecar handshake** (contract-v2 §1–§4). The core never returns/exits early on a bake; only the bake **primitive** moved off `codex exec` onto `mcp__codex__codex`: - **(A) core writes** `<out>.bakereq.json` = `{prompt_text, content_png, identity_ref, out_path, model:"gpt-5.5", config:{model_reasoning_effort:"xhigh", include_image_gen_tool:true}, sandbox:"workspace-write", cwd, created_at, min_bytes:500000, aspect, request_id:"<uuid4 hex>"}` (`prompt_text` ALREADY contains the absolute ref + out paths — the schema has **no** `-i`; `config` carries **both** `model_reasoning_effort:"xhigh"` AND `include_image_gen_tool:true` — without the latter codex won't fire its native image tool; `request_id` is a per-bake uuid4-hex nonce the core mints — see (D)+(E)), pre-deleting any stale `out_path` + status. - **(B) the agent calls** `mcp__codex__codex` with EXACTLY those params. - **(C) codex writes** a native PNG to `out_path`. - **(D) the agent writes** `<out>.bakestatus.json` = `{status:"ok"|"fail", failure_kind:"throttle"|"other", mcp_output:"<bounded raw codex tool output>", mcp_error, request_id:"<verbatim from bakereq>"}`. **`mcp_output` is MANDATORY on BOTH ok and fail** — the core feeds this status file to `pickup --transcript`, and the HARD-VETO scans it for `import struct`/`zlib.compress`/`<svg>`/`matplotlib`/… markers. **An `ok` status with no raw output makes the HARD-VETO inert** (a hand-drawn struct+zlib/SVG that still emits a ≥500KB sig-valid PNG would slip through) — so always embed the bounded raw tool result. **`request_id` is EQUALLY MANDATORY on BOTH ok and fail** — copy it VERBATIM from `<out>.bakereq.json` into the bakestatus; `pickup_image.py --out-existing --request-id` **fail-closes the bake** if the status `request_id` is missing or ≠ the bakereq nonce (a stale/foreign status at the same path can't be silently honored). - **(E) the core verifies** via `pickup_image.py --out-existing` on the EXPLICIT `out_path` (PNG sig + IHDR dims + size > 500000 + aspect band + `mtime >= created_at`), **HARD-VETOES** the markers in the transcript (= the status file's `mcp_output`), and (via `--request-id`) **fail-closes** if the status file's `request_id` is absent or ≠ the bakereq nonce — but all ONLY after it has read `status:"ok"`. Empty/timeout/any-non-ok = fail-closed (no pickup). NO fallback to `codex exec`/PIL/SVG; effort is ALWAYS `xhigh`; sandbox ALWAYS `workspace-write`. ### Who runs `--bake-mode=agent` (the agent-wrapper SOP) — REQUIRED for the default mode to function The comic-director **skill agent** is the bake fulfiller. Concretely: 1. Launch the orchestrator in the **BACKGROUND**: `python3 skills/comic-director/scripts/run_comic.py --project … --page … --panels … --bake-mode agent` (it blocks on each bake by polling `<out>.bakestatus.json`). 2. **Loop** until the orchestrator prints its final run-report JSON / exits: - watch the project's `panels/` dir for a new `*.bakereq.json`; - read it; call `mcp__codex__codex` with **exactly** its `{prompt: <prompt_text>, model:"gpt-5.5", config:{include_image_gen_tool:true, model_reasoning_effort:"xhigh"}, sandbox:"workspace-write", cwd:<cwd>}` (codex writes the native PNG to the sidecar's `out_path`). The `config` MUST carry **both** `include_image_gen_tool:true` AND `model_reasoning_effort:"xhigh"`: without `include_image_gen_tool` Codex will **not** fire its native image tool (it falls back to descriptive text / an SVG renderer), and `xhigh` is the required reasoning tier; - then read `request_id` from `<out>.bakereq.json` and write `<out>.bakestatus.json` carrying **the status, a bounded raw `mcp_output`, AND that `request_id` VERBATIM** — `mcp_output` so the core's HARD-VETO can scan it, and `request_id` because `pickup_image.py --out-existing --request-id` **fail-closes the bake if the status `request_id` is missing or mismatched** (it must be written on BOTH ok and fail). **Live-verified pitfall:** if the `mcp__codex__codex` reply TEXT is EMPTY (an image-only reply), serialize the RAW response object into `mcp_output` — never write an empty string (pickup fail-closes a blank `mcp_output` on the empty-transcript guard): `{"status":"ok","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}` on success, or `{"status":"fail","failure_kind":"throttle","mcp_output":"<raw>","request_id":"<verbatim from bakereq>"}` on a 429 / `MODEL_CAPACITY_EXHAUSTED` / overloaded error (otherwise `{"status":"fail","failure_kind":"other","mcp_output":"<raw>","mcp_error":"<raw>","request_id":"<verbatim from bakereq>"}`). 3. Stop when the orchestrator exits. (Without this wrapper, every bake polls to `--bake-timeout` and returns `generation_failed` with `failure_kind="other"` — fail-closed, not a hang, and never a false `throttle`.) > `--bake-mode=exec` is the legacy/CI **non-image** path and RAISES if it ever reaches a real bake — never use > it to produce a panel. ## Hard do / don't - **Do** author `comic.json` first with [`comic-author`](../comic-author/SKILL.md); copy `examples/comic_m3_audit/comic.json` as the shape. - **Do** `--dry-run` first; confirm every baked figure-panel carries ascii-tokenizable `expected_literals` (else the fail-closed gate refuses to run). - **Don't** run two bakes at once. The default `--bake-mode=agent` writes each native PNG to its **explicit per-panel `out_path`** and has **no `/tmp/aris_imagegen.lock`** (that code lock is GONE in agent mode — the agent wrapper itself serializes the `mcp__codex__codex` calls). The real race surface: concurrent runs on the **same project/page** collide on the per-panel `.bakereq.json`/`.bakestatus.json` sidecars + the shared `out_path` — so keep **ONE runner per project/page**. (The global generated-images dir that could cross-pollinate concurrent bakes is a hazard of the **LEGACY `--bake-mode=exec` path ONLY**, retired for real bakes.) - **Don't** resume after an image_gen throttle — launch a FRESH run for the remaining panels. - **Don't** treat a `needs_human`/flagged panel as shippable. ## Protocols (governance contracts this skill honors) - [`reviewer-independence`](../../protocols/reviewer-independence.md) — reviewers get file paths only; the executor (Codex image_gen) ≠ the visual judges (Gemini + Codex vision). - [`acceptance-gate`](../../protocols/acceptance-gate.md) — the loop can DRIVE but can't ACQUIT: a KEEP needs the deterministic token-diff clean **and** Gemini+Codex visual pass; the generator family never self-acquits; Claude gives the final structural sign-off. - [`artifact-integrity`](../../protocols/artifact-integrity.md) — the model that bakes a frame does not judge its own faithfulness; numbers are *verified*, never originated. - [`review-tracing`](../../protocols/review-tracing.md) — every reviewer's prompt+response is saved (`trace.jsonl`) so each verdict is auditable. - [`reviewer-routing`](../../protocols/reviewer-routing.md) — Codex `gpt-5.5` `xhigh`; Gemini `auto-gemini-3`; never downgrade the reviewer tier.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.