# Release v2.3.0

Released: 2026-05-19

## Features

### CoS / Scheduled Tasks

- **Plan-item ID system for parallel-safe agent work.** Every `- [ ]` checkbox in a managed app's PLAN.md now carries a stable `[<slug>]` ID, and CoS scheduled `feature-ideas` / `plan-task` jobs encode the slug in their worktree branch name (`cos/<task>/<slug>/<agent>`). Before spawning, the scheduler reads PLAN.md, parses checkboxes, scans `git branch -a` + `gh pr list` for any slug already in flight, and pre-picks the first unclaimed item — concurrent agents now sit on distinct items instead of stomping on the same one. Brainstorm runs (no eligible item) generate a slug at insert time. New helper `server/lib/planIds.js` (slugify + parser + scanner + picker, 17 tests). `taskSchedule.js` prompts bump to feature-ideas v8 / plan-task v4 with a new `{planConstraint}` block; `worktreeManager.createWorktree` accepts `options.planId`; `agentLifecycle.js` plumbs it through both spawn paths. Backed by slashdo v2.15.0 which owns the ID-assignment pass via `do:replan` Phase 0 and preserves slugs across all `do:*` commands that edit PLAN.md.
- **slashdo submodule bumped to v2.15.0.** Brings drift detection in `do:replan` (flags pending items whose execution would regress newer features) and a persistent `Monitor` in `do:rpr` (one stream emits events for new Copilot reviews + CI bucket transitions, replacing the 5+ background subshells that previously accumulated per multi-round review).
- **`/claim` slash command.** Human-driven counterpart to the scheduled `plan-task` agent — at `.claude/commands/claim.md`. Reads PLAN.md, picks the first `- [ ]` whose `[<slug>]` isn't in flight (scans `git branch -a` + `gh pr list` for slug-as-path-segment, skips drift-flagged + NEEDS_INPUT items), creates a `claim/<slug>` worktree off `origin/main`, walks the user through verify → implement → archive (PLAN.md → DONE.md with slug preserved verbatim) → `/simplify` + `/do:pr` → merge → cleanup. Coexists with CoS sub-agents' `cos/<task>/<slug>/<agent>` claims because both place the slug as a `/`-segment visible to either side's in-flight scan. Optional argument lets you target a specific slug for out-of-order work.

### Data Sync

- **Pipeline + universe categories sync over Tailscale.** Sync orchestrator now covers the creative pipeline state — `series.json`, `issues.json`, and `universe-builder.json` — alongside the existing goals / character / digitalTwin / meatspace categories. Record-level only (no image/video blobs; those stay on the share-bucket path). LWW by `updatedAt`; local-only `runs[]` survives every universe merge. The merge runs through dedicated service-level entry points (`mergeUniversesFromSync` in `universeBuilder.js`, `mergeSeriesFromSync` in `pipeline/series.js`, `mergeIssuesFromSync` in `pipeline/issues.js`) so the read-modify-write window lives INSIDE the existing per-file write queue (`queueUniverseWrite` / `queueSeriesWrite` / `queueIssueWrite`) — a concurrent local edit, bible PATCH, season-cover render, or LLM auto-save can't clobber (or be clobbered by) the sync apply. Each entry point also passes incoming remote records through the service's sanitizer (`sanitizeTemplate` / `sanitizeSeries` / `sanitizeIssue`) so older-schema peers (pre-v4 universes missing the canon `kind` field, etc.) land already-migrated. `applyPipelineRemote` returns `{ applied, count, seriesChanged, issuesChanged }` where `count` is records actually changed/added this cycle (NOT total post-merge records — the prior shape over-reported when callers summed cycle deltas). Client `Instances` page adds Universe + Pipeline category meta with Sparkles + Film icons.

### Universe Builder

