comic-json-compiler — locked storyboard + blueprints → one schema-valid comic.json (S9)
The last node of the left third of Figure 1: take the locked storyboard_spec (the authoritative page
order + the MOTIF STATE TABLE) and the locked per-panel blueprint nodes and compile them into the single
comic.json (comic-ir/1.0) that is the contract boundary to comic-director
(Phase 2/3). You don't hand-write this file panel-by-panel — that already happened upstream; this step is the
deterministic projection + reconciliation that turns the authoring nodes into the IR and catches the
integrity bugs that only show up once everything is assembled — chiefly the page-count / orphan-panel
mismatch (the orphan-panel class). The two deterministic scripts the compile gate runs are
per-page (run_comic.py needs --page/--panels) and the JSON schema leaves condition/content_svg
OPTIONAL — so neither of them sees the whole pages[]/panels{} set and neither catches an orphan. That
whole-comic reconciliation against the storyboard authority is THIS skill's own inline job (the ~7-line set
diff below), run before the gate; the gate then certifies the per-page + wiki-conformance floor.
locked storyboard_spec (page_order = AUTHORITY) ┐
locked motif_ledger (per-panel ground-truth) │
locked panel_spec × N (the 9-field specs) ├─▶ ① ASSEMBLE pages[] + panels{} per comic.schema.json
locked blueprint × N (content_svg + expected_literals) │ (author ONLY authored fields; engine fields EMPTY)
locked prompt_bundle × N (optional, identity refs) ┘ ▼
② RECONCILE page-count integrity ── INLINE python (this skill; no phantom script)
(orphans · dangling refs · page_count vs page_order · grid2x2==4 · recap legitimacy)
▼
③ VALIDATE jsonschema(comic.schema.json) — SHAPE only (it does NOT enforce content_svg/literals)
▼
④ FAIL-CLOSED contracts (every panel a content_svg · baked figure ⇒ expected_literals) ← THIS skill asserts; schema won't
▼
⑤ comic-cross-layer-gate decision:compile_<slug> --gate compile (DETERMINISTIC: run_comic.py --dry-run per page AND cli/validate_wiki.py both exit 0)
▼
approve? ─ revise ─▶ fix the OFFENDING node upstream, recompile (you don't invent content)
│ approve
▼
⑥ EMIT comic.json (output-versioning) + movie.project.json pointer-hub + asset-copy manifest
Constants
- CONTRACT BOUNDARY =
comic.json (comic-ir/1.0) + the assets it references. Everything downstream
(comic-director, run_comic.py, the Phase-3 viewer build packages/viewer/build_comic.py) reads ONLY this
file + its assets — so a bug that survives this step survives to production.
- AUTHORITY = the storyboard's
page_order. When the assembled comic.json page set disagrees with the
locked storyboard_spec.page_order, the storyboard wins — the compiler conforms, it does NOT silently
ship its own page count. (This is the converse of the storyboard gate's page-order veto.)
- GATE =
comic-cross-layer-gate decision:compile_<slug> --gate compile (the target is the real wiki
decision node this skill writes — there is NO comic node type and comic.json is not a wiki node, so it
can never be a gate target or an edge endpoint). Per comic-cross-layer-gate
the compile gate is purely DETERMINISTIC: NO review:* fan-out, NO Codex adjudication (so unlike every
other gate, the "create review nodes first" pre-step does NOT apply here). It PASSES (approve) iff BOTH
on-disk scripts exit 0: python3 skills/comic-director/scripts/run_comic.py --project <dir> --page <P> --panels <ids> --dry-run (per page) AND python3 cli/validate_wiki.py <dir>; else revise with their
stderr as the blocker list. (The whole-comic orphan/page-count catch is NOT in either script — it is this
skill's inline step ② below, run before the gate.)
- VERDICT enum (the
compile gate's legal set, per the gate skill): approve / revise. On approve flip
the compile decision to final and emit; on revise fix the offending upstream node and recompile (the
compiler never patches content into comic.json by hand). A verdict is never a node status: the status the
FLIP writes is locked on advance, under_review on needs-work, rejected on a terminal fail
(schema-enum statuses only — revise/regenerate are verdicts, not statuses).
- NO INVENTION — the compiler is a projection + reconciliation step. It must NOT invent a panel, a
number, an
expected_literal, a page, or a content_svg. A missing piece is a HALT with the offending node
id, not a fabrication (artifact-integrity).
- ENGINE FIELDS STAY EMPTY —
image_path / active_attempt_id / wiki_node_id are written by the spiral
engine on KEEP. Authoring them is a veto (you'd be claiming a bake that never ran).
- ASCII node-id discipline — every wiki touch goes through the one
node_id_to_filename helper (: → _),
per wiki-helper-resolution.
Input contract — what must be LOCKED before you compile
This is a compile step: it consumes locked upstream nodes, it does not author them.
- One
storyboard_spec (status: "locked") — its page_order (the authority), page_ids, panel_ids,
global_policies (page-order authority, text-mode rules, mirror-lock policy), motif_ledger_id,
consolidated_asset_requests.
- The
motif_ledger it points at — the per-panel ground-truth the gate uses to confirm condition prose
agrees with each panel's continuity row.
- One
panel_spec per panel (status: "locked") — page_type, world, text_mode, expected_literals,
content_blueprint, bubbles, side_narration, motifs, (cover/finale: safe_zones + endcard fields).
- One
blueprint per panel (status: "locked") — content_svg, expected_literals (verbatim),
safe_zones, html_bubbles, crop, negative_space_policy, generator_script, file_sha256.
- (optional) the
prompt_bundle per panel — for identity_ref_paths (else fall back to the project
canonical from identity_refs).
- HALT if any of these is not
locked (or its content_svg/output_ref file is missing) — name the
offending node id. Convergence is the upstream gate's job, not yours.
Workflow (the inline reconcile + the two deterministic gate scripts — all runnable, no phantom tool)
① ASSEMBLE pages[] + panels{}
Project the locked nodes into the IR. Copy examples/comic_m3_audit/comic.json as the shape.
- Top level —
schema_version: "comic-ir/1.0", comic_id, defaults (text_mode, default_locale,
panels_per_page_cap, nav_default, pixel_rendering), identity_refs (the locked cast hex table from
the style bible — pure pointer/hex hub, never duplicated art), ui_tokens (viewer theming, palette pinned).
pages[] — reading order, ONE entry per storyboard_spec.page_order page, in that exact order —
EXCEPT a declared endcard page (the ENDCARD FOLD, below: an endcard/finale storyboard page compiles into
the PRIOR page's closing{}, never its own pages[] entry). Each entry: id (the stable FINAL page id,
e.g. P00_cover/P_B03/P02_b08 — see the PAGE-ID rule below), type
(cover|single|grid|grid2x2|feature|finale), panel_ids (the panel ids that page shows), plus optional
beat/beat_title/narration/skill/title/links (cover/closing buttons — https:// only).
panels{} — ONE keyed entry per panel id that appears in any page. Per panel project:
condition{} (what to GENERATE): content_svg (the locked blueprint's deterministic SVG = the
content authority), expected_literals (verbatim from the blueprint), world, identity_ref
(from the prompt_bundle or null → project canonical), identity_desc, characters, scene.
- render fields (for the viewer):
text_mode, crop (shape∈hero|wide|square, position [x,y] in
0..1, zoom ≥ 1), safe_zones (each with an id), bubbles (speaker∈researcher|executor|reviewer,
style∈say|shout|terse|thought|whisper, text{zh,en}, optional anchor = one of this panel's
safe-zone ids), overlays, caption{zh,en}.
- leave EMPTY:
image_path, active_attempt_id, wiki_node_id.
- Emit
movie.project.json — the pure pointer-hub manifest (project_id, title{zh,en}, story,
schema_version, comic_json, art_bible, identity_ref, dirs, text_mode_default, palette). It
names the other artifacts; it NEVER duplicates their content.
Two storyboard→IR projections step ② MUST know (or the reference itself looks broken):
- THE ENDCARD FOLD. A storyboard page of type
endcard (the finale card) does NOT compile to its own
pages[] entry: its single panel becomes the PRIOR page's closing.image (with the logo/tag/links in
the same closing{} block). Reference: the storyboard declares 19 pages ending in a standalone
P03_end[S22·endcard]; the compiled IR ships 18 pages[] with P_B12 (type finale, panels S20+S21)
carrying closing.image: "S22". The 19-vs-18 difference IS the fold — by design, not a lost page. NOTE the
schema does not model closing{} at all (it validates via additionalProperties), so asserting "the fold
actually happened" is step ②'s inline job, nobody else's.
- THE PAGE-ID RULE. The compiled IR's canonical page ids are beat-keyed (
P_B<beat>: P_B03…P_B07,
P_B08_0…P_B12), while the human STORYBOARD_DRAFT.md's first-third draft ids are ordinal-prefixed
(P02_b03/P03_b04/…). The stable key is the beat token (b03 ↔ B03, case-insensitive); the
already-canonical anchors (P00_cover, P01_b02, P02_b08) keep their ids. The LOCKED
storyboard_spec.page_order — the reconcile authority — must be authored in the FINAL IR vocabulary;
the compiler never renames silently. Never build a page_order for the reconcile from the .md draft's
ordinal ids — that draft is a human artifact, not the locked node, and an element-wise compare against it
false-blocks the shipped reference.
② RECONCILE page-count integrity (the heart of this skill — INLINE python, NO external script)
There is no reconcile_pages.py — it never existed. This catch is run inline (the schema and the
per-page run_comic.py are both blind to it), so paste-and-run this ~15-line whole-comic diff against the
storyboard authority. This is where the orphan-panel class dies. Exit non-zero on ANY blocker; do NOT proceed to
the gate until it is clean.
# inline reconcile — run against the assembled comic.json + the locked storyboard_spec node (NO phantom tool)
import json, sys, re
c = json.load(open("comic.json", encoding="utf-8"))
sb = json.load(open("wiki/nodes/storyboard_<slug>.json", encoding="utf-8"))["payload"] # the LOCKED authority
page_refs = [pid for p in c["pages"] for pid in p["panel_ids"]] # every page→panel reference (with dups)
closing_refs = [(p.get("closing") or {}).get("image") for p in c["pages"] if (p.get("closing") or {}).get("image")]
ref_set = set(page_refs) | set(closing_refs); defined = set(c["panels"]) # a finale endcard (closing.image) IS a use
orphans = sorted(defined - ref_set) # defined, not on a page AND not a closing endcard → BLOCKER
dangling = sorted(ref_set - defined) # a page/closing points at a missing panel → BLOCKER
# PAGE-ORDER AUTHORITY — ELEMENT-WISE vs the storyboard (NOT a length check): catches reorder / id-swap / dup /
# over-count that a count comparison silently passes.
comic_order = [p["id"] for p in c["pages"]]
sb_order = list(sb["page_order"])
# ENDCARD FOLD — a declared endcard page has NO pages[] entry (its panel compiles into the PRIOR page's
# closing.image: storyboard P03_end[S22·endcard] → P_B12.closing.image; 19 declared → 18 pages[]). EXCLUDE the
# declared endcard pages from the element-wise compare, then assert the fold HAPPENED (a declared endcard panel
# on no page's closing.image = the finale silently vanished → BLOCKER). Read the declaration off the storyboard
# (its global_policies endcard map {endcard_page_id: panel_id}) — declared, never inferred.
gp0 = sb.get("global_policies", {})
endcard_map = dict(gp0.get("endcard_folds") or {}) # e.g. {"P03_end": "S22"}; {} when the comic has no endcard
sb_order_cmp = [pid for pid in sb_order if pid not in endcard_map]
endcard_unfolded = sorted(pn for pn in endcard_map.values() if pn not in set(closing_refs)) # fold missing → BLOCKER
order_mismatch = comic_order != sb_order_cmp # STRICT: any reorder / missing / extra / dup → BLOCKER
order_missing = [pid for pid in sb_order_cmp if pid not in comic_order] # storyboard page absent from comic.json
order_extra = [pid for pid in comic_order if pid not in sb_order_cmp] # comic.json page not in storyboard
page_id_dups = sorted({pid for pid in comic_order if comic_order.count(pid) > 1}) # duplicate page id (over-count)
bad_grid = [p["id"] for p in c["pages"] if p["type"] == "grid2x2" and len(p["panel_ids"]) != 4] # arity → BLOCKER
empty_pg = [p["id"] for p in c["pages"] if not p["panel_ids"]] # empty page → BLOCKER
# FAIL-CLOSED engine contracts — jsonschema leaves these OPTIONAL, so this inline step is the REAL enforcer:
def cond(pn): return pn.get("condition") or {}
no_csvg = sorted(pid for pid, pn in c["panels"].items() if not cond(pn).get("content_svg")) # null/missing content_svg
# cfg_usable EXACTLY (run_comic.py): the EFFECTIVE mode resolves from defaults (a panel inheriting a baked
# default is still baked); a baked panel WITH a content_svg MUST carry expected_literals as a non-empty LIST
# whose every entry is an ascii-tokenizable string. ONE predicate = never weaker than the engine floor.
def emode(pn): return pn.get("text_mode") or (c.get("defaults") or {}).get("text_mode") or "html"
def baked_lits_bad(pn):
if emode(pn) != "baked" or not cond(pn).get("content_svg"): return False
exp = cond(pn).get("expected_literals")
return not (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))
baked_lits_fail = sorted(pid for pid, pn in c["panels"].items() if baked_lits_bad(pn))
engine_authored = sorted(pid for pid, pn in c["panels"].items() # authored ENGINE fields MUST be empty
if pn.get("image_path") or pn.get("active_attempt_id") or pn.get("wiki_node_id"))
# bubble.anchor (if set) MUST resolve to a panel safe_zone id. safe_zones is a panel TOP-LEVEL field
# (comic.schema.json) — NOT under condition; jsonschema only TYPES anchor as a string, so assert it inline.
def sz_ids(pn): return {z.get("id") for z in (pn.get("safe_zones") or [])}
bad_anchor = sorted(f"{pid}:{b.get('anchor')}" for pid, pn in c["panels"].items()
for b in (pn.get("html_bubbles") or pn.get("bubbles") or [])
if isinstance(b, dict) and b.get("anchor") and b["anchor"] not in sz_ids(pn))
# DECLARED recap reuse is the ONLY legitimate multi-reference — read it from the storyboard, never guess.
gp = sb.get("global_policies", {})
sanctioned = set(gp.get("recap_panel_ids") or gp.get("sanctioned_recap") or []) # storyboard-declared recap/grid set
dups = sorted({pid for pid in page_refs if page_refs.count(pid) > 1} - sanctioned) # UNDECLARED panel dup → BLOCKER
blockers = {"orphans": orphans, "dangling": dangling, "order_mismatch": order_mismatch, "order_missing": order_missing,
"order_extra": order_extra, "page_id_dups": page_id_dups, "endcard_unfolded": endcard_unfolded,
"bad_grid2x2": bad_grid, "empty_pages": empty_pg,
"undeclared_dups": dups, "content_svg_null": no_csvg, "baked_literals_invalid": baked_lits_fail,
"engine_fields_authored": engine_authored,
"bubble_anchor_unresolved": bad_anchor}
if any(blockers.values()):
sys.exit(f"RECONCILE BLOCKERS: {blockers} (pages {len(comic_order)} vs storyboard {len(sb_order_cmp)} after endcard fold)")
print("reconcile OK")
- The orphan check counts
closing.image. In the reference comic.json, S22 (the wordless constellation
finale) is the endcard of the finale page P_B12 (P_B12.closing.image = "S22") — a legitimate use, NOT
an orphan. (An earlier draft of this check read only panel_ids and FALSE-flagged it.) A TRUE orphan is a
panel in panels{} referenced by neither a page's panel_ids NOR any closing.image; that is a real BLOCKER
the compiler must catch, not ship — page it, or (if genuinely cut) make an upstream storyboard edit + re-lock,
never a quiet drop. When a locked storyboard_spec node is present, its page_order is the element-wise page
authority (reorder / missing / extra / duplicate page id all block) — after excluding its declared endcard
pages, which by contract have no pages[] entry (the fold itself is asserted separately via
endcard_unfolded).
- Recap reuse is the ONE legitimate multi-reference and must be declared, not inferred. In the
reference comic,
S12,S13,S14,S15 are each referenced twice — once as individual single pages
(P_B08_1..4) and once in the grid2x2 recap/hero page (P02_b08). That is by-design (the storyboard's
mirror_locks / recap policy), so a panel referenced >1 time is a veto ONLY if it is not on the
storyboard's declared recap/grid set. The diff reads the storyboard's global_policies (the sanctioned
recap ids) to know which reuses are sanctioned — an undeclared duplicate reference is still a BLOCKER.
③ VALIDATE against the schema (SHAPE only — it does NOT enforce the fail-closed contracts)
python3 -c "import json,jsonschema,sys; jsonschema.validate(json.load(open(sys.argv[1]+'/comic.json')), json.load(open('schemas/comic.schema.json')))" <project_dir> # run from repo root
Know exactly what this does and does NOT catch. schemas/comic.schema.json leaves condition,
condition.content_svg, and condition.expected_literals all OPTIONAL (it constrains their shape when
present but never requires them). So jsonschema PASSES a panel with content_svg: null/absent or a baked
panel with empty expected_literals — exactly the states vetoes #5/#6 and the two fail-closed contracts forbid.
Therefore the schema is NOT the contract enforcer — step ④ (this skill's own assertion) and the
run_comic.py --dry-run cfg_usable fail-closed (step ⑤) are what bite. Do not rely on jsonschema for them.
build_comic.py is NOT a compile-time fallback. It lives at packages/viewer/build_comic.py and is
the Phase-3 post-bake viewer build: it requires every panel's image_path to be present and a real
image on disk. A freshly-authored comic.json (engine fields EMPTY, per this skill) would make build_comic.py
hard-fail on the missing images — it is the wrong tool here, and it never checks orphans. Cite it only for the
post-bake viewer stage (Phase 3), with its real packages/viewer/ path; the compile-time deterministic floor
is cli/validate_wiki.py + run_comic.py --dry-run (step ⑤), not build_comic.py.
④ Enforce the two FAIL-CLOSED engine contracts (see the dedicated section below)
Every panel has a real content_svg (never null); every baked figure-panel has non-empty,
ASCII-tokenizable expected_literals. The one nuance — the zero-text panel (S22) — is spelled out below.
⑤ Run the compile gate (DETERMINISTIC = both scripts exit 0; no reviewer fan-out)
The --gate compile floor is two real on-disk scripts. run_comic.py is a per-page spiral runner —
its argparse marks both --page and --panels as required, so it must be invoked once per page with that
page's panel ids (the project-only command crashes with an argparse error). It does NOT validate jsonschema
and never sees the whole pages[]/panels{} set — so it CANNOT catch the orphan/page-count (that was step
②). Its --dry-run job is per-page bake-readiness: it asserts every text_mode:"baked" figure-panel on that
page carries ascii-tokenizable expected_literals (the cfg_usable fail-closed) and prints each concrete bake
prompt (real scene + real literals, no placeholders).
# (a) per page in the storyboard page_order — bake-readiness + fail-closed literals (NOT schema/orphan):
python3 skills/comic-director/scripts/run_comic.py --project examples/comic_m3_audit --page P02_b08 --panels S12,S13,S14,S15 --dry-run
# …repeat for every page; a baked figure-panel with no ascii expected_literals → run_comic EXITS non-zero
# (b) wiki node/edge/payload/node_id/privacy conformance (the whole project, against node_schema.json):
python3 cli/validate_wiki.py examples/comic_m3_audit
First write the compile decision node + its edges (§ "Node it reads / writes"), then defer to the gate.
comic-cross-layer-gate decision:compile_<slug> --gate compile is purely deterministic (per the gate
skill): NO review:* nodes, NO Codex adjudication — it simply runs the two scripts above and returns approve
iff both exit 0, else revise carrying their stderr as the blocker list. (This is the one gate where the
"create review nodes first" pre-step does NOT apply — the scripts ARE the judge.) On approve → flip the
decision to final and emit; on revise → fix the offending upstream node and recompile. The gate (the
deterministic scripts, a different acquittal organ) — never this compiling agent — acquits
(acceptance-gate).
⑥ EMIT
Write comic.json + movie.project.json via output-versioning
(timestamped + fixed-name latest; downstream reads the fixed name). Emit the asset-copy manifest — the
flat list of every content_svg / identity_ref the IR references and where each lives — so Phase 2/3 can
stage them. Append the wiki nodes/edges (§ below) and trace the gate round to trace.jsonl
(review-tracing).
EXACT gate (compile) — deterministic checks, thresholds, vetoes
The compile gate's legal verdicts are {approve, revise} (per
comic-cross-layer-gate — this gate is purely deterministic: NO
reviewer fan-out, NO 0–5 scoring, NO Codex adjudication). The checks below are therefore machine predicates,
each PASS/FAIL, owned by a specific on-disk enforcer — not reviewer dimensions. The gate returns approve
iff its two scripts (run_comic.py --dry-run per page and cli/validate_wiki.py) exit 0; the whole-comic
structural predicates (orphan / page-count / dangling / grid arity) are this skill's inline step ②, asserted
before the gate (the scripts are per-page/wiki-only and cannot see them). Each predicate below is tagged with
[who enforces it] so there is no claim that a tool checks something it doesn't.
Structural predicates (each PASS/FAIL):
schema_valid [jsonschema, step ③] — comic.json validates against schemas/comic.schema.json
(SHAPE only; required top-level keys present; schema_version == "comic-ir/1.0"). NOTE: the schema does NOT
require condition/content_svg/expected_literals, so it does NOT enforce the contract predicates below —
those are owned by step ④ + run_comic.py. build_comic.py is NOT a fallback here (Phase-3 viewer build).
panel_refs_resolve [inline step ②] — every pages[].panel_ids entry exists as a key in panels{}
(no DANGLING ref).
page_count_authority_match [inline step ②] — the pages[] count + ids reconcile with the locked
storyboard_spec.page_order after excluding the storyboard's declared endcard pages (an endcard folds
into the prior page's closing{}, never its own entry; the fold is asserted via endcard_unfolded). No
short-count, no over-count, no re-ordering. This is the orphan-panel reconciliation predicate (neither
gate script sees it — it MUST run inline).
no_orphan_panels [inline step ②] — every panel defined in panels{} is referenced by at least one
page; any defined-but-unreferenced panel is surfaced (a sanctioned recap reuse counts as a reference).
grid2x2_arity [inline step ②] — every grid2x2 page carries exactly 4 panel ids; no empty page.
engine_fields_empty [inline step ②] — image_path / active_attempt_id / wiki_node_id are
absent/empty on every panel (the engine owns them).
bubble_anchor_safezone [inline step ②] — every bubble.anchor (if set) resolves to one of its
panel's safe_zone ids; crop.position ∈ [0,1]², crop.zoom ≥ 1. (jsonschema only TYPES anchor as a
string — it does NOT enforce the cross-reference, so this is asserted inline, not by the schema.)
Contract predicates (each PASS/FAIL — and note the schema does NOT enforce these, so step ④ + run_comic.py do):
every_panel_has_content_svg [inline step ④] — no panel has condition.content_svg: null or a
missing condition.content_svg. (jsonschema leaves it optional, so this MUST be asserted inline.)
baked_figure_has_literals [run_comic.py --dry-run cfg_usable, step ⑤] — every panel whose
text_mode == "baked" declares a non-empty, ASCII-tokenizable condition.expected_literals (run_comic.py
EXITS non-zero otherwise; the actual token blind-diff happens later, at BAKE time in the engine — here it is
only the presence assertion). The zero-text exception (negative_space_policy: "zero_glyph", build-time
assert "<text" not in svg) is the ONLY way an empty expected_literals passes — and only with
text_mode != "baked" (see the contracts section).
identity_refs_resolve [inline step ④] — every condition.identity_ref resolves to a real file (or
is null → the project canonical exists in identity_refs); the cast hex table is present.
asset_manifest_complete [inline step ④/⑥] — every referenced condition.content_svg /
identity_ref exists on disk and appears in the asset-copy manifest; no inline-invented asset.
manifest_is_pointer_hub [inline step ④] — movie.project.json names (does not duplicate)
comic.json, art_bible, identity_ref; schema_version matches.
Hard vetoes (any one → revise):
- An orphan panel — a panel defined in
panels{} referenced by neither a page's panel_ids NOR any
closing.image endcard (the orphan-panel class; a finale endcard via closing.image is a legitimate
use, NOT an orphan — see S22). Authority = the storyboard's page order — conform, do not ship the short count.
- A dangling page ref — a
pages[].panel_ids entry with no panels{} key.
- Page count disagrees with the storyboard authority (short, over, or re-ordered vs
page_order,
measured AFTER excluding declared endcard folds) — or a declared endcard panel appears on no page's
closing.image (the fold never happened: the vanished-finale case).
- A
grid2x2 page without exactly 4 panel ids, or any empty page.
- A panel with
condition.content_svg: null (or missing) — the engine fail-closed rejects it (asserted
inline at step ④; jsonschema will NOT catch it).
- A baked figure-panel with empty/non-ASCII
condition.expected_literals (and not the declared zero-glyph
exception) — run_comic.py --dry-run cfg_usable refuses it (the later bake-time blind-diff would have
nothing to verify, so a wrong number could slip).
- An engine field authored (
image_path / active_attempt_id / wiki_node_id non-empty) — you'd be
claiming a bake that never happened.
- An undeclared duplicate panel reference — a panel referenced by >1 page that is NOT on the storyboard's
sanctioned recap/grid list.
- An invented field — any panel/number/
expected_literal/page/content_svg not traceable to a locked
upstream node (compiler must not fabricate).
- Manifest duplicates content —
movie.project.json inlines what it should only point at, or its
schema_version mismatches comic.json.
Node it reads / writes (schemas/node_schema.json)
Reads (all must be status: "locked"):
- one
storyboard_spec (storyboard:<slug>) — payload source_outline_id, page_order, page_ids, panel_ids, global_policies, motif_ledger_id, consolidated_asset_requests. page_order is the page-count
authority the compile reconciles against.
- the
motif_ledger (motif:<slug>) it names — payload source_storyboard_id, columns, rows, invariants, mirror_locks; the gate uses rows to confirm each panel's condition prose agrees with its continuity row,
and mirror_locks to know which recap reuses are sanctioned.
panel_spec × N (panel:<slug>) — payload source_storyboard_id, page_id, panel_id, sequence_index, page_type, world, asset_ids, text_mode, expected_literals, content_blueprint, bubbles, side_narration, motifs.
blueprint × N (blueprint:<slug>) — payload source_panel_id, content_svg, expected_literals, safe_zones, html_bubbles, crop, negative_space_policy, generator_script, file_sha256.
- (optional)
prompt_bundle × N (prompt:<slug>) — payload source_panel_id, source_blueprint_id, style_prefix, composed_prompt, banned_vocab_scan, identity_ref_paths.
Writes:
- the
comic.json artifact (the comic-ir/1.0 IR — NOT a wiki node: there is no comic node_type and no
comic: node_id prefix, so it can never be an edge endpoint or a gate target; it is the contract boundary
file) + the movie.project.json pointer-hub + the asset-copy manifest.
- one
decision node (node_id decision:compile_<slug>, matching the schema pattern
^(intent|…|decision|fail):[a-z0-9_-]+$) — payload must contain every PAYLOAD_REQUIRED field for
decision: target_node_id, verdict, gate_kind (set gate_kind: "compile", verdict ∈ {approve, revise}, target_node_id = the storyboard_spec id storyboard:<slug> — the real wiki node whose
page_order the compile reconciles against, the only schema-legal target). status: "final" on the gate's
return.
- NO
review node here. The compile gate is purely deterministic (no reviewer fan-out, no Codex), so —
unlike every other gate — this skill does NOT mint review:* nodes or a reviews edge. (Writing a phantom
review would also fail the gate's own "the two scripts are the judge" contract.)
Author edges (all legal EDGE_TYPES in cli/validate_wiki.py, all between REAL wiki-node endpoints — NEVER
the comic.json file, which has no node_id): decides (decision:compile_<slug> → storyboard:<slug>),
derived_from (decision:compile_<slug> → storyboard:<slug>, the authority it was compiled against), and
one uses_blueprint per panel (decision:compile_<slug> → each blueprint:<slug>, the per-panel provenance).
There is NO compiled_from edge type — it is not in EDGE_TYPES, so writing it fails validate_wiki.py;
derived_from + uses_blueprint are the legal substitutes. The verdict-bearing runtime edges
(attempt_of/rollback_of/reviews) belong to Phase 2/3, not here.
Two engine contracts to author to (fail-closed)
The compiler's last duty is to guarantee the IR can never make the spiral REFUSE a panel for a structural
reason — verify both at compile time, do not defer to the dry run alone:
- Every panel needs a
condition.content_svg — a deterministic blueprint (figure OR scene-anchor
layout). condition.content_svg: null is rejected by the engine (spiral_engine.js reads
.condition.content_svg); assert it INLINE at compile time (jsonschema leaves it optional). A dialogue/scene
panel with no audited numbers still carries a layout blueprint (the scene composition + the
negative-space safe zones).
- A
baked figure-panel MUST declare non-empty, ASCII-tokenizable condition.expected_literals
(exact numbers/keys/fn-names/verdicts, verbatim) — else run_comic.py --dry-run's cfg_usable exits
non-zero (there'd be nothing for the later bake-time blind-diff to verify, so a wrong number could slip). A
scene panel with no audited numbers → text_mode: "html" (its text becomes an HTML overlay, not baked glyphs).
- The ONE documented exception — the zero-text panel. The reference S22 is the wordless constellation:
condition.content_svg set, condition.expected_literals: [], world: "starfield",
characters: "NONE … no text anywhere in the image", blueprint negative_space_policy: "zero_glyph"
enforced by a build-time assert "<text" not in svg. An empty expected_literals is legitimate only
when the blueprint is provably zero-glyph and text_mode != "baked" (the panel gates on
stray_text_absence, not a literal token-diff). Any other empty-literals baked figure-panel is veto #6.
Note S22 (the wordless finale) is the one panel with nothing to literal-gate — it gates on
stray_text_absence, not a token-diff — and it is wired as the finale via P_B12.closing.image, the
legitimate zero-glyph endcard (the closing-aware orphan check counts that as a use, so it is NOT an orphan).
Worked example
The canonical exhibit is examples/comic_m3_audit/comic.json
(comic-ir/1.0, comic_id: aris_comic_v1) + its pointer-hub
examples/comic_m3_audit/movie.project.json
(project_id: comic_m3_audit), the IR for the 24-panel "ARIS — I Handed Over Those 24 Hours" comic.
Copy its exact shape — and copy how it fails the integrity check, because that is the teaching case:
- The closing-aware orphan check — the reference IS the PASSING fixture. That file ships 18 pages /
24 panels (verified) — while its
STORYBOARD_DRAFT.md declares 19 pages (TOTALS: "24 panels · 19
pages") ending in a standalone P03_end[S22·endcard]. Both are right: the endcard FOLDS (P03_end →
P_B12.closing), so 19 declared − 1 fold = 18 pages[]. The draft's first-third ordinal ids
(P02_b03…P06_b07) also appear in the IR as beat-keyed P_B03…P_B07 (the PAGE-ID rule) — two projections a
naive element-wise diff against the draft would false-flag; the reconcile compares against the LOCKED
storyboard_spec.page_order (final IR vocabulary) minus its declared endcard pages, never the .md draft.
S22 (the wordless finale constellation) is NOT an orphan: it is wired as the
finale endcard via P_B12.closing.image == "S22", so the step-② reconcile (which counts closing.image
as a use) reports orphans = [] and prints reconcile OK on this file. A panel_ids-ONLY check would
FALSE-flag it — exactly the weaker-than-the-floor bug step-② fixes. To watch the orphan blocker FIRE, use a
deliberately-broken copy (delete P_B12.closing.image) → S22 is then referenced by neither a panel_ids
nor a closing.image, a TRUE orphan, and the reconcile exits non-zero. That is the fixture PAIR:import json
c = json.load(open("examples/comic_m3_audit/comic.json"))
page_refs = {pid for p in c["pages"] for pid in p["panel_ids"]}
closing_refs = {(p.get("closing") or {}).get("image") for p in c["pages"]} - {None}
defined = set(c["panels"])
print("ORPHANS :", sorted(defined - page_refs - closing_refs)) # -> [] (S22 is P_B12.closing.image) → PASS
print("DANGLING :", sorted((page_refs | closing_refs) - defined)) # -> []
# delete P_B12.closing.image and re-run → ORPHANS == ['S22'] → the reconcile BLOCKS (the negative fixture)
(The reference exhibit is a completed runtime trace with no Phase-1 storyboard_*.json node; in a real
compile run that node exists and the reconcile compares pages[] element-wise to its page_order — final
IR page ids, endcard pages excluded per the declared endcard_folds. Do NOT reconstruct a page_order from
STORYBOARD_DRAFT.md to "test" the reconcile on the reference: its 19 draft pages + ordinal ids would fire
order_missing/order_extra on a correct IR.)
- The legitimate recap reuse to PASS. In the same file
S12,S13,S14,S15 are each referenced twice —
the individual single pages P_B08_1..4 and the grid2x2 recap/hero page P02_b08. That is the
storyboard's declared recap (a mirror_lock), so those duplicate references are NOT veto #8 — the inline
reconcile (step ②) reads the storyboard's global_policies to know they are sanctioned. (A panel referenced
twice with NO such declaration would be a blocker.)
- A fully-projected baked panel to copy —
panels.S11 (the REJECT card): condition.content_svg: "assets/reject_verdict_round1_v1.svg", condition.expected_literals: ["REJECT","37","T-16:05"],
world: "dark-cyber", a rich scene + characters, text_mode: "baked", and bubbles carrying
{zh,en}. This is the shape every authored figure-panel takes.
- The zero-glyph finale to copy —
panels.S22: condition.content_svg: "assets/constellation_layout_v1.svg",
condition.expected_literals: [], condition.world: "starfield", condition.characters: "NONE …",
text_mode: "html". Empty literals are legitimate only because the blueprint is provably zero-glyph
(contract §2 exception); S22 is wired as the finale via P_B12.closing.image (the endcard), which the
closing-aware orphan check counts as a use.
- Engine fields in the reference are FILLED — every panel in that exhibit already has
image_path /
active_attempt_id / wiki_node_id populated, because it is the artifact of a completed run; that is
what the engine wrote back on KEEP. When you author a fresh comic.json, those fields are absent.
Use the reference for the condition{} + render-field shape, not as proof those fields belong in your output.
- The manifest pattern —
movie.project.json is a 13-key pointer hub: it names comic.json,
ART_BIBLE.md, assets/duo_canonical_ref_v001.png, the dirs{}, the text_mode_default, and the
two-world palette note — and duplicates none of their content.
Hard do / don't (earned lessons)
- DO treat the storyboard's
page_order as the page-count authority — reconcile comic.json against
it and BLOCK on any mismatch; conform to the storyboard, never silently ship a different page set.
- DO run the orphan / dangling / page-count check before the schema validate — the schema alone does
NOT catch an orphan panel (it is structurally valid JSON; only the storyboard-reconciliation catches
…(truncated)
1---2name: comic-json-compiler3description: Phase-1 (S9, the FINAL comic-author step) — assemble the LOCKED storyboard + locked per-panel blueprints into ONE schema-valid `comic.json` (the comic-ir/1.0 contract boundary handed to comic-director / run_comic.py). Project the page_order + each panel's condition{} + render fields into `pages[]` + keyed `panels{}` per schemas/comic.schema.json; author ONLY authored fields and leave image_path/active_attempt_id/wiki_node_id EMPTY for the engine. THE step where page-count integrity is reconciled and the orphan-panel class of bug is caught (a panel defined in panels{} that no page references — ship that and the finale silently vanishes) — caught by an INLINE whole-comic reconcile this skill runs (page_refs vs panels{} keys vs the storyboard page_order), because the deterministic scripts are per-page and the schema leaves condition/content_svg OPTIONAL. Gated by comic-cross-layer-gate --gate compile (the DETERMINISTIC gate: both run_comic.py --dry-run AND cli/validate_wiki.py must exit 0). Use when the user say4---56# comic-json-compiler — locked storyboard + blueprints → one schema-valid `comic.json` (S9)78The **last node of the left third of Figure 1**: take the locked `storyboard_spec` (the authoritative page9order + the MOTIF STATE TABLE) and the locked per-panel `blueprint` nodes and **compile** them into the single10`comic.json` (comic-ir/1.0) that is the contract boundary to [`comic-director`](../comic-director/SKILL.md)11(Phase 2/3). You don't hand-write this file panel-by-panel — that already happened upstream; this step is the12deterministic *projection* + *reconciliation* that turns the authoring nodes into the IR and **catches the13integrity bugs that only show up once everything is assembled** — chiefly the page-count / orphan-panel14mismatch (the **orphan-panel class**). The two deterministic scripts the compile gate runs are15**per-page** (`run_comic.py` needs `--page`/`--panels`) and the JSON schema leaves `condition`/`content_svg`16OPTIONAL — so **neither of them sees the whole `pages[]`/`panels{}` set and neither catches an orphan.** That17whole-comic reconciliation against the storyboard authority is **THIS skill's own inline job** (the ~7-line set18diff below), run *before* the gate; the gate then certifies the per-page + wiki-conformance floor.1920```text21 locked storyboard_spec (page_order = AUTHORITY) ┐22 locked motif_ledger (per-panel ground-truth) │23 locked panel_spec × N (the 9-field specs) ├─▶ ① ASSEMBLE pages[] + panels{} per comic.schema.json24 locked blueprint × N (content_svg + expected_literals) │ (author ONLY authored fields; engine fields EMPTY)25 locked prompt_bundle × N (optional, identity refs) ┘ ▼26 ② RECONCILE page-count integrity ── INLINE python (this skill; no phantom script)27 (orphans · dangling refs · page_count vs page_order · grid2x2==4 · recap legitimacy)28 ▼29 ③ VALIDATE jsonschema(comic.schema.json) — SHAPE only (it does NOT enforce content_svg/literals)30 ▼31 ④ FAIL-CLOSED contracts (every panel a content_svg · baked figure ⇒ expected_literals) ← THIS skill asserts; schema won't32 ▼33 ⑤ comic-cross-layer-gate decision:compile_<slug> --gate compile (DETERMINISTIC: run_comic.py --dry-run per page AND cli/validate_wiki.py both exit 0)34 ▼35 approve? ─ revise ─▶ fix the OFFENDING node upstream, recompile (you don't invent content)36 │ approve37 ▼38 ⑥ EMIT comic.json (output-versioning) + movie.project.json pointer-hub + asset-copy manifest39```4041## Constants42- **CONTRACT BOUNDARY** = `comic.json` (`comic-ir/1.0`) + the assets it references. Everything downstream43 (`comic-director`, `run_comic.py`, the Phase-3 viewer build `packages/viewer/build_comic.py`) reads ONLY this44 file + its assets — so a bug that survives this step survives to production.45- **AUTHORITY = the storyboard's `page_order`.** When the assembled `comic.json` page set disagrees with the46 locked `storyboard_spec.page_order`, the **storyboard wins** — the compiler conforms, it does NOT silently47 ship its own page count. (This is the converse of the storyboard gate's page-order veto.)48- **GATE** = `comic-cross-layer-gate decision:compile_<slug> --gate compile` (the target is the **real wiki49 `decision` node** this skill writes — there is NO `comic` node type and `comic.json` is not a wiki node, so it50 can never be a gate target or an edge endpoint). Per [`comic-cross-layer-gate`](../comic-cross-layer-gate/SKILL.md)51 the `compile` gate is **purely DETERMINISTIC**: NO `review:*` fan-out, NO Codex adjudication (so unlike every52 other gate, the "create review nodes first" pre-step does NOT apply here). It PASSES (`approve`) **iff BOTH53 on-disk scripts exit 0**: `python3 skills/comic-director/scripts/run_comic.py --project <dir> --page <P>54 --panels <ids> --dry-run` (per page) AND `python3 cli/validate_wiki.py <dir>`; else `revise` with their55 stderr as the blocker list. (The whole-comic orphan/page-count catch is NOT in either script — it is this56 skill's inline step ② below, run before the gate.)57- **VERDICT enum** (the `compile` gate's legal set, per the gate skill): `approve` / `revise`. On `approve` flip58 the compile `decision` to `final` and emit; on `revise` fix the **offending upstream node** and recompile (the59 compiler never patches content into `comic.json` by hand). A *verdict* is never a node `status`: the status the60 FLIP writes is `locked` on advance, `under_review` on needs-work, `rejected` on a terminal fail61 (schema-enum statuses only — `revise`/`regenerate` are verdicts, not statuses).62- **NO INVENTION** — the compiler is a *projection + reconciliation* step. It must NOT invent a panel, a63 number, an `expected_literal`, a page, or a `content_svg`. A missing piece is a HALT with the offending node64 id, not a fabrication ([`artifact-integrity`](../../protocols/artifact-integrity.md)).65- **ENGINE FIELDS STAY EMPTY** — `image_path` / `active_attempt_id` / `wiki_node_id` are written by the spiral66 engine on KEEP. Authoring them is a veto (you'd be claiming a bake that never ran).67- **ASCII node-id discipline** — every wiki touch goes through the one `node_id_to_filename` helper (`:` → `_`),68 per [`wiki-helper-resolution`](../../protocols/wiki-helper-resolution.md).6970## Input contract — what must be LOCKED before you compile71This is a *compile* step: it consumes locked upstream nodes, it does not author them.72- One **`storyboard_spec`** (`status: "locked"`) — its `page_order` (the authority), `page_ids`, `panel_ids`,73 `global_policies` (page-order authority, text-mode rules, mirror-lock policy), `motif_ledger_id`,74 `consolidated_asset_requests`.75- The **`motif_ledger`** it points at — the per-panel ground-truth the gate uses to confirm `condition` prose76 agrees with each panel's continuity row.77- One **`panel_spec`** per panel (`status: "locked"`) — `page_type`, `world`, `text_mode`, `expected_literals`,78 `content_blueprint`, `bubbles`, `side_narration`, `motifs`, (cover/finale: `safe_zones` + endcard fields).79- One **`blueprint`** per panel (`status: "locked"`) — `content_svg`, `expected_literals` (verbatim),80 `safe_zones`, `html_bubbles`, `crop`, `negative_space_policy`, `generator_script`, `file_sha256`.81- *(optional)* the **`prompt_bundle`** per panel — for `identity_ref_paths` (else fall back to the project82 canonical from `identity_refs`).83- **HALT if any of these is not `locked`** (or its `content_svg`/`output_ref` file is missing) — name the84 offending node id. Convergence is the upstream gate's job, not yours.8586## Workflow (the inline reconcile + the two deterministic gate scripts — all runnable, no phantom tool)8788### ① ASSEMBLE `pages[]` + `panels{}`89Project the locked nodes into the IR. Copy `examples/comic_m3_audit/comic.json` as the shape.901. **Top level** — `schema_version: "comic-ir/1.0"`, `comic_id`, `defaults` (`text_mode`, `default_locale`,91 `panels_per_page_cap`, `nav_default`, `pixel_rendering`), `identity_refs` (the locked cast hex table from92 the style bible — pure pointer/hex hub, never duplicated art), `ui_tokens` (viewer theming, palette pinned).932. **`pages[]` — reading order, ONE entry per `storyboard_spec.page_order` page, in that exact order —94 EXCEPT a declared endcard page** (the ENDCARD FOLD, below: an endcard/finale storyboard page compiles into95 the PRIOR page's `closing{}`, never its own `pages[]` entry). Each entry: `id` (the stable FINAL page id,96 e.g. `P00_cover`/`P_B03`/`P02_b08` — see the PAGE-ID rule below), `type`97 (`cover|single|grid|grid2x2|feature|finale`), `panel_ids` (the panel ids that page shows), plus optional98 `beat`/`beat_title`/`narration`/`skill`/`title`/`links` (cover/closing buttons — `https://` only).993. **`panels{}` — ONE keyed entry per panel id** that appears in any page. Per panel project:100 - **`condition{}`** (what to GENERATE): `content_svg` (the locked blueprint's deterministic SVG = the101 content authority), `expected_literals` (verbatim from the blueprint), `world`, `identity_ref`102 (from the prompt_bundle or `null` → project canonical), `identity_desc`, `characters`, `scene`.103 - **render fields** (for the viewer): `text_mode`, `crop` (`shape∈hero|wide|square`, `position [x,y]` in104 0..1, `zoom ≥ 1`), `safe_zones` (each with an `id`), `bubbles` (`speaker∈researcher|executor|reviewer`,105 `style∈say|shout|terse|thought|whisper`, `text{zh,en}`, optional `anchor` = one of this panel's106 safe-zone ids), `overlays`, `caption{zh,en}`.107 - **leave EMPTY**: `image_path`, `active_attempt_id`, `wiki_node_id`.1084. **Emit `movie.project.json`** — the pure pointer-hub manifest (`project_id`, `title{zh,en}`, `story`,109 `schema_version`, `comic_json`, `art_bible`, `identity_ref`, `dirs`, `text_mode_default`, `palette`). It110 **names** the other artifacts; it NEVER duplicates their content.111112**Two storyboard→IR projections step ② MUST know (or the reference itself looks broken):**113- **THE ENDCARD FOLD.** A storyboard page of type `endcard` (the finale card) does **NOT** compile to its own114 `pages[]` entry: its single panel becomes the **PRIOR page's `closing.image`** (with the logo/tag/links in115 the same `closing{}` block). Reference: the storyboard declares **19 pages** ending in a standalone116 `P03_end[S22·endcard]`; the compiled IR ships **18** `pages[]` with `P_B12` (type `finale`, panels S20+S21)117 carrying `closing.image: "S22"`. The 19-vs-18 difference IS the fold — by design, not a lost page. NOTE the118 schema does not model `closing{}` at all (it validates via `additionalProperties`), so asserting "the fold119 actually happened" is step ②'s inline job, nobody else's.120- **THE PAGE-ID RULE.** The compiled IR's canonical page ids are **beat-keyed** (`P_B<beat>`: `P_B03…P_B07`,121 `P_B08_0…P_B12`), while the human `STORYBOARD_DRAFT.md`'s first-third draft ids are **ordinal-prefixed**122 (`P02_b03`/`P03_b04`/…). The stable key is the beat token (`b03` ↔ `B03`, case-insensitive); the123 already-canonical anchors (`P00_cover`, `P01_b02`, `P02_b08`) keep their ids. The LOCKED124 `storyboard_spec.page_order` — the reconcile authority — must be authored in the **FINAL IR vocabulary**;125 the compiler never renames silently. **Never build a `page_order` for the reconcile from the `.md` draft's126 ordinal ids** — that draft is a human artifact, not the locked node, and an element-wise compare against it127 false-blocks the shipped reference.128129### ② RECONCILE page-count integrity (the heart of this skill — INLINE python, NO external script)130**There is no `reconcile_pages.py` — it never existed.** This catch is run *inline* (the schema and the131per-page `run_comic.py` are both blind to it), so paste-and-run this ~15-line whole-comic diff against the132storyboard authority. This is where the orphan-panel class dies. Exit non-zero on ANY blocker; do NOT proceed to133the gate until it is clean.134```python135# inline reconcile — run against the assembled comic.json + the locked storyboard_spec node (NO phantom tool)136import json, sys, re137c = json.load(open("comic.json", encoding="utf-8"))138sb = json.load(open("wiki/nodes/storyboard_<slug>.json", encoding="utf-8"))["payload"] # the LOCKED authority139page_refs = [pid for p in c["pages"] for pid in p["panel_ids"]] # every page→panel reference (with dups)140closing_refs = [(p.get("closing") or {}).get("image") for p in c["pages"] if (p.get("closing") or {}).get("image")]141ref_set = set(page_refs) | set(closing_refs); defined = set(c["panels"]) # a finale endcard (closing.image) IS a use142orphans = sorted(defined - ref_set) # defined, not on a page AND not a closing endcard → BLOCKER143dangling = sorted(ref_set - defined) # a page/closing points at a missing panel → BLOCKER144# PAGE-ORDER AUTHORITY — ELEMENT-WISE vs the storyboard (NOT a length check): catches reorder / id-swap / dup /145# over-count that a count comparison silently passes.146comic_order = [p["id"] for p in c["pages"]]147sb_order = list(sb["page_order"])148# ENDCARD FOLD — a declared endcard page has NO pages[] entry (its panel compiles into the PRIOR page's149# closing.image: storyboard P03_end[S22·endcard] → P_B12.closing.image; 19 declared → 18 pages[]). EXCLUDE the150# declared endcard pages from the element-wise compare, then assert the fold HAPPENED (a declared endcard panel151# on no page's closing.image = the finale silently vanished → BLOCKER). Read the declaration off the storyboard152# (its global_policies endcard map {endcard_page_id: panel_id}) — declared, never inferred.153gp0 = sb.get("global_policies", {})154endcard_map = dict(gp0.get("endcard_folds") or {}) # e.g. {"P03_end": "S22"}; {} when the comic has no endcard155sb_order_cmp = [pid for pid in sb_order if pid not in endcard_map]156endcard_unfolded = sorted(pn for pn in endcard_map.values() if pn not in set(closing_refs)) # fold missing → BLOCKER157order_mismatch = comic_order != sb_order_cmp # STRICT: any reorder / missing / extra / dup → BLOCKER158order_missing = [pid for pid in sb_order_cmp if pid not in comic_order] # storyboard page absent from comic.json159order_extra = [pid for pid in comic_order if pid not in sb_order_cmp] # comic.json page not in storyboard160page_id_dups = sorted({pid for pid in comic_order if comic_order.count(pid) > 1}) # duplicate page id (over-count)161bad_grid = [p["id"] for p in c["pages"] if p["type"] == "grid2x2" and len(p["panel_ids"]) != 4] # arity → BLOCKER162empty_pg = [p["id"] for p in c["pages"] if not p["panel_ids"]] # empty page → BLOCKER163# FAIL-CLOSED engine contracts — jsonschema leaves these OPTIONAL, so this inline step is the REAL enforcer:164def cond(pn): return pn.get("condition") or {}165no_csvg = sorted(pid for pid, pn in c["panels"].items() if not cond(pn).get("content_svg")) # null/missing content_svg166# cfg_usable EXACTLY (run_comic.py): the EFFECTIVE mode resolves from defaults (a panel inheriting a baked167# default is still baked); a baked panel WITH a content_svg MUST carry expected_literals as a non-empty LIST168# whose every entry is an ascii-tokenizable string. ONE predicate = never weaker than the engine floor.169def emode(pn): return pn.get("text_mode") or (c.get("defaults") or {}).get("text_mode") or "html"170def baked_lits_bad(pn):171 if emode(pn) != "baked" or not cond(pn).get("content_svg"): return False172 exp = cond(pn).get("expected_literals")173 return not (isinstance(exp, list) and len(exp) > 0174 and all(isinstance(e, str) and re.findall(r"[a-z0-9+._-]+", e.lower()) for e in exp))175baked_lits_fail = sorted(pid for pid, pn in c["panels"].items() if baked_lits_bad(pn))176engine_authored = sorted(pid for pid, pn in c["panels"].items() # authored ENGINE fields MUST be empty177 if pn.get("image_path") or pn.get("active_attempt_id") or pn.get("wiki_node_id"))178# bubble.anchor (if set) MUST resolve to a panel safe_zone id. safe_zones is a panel TOP-LEVEL field179# (comic.schema.json) — NOT under condition; jsonschema only TYPES anchor as a string, so assert it inline.180def sz_ids(pn): return {z.get("id") for z in (pn.get("safe_zones") or [])}181bad_anchor = sorted(f"{pid}:{b.get('anchor')}" for pid, pn in c["panels"].items()182 for b in (pn.get("html_bubbles") or pn.get("bubbles") or [])183 if isinstance(b, dict) and b.get("anchor") and b["anchor"] not in sz_ids(pn))184# DECLARED recap reuse is the ONLY legitimate multi-reference — read it from the storyboard, never guess.185gp = sb.get("global_policies", {})186sanctioned = set(gp.get("recap_panel_ids") or gp.get("sanctioned_recap") or []) # storyboard-declared recap/grid set187dups = sorted({pid for pid in page_refs if page_refs.count(pid) > 1} - sanctioned) # UNDECLARED panel dup → BLOCKER188blockers = {"orphans": orphans, "dangling": dangling, "order_mismatch": order_mismatch, "order_missing": order_missing,189 "order_extra": order_extra, "page_id_dups": page_id_dups, "endcard_unfolded": endcard_unfolded,190 "bad_grid2x2": bad_grid, "empty_pages": empty_pg,191 "undeclared_dups": dups, "content_svg_null": no_csvg, "baked_literals_invalid": baked_lits_fail,192 "engine_fields_authored": engine_authored,193 "bubble_anchor_unresolved": bad_anchor}194if any(blockers.values()):195 sys.exit(f"RECONCILE BLOCKERS: {blockers} (pages {len(comic_order)} vs storyboard {len(sb_order_cmp)} after endcard fold)")196print("reconcile OK")197```198- **The orphan check counts `closing.image`.** In the reference `comic.json`, `S22` (the wordless constellation199 finale) is the **endcard of the finale page `P_B12`** (`P_B12.closing.image = "S22"`) — a legitimate use, NOT200 an orphan. (An earlier draft of this check read only `panel_ids` and FALSE-flagged it.) A **TRUE orphan** is a201 panel in `panels{}` referenced by neither a page's `panel_ids` NOR any `closing.image`; that is a real BLOCKER202 the compiler must catch, not ship — page it, or (if genuinely cut) make an upstream *storyboard* edit + re-lock,203 never a quiet drop. When a locked `storyboard_spec` node is present, its `page_order` is the element-wise page204 authority (reorder / missing / extra / duplicate page id all block) — **after excluding its declared endcard205 pages**, which by contract have no `pages[]` entry (the fold itself is asserted separately via206 `endcard_unfolded`).207- **Recap reuse is the ONE legitimate multi-reference** and must be *declared*, not inferred. In the208 reference comic, `S12,S13,S14,S15` are each referenced **twice** — once as individual `single` pages209 (`P_B08_1..4`) and once in the `grid2x2` recap/hero page (`P02_b08`). That is by-design (the storyboard's210 `mirror_locks` / recap policy), so a panel referenced >1 time is a **veto ONLY if it is not on the211 storyboard's declared recap/grid set**. The diff reads the storyboard's `global_policies` (the sanctioned212 recap ids) to know which reuses are sanctioned — an *undeclared* duplicate reference is still a BLOCKER.213214### ③ VALIDATE against the schema (SHAPE only — it does NOT enforce the fail-closed contracts)215```bash216python3 -c "import json,jsonschema,sys; jsonschema.validate(json.load(open(sys.argv[1]+'/comic.json')), json.load(open('schemas/comic.schema.json')))" <project_dir> # run from repo root217```218**Know exactly what this does and does NOT catch.** `schemas/comic.schema.json` leaves `condition`,219`condition.content_svg`, and `condition.expected_literals` **all OPTIONAL** (it constrains their *shape* when220present but never *requires* them). So jsonschema **PASSES** a panel with `content_svg: null`/absent or a baked221panel with empty `expected_literals` — exactly the states vetoes #5/#6 and the two fail-closed contracts forbid.222**Therefore the schema is NOT the contract enforcer** — step ④ (this skill's own assertion) and the223`run_comic.py --dry-run` `cfg_usable` fail-closed (step ⑤) are what bite. Do not rely on jsonschema for them.224- **`build_comic.py` is NOT a compile-time fallback.** It lives at **`packages/viewer/build_comic.py`** and is225 the **Phase-3 post-bake viewer build**: it *requires* every panel's `image_path` to be present and a real226 image on disk. A freshly-authored `comic.json` (engine fields EMPTY, per this skill) would make `build_comic.py`227 hard-fail on the missing images — it is the wrong tool here, and it never checks orphans. Cite it only for the228 post-bake viewer stage (Phase 3), with its real `packages/viewer/` path; the compile-time deterministic floor229 is `cli/validate_wiki.py` + `run_comic.py --dry-run` (step ⑤), not `build_comic.py`.230231### ④ Enforce the two FAIL-CLOSED engine contracts (see the dedicated section below)232Every panel has a real `content_svg` (never `null`); every **baked** figure-panel has non-empty,233ASCII-tokenizable `expected_literals`. The one nuance — the **zero-text panel** (S22) — is spelled out below.234235### ⑤ Run the compile gate (DETERMINISTIC = both scripts exit 0; no reviewer fan-out)236The `--gate compile` floor is **two real on-disk scripts**. `run_comic.py` is a **per-page** spiral runner —237its argparse marks **both `--page` and `--panels` as required**, so it must be invoked once per page with that238page's panel ids (the project-only command crashes with an argparse error). It does **NOT** validate jsonschema239and **never** sees the whole `pages[]`/`panels{}` set — so it CANNOT catch the orphan/page-count (that was step240②). Its `--dry-run` job is per-page bake-readiness: it asserts every `text_mode:"baked"` figure-panel on that241page carries ascii-tokenizable `expected_literals` (the `cfg_usable` fail-closed) and prints each concrete bake242prompt (real scene + real literals, no placeholders).243```bash244# (a) per page in the storyboard page_order — bake-readiness + fail-closed literals (NOT schema/orphan):245python3 skills/comic-director/scripts/run_comic.py --project examples/comic_m3_audit --page P02_b08 --panels S12,S13,S14,S15 --dry-run246# …repeat for every page; a baked figure-panel with no ascii expected_literals → run_comic EXITS non-zero247# (b) wiki node/edge/payload/node_id/privacy conformance (the whole project, against node_schema.json):248python3 cli/validate_wiki.py examples/comic_m3_audit249```250First write the compile `decision` node + its edges (§ "Node it reads / writes"), then defer to the gate.251`comic-cross-layer-gate decision:compile_<slug> --gate compile` is **purely deterministic** (per the gate252skill): NO `review:*` nodes, NO Codex adjudication — it simply runs the two scripts above and returns `approve`253iff **both exit 0**, else `revise` carrying their stderr as the blocker list. (This is the one gate where the254"create review nodes first" pre-step does NOT apply — the scripts ARE the judge.) On `approve` → flip the255`decision` to `final` and emit; on `revise` → fix the **offending upstream node** and recompile. The gate (the256deterministic scripts, a different acquittal organ) — never this compiling agent — acquits257([`acceptance-gate`](../../protocols/acceptance-gate.md)).258259### ⑥ EMIT260Write `comic.json` + `movie.project.json` via [`output-versioning`](../../protocols/output-versioning.md)261(timestamped + fixed-name latest; downstream reads the fixed name). Emit the **asset-copy manifest** — the262flat list of every `content_svg` / `identity_ref` the IR references and where each lives — so Phase 2/3 can263stage them. Append the wiki nodes/edges (§ below) and trace the gate round to `trace.jsonl`264([`review-tracing`](../../protocols/review-tracing.md)).265266## EXACT gate (`compile`) — deterministic checks, thresholds, vetoes267The `compile` gate's legal verdicts are **`{approve, revise}`** (per268[`comic-cross-layer-gate`](../comic-cross-layer-gate/SKILL.md) — this gate is **purely deterministic**: NO269reviewer fan-out, NO 0–5 scoring, NO Codex adjudication). The checks below are therefore **machine predicates,270each PASS/FAIL**, owned by a specific on-disk enforcer — **not reviewer dimensions**. The gate returns `approve`271iff its two scripts (`run_comic.py --dry-run` per page **and** `cli/validate_wiki.py`) exit 0; the whole-comic272structural predicates (orphan / page-count / dangling / grid arity) are this skill's **inline step ②**, asserted273*before* the gate (the scripts are per-page/wiki-only and cannot see them). Each predicate below is tagged with274**[who enforces it]** so there is no claim that a tool checks something it doesn't.275276Structural predicates (each PASS/FAIL):277- **`schema_valid`** **[jsonschema, step ③]** — `comic.json` validates against `schemas/comic.schema.json`278 (SHAPE only; required top-level keys present; `schema_version == "comic-ir/1.0"`). NOTE: the schema does NOT279 require `condition`/`content_svg`/`expected_literals`, so it does NOT enforce the contract predicates below —280 those are owned by step ④ + `run_comic.py`. `build_comic.py` is NOT a fallback here (Phase-3 viewer build).281- **`panel_refs_resolve`** **[inline step ②]** — every `pages[].panel_ids` entry exists as a key in `panels{}`282 (no DANGLING ref).283- **`page_count_authority_match`** **[inline step ②]** — the `pages[]` count + ids reconcile with the locked284 `storyboard_spec.page_order` **after excluding the storyboard's declared endcard pages** (an endcard folds285 into the prior page's `closing{}`, never its own entry; the fold is asserted via `endcard_unfolded`). No286 short-count, no over-count, no re-ordering. **This is the orphan-panel reconciliation predicate** (neither287 gate script sees it — it MUST run inline).288- **`no_orphan_panels`** **[inline step ②]** — every panel defined in `panels{}` is referenced by at least one289 page; any defined-but-unreferenced panel is surfaced (a sanctioned recap reuse counts as a reference).290- **`grid2x2_arity`** **[inline step ②]** — every `grid2x2` page carries **exactly 4** panel ids; no empty page.291- **`engine_fields_empty`** **[inline step ②]** — `image_path` / `active_attempt_id` / `wiki_node_id` are292 absent/empty on every panel (the engine owns them).293- **`bubble_anchor_safezone`** **[inline step ②]** — every `bubble.anchor` (if set) resolves to one of its294 panel's `safe_zone` ids; `crop.position ∈ [0,1]²`, `crop.zoom ≥ 1`. (jsonschema only TYPES `anchor` as a295 string — it does NOT enforce the cross-reference, so this is asserted inline, not by the schema.)296297Contract predicates (each PASS/FAIL — and note the schema does NOT enforce these, so step ④ + `run_comic.py` do):298- **`every_panel_has_content_svg`** **[inline step ④]** — no panel has `condition.content_svg: null` or a299 missing `condition.content_svg`. (jsonschema leaves it optional, so this MUST be asserted inline.)300- **`baked_figure_has_literals`** **[`run_comic.py --dry-run` `cfg_usable`, step ⑤]** — every panel whose301 `text_mode == "baked"` declares a non-empty, ASCII-tokenizable `condition.expected_literals` (`run_comic.py`302 EXITS non-zero otherwise; the actual token blind-diff happens later, at BAKE time in the engine — here it is303 only the *presence* assertion). The **zero-text exception** (`negative_space_policy: "zero_glyph"`, build-time304 `assert "<text" not in svg`) is the ONLY way an empty `expected_literals` passes — and only with305 `text_mode != "baked"` (see the contracts section).306- **`identity_refs_resolve`** **[inline step ④]** — every `condition.identity_ref` resolves to a real file (or307 is `null` → the project canonical exists in `identity_refs`); the cast hex table is present.308- **`asset_manifest_complete`** **[inline step ④/⑥]** — every referenced `condition.content_svg` /309 `identity_ref` exists on disk and appears in the asset-copy manifest; no inline-invented asset.310- **`manifest_is_pointer_hub`** **[inline step ④]** — `movie.project.json` names (does not duplicate)311 `comic.json`, `art_bible`, `identity_ref`; `schema_version` matches.312313**Hard vetoes (any one → `revise`):**3141. **An orphan panel** — a panel defined in `panels{}` referenced by neither a page's `panel_ids` NOR any315 `closing.image` endcard (the **orphan-panel class**; a finale endcard via `closing.image` is a legitimate316 use, NOT an orphan — see S22). Authority = the storyboard's page order — conform, do not ship the short count.3172. **A dangling page ref** — a `pages[].panel_ids` entry with no `panels{}` key.3183. **Page count disagrees with the storyboard authority** (short, over, or re-ordered vs `page_order`,319 measured AFTER excluding declared endcard folds) — **or a declared endcard panel appears on no page's320 `closing.image`** (the fold never happened: the vanished-finale case).3214. **A `grid2x2` page without exactly 4 panel ids**, or any empty page.3225. **A panel with `condition.content_svg: null`** (or missing) — the engine fail-closed rejects it (asserted323 inline at step ④; jsonschema will NOT catch it).3246. **A baked figure-panel with empty/non-ASCII `condition.expected_literals`** (and not the declared zero-glyph325 exception) — `run_comic.py --dry-run` `cfg_usable` refuses it (the later bake-time blind-diff would have326 nothing to verify, so a wrong number could slip).3277. **An engine field authored** (`image_path` / `active_attempt_id` / `wiki_node_id` non-empty) — you'd be328 claiming a bake that never happened.3298. **An undeclared duplicate panel reference** — a panel referenced by >1 page that is NOT on the storyboard's330 sanctioned recap/grid list.3319. **An invented field** — any panel/number/`expected_literal`/page/`content_svg` not traceable to a locked332 upstream node (compiler must not fabricate).33310. **Manifest duplicates content** — `movie.project.json` inlines what it should only point at, or its334 `schema_version` mismatches `comic.json`.335336## Node it reads / writes (`schemas/node_schema.json`)337**Reads** (all must be `status: "locked"`):338- one **`storyboard_spec`** (`storyboard:<slug>`) — payload `source_outline_id, page_order, page_ids,339 panel_ids, global_policies, motif_ledger_id, consolidated_asset_requests`. **`page_order` is the page-count340 authority** the compile reconciles against.341- the **`motif_ledger`** (`motif:<slug>`) it names — payload `source_storyboard_id, columns, rows, invariants,342 mirror_locks`; the gate uses `rows` to confirm each panel's `condition` prose agrees with its continuity row,343 and `mirror_locks` to know which recap reuses are sanctioned.344- **`panel_spec`** × N (`panel:<slug>`) — payload `source_storyboard_id, page_id, panel_id, sequence_index,345 page_type, world, asset_ids, text_mode, expected_literals, content_blueprint, bubbles, side_narration,346 motifs`.347- **`blueprint`** × N (`blueprint:<slug>`) — payload `source_panel_id, content_svg, expected_literals,348 safe_zones, html_bubbles, crop, negative_space_policy, generator_script, file_sha256`.349- *(optional)* **`prompt_bundle`** × N (`prompt:<slug>`) — payload `source_panel_id, source_blueprint_id,350 style_prefix, composed_prompt, banned_vocab_scan, identity_ref_paths`.351352**Writes:**353- the **`comic.json` artifact** (the comic-ir/1.0 IR — **NOT a wiki node**: there is no `comic` node_type and no354 `comic:` node_id prefix, so it can never be an edge endpoint or a gate target; it is the contract boundary355 *file*) + the **`movie.project.json`** pointer-hub + the **asset-copy manifest**.356- one **`decision`** node (`node_id` `decision:compile_<slug>`, matching the schema pattern357 `^(intent|…|decision|fail):[a-z0-9_-]+$`) — payload **must** contain every PAYLOAD_REQUIRED field for358 `decision`: **`target_node_id, verdict, gate_kind`** (set `gate_kind: "compile"`, `verdict ∈ {approve,359 revise}`, `target_node_id` = the **`storyboard_spec` id** `storyboard:<slug>` — the real wiki node whose360 page_order the compile reconciles against, the only schema-legal target). `status: "final"` on the gate's361 return.362- **NO `review` node here.** The `compile` gate is purely deterministic (no reviewer fan-out, no Codex), so —363 unlike every other gate — this skill does NOT mint `review:*` nodes or a `reviews` edge. (Writing a phantom364 `review` would also fail the gate's own "the two scripts are the judge" contract.)365366Author edges (all legal `EDGE_TYPES` in `cli/validate_wiki.py`, all between REAL wiki-node endpoints — NEVER367the `comic.json` file, which has no node_id): `decides` (`decision:compile_<slug>` → `storyboard:<slug>`),368`derived_from` (`decision:compile_<slug>` → `storyboard:<slug>`, the authority it was compiled against), and369one `uses_blueprint` per panel (`decision:compile_<slug>` → each `blueprint:<slug>`, the per-panel provenance).370**There is NO `compiled_from` edge type** — it is not in `EDGE_TYPES`, so writing it fails `validate_wiki.py`;371`derived_from` + `uses_blueprint` are the legal substitutes. The verdict-bearing *runtime* edges372(`attempt_of`/`rollback_of`/`reviews`) belong to Phase 2/3, not here.373374## Two engine contracts to author to (fail-closed)375The compiler's last duty is to guarantee the IR can never make the spiral REFUSE a panel for a structural376reason — verify both at compile time, do not defer to the dry run alone:3771. **Every panel needs a `condition.content_svg`** — a deterministic blueprint (figure OR scene-anchor378 layout). `condition.content_svg: null` is rejected by the engine (`spiral_engine.js` reads379 `.condition.content_svg`); assert it INLINE at compile time (jsonschema leaves it optional). A dialogue/scene380 panel with no audited numbers still carries a **layout** blueprint (the scene composition + the381 negative-space safe zones).3822. **A `baked` figure-panel MUST declare non-empty, ASCII-tokenizable `condition.expected_literals`**383 (exact numbers/keys/fn-names/verdicts, **verbatim**) — else `run_comic.py --dry-run`'s `cfg_usable` exits384 non-zero (there'd be nothing for the later bake-time blind-diff to verify, so a wrong number could slip). A385 scene panel with no audited numbers → `text_mode: "html"` (its text becomes an HTML overlay, not baked glyphs).386 - **The ONE documented exception — the zero-text panel.** The reference S22 is the wordless constellation:387 `condition.content_svg` set, `condition.expected_literals: []`, `world: "starfield"`,388 `characters: "NONE … no text anywhere in the image"`, blueprint `negative_space_policy: "zero_glyph"`389 enforced by a build-time `assert "<text" not in svg`. An empty `expected_literals` is legitimate **only**390 when the blueprint is provably zero-glyph **and** `text_mode != "baked"` (the panel gates on391 `stray_text_absence`, not a literal token-diff). Any *other* empty-literals baked figure-panel is veto #6.392 Note S22 (the wordless finale) is the one panel with nothing to literal-gate — it gates on393 `stray_text_absence`, not a token-diff — and it is wired as the finale via `P_B12.closing.image`, the394 legitimate zero-glyph endcard (the closing-aware orphan check counts that as a use, so it is NOT an orphan).395396## Worked example397The canonical exhibit is **[`examples/comic_m3_audit/comic.json`](../../examples/comic_m3_audit/comic.json)**398(comic-ir/1.0, `comic_id: aris_comic_v1`) + its pointer-hub399**[`examples/comic_m3_audit/movie.project.json`](../../examples/comic_m3_audit/movie.project.json)**400(`project_id: comic_m3_audit`), the IR for the **24-panel** "ARIS — I Handed Over Those 24 Hours" comic.401Copy its exact shape — and copy how it *fails* the integrity check, because that is the teaching case:402403- **The closing-aware orphan check — the reference IS the PASSING fixture.** That file ships **18 pages /404 24 panels** (verified) — while its `STORYBOARD_DRAFT.md` declares **19 pages** (TOTALS: "24 panels · 19405 pages") ending in a standalone `P03_end[S22·endcard]`. Both are right: the endcard FOLDS (`P03_end` →406 `P_B12.closing`), so 19 declared − 1 fold = 18 `pages[]`. The draft's first-third ordinal ids407 (`P02_b03…P06_b07`) also appear in the IR as beat-keyed `P_B03…P_B07` (the PAGE-ID rule) — two projections a408 naive element-wise diff against the *draft* would false-flag; the reconcile compares against the LOCKED409 `storyboard_spec.page_order` (final IR vocabulary) minus its declared endcard pages, never the `.md` draft.410 `S22` (the wordless finale constellation) is **NOT** an orphan: it is wired as the411 finale endcard via **`P_B12.closing.image == "S22"`**, so the step-② reconcile (which counts `closing.image`412 as a use) reports `orphans = []` and prints **`reconcile OK`** on this file. A `panel_ids`-ONLY check would413 FALSE-flag it — exactly the weaker-than-the-floor bug step-② fixes. To watch the orphan blocker FIRE, use a414 deliberately-broken copy (delete `P_B12.closing.image`) → `S22` is then referenced by neither a `panel_ids`415 nor a `closing.image`, a TRUE orphan, and the reconcile exits non-zero. That is the fixture PAIR:416 ```python417 import json418 c = json.load(open("examples/comic_m3_audit/comic.json"))419 page_refs = {pid for p in c["pages"] for pid in p["panel_ids"]}420 closing_refs = {(p.get("closing") or {}).get("image") for p in c["pages"]} - {None}421 defined = set(c["panels"])422 print("ORPHANS :", sorted(defined - page_refs - closing_refs)) # -> [] (S22 is P_B12.closing.image) → PASS423 print("DANGLING :", sorted((page_refs | closing_refs) - defined)) # -> []424 # delete P_B12.closing.image and re-run → ORPHANS == ['S22'] → the reconcile BLOCKS (the negative fixture)425 ```426 (The reference exhibit is a *completed runtime* trace with no Phase-1 `storyboard_*.json` node; in a real427 compile run that node exists and the reconcile compares `pages[]` element-wise to its `page_order` — final428 IR page ids, endcard pages excluded per the declared `endcard_folds`. Do NOT reconstruct a `page_order` from429 `STORYBOARD_DRAFT.md` to "test" the reconcile on the reference: its 19 draft pages + ordinal ids would fire430 `order_missing`/`order_extra` on a correct IR.)431- **The legitimate recap reuse to PASS.** In the same file `S12,S13,S14,S15` are each referenced **twice** —432 the individual `single` pages `P_B08_1..4` *and* the `grid2x2` recap/hero page `P02_b08`. That is the433 storyboard's declared recap (a `mirror_lock`), so those duplicate references are **NOT** veto #8 — the inline434 reconcile (step ②) reads the storyboard's `global_policies` to know they are sanctioned. (A panel referenced435 twice with NO such declaration *would* be a blocker.)436- **A fully-projected baked panel to copy** — `panels.S11` (the REJECT card): `condition.content_svg:437 "assets/reject_verdict_round1_v1.svg"`, `condition.expected_literals: ["REJECT","37","T-16:05"]`,438 `world: "dark-cyber"`, a rich `scene` + `characters`, `text_mode: "baked"`, and `bubbles` carrying439 `{zh,en}`. This is the shape every authored figure-panel takes.440- **The zero-glyph finale to copy** — `panels.S22`: `condition.content_svg: "assets/constellation_layout_v1.svg"`,441 `condition.expected_literals: []`, `condition.world: "starfield"`, `condition.characters: "NONE …"`,442 `text_mode: "html"`. Empty literals are legitimate **only** because the blueprint is provably zero-glyph443 (contract §2 exception); S22 is wired as the finale via **`P_B12.closing.image`** (the endcard), which the444 closing-aware orphan check counts as a use.445- **Engine fields in the reference are FILLED** — every panel in that exhibit already has `image_path` /446 `active_attempt_id` / `wiki_node_id` populated, because it is the artifact of a **completed run**; that is447 what the *engine* wrote back on KEEP. When you **author** a fresh `comic.json`, those fields are **absent**.448 Use the reference for the `condition{}` + render-field shape, not as proof those fields belong in your output.449- **The manifest pattern** — `movie.project.json` is a 13-key pointer hub: it *names* `comic.json`,450 `ART_BIBLE.md`, `assets/duo_canonical_ref_v001.png`, the `dirs{}`, the `text_mode_default`, and the451 two-world palette note — and duplicates **none** of their content.452453## Hard do / don't (earned lessons)454- **DO** treat the **storyboard's `page_order` as the page-count authority** — reconcile `comic.json` against455 it and BLOCK on any mismatch; conform to the storyboard, never silently ship a different page set.456- **DO** run the orphan / dangling / page-count check **before** the schema validate — the schema alone does457 NOT catch an orphan panel (it is structurally valid JSON; only the storyboard-reconciliation catches458459…(truncated)