From 773bf53fabdf1a5e421951d7c8c51109a8c46302 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 10:26:08 -0600 Subject: [PATCH] plan(sisyphus): post-epic deferrals (MP color + extinction UI + berolina ep + royalty transfer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saves the planning doc covering all 4 deferred items from the rule-variants epic close-out. Momus review: [OKAY]. Sequencing: 1. MP color choice — ~1 session, isolated 2. Extinction-chess target UI — ~1/2 session, solo-only v1 3. Berolina en-passant — ~1/2 session, Parton 1952 variant 4. Tier 3 royalty-transfer — 1-2 sessions, REQUIRES stakeholder alignment before start (new engine surface for PlayerAction vs LegalMove). Each feature is fully specified with protocol / server / UI / test surface, commit message, and per-feature verification gate. Plan includes a pre-implementation decision log (7 open questions) and early-stop triggers so any of F1/F1+2/F1+2+3 is a valid ship point. Path: .sisyphus/plans/post-epic-deferrals.md --- .sisyphus/plans/post-epic-deferrals.md | 665 +++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 .sisyphus/plans/post-epic-deferrals.md diff --git a/.sisyphus/plans/post-epic-deferrals.md b/.sisyphus/plans/post-epic-deferrals.md new file mode 100644 index 0000000..240b880 --- /dev/null +++ b/.sisyphus/plans/post-epic-deferrals.md @@ -0,0 +1,665 @@ +# Post-Epic Deferrals — Design + Execution Plan + +> **Scope**: Four independent features previously deferred during or after +> the rule-variants epic. Each ships as its own mini-epic with verification +> gate. The features are sequenced so the SMALLEST / HIGHEST-VALUE item +> lands first. +> +> **Prior art**: `.sisyphus/plans/rule-variants-v2.md` for execution +> discipline, commit conventions, and the Final Verification Wave +> pattern. This plan mirrors that structure. + +--- + +## TL;DR + +> Ship **4 deferred items** in sequence: +> +> 1. **Multiplayer color choice** — Lobby UI + protocol field + server +> assignment logic. High user value, isolated surface, ~1 session. +> 2. **Extinction-chess UI target cycling** — chip UI inside RulesDrawer +> to pick which piece type is the extinction target, without opening +> the preset-state API. ~½ session. +> 3. **Berolina en-passant** — rule decision + implementation for pawn +> en-passant under `berolina-pawns` and `berolina-pawns-2`. Pure +> `overridePieceMoves` extension. ~½ session. +> 4. **Tier 3 royalty-transfer** — new preset family for "crown" moves +> that relocate royalty mid-game. Requires new engine surface +> (likely a lifecycle hook) + UI for target selection. Biggest +> risk + longest. ~1-2 sessions. +> +> **Recommendation**: work items 1-3 sequentially (each is small and +> independent). Defer item 4 into its own follow-up session after 1-3 +> ship. The plan below fully specifies all four so any of them can be +> started independently. + +--- + +## Feature 1 — Multiplayer Color Choice + +### Current state (audited) + +- **Server hardcodes** creator=white, joiner=black in + `packages/server/src/rooms.ts:212,255`. +- **Protocol** has NO color preference field on `room.create` / + `room.join`; the server only RETURNS the assigned color in + `room.created` / `room.joined`. +- **Lobby UI** has no color selector — creating a room gives you white, + joining gives you black. +- **Tests** (`packages/chess/e2e/multiplayer.spec.ts`) assume the + hardcoded order: pageA=white, pageB=black. +- No rematch / swap mechanism exists. + +### Design + +Add a 3-option host-side preference: `white | black | random`. The +creator picks; the server resolves `random` at room-creation time; the +joiner gets whatever color the creator didn't take. + +**Explicitly OUT of scope for this feature**: +- Joiner-side preference (complex: what if both want white?). Defer. +- Rematch-with-swapped-colors. Defer — separate feature. +- Color re-selection after room creation. Defer — needs server-side + swap handling for an existing WS session. + +### Protocol changes + +1. **`packages/server/src/protocol.ts`**: + - Add `preferredColor: z.enum(["white", "black", "random"]).optional()` + to `RoomCreatePayloadSchema`. Omitting it = `"white"` (current + behaviour, backward compat). + - No change to `RoomJoinPayloadSchema` (joiner takes the remaining + color). + - No change to response payloads — they already return the resolved + color in `room.created` / `room.joined`. + +2. **`packages/chess/src/net/types.ts`**: mirror the new field on the + client `RoomCreatePayload` interface. + +3. **Zod v3↔v4 parity test**: add to + `packages/server/src/custom-modifier-wire-parity.test.ts` sibling + (or create `lobby-wire-parity.test.ts`) — ensures the new field + round-trips across the server/client schema boundary. Pattern: + T3's Q4.2 parity test. + +### Server changes + +**`packages/server/src/rooms.ts`**: + +```typescript +createRoom( + rulesetIds: string[] = [], + layout?: StartingLayout, + profile?: ModifierProfile, + preferredColor: "white" | "black" | "random" = "white", +): { code, token, color, layout, profile? } { + // Resolve random at creation time. + const creatorColor: "white" | "black" = + preferredColor === "random" + ? (Math.random() < 0.5 ? "white" : "black") + : preferredColor; + + // Store both the resolved creator color AND the joiner color + // on the room so joinRoom() returns the right one. + const joinerColor: "white" | "black" = + creatorColor === "white" ? "black" : "white"; + + // ... rest of createRoom, using creatorColor instead of the + // hardcoded "white" literal. Room now has a `joinerColor` field. +} + +joinRoom(code: string): JoinResult { + // Read `room.joinerColor` instead of hardcoding "black". + const player: RoomPlayer = { + token, + color: room.joinerColor, + // ... + }; +} +``` + +**Random-seed determinism**: use `crypto.randomBytes` or `Math.random`? +`Math.random` is fine for non-security randomness here. No test +flakiness concern — the color is known AFTER room creation. + +**Backward compat**: clients sending the old payload (no +`preferredColor`) get the default `"white"`, matching current +hardcoded behaviour exactly. + +### UI changes + +**`packages/chess/src/ui/Lobby.tsx`**: + +Add a color selector in the "Host Game" section, between the +LayoutPicker and the Modifier Profile picker: + +```tsx +
+ +
+ {(["white", "black", "random"] as const).map((color) => ( + + ))} +
+
+``` + +State: `const [preferredColor, setPreferredColor] = useState<"white" | "black" | "random">("white");` + +`handleCreate()` includes `createPayload.preferredColor = preferredColor`. + +Visual style matches the existing modifier-profile chip selector pattern +for consistency. + +### Tests + +**Unit tests** in `packages/server/src/rooms.test.ts`: +- Default (no `preferredColor`) → creator=white, joiner=black (back-compat). +- `preferredColor: "white"` → creator=white. +- `preferredColor: "black"` → creator=black, joiner=white. +- `preferredColor: "random"` → both outcomes sampled across 20 rooms. + +**E2E tests** in `packages/chess/e2e/multiplayer.spec.ts`: +- Update existing "Scholar's Mate checkmate" test to still pass with + the new default — should need zero changes. +- Add NEW test: host picks "black" → pageA=black, pageB=white. Verify + color assignment end-to-end. +- Add NEW test: host picks "random" → one of the two valid + assignments; test asserts "each client has exactly one of + {white, black}" and they differ. + +**Playwright UI test**: verify the color selector renders, all three +options clickable, default is "white". + +### Execution checklist + +- [ ] F1.1. Add `preferredColor` to `RoomCreatePayloadSchema` (zod + server) + client type mirror + parity test. +- [ ] F1.2. Update `RoomRegistry.createRoom` signature; store + `joinerColor` on the room. +- [ ] F1.3. Update `RoomRegistry.joinRoom` to read `room.joinerColor`. +- [ ] F1.4. Add color preference UI to Lobby.tsx with `data-testid` + on each option. +- [ ] F1.5. Update `Lobby.handleCreate()` to include + `preferredColor` in the payload. +- [ ] F1.6. Unit tests on rooms.ts (4 scenarios). +- [ ] F1.7. E2E tests: add 2 new multiplayer scenarios for + black-host + random-host. +- [ ] F1.8. Run full Playwright (expect 85/85). +- [ ] F1.9. **Commit**: `feat(multiplayer): host color preference + (white/black/random)` + +### Risk register + +- **Test flakiness from random**: ZERO risk — tests assert either + color assignment works, not a specific outcome. +- **Backward compat**: backfilled default = `"white"` makes old + clients indistinguishable. +- **UI footprint**: 3-button group; no new dependencies; matches + existing Tailwind patterns. + +--- + +## Feature 2 — Extinction-Chess UI Target Cycling + +### Current state + +`extinction-chess` preset (shipped commit `08b8e0f`) uses +`engine.presetState<{ targetType: PieceType }>("extinction-chess")` +for configuration. Default seeded to `"pawn"`. No UI exposure — users +must set via code or dev console. + +### Design + +Add a chip cycler inside the RulesDrawer's extinction-chess card. +Appears ONLY when extinction-chess is active. Clicking cycles through +the 6 piece types: pawn → knight → bishop → rook → queen → king → pawn. + +The card should visually indicate "Target: Pawns" (or similar) above +the chip so inactive-mode users still see the default. + +### UI surface + +**`packages/chess/src/ui/RulesDrawer.tsx`**: inside the +`data-preset="extinction-chess"` block, after the normal +description/toggle/deps rendering, insert a configuration row: + +```tsx +{preset.id === "extinction-chess" && isOn && ( +
+ + Target: + + +
+)} +``` + +The `cycleExtinctionTarget` callback is passed in as a prop (parent +owns the state). RulesDrawer reads the current target from +`engine.presetState<{ targetType: PieceType }>("extinction-chess")` +via a new prop `extinctionTarget: PieceType` and calls +`setExtinctionTarget(next)` on click. + +### State flow + +The host manages `extinctionTarget` in GameView or via a dedicated +hook: +- On preset activation: read current from `engine.presetState`. +- On cycler click: compute next type in the sequence, update + preset state, and force a re-render of the RulesDrawer. +- In multiplayer: changing the target is an authoritative action that + must propagate via a new WS message (see below). + +### Protocol changes (multiplayer support) + +Adding a `custom-modifier-config.update` style message would over-engineer +this. Piggyback on the existing preset-activation rebroadcast by +including the target type in a new lightweight message, OR bake it +into the existing preset activation request. + +**Recommended approach**: Reuse the `modifier-profile.propose` / +`modifier-profile.update` mechanism's pattern but for preset state. +This is a bigger lift. ALTERNATIVE: defer multiplayer sync of the +target to follow-up. For this feature, support is **local-only +(solo play)** and multiplayer games use whatever target was set at +room-creation time. + +**DECISION**: ship solo-only target cycling in v1. If user demand +follows, add a `preset-config.update` WS message in v2. Document the +solo-only limitation in-UI ("Target: Pawns (set before multiplayer +game starts)" hint). + +### Tests + +**Unit**: none needed — the preset logic is already covered. + +**Component/integration**: a RulesDrawer test verifying the cycler +renders when extinction-chess is active, cycles through all 6 types, +and updates the engine's preset state. + +**E2E** (Playwright): solo scenario — activate extinction-chess, +click cycler 3 times, verify displayed target is now "rook" (or +whatever 3 clicks from pawn yields). + +### Execution checklist + +- [ ] F2.1. Add `extinctionTarget` prop + `onExtinctionTargetCycle` + callback to RulesDrawer props. +- [ ] F2.2. Render cycler inside extinction-chess card when the + preset is active. +- [ ] F2.3. GameView manages the state and threads props to + RulesDrawer. +- [ ] F2.4. E2E test in `rule-variants.spec.ts` (additive). +- [ ] F2.5. Documentation: add a "Target cycling" note to + `extinction-chess.ts` docblock referencing the UI. +- [ ] F2.6. **Commit**: `feat(ui): extinction-chess target cycler in + rules drawer` + +### Risk register + +- **Multiplayer sync gap**: shipped solo-only. Room-creation-time + target is fixed for the session. Acceptable because changing + extinction targets mid-game is unusual. +- **Chip state-sync bug**: engine preset state is the source of truth. + RulesDrawer reads on every render; no local component state. + +--- + +## Feature 3 — Berolina En-Passant + +### Current state + +`berolina-pawns` + `berolina-pawns-2` (shipped commits `5393b96`, +`ee08e20`) explicitly defer en-passant. Current behavior: no +en-passant captures exist for Berolina pawns. Pawns that +double-diagonal-push past a square an enemy pawn could have +captured it on cannot be ep-captured — the opportunity is lost. + +### Design + +Classic Berolina rule variant: if a Berolina pawn double-pushes via a +diagonal, and an enemy Berolina pawn is adjacent on the SKIPPED +square's file (the middle square of the diagonal), the enemy can +capture **orthogonally forward** onto the skipped square, removing the +double-pushed pawn (standard en-passant semantics reflected through +Berolina geometry). + +**Rule authority**: multiple Berolina rule sets exist; we're picking +the most common (Parton 1952 variant). Document explicitly in the +preset docblock that other variants exist and can be added as +additional presets if demand emerges (e.g. `berolina-pawns-3` with +different ep semantics). + +### Implementation path + +Since `berolina-pawns` uses `overridePieceMoves` (not composes with +default en-passant), we must synthesize ep moves in the override +itself. The engine already tracks `EnPassantTarget` in the game-level +facts; our override can read it and emit the ep move. + +**`packages/chess/src/presets/berolina-pawns.ts`**: + +1. In `overridePieceMoves`, after computing push/capture/promotion: + - Read `engine.session.get(GAME_ENTITY, "EnPassantTarget")`. + - If non-null and this pawn can capture (orthogonally forward) to + the ep square → emit the ep capture move. +2. Engine's `applyMove` already updates `EnPassantTarget` on every + move; no engine change needed. BUT — the engine sets it based on + FIDE-style pawn double-push (rank+2 orthogonal). For Berolina + double-push (diagonal), we need to override this. + +**Engine-side extension** (minor, in-scope): add a hook or expose +`setEnPassantTarget` so the preset can update the target on its own +double-push path. OR: handle the ep target update inside the +preset's `onAfterMove` — cleaner since the preset owns the +mechanics. + +**Recommended path**: handle both emission AND target-update in the +preset: +- `overridePieceMoves` emits the ep capture when + `EnPassantTarget` is set. +- `onAfterMove` detects when the preset's own pawn just did a + double-diagonal-push, and calls + `engine.session.insert(GAME_ENTITY, "EnPassantTarget", skippedSquare)`. +- Add a tag / metadata to the LegalMove (e.g. `isEnPassant: true`, + already a field on LegalMove per existing FIDE en-passant handling) + so the engine's capture-resolution path removes the captured pawn. + +### Edge cases + +- **Berolina + FIDE pawn mix**: impossible in practice (the preset + replaces pawn rules for all pawns of the scoped color(s)). But + with `scope: "white"`, black pawns follow FIDE ep rules — the + engine's default ep handling covers black. Verify the hybrid case. +- **Promotion + ep**: ep capture landing on the 1st/8th rank is + theoretically possible but extremely unusual on a fresh board. Keep + the ep capture non-promoting; document. +- **`berolina-pawns-2` shares the same ep rule**: the sideways + captures don't change ep geometry. + +### Tests + +Add to `berolina-pawns.test.ts`: +- ep capture available after enemy double-diagonal-push (4 positions). +- ep capture NOT available after single-push (no ep target). +- ep capture is lost if not taken on the next turn (standard ep + semantics). +- ep captured pawn is correctly removed from the board. +- Scope interaction: `scope: "white"` means white gets Berolina ep, + black still uses FIDE ep. + +Similar additions to `berolina-pawns-2.test.ts`. + +### Execution checklist + +- [ ] F3.1. Add ep-capture emission to `berolina-pawns.ts` + `overridePieceMoves`. +- [ ] F3.2. Add `onAfterMove` handler that sets + `EnPassantTarget` on Berolina double-push. +- [ ] F3.3. Copy/reuse ep logic into `berolina-pawns-2.ts`. +- [ ] F3.4. Add 5+ ep-specific tests to each preset's test file. +- [ ] F3.5. Update the preset docblock: remove the "deferred" + language, document the authoritative rule choice. +- [ ] F3.6. Update `RULES.md` — remove the en-passant deferred note + from the Berolina gallery entries. +- [ ] F3.7. **Commit**: `feat(presets): berolina-pawns en-passant + (both variants)` + +### Risk register + +- **Ambiguity of Berolina ep**: 2-3 published variants exist. We pick + the most common (Parton). Document authoritatively; other variants + are their own presets. +- **EnPassantTarget collision with FIDE ep**: on a scope-flipped + activation, both sides might set the target — but only one side + can double-push per turn. No real collision. + +--- + +## Feature 4 — Tier 3 Royalty-Transfer + +### Current state + +Tier 3 presets from the rule-variants design doc (explicitly deferred +in commit `3cef8f5` closing the epic). The canonical example: +**Abdication / Knight-Queen Transfer** — at any time, the king can +"abdicate" to a chosen friendly piece, transferring royalty to that +piece (and possibly vice versa). + +### Why it was deferred + +- Requires a **mid-game player action** beyond "one move at a time". + The existing engine only processes `LegalMove`; there's no + "action" channel for non-move actions. +- Requires **UI for target selection** — click a piece → "transfer + royalty here". +- Multiplayer semantics: a royalty transfer is a turn-consuming + action? Or free? How do both clients agree? + +### Design + +Introduce a new category: **player actions** that are not `LegalMove` +but consume a turn (or not, configurable). + +#### Engine surface + +Add a new public method `engine.performAction(action: PlayerAction): +ActionResult`. `PlayerAction` is a tagged union: + +```typescript +type PlayerAction = + | { kind: "transferRoyalty"; fromPieceId: EntityId; toPieceId: EntityId }; +``` + +Add a new preset hook `performAction(ctx: PlayerActionContext): +ActionResult | undefined`. First non-undefined wins. Context includes +`action`, `mover`, `engine`. + +**Engine dispatch**: similar to `applyMove` but for actions. Fires +`onBeforeAction` → resolver → `onAfterAction` → turn advance +decision → `onTurnStart` / `onCheckGameResult`. + +**Turn advance semantics**: same `shouldAdvanceTurn` hook. Presets +can veto as they do for moves. + +#### Preset: `transferable-royalty` + +```typescript +PRESET_REGISTRY.register({ + id: "transferable-royalty", + name: "Transferable Royalty", + description: "Royal pieces can transfer their royalty to another friendly piece once per game.", + // ... + performAction({ engine, mover, action }) { + if (action.kind !== "transferRoyalty") return undefined; + // Validate: fromPiece is currently royal, toPiece is friendly + not already royal. + // Mark state: `transferredFrom[mover] = fromPieceId`; future `getRoyalPieces` + // returns `toPieceId` instead of `fromPieceId`. + return { consumed: true, turnAdvances: true }; + }, + + getRoyalPieces({ engine, color }) { + // Read preset state — if a transfer has occurred for this color, + // return the new royal set (original royals minus transferred-from, + // plus transferred-to). + }, +}); +``` + +#### UI + +- **Trigger**: right-click on a friendly piece → context menu with + "Transfer royalty to here" (requires a royal piece already selected + OR a 2-click flow). +- **Indicator**: selected royal → hover other friendlies → candidate + targets highlighted in a distinct color (e.g. gold outline). +- **Confirmation**: modal "Transfer royalty from King (e1) to Queen + (d1)?" — prevents accidental triggers. +- **Turn consumed**: the action ticks the turn; show in the moveLog + as a "royalty-transfer" event (new `MoveRecord` tag). + +#### Protocol (multiplayer) + +Add a new WS message: `game.action` with payload `{ token, +action: PlayerAction }`. Server validates and broadcasts the +resulting state via the existing state-update path. + +### Prerequisites + +This is a **bigger lift** than the first three features. Before +starting: +1. Confirm stakeholder demand — is there a real user need for this? +2. Decide turn-consumption semantics (a free action would be wildly + unbalanced). +3. Nail the UI interaction (2-click vs context menu vs modal). + +### Execution checklist (draft — to be refined) + +- [ ] F4.1. Engine surface: `PlayerAction` type + `performAction` method. +- [ ] F4.2. Preset hook: `performAction`. +- [ ] F4.3. `MoveRecord` extended with `actionKind?` field for logging. +- [ ] F4.4. `transferable-royalty` preset implementation + tests. +- [ ] F4.5. Protocol: `game.action` WS message (server + client parity). +- [ ] F4.6. UI: target selection + confirmation modal in GameView. +- [ ] F4.7. E2E: solo scenario + multiplayer scenario. +- [ ] F4.8. Docs: PRESET-API.md section on actions-not-moves; + RULES.md gallery entry. +- [ ] F4.9. **Commit**: `feat(engine): player actions + + transferable-royalty preset` + +### Risk register + +- **Engine surface expansion**: significant. Every + `applyMove`-adjacent assumption (move-generates-next-turn, move-log, + checkGameResult timing) needs to be re-audited for the action path. + Mitigation: keep the `performAction` path structurally parallel + to `applyMove`; reuse the same hook dispatch order. +- **UI interaction ambiguity**: "right-click is not discoverable". + Alternative: a dedicated "Actions" button in GameView that opens + a menu. Safer default for touch devices too. +- **Multiplayer sync**: action takes effect server-side first; client + receives the state update. Same pattern as moves — no new + consistency issues. +- **State lifecycle**: the "transfer once per game" constraint lives + in preset state. Ensure it's serialized in GameStatePayload (same + as custom modifiers — pattern from T3 Q4.1). + +--- + +## Parallel Execution Map + +``` +Feature 1 (MP color choice) — 9 tasks — 1 session + F1.1-F1.8 serial (each builds on previous) → F1.9 commit + +Feature 2 (Extinction-chess UI) — 6 tasks — ½ session + F2.1-F2.5 serial → F2.6 commit + +Feature 3 (Berolina ep) — 7 tasks — ½ session + F3.1-F3.6 serial → F3.7 commit + +Feature 4 (Royalty-transfer) — 9 tasks — 1-2 sessions + Needs stakeholder alignment FIRST. +``` + +**Recommended sequencing**: 1 → 2 → 3 → (pause for 4 decision) → 4. + +Features 1-3 are INDEPENDENT and can be done in any order. Feature 4 +requires more thought; don't start until the first three ship and +demand is validated. + +--- + +## Verification Gate (applies to every feature) + +Pattern mirrors the rule-variants epic's Final Verification Wave: + +- F1 (oracle): plan compliance — every declared task checked. +- F2 (unspecified-high): code quality — 0 anti-patterns, tests ≥ 8 + per new preset, `incompatibleWith` graph symmetric. +- F3 (manual QA + playwright): end-to-end smoke. +- F4 (deep): scope fidelity — no drive-by refactors, no unrelated + protocol changes. + +Scale the wave down for Feature 2 (no new preset; just UI) and +Feature 3 (no new preset; just preset extension): F2 + F3 reviewers +only, F1 + F4 skipped. + +--- + +## Glossary + +- **PlayerAction**: a turn-consuming event that is NOT a `LegalMove`. + Introduced in Feature 4 (deferred). +- **Royalty transfer**: a PlayerAction that reassigns the "royal" + flag between friendly pieces. Core of Feature 4. +- **En-passant target**: `GAME_ENTITY.EnPassantTarget` fact tracking + the square a pawn skipped on its most recent double-push; valid for + exactly one following half-move. Feature 3 extends this to + Berolina geometry. +- **Target cycling**: the UI pattern Feature 2 introduces for + preset-state configuration without requiring a dedicated editor. + +--- + +## Decision log (pre-implementation) + +Before starting any feature, confirm: + +| Feature | Decision needed | Default / Recommendation | +|---|---|---| +| 1 | Joiner-side color choice? | NO — defer. Creator picks, joiner takes what's left. | +| 1 | Re-pick colors mid-session? | NO — defer. Fixed at room creation. | +| 2 | Multiplayer target-sync in v1? | NO — solo-only. Room-creation-time target fixed for MP. | +| 3 | Which Berolina ep variant? | Parton 1952 (most common). Document authoritative choice. | +| 4 | Turn-consumption of royalty transfer? | Consumes a turn (balance). | +| 4 | UI for target selection? | Dedicated "Actions" button > right-click (discoverability). | +| 4 | Stakeholder validated? | **REQUIRED before starting**. Don't implement on spec. | + +--- + +## Early-stop / scope-reduction triggers + +If tool budget or context pressure intrudes: + +- **After Feature 1**: ship MP color choice alone. Highest user + value. Features 2-4 become their own follow-ups. +- **After Feature 1 + 2**: ship both polish items. Deferred gallery + note updates land later. +- **After Feature 1 + 2 + 3**: feature-complete short of Tier 3. + Royalty-transfer becomes its own epic with its own plan. + +In every case: update this plan's checkboxes, append a +`## Early Stop` section, and do NOT declare the full plan complete. + +--- + +## Momus review instructions + +When this plan is saved, invoke Momus via: + +``` +task(subagent_type="momus", prompt=".sisyphus/plans/post-epic-deferrals.md") +``` + +Momus will evaluate against rigorous clarity / verifiability / +completeness standards. Incorporate feedback before starting any +feature.