- **Lock-by-default for canon, variations, and composite sheets + per-row render thumbnails.** Universe canon entries (characters / places / objects), category-bucket variations, and composite sheets are now locked on creation so AI rewrite paths (refine, differentiate, expand-merge) skip them until the user explicitly unlocks. Lock guards prompt-data rewrites only — render paths stay open on locked entries (rendering an image doesn't mutate the prompt; it just appends to `imageRefs[]`). Server-side: `sanitizeVariation` + `sanitizeCompositeSheet` default `locked: true`, `applyCanonExtras` in `storyBible.js` now persists both `locked: true` and `locked: false` (the writers-room legacy "absent = unlocked" shape stays for callers that never set the flag), `sanitizeTemplate` runs a `defaultLockCanon` pass on universe canon arrays so existing on-disk records read locked, and `extractCanonFromProse` defaults `autoLock: true` (with explicit `autoLock: false` opt-out that stamps `locked: false` on new inserts to survive the universe-side default). `promoteVariationToCanon` stamps `locked: true` on the promoted entry. Two new bulk endpoints: `PATCH /api/universe-builder/:id/canon/:kind/lock-all` and `PATCH /api/universe-builder/:id/variations/lock-all` (the latter accepts `{ category, includeSheets }`). Client adds a single icon-only **lock-all toggle** to every section header (canon Characters / Places / Objects sections, each category variation bucket, composite-boards section) that mirrors the per-item lock-toggle visual: Lock icon when everything is locked → click unlocks all, Unlock icon for the all-unlocked + mixed cases → click locks all so the toggle always normalizes upward. Per-item lock toggles now write explicit `locked: false` on unlock so the bit survives the next read. **Per-row render thumbnails** in every variation and canon card: `EntryThumbSlot` is a three-state slot (pending / empty / completed) that swaps in a `MediaJobThumb` spinner during a render, a portrait placeholder box with a Sparkles render button when no images exist yet, or the rendered avatar once complete. Pending state is driven by a new `entryJobs: [{ jobId, entryRef }]` field on the `/render` route response + a `pendingByEntryId` map on the universe-builder page; completion fires through `useMediaJobProgress` and optimistically appends the rendered filename to the row's `imageRefs[]`. New `xs` size on `MediaJobThumb` (48x80, 3:5 portrait) matches the 2:3 aspect of typical 1024x1536 universe renders so all three states share a footprint and the row doesn't jump mid-render. Clicking any variation thumb opens the page-level `MediaPreview` lightbox (same modal the gallery uses), built from a `previewItems` array spanning every variation + composite-sheet `imageRefs[]` on the draft; canon entries continue to route through `UniverseCanonSection`'s own `MediaPreview` (which carries the character reference-sheet `image-refs/` prefix). Canon cards switched from labeled pill buttons to an icon-only horizontal action strip (Render reference + Clean plate + AI: differentiate + Lock toggle) matching the variation cards. Migration `024-lock-canon-and-variations.js` materializes the locked-by-default state to disk: stamps `locked: true` on every existing canon entry / variation / composite sheet (skips entries with explicit `locked: false`), mints stable `id`s for the historically id-less variations + sheets (so future renders can attach `imageRefs` via the `entryRef` collection hook), and best-effort back-fills `imageRefs[]` on variations from surviving completed jobs in `media-jobs.json` via `params.universeRun.{universeId, category, label}` matching — historical renders whose jobs have aged out of the queue file aren't recoverable.
- **Character bible / reference-sheet `/simplify` follow-ups.** Nine cleanups bundled from the simplify review during the character bible expansion PR. (1) `CanonCard`'s four character-only props collapsed into a single `characterExtensions` object so non-character call sites skip them entirely. (2) New `client/src/lib/bibleLimits.js` mirrors `server/lib/storyBible.js` `BIBLE_LIMITS` so client-side `max:` literals can't drift from the server caps. (3) Single-field `useFieldDraft` hook shared by `CharacterDetailEditor#DraftField` and a new per-row `WardrobeRow` sub-component (replaces the indexed `${idx}:${field}` drafts map). (4) `runPromptRefine` split into a lower-level `runPromptRefineRaw` so multi-field merges (`universeCharacterExpand`) can reuse the empty-guard + rationale-trim + log-tag scaffold. (5) `resolveGalleryImage` / `resolveImageRef` / `resolveTemplateAsset` collapsed onto a `makePathResolver(getRoot, { extensions, cache })` factory — thunk-only API (throws on non-function `getRoot`) so test-time mutations of `PATHS.x` at mock-eval time still steer the resolver; `rootAbsPrefix` is cached lazily on first call. (6) New `shortId(id, n=8)` helper in `fileUtils.js` replaces inline `String(x).slice(0, 8)` across the universe character family. (7) New `createImageGenWaiter` extracts the shared `imageGenEvents` completion-waiter pattern used by `sdapi.js#createCompletionWaiter` and `voice/tools.js#image_gen`. (8) `flattenStats`/`flattenPalette`/`flattenWardrobes`/`flattenProps`/`flattenNamedList` moved from `universeCharacterSheet.js` to `canonPrompt.js` alongside `RICH_SPEC`. (9) `CharacterReferenceSheetPanel#waitForImageRef` accepts an `AbortSignal` so unmount aborts the 3 s HEAD-poll loop instead of letting it issue requests against a dead component. Three sub-bullets deferred to dedicated PLAN entries: `[purge-reference-sheet-image-ref-on-sheet-delete]` and `[clear-pending-sheet-slot-on-entry-delete]` gate on character/universe-delete routes that don't exist yet; `[characterdetaileditor-listrow-row-draft-hook]` carves off the `ListRow` half of item 3 — the multi-column ride-along race protection can't survive a per-field split, so a `useRowDraft` hook for multi-column row state is the right successor.
- **Character bible expansion + artist reference sheet.** Universe canon characters now carry novelist + graphic-novelist depth and can render a single dense artist reference sheet styled to match the universe. `sanitizeCharacter` in `server/lib/storyBible.js` gains 15 prose fields (pronouns, age, coreTheme, speechAccent, visualNotes, silhouetteNotes, postureNotes, specialTraits, visualIdentity, motivations, likes, dislikes, mannerisms, relationships, skills) + 5 structured lists (stats, colorPalette, props, expressions, handGestures) + 1 operational pointer (`referenceSheetImageRef`). Stats are an open `[{label, value}]` list so non-human characters (ghosts, spiders, clouds, AIs) aren't shoehorned into human anatomy. Two new endpoints: `POST /api/universe-builder/:id/characters/:entryId/expand` runs one LLM call (new `universe-character-expand` stage) that fills BLANK extended fields without clobbering populated content; `POST /api/universe-builder/:id/characters/:entryId/render-reference-sheet` enqueues a media-job render driven by a rich TEXT prompt (no init image required) that works equally well across codex and local image-gen backends. The renderer emits a multi-paragraph layout-prescriptive prompt (turnaround, expression progression, color palette swatches, wardrobe + prop cards, hand gestures), routes through `mediaJobQueue` with the active `imageGen.mode` (codex → codex lane, local → GPU lane; external SD-API returns `UNIVERSE_CHARACTER_SHEET_UNSUPPORTED_MODE` rather than a silently-degraded render), and on `mediaJobEvents` completion copies the result into `data/image-refs/` then stamps `character.referenceSheetImageRef`. Completed renders are also filed into the universe's media collection (`Universe: <name>` bucket) via `universeBuilderCollectionHook` by attaching a `universeRun` tag to the enqueued job, alongside the rest of the universe's concept art. `/data/image-refs/` is now exposed as a static route so the UI can render the sheet inline. New UI: `CharacterDetailEditor.jsx` (sectioned collapsible editor for the extended fields) + `CharacterReferenceSheetPanel.jsx` (Generate / Regenerate / lightbox) embedded in `CanonCard.jsx` behind a single "Character details + reference sheet" toggle that only renders when the caller passes the universe wiring — the per-series NounsStage stays focused on naming + visual refs. New migration `022-character-extended-fields.js` is record-only (sanitizer fills defaults on next read). 18+ new server tests (storyBible extensions + universeCharacterSheet prompt builder + universeCharacterExpand merge semantics + route mocks).

- **CanonCard provenance chip now names the source series.** The "from series" chip on `CanonCard` (rendered by both the Universe Canon section and the per-issue Nouns stage) now reads `from <Series Name>` when an entry's `sourceSeriesId` resolves through a new `seriesNameMap` prop; the tooltip carries both name and id (`Introduced by series "<name>" (<id>)`). Map is sourced from a new top-level `seriesNameMap` field on the `GET /api/universe-builder/:id/canon-usage` response, which now lists every linked series — not just ones with a prose-match row — so an entry whose `sourceSeriesId` points at a series with no matching prose still resolves to a name. Missing-map / unknown-id cases keep the legacy `from series` label + id-only tooltip. New `SourceSeriesChip` helper extracted in `client/src/components/pipeline/CanonCard.jsx`; 4 vitest cases in `CanonCard.test.jsx` + 2 vitest cases in `server/services/canonUsage.test.js`.
- **Other tab — Auto-sort with AI.** New `POST /api/universe-builder/:id/auto-sort` batches every `kind: 'other'` bucket into one LLM call that classifies each into characters / settings / objects (and optionally suggests a clearer key). Reassignments land atomically via one `updateUniverse` patch (defensive per-entry kind gate filters hallucinated kinds so the sanitizer can't coerce them back to `other`); `suggestedKey` renames surface in the response but stay opt-in. The Other-tab button replaces its old toast stub with a real spinner + result summary. New service `server/services/universeBuilderAutoSort.js` (16 unit tests, including separate prompt-injection pins for ASCII newlines on labels + styleNotes + logline, and for Unicode line/paragraph separators + NEL + form feed + vertical tab on the same three fields) + `server/routes/universeBuilder.test.js` (4 route tests) + `client/src/pages/UniverseBuilder.test.jsx` (2 client tests for the `OtherTab` Auto-sort button — clicks fire `onAutoSort` when enabled; disabled with "Sorting…" label while `autoSorting=true`); client helper `autoSortBuckets` in `apiUniverseBuilder.js`.
- **Other tab — manual trunk assignment.** Each bucket on the Other tab now has an **Assign to…** menu (Cast / Places / Objects) that retags `categories[bucket].kind` so the bucket moves out of Other into the matching trunk on next render. Variations stay in place; per-variation promote-to-canon remains a separate action.
- **Render tab — Other group + dedupe.** Removed the redundant "Prompt set" dropdown from the Batch render card — scope is now always chosen via the Render targets section below. Render targets gained an **Other** group with per-bucket sub-buttons and a `Bulk-render all (N)` button. Top-level action is now **Render everything (N)**.
- **Batch render — full Image Gen surface.** Batch Render now uses the shared `ImageGenSettingsForm`, so switching the backend chip (Local / External / Codex) reveals every per-mode option: model, resolution, steps, guidance/CFG, quantize, seed, **LoRAs**, **style preset**, negative prompt, and extra style. Server-side render schema accepts these as optional per-batch overrides and threads them through prompt composition + every enqueued job (`server/routes/universeBuilder.js` + `server/services/universeBuilder.js`).

### Media Gen

- **Removed duplicate Universe Builder tab.** The MediaGen tab strip no longer shows a Universe Builder entry — the Create sidebar already links straight to `/universe-builder`. Legacy `/media/universe-builder/*` URLs redirect to the top-level routes so bookmarks keep working.
- **Image Gen — multi-reference editing for FLUX.2 (UI + server contract).** A 4-slot reference-image picker appears under the Init Image block whenever a FLUX.2 model is selected. Each slot accepts a PNG/JPG/WebP upload with a 0..1 strength slider; populated slots are packed in submit order and posted as `referenceImage1`…`referenceImage4` multipart fields with a parallel `referenceStrengths` array. New `optionalUploadFields` helper in `server/lib/multipart.js`; the Image Gen route extends `generateSchema` with `referenceStrengths` (max 4 entries, 0..1 each), copies each upload into a sibling `data/image-refs/` directory (NOT the gallery) so refs never surface in the flat `.png` listing, and threads the paths + strengths through `local.js` → `buildArgs` → the FLUX.2 Python runner via `--reference-images`/`--reference-strengths`. The UI shows an "Experimental — no-op today" banner above the slot grid: the Python runner currently ignores these flags pending the `FLUX.2-klein-9B-kv` model swap (see PLAN.md `[flux2-multi-reference-python-runner]`), but accepts them as no-op argparse stubs so single-init renders still work. The route resolves the effective backend (`local` / `external` / `codex`) BEFORE staging any uploads and rejects refs with `REFERENCE_IMAGES_LOCAL_ONLY` for non-local backends + `REFERENCE_IMAGES_FLUX2_ONLY` for non-FLUX.2 models — both gates also unlink the multer-staged temp files before throwing, so a rejected request doesn't leak under `os.tmpdir()`.

### Pipeline

- **Per-season editorial locks.** New `season.locked: boolean` field freezes a volume's editorial content (title, logline, synopsis, themes, ending hook, episode count target, delete) while leaving the production-status workflow free to advance. Strict-true sanitizer so a typo can't accidentally freeze a volume; the server's `LOCKED_SEASON_ALLOWED_KEYS` allow-list lets `{ locked: false }` and `{ status }` patches through, blocks everything else with `ERR_LOCKED` (409). `arcPlanner.generateSeasonEpisodes` refuses on a locked target before the LLM call (matches the existing `ERR_VALIDATION` arc-lock idiom); `issuesSvc.bulkReassignSeason` refuses to move issues OUT of or INTO a locked season (`ERR_SEASON_LOCKED`, 409); `seasonsSvc.deleteSeason` refuses to delete a locked season. Arc Canvas's `SeasonRow` gets a Lock/Unlock toggle next to Edit/Trash, the editor's six content inputs flip to `disabled` + `opacity-60` when locked (status select stays interactive — production workflow is independent of editorial freeze), and a locked-state banner explains the freeze. `PipelineIssue.jsx` reads the parent season from `series.seasons` and surfaces lock state both as a trailing `<Lock>` icon (in place of the per-stage status dot) across the TabPills strip AND as a sub-header pill linking the user to unlock-on-arc-canvas. 10 new server tests + zero client test additions (UI changes are minimal pass-through props; server gates own the contract). Closes PLAN.md `[per-season-and-per-field-locks-extend-arc-lock]`; two follow-ups deferred: `[per-field-locks-arc-and-stage-granularity]` (per-arc-field + per-issue-stage locks) and `[season-lock-aware-arc-resolve]` (resolveVerifyIssues currently still rewrites locked seasons via `commitSeasonsWithRemap` since the arc lock is the only gate at that path).
- **Manual canon extraction from comicScript / teleplay stages.** Auto-extract still runs only after `prose`, but a new "Extract canon" button on both script stages lets writers pull characters/places/objects that surface only in panel directions or dialogue cues (a barkeep in a stage direction, a recurring location named only in the script) into the linked universe on demand. New route `POST /api/pipeline/issues/:id/stages/:stageId/extract-canon` (`stageId` must be `comicScript` or `teleplay`) reads the stage's `output` as the corpus and reuses the generic `extractCanonFromProse` with the same `parallel: true` + `autoLock: true` + `sourceSeriesId` stamping as the prose auto-extract, so script-derived entries survive later AI refines and stay attributable. Returns the merged universe + per-kind counts so the UI toast can summarize the merge. Three precondition errors: `PIPELINE_CANON_EXTRACT_BAD_STAGE`, `PIPELINE_CANON_EXTRACT_NO_CORPUS`, `PIPELINE_CANON_EXTRACT_NO_UNIVERSE`. New shared component `ExtractCanonButton.jsx` (gated on stage content + linked universe; tooltip explains the specific blocker when disabled); wired into `TeleplayStage` via `TextStagePanel.extraActions` and `ComicScriptStage` directly in its header. Client helper `extractPipelineCanonFromScript` in `apiPipeline.js`. 5 new vitest cases cover all precondition guards + happy path for both stage IDs.
- **Auto-resolve prompt now anchors edits in per-episode synopses.** `buildVerifyContext` already wires `seasonsTreeJson.episodes[].synopsis` into both the verify AND resolve passes (resolve reuses verify's context via `buildResolveContext`), but the resolve prompt's "How to resolve" list never explicitly told the LLM to USE those episode-level entries — so the auto-resolve pass could rewrite a volume's `synopsis` in ways that contradicted what its child episodes already describe. New first bullet in `data.sample/prompts/stages/pipeline-arc-resolve.md` pins the LLM to treat episode synopses as ground truth: volume-level synopsis rewrites must summarize what the child episodes describe, can't invent beats no episode covers, and can't paper over contradictions that originate at the episode level. Empty/null episode synopses are called out as the "user hasn't drafted that issue yet" signal so the LLM doesn't infer the volume has no content. Migration `023-resolve-prompt-episode-anchor.js` auto-bumps unmodified installs from any of the three prior shipped hashes (pre-005, pre-019, post-019) to the new template; customized installs get a manual-merge warning. Migration 019's `NEW_SHIPPED_MD5` for `pipeline-arc-resolve.md` is bumped to track the live sample so fresh installs (where `data/` is seeded post-023) don't trip 019's "customized" warning. 6 new tests in `023-resolve-prompt-episode-anchor.test.js`. Closes PLAN.md `[resolve-issues-inherits-verify-gaps-verify-the]`.

### Brain / Notes

- **Obsidian vault picker opens to the iCloud Obsidian folder by default.** The "Add Custom Path" folder picker on Brain → Notes now defaults its initial open location to `~/Library/Mobile Documents/iCloud~md~obsidian/Documents` so users land directly inside their iCloud Obsidian root and can pick a vault subfolder without navigating from the project parent. Falls back silently to the server default when the folder doesn't exist (non-Mac, no iCloud Obsidian) — fallback is gated on the specific `INVALID_PATH` / `NOT_A_DIRECTORY` server error codes so network blips and 5xx surface as real errors instead of permanently disabling the default. The unavailable result is cached in a ref so subsequent re-opens skip the wasted 400 round trip. New `defaultPath` prop on `FolderPicker.jsx`; server `/api/scaffold/directories` route now expands a leading `~/` in paths (embedded `~` chars in the iCloud bundle name are preserved verbatim).

### Sharing

- **"🔄 live" badge on inbox subscription rows.** Sharing → Inbox now flags subscription rows whose latest manifest was queued within the last 5 minutes with a small green pill (spinning RefreshCw icon + "live" label). Re-exports fire on every record edit with a 3s server-side debounce and propagate via chokidar in <1s, so a recently-received subscription is a real-time "sender is editing right now" signal. Pure `isLiveSubscription(item, now)` helper exported from `client/src/pages/Sharing.jsx` (handles null inputs + unparseable `receivedAt` + one-shot non-subscription shares without throwing); 5 new vitest cases in `Sharing.test.jsx`. Freshness is computed on render so the badge naturally decays as the page re-renders past the 5-min mark.
- **Per-sender subscription filenames.** `subscriptionFilename()` now includes the sender's instance id in the bucket-side filename (`sub-<kind>-<recordId>-<senderInstanceId>.json`) so two peers subscribed to the same series/universe don't clobber each other's deterministic share files. Inbox dedup keys on `(recordKind, recordId, senderInstanceId)` so a peer's update no longer wipes the other peer's inbox row. `unsubscribe` best-effort cleans up the pre-v2 sender-less filename when its `senderInstanceId` matches this instance, leaving any other peer's pre-upgrade file intact. New `legacySubscriptionFilename` export for the legacy name. Touched `server/services/sharing/{manifest,subscriptions,importer,exporter}.js` + tests.
- **Rotation-orphan inbox cull.** Per-sender inbox dedup means a peer that rotates its `instanceId` (factory reset / new device) would otherwise leave its old identity's row stranded forever. `applyInbox` now culls cross-sender orphans on every subscription-manifest arrival when (a) same `(recordKind, recordId, source)`, (b) different `senderInstanceId`, (c) existing row older than 30 days. The conservative window keeps two legitimately distinct peers with the same display name from being incorrectly culled. Cull removals persist even when the freshness-gate (`inbox-has-newer`) short-circuits the rest of `applyInbox`. 4 new integration tests cover the cull/preserve cases plus the freshness-gate early-return regression.

## Changed

- **Character reference sheet — text-template rendering across all image-gen modes.** The character reference sheet renderer no longer requires the FLUX.2-only init-image + multi-reference plumbing. The structured layout prompt (turnaround, expression progression, color palette, wardrobe + prop cards, hand gestures) is now the entire "template" — no init image, no portrait multi-ref — so the renderer works with codex (preferred, plus future nano-banana) and local backends through the same code path. `renderCharacterReferenceSheet` resolves the active `imageGen.mode` and routes through `mediaJobQueue` with mode-appropriate params: codex → codex lane (`codexPath` + `model`), local → GPU lane (`pythonPath` + `modelId` via `resolveSheetModelId`, which no longer prefers FLUX.2). External SD-API returns a clear `UNIVERSE_CHARACTER_SHEET_UNSUPPORTED_MODE` 400 rather than a silently-degraded render. `buildCharacterReferenceSheetPrompt` drops its `initImagePath` / `initImageStrength` / `referenceImagePaths` / `referenceImageStrengths` return fields and the "Honor the layout shown in the reference image" prompt line. Route/client `template` arg removed. Old `UNIVERSE_CHARACTER_SHEET_REQUIRES_LOCAL` / `UNIVERSE_CHARACTER_SHEET_NO_TEMPLATE` error codes gone.
- **Universe Builder — starter idea is no longer capped at 4000 chars.** The starter idea is whatever the user wants to type, from a one-line pitch to a full treatment. The Zod-backed `STARTER_PROMPT_MAX` is raised to 200,000 chars (a sanity ceiling, not an artificial brevity constraint) and the textarea's `maxLength={4000}` attribute is removed.
- **Sidebar + ⌘K label shortened to "Universe".** The Create sidebar link and palette manifest entry for `/universe-builder` now read "Universe" instead of "Universe Builder"; voice/palette aliases still include `universe-builder` so old phrasings keep resolving.
- **README — full coverage of the Create suite + post-v1 features.** README.md was stale from before the Create surface landed and was missing most of the v2.x work. Adds top-level sections for **Create Suite** (Writers Room, Universe Builder, Series Pipeline, Media Gen image/video/Creative Director/Timeline/Collections/LoRAs/Models, Importer, Sharing, Image Cleaner), **Comms** (Inbox/Drafts/Sync, OpenClaw, Social Agents), **Calendar** (Day/Week/Month/Agenda/Lifetime/Review/Sync), and **Wiki** (Browse/Search/Graph/Log). Expands Digital Twin with Autobiography / Ask Yourself / Character Sheet / Time Capsule / Accounts; expands Brain with Daily Log, Notes, Feeds (RSS), Rapid Reader, Graph, Insights, Import; expands Developer Tools with Feature Agents, GitHub, Submodules, JIRA Reports, DataDog, Loops, Usage, Reference Repos, Templates, Review Hub; expands Infrastructure with Ambient Mode, Command Palette (⌘K), Dashboard Layouts grid, Theming, Cross-Network Sharing, Filesystem Backups. Adds a third screenshot row (Login + CyberCity), Writers Room + OpenClaw doc links, and lib/slashdo + scripts/ in the project structure tree.

## Fixes

- **Pipeline — comic page lightbox now shows the real generation prompt.** The `Image settings` panel for a rendered series comic page previously displayed `Page 1` (the bare page-index label) instead of the prompt that was actually sent to the image-gen backend. The render slot (`page.proofImage` / `page.finalImage`) always carried `prompt`, but `ComicScriptStage.buildPageItem` was reading `page.prompt` from the page root, which is never populated. Now reads from the slot, and `openPreview` matches the clicked filename against the proof/final slots so the panel surfaces the prompt that produced *this* image (proof and final can diverge when re-rendered independently). `Refine Prompt` now has something real to refine. (`client/src/components/pipeline/stages/ComicScriptStage.jsx`)

- **Bible `SETTING` kind rename to `PLACE` (Phase 0b).** Completes the deferred rename from migration 018 (PR #265): `BIBLE_KIND.SETTING` → `PLACE`, `BIBLE_FIELD[PLACE]` → `'places'`, `sanitizeSetting` → `sanitizePlace`, `matchSettingsInText` → `matchPlacesInText`, `CATEGORY_KINDS` plural `'settings'` → `'places'`, writers-room route `/works/:id/settings` → `/works/:id/places`, service file `writersRoom/settings.js` → `places.js` with `listPlaces/createPlace/updatePlace/deletePlace/mergeExtractedPlaces` exports and `wr-place-` id prefix, validation schema rename. Persisted state (`universe.settings[]` → `universe.places[]`, `series.settings[]` → `series.places[]`, per-work `settings.json` → `places.json`) migrated by `scripts/migrations/022-rename-bible-setting-to-place.js`; the migration also patches `data/prompts/stages/writers-room-places.md` + `_partials/bible-deference.md` to use `{{existingPlacesJson}}` + `"places":` when they match the OLD shipped MD5, and `setup-data.js`'s drift list now treats the post-022 hash as the new shipped baseline with the pre-022 hash auto-updatable. Client UI (`UniverseBuilder.jsx` `TRUNK_TABS`, `NounsStage`, `CanonCard`, `UniverseCanonSection`, `StoryboardPanel`, `WorkEditor`, `ProseReader`, `ProseTokenPopover`, `proseTokenizer`, `apiWritersRoom`) flips to the new vocabulary; non-goals stay untouched (`data/settings.json`, `/api/settings`, Settings page). Existing pipeline canon entry id prefix `set-` is retained — ids are opaque after creation, and changing the prefix would force a second migration over per-entry records. ~30 files, 5499 server tests + 77 client tests passing.

## Refactors

- **NounsStage — drop heavy `canon-usage` fetch for a thin series-names lookup.** `NounsStage.jsx` was calling `GET /api/universe-builder/:id/canon-usage` purely to extract `seriesNameMap`, which forces the server to iterate every linked series × every issue × 3 prose matchers (O(series × issues × matchers)) just to label a CanonCard chip. New endpoint `GET /api/universe-builder/:id/series-names` returns `[{id,name}]` for linked series only — no issue scan, no matchers. Backed by `listLinkedSeriesNames` in `server/services/canonUsage.js`. `UniverseCanonSection` still uses the full `canon-usage` endpoint because it needs the per-entry usage counts. +3 vitest cases in `canonUsage.test.js`.
- **`canonPrompt` shared helper (`server/lib/canonPrompt.js` + `client/src/lib/canonPrompt.js` mirror).** Single source of truth for per-kind canon descriptor field precedence. Exports `shortCanonDescriptorFragments` (UI-summary scope), `richCanonDescriptorFragments` (render-prompt scope), `descriptorForCanonEntry` (flattened short string), and `hasCanonDescriptorContent` (rich-field union for "has anything to render?" gates). Migrated four call sites: `synthesizeCanonPrompt` in `server/services/universeBuilder.js`, `KINDS[].descFor` in `client/src/components/universe/UniverseCanonSection.jsx`, `canonEntryHasContent` in `client/src/pages/UniverseBuilder.jsx`, and `settingFrags` in `server/lib/scenePrompt.js` + client mirror. Minor cosmetic uniformity: lowercase `palette:`/`era:`/`weather:`/`significance:` prefixes in canon render prompts are now capitalized to match the UI descriptor format — case-only change, no functional impact on diffusion models. 29 new tests in `server/lib/canonPrompt.test.js`.
- **`TabPills` UI primitive (`client/src/components/ui/TabPills.jsx`).** Consolidates ten duplicated tab-nav patterns — two local `TabNav` components (UniverseBuilder, StoryboardPanel) plus eight inline tab-button blocks (Brain, Calendar, ChiefOfStaff, DigitalTwin, MediaGen, Messages, Settings, Wiki). Two variants (`underline` default, `pills`) with knobs for the call-site quirks (`runningKind` per-tab spinner, `stretch` flex-1 equal-width, `mobileDropdown` `<select>` fallback, `hideLabelOnMobile` icon-only at < sm, `controlsIdPrefix` for `aria-controls` wiring, `listRef`+`onScroll` for ChiefOfStaff's scroll-arrow overlay). Covered by 7 vitest cases. Minor intentional visual unification across the surface area: StoryboardPanel active tab text-port-accent (was text-white), ChiefOfStaff and Settings gained the standard `bg-port-accent/5` active wash + `hover:bg-port-card` inactive.
- **`TabPills` — `trailing` slot for per-tab status indicators.** Added an optional `t.trailing` ReactNode rendered after the count in both `underline` and `pills` variants. Migrates `PipelineIssue.jsx`'s 11th inlined tab-row (the only remaining outlier — its per-tab status dot didn't fit the prior shape) onto the shared primitive, replacing 22 lines of bespoke `<button>` markup with the standard underline-variant render. Also brings the row's mobile touch targets (`min-h-[44px]`) and icon size (16 vs 14) in line with the rest of the app. +1 vitest case asserting DOM-order (`trailing` follows `count`).
- **CoS — explicit `init()` instead of module-level auto-init.** `server/services/cos.js` previously called `init()` at the bottom of the module, gated by `NODE_ENV !== 'test' && VITEST !== 'true'` so unit tests didn't spin up listeners/timers. The guard was a "test hack in the prod boot path" — `init` is now exported and called explicitly from `server/index.js` alongside `automationScheduler.init()` / `agentActionExecutor.init()` / etc. Test imports of `cos.js` no longer have side effects, and the prod boot path no longer branches on test-env vars.
- **`LoraPicker` extracted** (`client/src/components/imageGen/LoraPicker.jsx`). The LoRA multi-select previously inlined in `ImageGen.jsx` is now a shared component used by both the standalone Image Gen page and the Universe Builder batch-render form.
- **`ImageGenSettingsForm` extended** with `showLoras` + `showStylePreset` props (mounts `LoraPicker` / `StylePresetPicker`) and the previously-hidden CFG / quantize / model-defaults reset.
- **`getStylePresetById` helper** added to `server/lib/writersRoomStylePresets.js` — Map-backed O(1) lookup that replaces the hand-rolled `STYLE_PRESETS.find(...)` pattern.
- **`updateUniverse(id, patch, options)`** API client now passes options through to the underlying `request()` helper, enabling silent error mode.
- **`resolveProviderAndModel({providerId, model})`** added to `server/lib/promptRunner.js`. Collapses the 4 inlined `getProviderById/getActiveProvider/resolveEffectiveModel` chains in `universeBuilderExpand.js` (×2), `universeBuilderRefine.js`, and `universeBuilderPromote.js` onto one helper. The next caller is a one-import addition; the previous pattern was already drifting in error-handling specifics.
- **`findBibleEntryByName(list, name)`** added to `server/lib/storyBible.js` for the "match name OR aliases case-insensitively" predicate. Replaces the inlined `.find(...)` in `universeBuilderPromote.js`'s duplicate-collision check. (`foldRetiredCharactersBucket` keeps its O(1) `Set` index over normalized names+aliases — a per-iteration live-array lookup would be O(n*m) when folding a large retired bucket against a large canon.)
- **`updateUniverse(id, mutator)` callback overload** (`server/services/universeBuilder.js`). `updateUniverse` now also accepts an async `mutator(latest) => patch | null` callback that runs INSIDE `queueUniverseWrite` against the freshest persisted record. `autoSortOtherBuckets` and `promoteVariationToCanon` route their read-modify-write through it — closes the narrow race where a concurrent edit landing between the LLM call and the persist could be silently dropped. Promote's variation removal now re-locates by label inside the queue (the pre-LLM `sourceIdx` is stale by write time) and re-checks the duplicate-name 409 against the latest canon, surfacing `UNIVERSE_PROMOTE_CATEGORY_GONE` / `UNIVERSE_PROMOTE_VARIATION_GONE` when the bucket/variation disappears mid-flight. Duplicate-collision detection lives in a single `findCanonCollision(canon, label, targetKind, secondarySlug?)` helper used by both the pre-LLM short-circuit and the in-queue re-check. +6 server tests pinning the overload contract (happy path, null short-circuit, throw propagation, non-object validation, array rejection, queue-ordering against a parallel literal-patch write).
- **Universe Builder — flush dirty draft before LLM-driven mutators.** `handleAutoSort` / `handlePromoteVariation` / `handleGenerateInCategory` previously operated against the server-persisted universe; if the user had unsaved local edits when they clicked, the server's stale copy got merged back into the draft and silently discarded the pending edits. The page now tracks a `savedDraftSnapshotRef` baseline (via a `markDraftSaved` helper, computed by `draftSnapshotForDirty`) and each LLM handler awaits `handleSave()` first when the draft is dirty, aborting on save failure. `useUniverseAction` gained an optional `preflight` callback that runs AFTER the ref/savedId guards so double-clicks short-circuit before flushing. +4 client tests covering the preflight contract (returns-false abort, throw-as-false, returns-true continue, post-ref-guard ordering).

## Full Changelog

**Full Diff**: https://github.com/atomantic/PortOS/compare/v2.2.0...v2.3.0
