plan(sisyphus): post-epic deferrals (MP color + extinction UI + berolina ep + royalty transfer)

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
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 10:26:08 -06:00
commit 773bf53fab
No known key found for this signature in database

View file

@ -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
<div className="space-y-1.5">
<label className="text-xs font-semibold text-neutral-600">
Play as
</label>
<div className="flex gap-2">
{(["white", "black", "random"] as const).map((color) => (
<button
key={color}
data-testid={`color-preference-${color}`}
type="button"
onClick={() => setPreferredColor(color)}
className={/* active/inactive styling */}
>
{color === "white" ? "White" : color === "black" ? "Black" : "Random"}
</button>
))}
</div>
</div>
```
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 && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs font-semibold text-neutral-600">
Target:
</span>
<button
data-testid="extinction-target-cycler"
type="button"
onClick={cycleExtinctionTarget}
className="px-2.5 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200 text-xs font-medium text-neutral-700 capitalize"
>
{currentExtinctionTarget}
</button>
</div>
)}
```
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.