# Comic JSON Compiler

> 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 say

- Skill: `wanshuiyin/comic-json-compiler` (Agent Skill)
- Install (CLI): `npx skillmds@latest add wanshuiyin/comic-json-compiler`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wanshuiyin/comic-json-compiler/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: wanshuiyin (https://skillmd.com/u/wanshuiyin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/wanshuiyin/comic-json-compiler

---


# 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`](../comic-director/SKILL.md)
(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.

```text
  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`](../comic-cross-layer-gate/SKILL.md)
  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`](../../protocols/artifact-integrity.md)).
- **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`](../../protocols/wiki-helper-resolution.md).

## 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.
1. **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).
2. **`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).
3. **`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`.
4. **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.
```python
# 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)
```bash
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).
```bash
# (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`](../../protocols/acceptance-gate.md)).

### ⑥ EMIT
Write `comic.json` + `movie.project.json` via [`output-versioning`](../../protocols/output-versioning.md)
(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`](../../protocols/review-tracing.md)).

## EXACT gate (`compile`) — deterministic checks, thresholds, vetoes
The `compile` gate's legal verdicts are **`{approve, revise}`** (per
[`comic-cross-layer-gate`](../comic-cross-layer-gate/SKILL.md) — 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`):**
1. **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.
2. **A dangling page ref** — a `pages[].panel_ids` entry with no `panels{}` key.
3. **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).
4. **A `grid2x2` page without exactly 4 panel ids**, or any empty page.
5. **A panel with `condition.content_svg: null`** (or missing) — the engine fail-closed rejects it (asserted
   inline at step ④; jsonschema will NOT catch it).
6. **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).
7. **An engine field authored** (`image_path` / `active_attempt_id` / `wiki_node_id` non-empty) — you'd be
   claiming a bake that never happened.
8. **An undeclared duplicate panel reference** — a panel referenced by >1 page that is NOT on the storyboard's
   sanctioned recap/grid list.
9. **An invented field** — any panel/number/`expected_literal`/page/`content_svg` not traceable to a locked
   upstream node (compiler must not fabricate).
10. **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:
1. **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).
2. **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`](../../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`](../../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:
  ```python
  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)
