feat(pipeline): length profile, per-stage gen config, comic front cover#233
Merged
Conversation
Three additive pipeline-UX features rescued from a pre-crash recovery
branch and cherry-picked into main:
- Length profile picker in the issue header (teaser / standard /
extended / finale / custom). Drives prompt-template size targets via
{{lengthTargets.*}} so beat counts, prose word ranges, and page
counts scale with the profile instead of hardcoded 22pg/24min.
Season-episodes generator emits a lengthProfile per episode.
- Per-stage generation settings modal (gear icon) on visual stages.
Sets imageMode (auto/local/codex), pinned local image model, and a
refine-LLM override. Persisted on stages.<stageId>.genConfig and
threaded through generate + refine APIs. visualStages.resolveMode
now defaults to codex when imageGen.codex.enabled.
- Comic-issue front cover. Optional cover concept on
stages.comicPages.cover, new POST /comicPages/cover/render route
that composes a cover-art prompt (series masthead + issue-number
tag + concept) and enqueues an image-gen job.
- Comic-script parser now accepts an optional ## Cover concept section
plus the simpler "Panel N" / "Field:" plain-line format alongside
the legacy ### Panel / **Field:** form.
All server tests pass (4556), client build + lint clean.
ComicPagesStage is unreachable in the running app — PipelineIssue redirects /comicPages URLs to /comicScript where ComicScriptStage owns the merged page editor. The cover UI initially placed in ComicPagesStage never rendered for users. Moves the cover card into ComicScriptStage, backed by the same stages.comicPages.cover record, and uses the existing renderOpts so it picks up the user's image-gen drawer choice. Also: extract-pages now seeds a blank cover.script from the LLM's parsed `## Cover concept` section (preserves any user-edited cover); drop unused getProfileLabels export; drop dead `comicPages` key from VISUAL_STAGE_LABELS (stageId is never that value).
There was a problem hiding this comment.
Pull request overview
Adds pipeline UX controls for issue length, per-stage visual generation settings, and comic cover rendering, with server-side prompt/render support and parser updates.
Changes:
- Adds issue length profiles and injects computed targets into pipeline prompt contexts/templates.
- Adds visual generation settings UI/helpers and threads image/refine overrides into storyboard/comic render calls.
- Adds comic cover concept parsing, persistence, API rendering, and UI controls.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| server/services/pipeline/visualStages.js | Adds cover prompt composition/enqueue and Codex-preferred visual mode fallback. |
| server/services/pipeline/textStages.js | Adds length target context for text-stage prompt rendering. |
| server/services/pipeline/issues.js | Persists length profile, genConfig, and cover metadata on issues/stages. |
| server/services/pipeline/arcPlanner.js | Shapes season episode length profiles. |
| server/routes/pipeline.js | Adds schemas/routes for length fields, cover render, and cover extraction seeding. |
| server/lib/issueLength.js | Defines server length profiles and target derivation. |
| server/lib/comicScriptParser.js | Adds cover concept and plain-format panel parsing. |
| server/lib/comicScriptParser.test.js | Updates parser expectations and adds cover/plain-format tests. |
| data.sample/prompts/stages/pipeline-idea-expansion.md | Uses length targets in idea prompt. |
| data.sample/prompts/stages/pipeline-prose.md | Uses length targets in prose prompt. |
| data.sample/prompts/stages/pipeline-comic-script.md | Uses page target and plain panel format in comic prompt. |
| data.sample/prompts/stages/pipeline-tv-script.md | Uses runtime target in TV prompt. |
| data.sample/prompts/stages/pipeline-season-episodes.md | Requests per-episode length profiles. |
| client/src/services/apiPipeline.js | Adds comic cover render API helper. |
| client/src/pages/PipelineIssue.jsx | Adds length picker and visual settings modal entry point. |
| client/src/lib/issueLength.js | Adds client-side length profile display helpers. |
| client/src/components/pipeline/LengthProfilePicker.jsx | Adds header dropdown for preset/custom length selection. |
| client/src/components/pipeline/stages/VisualGenSettings.jsx | Adds reusable visual generation settings panel and option mappers. |
| client/src/components/pipeline/stages/StoryboardsStage.jsx | Threads visual genConfig into storyboard render/refine actions. |
| client/src/components/pipeline/stages/ComicScriptStage.jsx | Adds cover concept UI and cover render action. |
| client/src/components/pipeline/stages/ComicPagesStage.jsx | Threads genConfig into comic page/panel render/refine actions. |
| .changelog/NEXT.md | Documents the added pipeline features. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…h bounds
- updateIssue() now per-stage-merges so partial patches like { genConfig }
or { cover } no longer erase sibling fields like scenes / pages
- comicScriptParser PANEL_RE requires a standalone header line so
description text mentioning "Panel N" no longer splits panels
- length-profile bounds (custom pages 4-120 / minutes 4-240) shared between
the sanitizer, Zod schema, and client picker via exported constants
- arcPlanner season-episode generation rejects the `custom` sentinel and
falls back to `finale` when arcRole=finale (otherwise default profile)
- comicCoverRenderSchema accepts `seed` so the shared image-gen drawer
flows render settings into the cover render
- sanitizeVisualStage drops `cover` on non-comicPages stages, matching the
documented contract
- LengthProfilePicker exposes aria-haspopup/aria-expanded and disables
during auto-run so picker state stays consistent across stages
- tests: new issueLength suite, cover compose/enqueue suite, panel-regex
regression test, per-stage merge test, cover-drop contract test,
arcRole length fallback test
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
.changelog/NEXT.md:31
- The standalone Comic Pages tab is hidden and
/comicPagesredirects to the merged Comic tab, so saying the cover card sits in the "Comic Pages tab" is misleading for users reading the release notes. Refer to the Comic editor/tab instead.
user's concept) and enqueues an image-gen job; the cover card sits
above the page list in the Comic Pages tab.
…de, clear stale state
- updateIssue() deep-merges `cover` and `genConfig` sub-objects so a partial
`{ cover: { script } }` blur save preserves the imageJobId/prompt that a
racing Render-cover mutation just persisted
- updateIssue() clears `errorMessage` when a stage transitions out of
error/generating state, restoring the implicit-clear behavior the
per-stage merge introduced as a regression
- ComicScriptStage cover blur now sends only `{ script }` (relies on
server merge) — eliminates the closure-stale-cover race
- VisualGenSettings `resolveAutoLabel` mirrors server resolveMode priority
(settings.imageGen.mode first, then codex.enabled) so the UI Auto label
matches actual dispatch
- VisualGenSettings lookup cache clears on transient failure so a later
mount can retry instead of being stuck with fallback null/[] forever
- extract-pages route clears stale imageJobId/prompt when seeding new
cover.script from the parsed concept (they belonged to a different script)
- comic-script prompt now emits a `## Cover concept` section so the parser
auto-seeding path actually fires in normal generation
- changelog scoping: gear modal is on `storyboards` only; comicScript keeps
its own image-gen drawer
- tests: deep-merge for cover/genConfig, explicit null-clear semantics,
error-clear on status transition, error-preserve when still in error
…ig-length-cover # Conflicts: # .changelog/NEXT.md
Track cover job status via MediaJobThumb's onStatus callback (same pattern PageRow uses) so the Render cover button stays disabled across the full enqueue → queued → in-progress → completion lifecycle, not just during the POST request. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…a fixes
- client/src/lib/issueLength.js: clampInt('') returns null per contract
instead of coercing to 0 and clamping up to the minimum.
- data.sample/prompts/stages/pipeline-comic-script.md: align the "MUST
start with" instruction with the shown output structure so the LLM
doesn't drop the issue title heading.
- .changelog/NEXT.md: point the cover-card description at the Comic
tab (where the UI actually lives) instead of the redirected-away
Comic Pages tab.
- server/services/pipeline/issues.js: rewrite the misleading updateStage
comment to accurately describe its shallow-merge semantics.
- server/routes/pipeline.js: schema parity — issueCreateSchema now
accepts the new length fields (lengthProfile/pageTarget/minutesTarget)
to match the createIssue service signature.
…trigger The popup is a mixed disclosure panel (preset buttons + custom inputs), not a WAI-ARIA menu. Remove aria-haspopup="menu" — which incorrectly advertised menu semantics to assistive tech — and keep only aria-expanded, which correctly describes a generic disclosure control. Also add an Escape keydown listener so the panel can be dismissed without a mouse click. Addresses Copilot review PRRT_kwDOQx8jQ86CQynM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ver route tests
- pipeline-prose.md: drop the conflicting "one scene per 3-5 comic pages"
guidance — the explicit beatsMin/beatsMax range already pins the scene
count, and the page-density phrasing implied 4-7 scenes vs. the actual
8-12 for a 22-page standard issue.
- pipeline-tv-script.md: template the prose-source word count
({{lengthTargets.proseWordsMin}}-{{lengthTargets.proseWordsMax}})
instead of hardcoding "800 words" — the prose stage now scales to
the issue's length profile so the TV stage shouldn't undercount.
- visualStages.js: gate codex mode on `imageGen.codex.enabled` in
resolveMode so explicit `mode: 'codex'` (per-stage or settings-level
default) falls back to local when the toggle is off, instead of
enqueuing a doomed job the dispatcher will reject.
- VisualGenSettings.jsx: mirror the same gate in the client preview so
the modal's "Auto -> Codex" / "Codex (saved)" labels reflect the
actual enabled state.
- pipeline.test.js: add enqueueComicCover to the visualStages mock and
three integration tests for the cover render route (success +
persistence, 404, 400 schema rejection).
…er/length test coverage
VisualGenSettings.jsx:
- Clear pinned imageModelId when switching backend to auto/codex so the
saved genConfig stops advertising a model id that genConfigToImageOptions
ignores. Mirrored on the server in sanitizeGenConfig.
- Rewrite the codex-disabled warning to describe the actual fallback
behavior ("renders will fall back to local diffusion") rather than the
old "render will fail" wording — resolveMode now falls through to local
when codex is disabled.
- Force a fresh settings + providers fetch each time the modal mounts via
a refreshOnMount hook flag, so toggling Codex/providers in Settings
takes effect on the next modal open instead of waiting for a hard reload.
- Track providersLoaded separately and gate the "No enabled providers"
empty-state message on it, so the message no longer flashes during the
in-flight providers lookup.
PipelineIssue.jsx:
- Reset settingsOpen to false when stageId changes so the modal does not
re-mount in an open state after a tab round-trip.
issues.js:
- Sanitizer now Math.round() fractional pageTarget/minutesTarget to match
computeIssueTargets, so persisted values agree with rendered prompt
context (22.7 stored as 23, not 22).
Tests:
- pipeline.test.js: extract-pages cover-seed + preserve-existing-cover
cases for the new comicScript-driven cover seeding path.
- textStages.test.js: assert lengthTargets context shape for both a
named non-default profile (extended) and the derived custom profile.
- issues.test.js: regression test for the rounding fix and a new case
asserting imageModelId is cleared when imageMode is non-local.
…-episode lengthProfile test VisualGenSettings.jsx: - Disable the "Override default" checkbox until providersLoaded so a click during the in-flight providers fetch can't dereference providers[0] and throw. Tooltip explains the disabled state. data/migrations/003-update-pipeline-stage-prompts.js (new): - Migration that updates each of the 5 affected pipeline stage prompts (idea, prose, comic-script, tv-script, season-episodes) to the new length-profile-aware version, but only when the on-disk file still matches the pre-feature MD5. Customized files are left alone and the user is warned so they can merge manually. Idempotent. scripts/setup-data.js: - After setup, compare data.sample/prompts/stages/*.md against the installed copies and emit a one-line warning when any drift exists, pointing the user at `npm run migrations`. Fresh installs skip the warning (they just got a full copy). server/routes/pipeline.test.js: - Existing season-episode commit:true test now uses lengthProfile:'extended' on one of the mocked episodes and asserts it round-trips into the created issue, so the mapping at pipeline.js:602 has regression cover. server/lib/issueLength.test.js: - Grammar nit: "A request for twice as many pages" (added missing "for"). .changelog/NEXT.md: - New "Fixed" entry pointing users at `npm run migrations` to receive the updated stage prompts on existing installs.
…ove unused copyFile import - scripts/setup-data.js: drift detection now checks only the 5 pipeline stage prompts managed by migration 003 (pipeline-idea-expansion.md, pipeline-prose.md, pipeline-comic-script.md, pipeline-tv-script.md, pipeline-season-episodes.md) instead of all *.md files under data.sample/prompts/stages/. Scanning everything produced misleading "pipeline stage prompt" warnings for unrelated prompts (e.g. cd-evaluate.md, writers-room prompts) that have no migration counterpart. Added a comment pointing at the migration as source of truth for the list. - data/migrations/003-update-pipeline-stage-prompts.js: removed unused `copyFile` import from fs/promises — the migration only uses readFile and writeFile. Addresses review threads PRRT_kwDOQx8jQ86CRP4e and PRRT_kwDOQx8jQ86CRP4Q. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…over render, scope drift warning Migration scope (already pushed in 7ed6e10e — re-listing here for context): - scripts/setup-data.js: drift scan now iterates an explicit list of the 5 prompts migration 003 manages, not the full data.sample/prompts/stages directory, so customizing an unrelated prompt (cd-evaluate.md, writers- room, etc.) no longer produces a misleading "pipeline stage prompt" warning pointing at a migration that can't update it. - 003-update-pipeline-stage-prompts.js: drop unused copyFile import. This commit: - client/src/lib/issueLength.js: export CUSTOM_PAGE_MIN/MAX and CUSTOM_MINUTE_MIN/MAX mirroring server/lib/issueLength.js (with a pointer comment), so the client picker can't drift from the server bounds. - LengthProfilePicker.jsx: use the mirrored constants for both number- input min/max attributes and the clampInt() calls. - ComicScriptStage.jsx: on cover-script blur, when the text changed AND no render is currently in flight, send imageJobId:null + prompt:null alongside the new script so the stale-render thumbnail isn't shown alongside a fresh concept. The render/blur race is preserved — an in-flight render still tracks to completion. No-op blurs (text unchanged) leave the matching render alone.
…custom-profile validity
ComicScriptStage.jsx:
- Split coverJobInFlight into two booleans: coverJobInFlight (gates the
Render button; still treats 'unknown' as in-flight to avoid double-
enqueue) and coverJobActivelyRunning (excludes 'unknown'; gates the
stale-render clear inside persistCoverScript). After a reload, an
edit+blur on the cover concept now correctly clears the stale image
job rather than preserving it because status hasn't reported yet.
- Suppress the textarea's blur-save when the user clicks the Render
cover button: a useRef tracks render-button onMouseDown, and the
blur handler skips persistCoverScript when that flag is set. The
render handler already sends draftCoverScript, so no edit is lost.
Prevents the blur-fires-before-click race that could erase a
freshly-queued imageJobId by sending {imageJobId: null} after the
render route persisted the new job.
LengthProfilePicker.jsx:
- Disable the Apply button when the custom-mode page or minute input
is empty/non-numeric (clampInt returns null). Adds a "fill both
fields" tooltip. Previously the user could save lengthProfile:
'custom' with null pageTarget/minutesTarget, which the server then
rendered with standard fallback targets — a silent miss of the
user's intent. Reuses the single clampInt call for both the gate
and the save payload.
…ation and setup-data drift check Addresses Copilot review PRRT_kwDOQx8jQ86CRlzw: on Windows checkouts with CRLF line endings, raw MD5 of an unmodified .md file diverges from the hardcoded LF- based hashes, causing the migration to incorrectly treat unmodified installs as customized and skip the length-profile prompt update. Normalize \r\n → \n and bare \r → \n before hashing in both: - data/migrations/003-update-pipeline-stage-prompts.js (md5 helper) - scripts/setup-data.js (drift-detection md5 helper) All five NEW_SHIPPED_MD5 constants remain correct — sample files are checked in with LF so normalized and raw hashes are identical in the LF case. Also applies the previously-deferred ComicScriptStage simplification: remove the onMouseDown/ref blur-race guard in favour of a simpler persistCoverScript that never touches imageJobId/prompt, eliminating the race without needing special- cased ref tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t, actionable drift warnings server/services/pipeline/issues.js: - Add a per-issue Promise-chain serialization queue (queueIssueWrite) and wrap both updateIssue and updateStage in it. Each PATCH against a given issue id awaits the previous PATCH for that issue before reading state, so a blur "cover.script" save can no longer race the render-cover route by reading a snapshot from before the render route wrote its imageJobId. Each pipeline gets the freshest persisted record at write time. ComicScriptStage.jsx: - Add a 5-second "unknown-status expired" timer keyed on cover.imageJobId. When MediaJobThumb cannot fetch a persisted job (e.g. archive expired), status stays 'unknown' indefinitely; previously this kept the Render cover button disabled forever. The button now re-enables 5s after the status fails to leave 'unknown', so the user can queue a replacement. scripts/setup-data.js: - Classify drift into auto-updatable (matches OLD_SHIPPED_MD5 — migration 003 will fix it) vs customized (matches neither old nor new — needs a manual merge). Emit different warnings for each: auto-updatable points at `npm run migrations`; customized points at the data.sample copy and asks the user to merge. Previously the warning told users to run migrations on customized files where migration would silently skip them, leaving the warning permanently active.
Round 11 review caught two issues with the round-10 per-issue write queue: 1. The Map<issueId, Promise> only serialized writes for the SAME issue id; two writes to different issues against the shared pipeline-issues.json could still read the same initial state and the later atomicWrite would overwrite the earlier change. Fixed by collapsing to a single file-level Promise chain (issueWriteTail) — every updateIssue / updateStage call awaits the previous tail, regardless of issue id. 2. The Map entries were never removed, growing unbounded as new issues were updated over the life of the process and retaining resolved payloads on the chain. Fixed by tracking only the current tail and releasing it back to Promise.resolve() when this write settles AND no later write has replaced it as the tail. No GC pressure regardless of issue churn. The "simple re-entrancy guard" idea from CLAUDE.md still applies — this is just a coarser guard at the file boundary, where the original race actually lived.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
client/src/pages/PipelineIssue.jsx:168
- Generation settings are persisted asynchronously, but the stage continues to read render/refine options from
issue.stages[stageId].genConfiguntil the PATCH response updates parent state. If the user changes the backend/LLM override and immediately renders or refines, the request can still be sent with the previous config. Consider applying the new config optimistically or disabling the affected actions until the save completes.
const handleGenConfigChange = async (next) => {
const updated = await updatePipelineIssue(issueId, {
stages: { [stageId]: { genConfig: next } },
}).catch((err) => {
toast.error(err.message || 'Save failed');
return null;
});
if (updated) setIssue(updated);
…ording PipelineIssue.jsx: - Track lengthProfileSaving while the LengthProfilePicker PATCH is in flight, and disable the Auto-run/Run-everything buttons (plus the picker itself) until the save settles. Without this, the user could pick a new profile and immediately fire auto-run before the PATCH landed, causing generation to run against the previous profile's size targets. Tooltip flips to "Saving length profile…" while gated. ComicScriptStage.jsx: - Honest placeholder copy on the cover-concept input: the masthead + issue-number tag are "included in the prompt automatically", not "composited in automatically" — the latter implied a deterministic post-processing step that does not exist. visualStages.js (+ test): - Cover prompt now refers to "issue title" instead of "episode title". The cover is for a serialized comic-book issue; the mismatched TV wording was steering the image model toward TV-episode framing on the cover art.
Three new conventions distilled from the 10-round Copilot review on the
length-profile / per-stage genConfig / comic cover feature:
1. **In-flight saves gate dependent actions.** Sibling rule to the existing
"Run Now actions must gate on saved state" — covers the case where a
field's PATCH is async and a button triggers server work that reads it
(e.g. lengthProfile + auto-run).
2. **Async PATCH races require server-side serialization.** Client-side
guards (refs, onMouseDown, status checks) are unreliable; the only
reliable fix is a file-level write queue so every PATCH merges against
the freshest persisted state. Also clarifies that per-record queues
miss writes to different records against the same JSON file.
3. **Stage-prompt template changes need a migration.** setup-data.js only
copies missing files, so existing installs keep their old templates;
{{template.variable}} additions need a migration entry. Normalize line
endings before hashing to handle Windows CRLF checkouts. Drift warning
must distinguish auto-updatable from customized files so the
remediation stays actionable.
atomantic
added a commit
that referenced
this pull request
May 15, 2026
… serialization Follow-up to PR #233's review loop — 5 findings on the merged commit. Client (gating dependent actions while async PATCHes are in flight): - PipelineIssue.jsx: add genConfigSaving alongside the existing lengthProfileSaving, set it during the genConfig PATCH, and propagate the combined `actionsGated = lengthProfileSaving || genConfigSaving` down to every per-stage component that dispatches a server action. - TextStagePanel, IdeaStage, StoryboardsStage, ComicScriptStage, ComicPagesStage: accept the actionsGated prop and gate every Generate / Refine / Render button on it, with a "Saving settings…" tooltip while gated. Without this, picking a new length profile or visual setting and immediately firing a stage action dispatched the request with the old server-side targets. - apiPipeline.js: `generatePipelineComicCover` now accepts an options bag and threads it into request(), per the CLAUDE.md convention. ComicScriptStage's cover-render handler passes `{ silent: true }` so its existing catch-and-toast doesn't fire a duplicate toast. Server (extending the file-level write queue): - issues.js: queueIssueWrite now also wraps createIssue and deleteIssue — they previously read/modified/wrote pipeline-issues.json outside the tail, so a queued stage save could still race them against the shared file. - New `updateStageWithLatest(issueId, stageId, computeFn)` helper that runs computeFn(currentStage) inside the queue and applies its return value as the patch. updateStage is now a thin wrapper over it. - pipeline.js extract-pages route: cover preservation moves into a computeFn passed to updateStageWithLatest, so it reads the freshest persisted cover.script — not the stale snapshot read before entering the queue. Fixes the race where a parallel cover-render PATCH could land between the read and write, and have its imageJobId clobbered. Known sibling races to address in a follow-up (same shape, also in server/routes/pipeline.js): the comicPages/pages/:pageIndex PATCH and the comicPages/pages/:pageIndex/render route both read the issue snapshot before splicing/stamping, so concurrent writes against different page indices can lose each other. Both fixable by switching to updateStageWithLatest.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cherry-picks three additive pipeline-UX features from a pre-crash recovery branch into main, integrated with main's current Nouns / SeriesLlmPicker / universeBuilder direction (the recovery branch's removals of those are NOT applied here).
1. Per-issue length profile picker
teaser(8pg/10min),standard(22pg/24min, default),extended(32pg/36min),finale(44pg/48min), orcustom(free-form pages + minutes).{{lengthTargets.*}}variables (`profile`, `pageTarget`, `minutesTarget`, `proseWordsMin/Max`, `beatsMin/Max`). All five text-stage prompts (idea, prose, comic-script, tv-script, season-episodes) now consume these instead of hardcoded "22 pages / 24 minutes".2. Per-stage generation settings (gear icon)
3. Comic-issue front cover
Parser improvements
Conflicts not applied
The recovery branch also removed Nouns, SeriesLlmPicker, refinePipelineCharacter, resolvePipelineArcIssues, updatePipelineComicPage, and renamed universeBuilder → worldBuilder. None of those are in this PR — main has actively kept developing the universeBuilder + Nouns / Universe Canon Phase B work since the recovery branch was created (2026-05-14), and main has gone the opposite direction on naming. Only the additive features were cherry-picked.
Test plan
🤖 Generated with Claude Code