houserules/.sisyphus/plans/visual-modifier-builder.md
Joey Yakimowich-Payne 88581ff6a9
fix(thressgame-coverage): F1+F4 remediation (T58 RequestChoiceModal + coin-flip kind)
Final Verification Wave found two real blockers:
1. T58 RequestChoiceModal.tsx was marked complete but did NOT exist on disk.
2. request-choice locked 6-kind enum was shipped as 5 (missing 'coin-flip').

Remediation:
- Build RequestChoiceModal.tsx with role=dialog, aria-modal=true, ESC/backdrop close, kind-specific input UI for all 6 kinds (rps / coin-flip / piece / square / column / row); 4 tests
- Add 'coin-flip' to:
  - request-choice primitive paramsSchema enum
  - PendingChoice.kind union (schema.ts + util/pending-choices.ts)
  - WS protocol ChoiceKindSchema (server/protocol.ts)
  - choice-timeout.firstDefaultForKind (defaults to 'heads')
  - broadcast.isValidChoiceValue (accepts 'heads' | 'tails')
- AutoChoiceResolver: deterministic alternating heads/tails for coin-flip

T68 e2e tests remain .skip()'d pending UI integration (modal-into-GameView wiring + activate-descriptor UI) — a follow-up task. The sentinel test asserts the gap exists so when integration lands, skips lift in the documented order.

Tests: 2740 -> 2744 (+4). bun run check exit 0.
2026-04-26 14:09:28 -06:00

1849 lines
104 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Visual Modifier Builder + DSL Trigger Expansion
## TL;DR
> **Quick Summary**: Add a hybrid "Visual" mode to the existing `CustomModifierEditor` (block/stack editor + live preview) and extend the T3 modifier DSL with 7 new trigger primitives (`on-move`, `on-turn-end`, `on-promotion`, `on-check-received`, `on-check-delivered`, `on-moved-onto-square`, `on-captured`) plus a primitive-context extension for target redirection.
>
> **Deliverables**:
> - 7 new trigger primitives with full DSL pipeline (schema, validator, apply, evaluator, dispatch, consumer registration)
> - `PrimitiveApplyContext` extension: `target` redirection resolver (self/ally/enemy/square) + per-trigger event context
> - Block/stack visual builder (dnd-kit) with live preview (narrative + JSON + mini board diagram)
> - Mode toggle ("Form" ↔ "Visual") inside existing `CustomModifierEditor.tsx`
> - Extract `ParamField` from existing inspector (pure refactor)
> - New `narrate.ts` pure module
> - 2+ new recipes showcasing new triggers
> - Updated docs: `RULES.md`, `PRESET-API.md`, user-facing trigger semantics
>
> **Estimated Effort**: XL (~30 atomic tasks across 4 waves)
> **Parallel Execution**: YES — 4 waves with 6-9 parallel tasks each
> **Critical Path**: Context extension (T1) → Attr/consumer/schema (T2–T4) → Primitives (T5–T11 parallel) → Dispatchers (T12) → UI wiring (T21–T25) → E2E + docs → Final Verification
---
## Context
### Original Request
> "we have a chess game implemented with a rules engine. We have a custom modifier dsl. I want to make it easier to use. Right now, you have to type some json. Plan out how to implement some sort of visual builder for all primitives (as well as adding more stuff like 'on move')"
### Interview Summary
**Key Discussions**:
- Relationship to existing editor: **Complement, not replace** — add Form/Visual mode toggle inside `CustomModifierEditor.tsx`
- Paradigm: **Hybrid** — block/stack editor + live preview (narrative + JSON + mini board diagram)
- New triggers: **7** (on-move, on-turn-end, on-promotion, on-check-received, on-check-delivered, on-moved-onto-square, on-captured)
- `on-promotion`: exposes **both old + new PieceType** → requires primitive context extension
- `on-captured`: **requires target redirection** (self/ally/enemy/square) → all primitive contexts get a `target` resolver
- Check semantics split into `on-check-received` (edge-triggered, attached to royal) + `on-check-delivered` (attached to attacker, discovered check = attacking piece fires)
- `on-moved-onto-square` predicate: `{ squares: Square[] }` OR `{ file?, rank? }`; `color` dropped (ambiguous)
- `on-move` fires on any Position change (captures too — both `on-capture` and `on-move` fire, dispatched in order)
- DSL `version: 1` kept — new kinds are additive; existing descriptors load unchanged
- T1 `ModifierProfileEditor` out of scope
- Multiplayer `onShareWithRoom` preserved (descriptor shape unchanged)
- DnD: `@dnd-kit/core + sortable + utilities` (new deps)
- Test strategy: **TDD** (RED-GREEN-REFACTOR) per primitive
**Research Findings**:
- Existing 14 T3 primitives, 4 triggers: `on-turn-start`, `on-capture`, `on-damaged`, `conditional` — canonical pattern to mirror
- `MAX_RECURSION_DEPTH = 3` in validator; runtime walker caps at 8. Stays at 3.
- `MAX_PRIMITIVE_COUNT = 50` in 3 places (schema, validator, server wire) — cross-package parity test Q4.2 protects drift
- Dispatch pattern: integration preset in `modifiers/apply.ts`, `onAfterMove` calls `fire*Hooks()` from `triggers.ts`
- Attr consumer pattern: `registerAttrConsumer(attrKey)` in `apply.ts`; boot-time integrity check fails if any primitive-seeded attr lacks a consumer
- Pre-move snapshots: `PRE_MOVE_HP_SNAPSHOTS`, `PRE_MOVE_CAPTURE_ATTACKERS` via per-engine WeakMaps set in `onBeforeMove`
- Existing editor: `packages/chess/src/ui/CustomModifierEditor.tsx` (872 lines) — 3-col palette/tree/inspector, Zod-introspecting forms
- Frontend: React 19, Tailwind 4, Vitest + happy-dom, Playwright e2e. No DnD library currently.
- Storage: `houserules:custom-modifiers:v1` localStorage (20 entries FIFO, starred exempt)
- `packages/rete` = pure rules engine, NOT a visual node-graph editor
### Metis Review
**Identified Gaps (addressed)**:
- `on-promotion` timing → user chose "both types in context" → drives context extension (T1)
- `on-check-received` edge vs level → edge-triggered (prevents infinite sustain)
- `on-move` firing rule → "any Position fact change" (clean single rule)
- `on-moved-onto-square` predicate `color` ambiguity → dropped
- `on-captured` self-only limitation → user opted for target redirection → drives context extension (T1)
- Dispatch ordering → Metis-proposed order locked by explicit test
- `on-move` + `on-capture` overlap → both fire
- DnD-kit SSR/happy-dom/a11y concerns → addressed via `DragOverlay`, `KeyboardSensor`, test via Playwright (not happy-dom pointer sim)
**Metis directives incorporated**:
- ParamField extraction is a PURE REFACTOR with golden snapshots (T14)
- `narrate.ts` is a PURE module (no session/engine access) (T15)
- Backward compat test with old-kinds-only fixture (T20)
- Server wire schema parity test unxfailed after new kinds added (T13)
- Recipes showcasing new triggers (T26)
- Each primitive TDD: RED test → GREEN impl → commit
- Atomic commit hygiene: no mixing refactor + feature
---
## Work Objectives
### Core Objective
Make the chess modifier DSL authorable without writing JSON by adding a visual block/stack editor with live preview, AND extend the DSL with 7 new trigger primitives plus a target-redirection mechanism so death-rattle and ally-buff patterns become expressible.
### Concrete Deliverables
**DSL Extensions (backend)**:
- `packages/chess/src/modifiers/primitives/context.ts` — new `TargetResolver` type + `resolveTargets()` helper
- 7 new primitive files in `packages/chess/src/modifiers/primitives/`:
- `on-move.ts` + `.test.ts`
- `on-turn-end.ts` + `.test.ts`
- `on-promotion.ts` + `.test.ts`
- `on-check-received.ts` + `.test.ts`
- `on-check-delivered.ts` + `.test.ts`
- `on-moved-onto-square.ts` + `.test.ts`
- `on-captured.ts` + `.test.ts`
- Updated `primitives/types.ts` — `PrimitiveKind` union extended; `PrimitiveApplyContext` gains `target` field + `event` field
- Updated `primitives/index.ts` — side-effect imports for new primitives
- Updated `schema.ts` — 7 new `ChessAttrMap` entries (`OnMoveHooks`, `OnTurnEndHooks`, `OnPromotionHooks`, `OnCheckReceivedHooks`, `OnCheckDeliveredHooks`, `OnMovedOntoSquareHooks`, `OnCapturedHooks`)
- Updated `modifiers/triggers.ts` — 7 new `fire*Hooks()` evaluator functions
- Updated `modifiers/apply.ts` — 7 new `registerAttrConsumer()` calls, dispatch ordering in `onAfterMove`, pre-move state snapshots for check/promotion diff
- Updated `modifiers/custom/recipes.ts` — 2+ new recipes using new triggers
- Updated server wire schema (`packages/server/src/wire/custom-modifier.ts` or equivalent) — mirror new kinds if server validates
- Updated `packages/chess/RULES.md` — new trigger semantics section
- Updated `packages/chess/docs/PRESET-API.md` — context target-redirection + event context docs
**UI (frontend)**:
- `packages/chess/src/ui/ParamField.tsx` — extracted pure-presentational component (from CustomModifierEditor inspector)
- `packages/chess/src/ui/narrate.ts` + `.test.ts` — pure descriptor → English narrative module
- `packages/chess/src/ui/visual-builder/BlockCard.tsx` + `.test.tsx` — single primitive block renderer
- `packages/chess/src/ui/visual-builder/BlockList.tsx` + `.test.tsx` — dnd-kit SortableContext tree with nested trigger children
- `packages/chess/src/ui/visual-builder/VisualBuilderPane.tsx` — composes BlockList + PreviewPane, replaces center+right when mode=visual
- `packages/chess/src/ui/visual-builder/preview/PreviewPane.tsx` — tabs: Narrative / JSON / Board
- `packages/chess/src/ui/visual-builder/preview/NarrativeView.tsx`
- `packages/chess/src/ui/visual-builder/preview/JsonView.tsx`
- `packages/chess/src/ui/visual-builder/preview/BoardDiagramView.tsx` — 8x8 SVG showing affected squares/auras
- Updated `packages/chess/src/ui/CustomModifierEditor.tsx` — add Form/Visual mode toggle + persist choice via localStorage key `houserules:custom-modifier-editor-mode:v1`
**Tests**:
- Unit tests co-located with each source file (Vitest + happy-dom)
- E2E: `packages/chess/e2e/custom-modifier-visual.spec.ts` — Playwright flow
- Integration: `packages/chess/src/modifiers/apply.test.ts` — dispatch order assertion for all 11 triggers
- Backward-compat fixture: `packages/chess/src/modifiers/custom/__fixtures__/legacy-descriptor.json`
- Server wire parity fixture for new kinds
### Definition of Done
Each criterion is runnable by an agent (no "user confirms").
- [ ] `bun run check` (from repo root) exits 0 — typecheck + lint + tests
- [ ] `bun test packages/chess` — all 22 primitives registered (15 pre-existing + 7 new), all trigger tests pass
- [ ] `bun test packages/chess/src/modifiers/apply.test.ts` — dispatch ordering test proves 12-stage order
- [ ] `bun test packages/chess/src/ui/narrate.test.ts` — golden-output tests for all 21 kinds
- [ ] `bun test packages/chess/src/modifiers/custom/validate.test.ts` — depth-3 accepted, depth-4 rejected, cycles rejected
- [ ] `cd packages/chess && bun x playwright test e2e/custom-modifier-visual.spec.ts` — passes
- [ ] Legacy descriptor fixture (15 old kinds only) parses + validates + applies identically (round-trip byte-equal)
- [ ] Server wire schema includes 21 kinds; cross-package parity test green
- [ ] Bundle size delta (chess package) ≤ 25kb gz after dnd-kit + new primitives (measure via `bun run build`)
- [ ] Mode toggle round-trip: Form → Visual → Form yields `JSON.stringify`-equal descriptor
- [ ] All 7 new hook attrs appear in `getStaticPrimitiveSeedManifest()` result
- [ ] Every new hook attr has a `registerAttrConsumer()` call — `assertSeedConsumerIntegrity()` passes at boot
- [ ] 2+ new recipes in `custom/recipes.ts` validate via `validateCustomDescriptor` → `{ok: true}`
### Must Have
- 7 new trigger primitives, fully wired through the DSL pipeline
- `PrimitiveApplyContext` extension: `target` resolver + per-event `event` field
- Visual block editor (dnd-kit based) with keyboard-accessible reorder
- Live preview with 3 tabs: Narrative, JSON, Board diagram
- Mode toggle in existing `CustomModifierEditor.tsx`
- Backward compat: existing saved descriptors load + validate + apply unchanged
- TDD per primitive; golden snapshots for `narrate.ts`; pure-refactor proof for `ParamField`
- E2E Playwright coverage of visual-mode authoring flow
### Must NOT Have (Guardrails)
**Scope creep locks** (explicit):
- MUST NOT change `CustomModifierDescriptor.version` from `1`
- MUST NOT change `uiForm: 'primitive-composer'` or `source: 'custom'` literals
- MUST NOT raise `MAX_RECURSION_DEPTH` from 3 or `MAX_PRIMITIVE_COUNT` from 50
- MUST NOT touch `packages/rete/**` (pure engine, orthogonal)
- MUST NOT touch `ModifierProfileEditor` beyond incidental (T1 modifiers out of scope)
- MUST NOT change `onShareWithRoom` wire format
- MUST NOT refactor existing triggers' behavior byte for byte — only add new ones
- MUST NOT add runtime deps beyond `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
- MUST NOT introduce a state-management library (no Redux/Zustand/Jotai)
- MUST NOT add CSS-in-JS — Tailwind classes only
- MUST NOT add undo/redo inside visual builder (existing editor lacks it too)
- MUST NOT add a "simulator" preview mode (narrate + JSON + static diagram only)
- MUST NOT add copy-paste, multi-select, or bulk-delete in block list
- MUST NOT add keyboard shortcuts beyond standard a11y (Space/Arrow for dnd-kit reorder)
- MUST NOT add mobile-first responsive redesign (document as explicit limitation)
- MUST NOT merge ParamField extraction and visual-mode wiring into one commit
**AI slop patterns to avoid**:
- Generic variable names (`data`, `result`, `temp`, `item`)
- Empty catch blocks — if something can fail, handle or propagate
- `as any` or `@ts-ignore` — use structural casts with narrow types (existing pattern in CustomModifierEditor line 745)
- Over-commented code — comments explain WHY, not WHAT
- Adding JSDoc to trivial functions
- Creating "utility" files with one-off helpers — inline if used once
- Extracting abstractions that have only one consumer
- `console.log` left in production code
---
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
### Test Decision
- **Infrastructure exists**: YES
- **Automated tests**: YES (TDD per primitive + test-after for UI)
- **Framework**: Vitest + happy-dom (unit), Playwright (e2e)
- **TDD discipline**: Each of the 7 primitives: RED (failing test) → GREEN (minimal impl) → commit. Dispatchers and context extension also TDD.
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
- **Backend DSL**: Use `Bash` (bun test) — run targeted tests, capture output with pass/fail counts
- **UI components**: Use `Bash` (vitest) for unit tests + Playwright for e2e
- **Visual builder E2E**: Use Playwright — open editor, toggle to Visual mode, drag blocks, save, reopen, assert state
- **Bundle size**: Use `Bash` — `bun run build` then measure dist output bytes
- **Backward compat**: Use `Bash` — load legacy fixture via Node, assert parse result
---
## Execution Strategy
### Parallel Execution Waves
> Target: 5-8 tasks per wave. Wave 1 unblocks Wave 2. Wave 2 unblocks Wave 3. Wave 4 is verification.
```
Wave 1 (START — foundations, 6 parallel):
├── T1: PrimitiveApplyContext extension (target + event fields) [deep]
├── T2: ChessAttrMap 7 new hook attrs [quick]
├── T3: registerAttrConsumer calls + manifest consumer integrity [quick]
├── T4: Pre-move state snapshots (check lines, promotion flag) [unspecified-high]
├── T13: Server wire schema mirror + parity fixture [unspecified-high]
├── T14: ParamField extraction (pure refactor + golden snapshot) [artistry]
└── T15: narrate.ts pure module + unit tests [deep]
Wave 2 (AFTER Wave 1 — primitives + UI parts, 9 parallel):
├── T5: on-move primitive + TDD tests [deep]
├── T6: on-turn-end primitive + TDD tests [deep]
├── T7: on-promotion primitive + TDD tests [deep]
├── T8: on-check-received primitive + TDD tests [deep]
├── T9: on-check-delivered primitive + TDD tests [deep]
├── T10: on-moved-onto-square primitive + TDD tests [deep]
├── T11: on-captured primitive + TDD tests [deep]
├── T16: BlockCard component + unit tests [visual-engineering]
└── T17: PreviewPane (Narrative/JSON/Board) + unit tests [visual-engineering]
Wave 3 (AFTER Wave 2 — integration + wiring, 6 parallel):
├── T12: 7 fire*Hooks evaluators in triggers.ts [deep]
├── T18: BlockList with dnd-kit SortableContext (nested) + tests [visual-engineering]
├── T19: VisualBuilderPane composition [visual-engineering]
├── T20: Backward-compat legacy descriptor fixture + round-trip test [unspecified-high]
├── T21: Dispatcher wiring in apply.ts onAfterMove + ordering test [deep]
└── T22: CustomModifierEditor mode toggle + localStorage persistence [visual-engineering]
Wave 4 (AFTER Wave 3 — polish, docs, e2e):
├── T23: Mode toggle round-trip test (Form↔Visual byte-equal) [quick]
├── T24: Depth-3 composition test (trigger-in-conditional-in-trigger) [quick]
├── T25: Playwright e2e visual authoring flow [unspecified-high]
├── T26: 2+ new recipes showcasing new triggers [quick]
├── T27: RULES.md + PRESET-API.md updates [writing]
└── T28: Bundle size verification + registry count assertion [quick]
Wave FINAL (AFTER ALL — 4 parallel reviews, then user okay):
├── F1: Plan compliance audit (oracle)
├── F2: Code quality review (unspecified-high)
├── F3: Real manual QA end-to-end (unspecified-high + playwright skill)
└── F4: Scope fidelity check (deep)
→ Present consolidated results → await explicit user okay
Critical Path: T1 → T2 → T3 → T5–T11 (parallel) → T12 → T21 → T25 → F1–F4
Parallel Speedup: ~65% faster than sequential
Max Concurrent: 9 (Wave 2)
```
### Dependency Matrix
- **T1 (context ext)**: depends ∅ → unblocks T5–T11, T21
- **T2 (attrs)**: depends ∅ → unblocks T3, T5–T12
- **T3 (consumers)**: depends T2 → unblocks T5–T12 runtime
- **T4 (snapshots)**: depends ∅ → unblocks T7, T8, T9 (need pre-move check/promotion state)
- **T5 on-move**: depends T1, T2, T3 → unblocks T12
- **T6 on-turn-end**: depends T1, T2, T3 → unblocks T12
- **T7 on-promotion**: depends T1, T2, T3, T4 → unblocks T12
- **T8 on-check-received**: depends T1, T2, T3, T4 → unblocks T12
- **T9 on-check-delivered**: depends T1, T2, T3, T4 → unblocks T12
- **T10 on-moved-onto-square**: depends T1, T2, T3 → unblocks T12
- **T11 on-captured**: depends T1, T2, T3 → unblocks T12
- **T12 evaluators**: depends T5–T11 → unblocks T21
- **T13 server wire**: depends ∅ → unblocks T20
- **T14 ParamField**: depends ∅ → unblocks T16, T22
- **T15 narrate**: depends ∅ → unblocks T17
- **T16 BlockCard**: depends T14, T15 → unblocks T18
- **T17 PreviewPane**: depends T15 → unblocks T19
- **T18 BlockList**: depends T16 → unblocks T19
- **T19 VisualBuilderPane**: depends T17, T18 → unblocks T22
- **T20 legacy compat**: depends T13 → unblocks T25
- **T21 dispatcher wiring**: depends T12 → unblocks T25
- **T22 mode toggle**: depends T14, T19 → unblocks T23, T25
- **T23 round-trip test**: depends T22 → unblocks F3
- **T24 depth composition**: depends T21 → unblocks F1
- **T25 e2e**: depends T20, T21, T22 → unblocks F3
- **T26 recipes**: depends T21 → unblocks F1
- **T27 docs**: depends T21 → unblocks F1
- **T28 bundle check**: depends T22 → unblocks F2
### Agent Dispatch Summary
- **Wave 1**: 7 tasks — T1 → `deep`, T2 → `quick`, T3 → `quick`, T4 → `unspecified-high`, T13 → `unspecified-high`, T14 → `artistry`, T15 → `deep`
- **Wave 2**: 9 tasks — T5–T11 → `deep` (7×), T16 → `visual-engineering`, T17 → `visual-engineering`
- **Wave 3**: 6 tasks — T12 → `deep`, T18 → `visual-engineering`, T19 → `visual-engineering`, T20 → `unspecified-high`, T21 → `deep`, T22 → `visual-engineering`
- **Wave 4**: 6 tasks — T23 → `quick`, T24 → `quick`, T25 → `unspecified-high` + `playwright` skill, T26 → `quick`, T27 → `writing`, T28 → `quick`
- **FINAL**: 4 tasks — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high` + `playwright` skill, F4 → `deep`
---
## TODOs
- [x] 1. `packages/chess/src/modifiers/primitives/types.ts` + `context.ts`: Extend `PrimitiveApplyContext` with `target` resolver and `event` field
**What to do**:
- Add new `TargetResolver` type: `'self' | 'attacker' | 'defender' | { squares: Square[] } | { relation: 'ally' | 'enemy', filter?: { pieceType?: PieceType } }`
- Extend `PrimitiveApplyContext`: add `readonly target: TargetResolver` (default `'self'`) and `readonly event: PrimitiveEvent | undefined` (carries trigger-specific metadata like `{ promotedFrom: PieceType, promotedTo: PieceType }` for on-promotion)
- Create new file `packages/chess/src/modifiers/primitives/context.ts` exporting `resolveTargets(ctx, target): readonly EntityId[]` — walks session to find matching pieces
- Preserve `ctx.pieceId` as the CURRENT-apply target; `resolveTargets()` returns `[ctx.pieceId]` when `target === 'self'`
- Update the 15 existing primitives that use `ctx.pieceId` to route through `resolveTargets()` when `ctx.target !== 'self'` — but default-case path is unchanged so existing behavior is byte-identical
- Add unit tests: `context.test.ts` covers each TargetResolver variant against a fixture session
**Must NOT do**:
- Do NOT change the `ctx.pieceId` field — keep it as the "current iteration target"
- Do NOT break the existing 14 primitives' behavior when `target === 'self'` (default) — byte-identical semantics
- Do NOT add target resolution logic inside individual primitives — centralize in `resolveTargets()`
- Do NOT change `CustomModifierDescriptor` schema (target is per-invocation context, not per-descriptor)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: Architectural change touching the primitive-apply contract; requires careful preservation of 15 existing primitives' behavior
- **Skills**: []
- Reason: No external doc lookup needed; pattern is codebase-internal
- **Skills Evaluated but Omitted**:
- `context7`: No external library involved
- `code-search`: Pattern is unique to this codebase
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with T2, T3, T4, T13, T14, T15)
- **Blocks**: T5, T6, T7, T8, T9, T10, T11, T21 (all need the new context shape)
- **Blocked By**: None
**References**:
**Pattern References** (existing code to follow):
- `packages/chess/src/modifiers/primitives/types.ts:48-55` — current `PrimitiveApplyContext` interface (must preserve existing fields)
- `packages/chess/src/modifiers/primitives/on-capture.ts` — canonical trigger primitive that uses `ctx.pieceId`
- `packages/chess/src/modifiers/custom/apply.ts` — orchestrator that constructs the context; update here to populate new fields
- `packages/chess/src/modifiers/triggers.ts:149-180` — `fireOnCaptureHooks` — shows pattern of how trigger evaluators iterate pieces
**API/Type References**:
- `packages/chess/src/schema.ts:Square` — the Square algebraic string type
- `packages/chess/src/schema.ts:PieceType` — enum for filter.pieceType
- `@paratype/rete:EntityId` — re-exported via primitives/types.ts
**Test References**:
- `packages/chess/src/modifiers/primitives/on-capture.test.ts` — test structure for primitive behavior
- `packages/chess/src/modifiers/reconcile.test.ts` — shows session-fixture pattern for multi-piece tests
**WHY Each Reference Matters**:
- `types.ts:48-55`: The shape being extended — MUST preserve all existing fields and their optionality/readonly markers
- `on-capture.ts`: Template for how trigger primitives consume the context — extension must not break this shape
- `custom/apply.ts`: The construction site for `PrimitiveApplyContext` — this is where defaults for `target` and `event` are injected
**Acceptance Criteria**:
- [ ] `packages/chess/src/modifiers/primitives/context.ts` exists and exports `TargetResolver`, `PrimitiveEvent`, `resolveTargets`
- [ ] `bun test packages/chess/src/modifiers/primitives/context.test.ts` → PASS (≥6 scenarios: self, squares list, ally filter, enemy filter, attacker, defender)
- [ ] `bun test packages/chess/src/modifiers/primitives/on-capture.test.ts` → PASS (existing test unchanged after type extension)
- [ ] `bun test packages/chess/src/modifiers/primitives/on-turn-start.test.ts` → PASS (existing test unchanged)
- [ ] `bun tsc --noEmit` → 0 errors
- [ ] Running `grep -r "target: 'self'" packages/chess/src/modifiers/primitives/` shows the default applied in each existing primitive's `apply()` OR in centralized apply orchestrator
**QA Scenarios**:
```
Scenario: Default target='self' preserves existing primitive behavior
Tool: Bash (bun test)
Preconditions: T1 implementation complete; existing primitive test suites unchanged
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives
2. Capture output showing all existing primitive tests pass
3. Assert: test count ≥ current baseline; 0 failures
Expected Result: All 15 existing primitive test files pass unchanged
Failure Indicators: Any existing test breaks; means default target='self' path is not byte-equivalent
Evidence: .sisyphus/evidence/task-1-existing-primitives-pass.txt
Scenario: Target='ally' resolves to same-color pieces
Tool: Bash (bun test specific)
Preconditions: context.test.ts includes "ally" case
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives/context.test.ts -t "ally"
2. Assert: PASS; resolveTargets returns all pieces with matching Color attribute excluding self
Expected Result: Test passes; resolveTargets returns [...ally pieceIds] excluding ctx.pieceId
Evidence: .sisyphus/evidence/task-1-ally-resolver.txt
Scenario: Target='attacker' requires event context, throws without it
Tool: Bash (bun test specific)
Preconditions: context.test.ts includes "attacker-requires-event" case
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives/context.test.ts -t "attacker"
2. Assert: resolveTargets with target='attacker' and event=undefined throws explicit error
Expected Result: Clear error "target='attacker' requires event with attackerId"
Evidence: .sisyphus/evidence/task-1-attacker-requires-event.txt
```
**Evidence to Capture**:
- [ ] `task-1-existing-primitives-pass.txt` — full bun test output
- [ ] `task-1-ally-resolver.txt` — specific test output
- [ ] `task-1-attacker-requires-event.txt` — specific test output
**Commit**: YES
- Message: `feat(chess/modifiers): extend PrimitiveApplyContext with target resolver + event field`
- Files: `packages/chess/src/modifiers/primitives/{types.ts,context.ts,context.test.ts}` + updates to `custom/apply.ts`
- Pre-commit: `bun run check`
- [x] 2. `packages/chess/src/schema.ts`: Add 7 new hook attrs to `ChessAttrMap`
**What to do**:
- Add to `ChessAttrMap` interface:
- `OnMoveHooks: readonly EffectPrimitiveNode[][]`
- `OnTurnEndHooks: readonly EffectPrimitiveNode[][]`
- `OnPromotionHooks: readonly EffectPrimitiveNode[][]`
- `OnCheckReceivedHooks: readonly EffectPrimitiveNode[][]`
- `OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][]`
- `OnMovedOntoSquareHooks: readonly OnMovedOntoSquareHookEntry[]` where entry = `{ filter: SquareFilter, primitives: EffectPrimitiveNode[] }`
- `OnCapturedHooks: readonly EffectPrimitiveNode[][]`
- Define `SquareFilter` type: `{ kind: 'squares', squares: readonly Square[] } | { kind: 'predicate', file?: 'a'|...|'h', rank?: 1|...|8 }`
- Update `ChessAttrKey` union (auto-derived from ChessAttrMap) — no manual work if union uses `keyof`
**Must NOT do**:
- Do NOT alter existing ChessAttrMap entries — pure additive change
- Do NOT add any attrs beyond these 7 + SquareFilter type
- Do NOT add optional markers — all hook attrs are `readonly` arrays (empty array is the absent state)
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Single-file additive type change; tiny scope
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1
- **Blocks**: T3 (consumer registration needs the attr names), T5–T12
- **Blocked By**: None
**References**:
- `packages/chess/src/schema.ts` — existing `ChessAttrMap` — add new entries following the pattern of `OnTurnStartHooks`, `OnCaptureHooks`, `OnDamagedHooks`, `ConditionalHooks`
- `packages/chess/src/modifiers/primitives/types.ts:EffectPrimitiveNode` — the array element type
**WHY Each Reference Matters**:
- `schema.ts`: Single source of truth for attr types — additions here drive type safety in apply/triggers/validate
- `EffectPrimitiveNode`: each hook attr's array-of-arrays element type is this structure
**Acceptance Criteria**:
- [ ] `bun tsc --noEmit` → 0 errors
- [ ] `grep -c "Hooks:" packages/chess/src/schema.ts` → ≥ 11 (4 existing + 7 new)
- [ ] Running `bun test packages/chess/src/schema.test.ts` → PASS
**QA Scenarios**:
```
Scenario: Type-check passes with new attrs added
Tool: Bash (bun tsc)
Preconditions: T2 implementation complete
Steps:
1. Run: cd /home/joey/Projects/rules && bun tsc --noEmit -p packages/chess/tsconfig.json 2>&1 | tee .sisyphus/evidence/task-2-tsc.txt
2. Assert: Output shows 0 errors
Expected Result: TypeScript compiles with zero errors
Evidence: .sisyphus/evidence/task-2-tsc.txt
Scenario: Attr count proves additive extension
Tool: Bash
Steps:
1. Run: grep -c "Hooks:" /home/joey/Projects/rules/packages/chess/src/schema.ts
2. Assert: Result ≥ 11
Expected Result: 11 or more Hook attrs in ChessAttrMap
Evidence: .sisyphus/evidence/task-2-attr-count.txt
```
**Evidence to Capture**:
- [ ] `task-2-tsc.txt`
- [ ] `task-2-attr-count.txt`
**Commit**: YES
- Message: `feat(chess/schema): add 7 new hook attrs to ChessAttrMap`
- Files: `packages/chess/src/schema.ts`
- Pre-commit: `bun run check`
- [x] 3. `packages/chess/src/modifiers/apply.ts`: Register 7 new attr consumers
**What to do**:
- Add 7 `registerAttrConsumer()` calls for the new hook attrs, grouped near existing consumer calls
- Each call: `registerAttrConsumer("OnMoveHooks")`, etc.
- Place at module-load time so boot integrity check sees them before `assertSeedConsumerIntegrity()` fires
- Add inline comment explaining which subsystem reads each attr (the evaluator in triggers.ts)
**Must NOT do**:
- Do NOT add consumers for attrs that don't exist in ChessAttrMap — T2 must land first
- Do NOT register twice — idempotent API but confusing signal
**Recommended Agent Profile**:
- **Category**: `quick`
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES (Wave 1, after T2)
- **Blocks**: T5–T12 (primitives will write these attrs; integrity check runs at engine boot during primitive tests)
- **Blocked By**: T2
**References**:
- `packages/chess/src/modifiers/apply.ts:87-98` — existing `registerAttrConsumer` calls — add new ones in the same style
- `packages/chess/src/modifiers/primitives/manifest.ts:122-129` — `registerAttrConsumer` + `getRegisteredConsumers` + `assertSeedConsumerIntegrity`
**WHY Each Reference Matters**:
- `apply.ts:87-98`: The exact pattern and location — new entries follow this idiom
- `manifest.ts:122-129`: Shows the contract — every attr any primitive seeds MUST have ≥1 registered consumer or boot fails loudly
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/manifest.test.ts` → PASS
- [ ] `bun test packages/chess/src/modifiers/primitives/consumer-integration.test.ts` → PASS
- [ ] `grep "registerAttrConsumer" packages/chess/src/modifiers/apply.ts | wc -l` → ≥ 11 (existing 4 + 7 new minimum)
**QA Scenarios**:
```
Scenario: Consumer integrity check passes with new attrs
Tool: Bash (bun test)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives/consumer-integration.test.ts 2>&1 | tee .sisyphus/evidence/task-3-consumer-integrity.txt
2. Assert: Output shows "PASS"; no unsatisfied consumer assertion errors
Expected Result: Integrity check passes; all 7 new attrs have a registered consumer
Evidence: .sisyphus/evidence/task-3-consumer-integrity.txt
Scenario: Consumer count proves 7 new registrations
Tool: Bash
Steps:
1. Run: grep "registerAttrConsumer" /home/joey/Projects/rules/packages/chess/src/modifiers/apply.ts | wc -l
2. Assert: Result ≥ 11
Evidence: .sisyphus/evidence/task-3-consumer-count.txt
```
**Evidence to Capture**:
- [ ] `task-3-consumer-integrity.txt`
- [ ] `task-3-consumer-count.txt`
**Commit**: YES
- Message: `feat(chess/modifiers): register 7 new attr consumers`
- Files: `packages/chess/src/modifiers/apply.ts`
- Pre-commit: `bun run check`
- [x] 4. `packages/chess/src/modifiers/apply.ts`: Pre-move state snapshots for check + promotion diff
**What to do**:
- Add per-engine WeakMaps (like existing `PRE_MOVE_HP_SNAPSHOTS`, `PRE_MOVE_CAPTURE_ATTACKERS`):
- `PRE_MOVE_CHECK_STATE_SNAPSHOTS: WeakMap<ChessEngine, { white: CheckLines, black: CheckLines }>` — captures which piece(s) attack which royal(s) BEFORE the move
- `PRE_MOVE_PROMOTION_FLAGS: WeakMap<ChessEngine, Set<EntityId>>` — IDs of pawns eligible to promote this move
- Populate in `onBeforeMove` (integration preset)
- Expose read-only getters: `getPreMoveCheckState(engine)`, `getPreMovePromotionFlags(engine)`
- Clear on `onAfterMove` end (after all triggers fire) to avoid stale state
**Must NOT do**:
- Do NOT leak snapshots across moves — ALWAYS clear at end of onAfterMove
- Do NOT compute check lines inline — use engine's existing check detection helpers
- Do NOT modify existing `PRE_MOVE_HP_SNAPSHOTS` / `PRE_MOVE_CAPTURE_ATTACKERS`
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Requires understanding engine's check-detection internals + careful lifecycle management
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES (Wave 1)
- **Blocks**: T7 (on-promotion), T8 (on-check-received), T9 (on-check-delivered)
- **Blocked By**: None
**References**:
- `packages/chess/src/modifiers/apply.ts` — search for `PRE_MOVE_HP_SNAPSHOTS` and `PRE_MOVE_CAPTURE_ATTACKERS` — mirror the WeakMap + lifecycle pattern
- `packages/chess/src/engine.ts:1350-1376` — promotion resolution in `applyMove`; `opponentInCheck` computation line 1371
- `packages/chess/src/rules/` — check-detection helpers
**WHY Each Reference Matters**:
- `PRE_MOVE_HP_SNAPSHOTS` pattern is the canonical lifecycle for pre-move state; copy structure exactly
- `engine.ts:1350-1376` shows exactly where promotion happens and where to snapshot pre-move state
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/apply.test.ts -t "pre-move-check-snapshot"` → PASS
- [ ] `bun test packages/chess/src/modifiers/apply.test.ts -t "pre-move-promotion-flag"` → PASS
- [ ] After 10 sequential moves, `PRE_MOVE_CHECK_STATE_SNAPSHOTS.get(engine)` returns clean state (no leak)
**QA Scenarios**:
```
Scenario: Pre-move check state captured before king move
Tool: Bash (bun test)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/apply.test.ts -t "pre-move-check" 2>&1 | tee .sisyphus/evidence/task-4-check-snapshot.txt
2. Assert: Test sets up position where white king is in check, plays a move resolving check, snapshot shows attacker IDs BEFORE move
Expected Result: Snapshot captured correctly; post-move check state differs
Evidence: .sisyphus/evidence/task-4-check-snapshot.txt
Scenario: Snapshot cleared after onAfterMove
Tool: Bash (bun test)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/apply.test.ts -t "snapshot-cleared" 2>&1 | tee .sisyphus/evidence/task-4-snapshot-cleanup.txt
2. Assert: After move resolves, `PRE_MOVE_CHECK_STATE_SNAPSHOTS.has(engine) === false`
Expected Result: Per-move snapshots don't leak across moves
Evidence: .sisyphus/evidence/task-4-snapshot-cleanup.txt
```
**Evidence to Capture**:
- [ ] `task-4-check-snapshot.txt`
- [ ] `task-4-snapshot-cleanup.txt`
**Commit**: YES
- Message: `feat(chess/modifiers): add pre-move check/promotion state snapshots`
- Files: `packages/chess/src/modifiers/apply.ts` + `apply.test.ts`
- Pre-commit: `bun run check`
- [x] 5. `packages/chess/src/modifiers/primitives/on-move.ts` + `.test.ts`: Add `on-move` primitive (TDD)
**What to do**:
- RED first: author `on-move.test.ts` with 4 scenarios: (a) fires when piece moves to new square, (b) fires on captures too, (c) fires on castling rook, (d) does NOT fire when piece is static
- GREEN: implement `on-move.ts` mirroring `on-capture.ts` structure — paramsSchema `{ primitives: z.array(EffectPrimitiveNodeSchema).max(10) }`, `apply()` writes `OnMoveHooks` array to ctx.pieceId, `childPrimitives(params) => params.primitives`, `seedsAttrs: ['OnMoveHooks']`, `maxDepth: 3`, `longDescription` + ≥2 examples
- Register in `packages/chess/src/modifiers/primitives/index.ts` (side-effect import)
- Extend `PrimitiveKind` union in `types.ts`
**Must NOT do**: change existing primitives; add new schema fields beyond `primitives[]`; skip docs metadata
**Recommended Agent Profile**: `deep` — mirrors existing trigger pattern precisely; skills: []
**Parallelization**: Wave 2, parallel with T6–T11, T16, T17. Blocks T12, T21. Blocked by T1, T2, T3.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — canonical pattern to mirror structure exactly
- `packages/chess/src/modifiers/primitives/on-capture.test.ts` — test shape + fixture setup
- `packages/chess/src/modifiers/primitives/registry.ts` — where the new primitive self-registers
- `packages/chess/src/modifiers/primitives/index.ts` — barrel import (add `import './on-move.js';`)
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-move.test.ts` → PASS (≥4 scenarios)
- [ ] `bun test packages/chess/src/modifiers/primitives/registry.test.ts` shows 22 primitives registered (15 prev + 7 new, counted at end)
- [ ] `bun test packages/chess/src/modifiers/primitives/docs.test.ts` → PASS (on-move has longDescription + ≥1 example)
- [ ] Imports side-effect-register at module load (verify by asserting `PRIMITIVE_REGISTRY.get('on-move')` returns non-undefined)
**QA Scenarios**:
```
Scenario: Primitive registered and retrievable
Tool: Bash (bun test)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives/on-move.test.ts 2>&1 | tee .sisyphus/evidence/task-5-on-move-tests.txt
2. Assert: All 4 scenarios PASS
Expected: Test suite reports 4/4 pass, 0 fail
Evidence: .sisyphus/evidence/task-5-on-move-tests.txt
Scenario: on-move does NOT fire on static position
Tool: Bash (bun test specific)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/modifiers/primitives/on-move.test.ts -t "static"
2. Assert: Test passes; no hook dispatched when piece unchanged
Expected: Test confirms firing rule = "Position fact changed"
Evidence: .sisyphus/evidence/task-5-on-move-static.txt
```
**Evidence**: task-5-on-move-tests.txt, task-5-on-move-static.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-move primitive`. Files: `on-move.ts`, `on-move.test.ts`, `index.ts`, `types.ts`. Pre-commit: `bun run check`
- [x] 6. `packages/chess/src/modifiers/primitives/on-turn-end.ts` + `.test.ts`: Add `on-turn-end` primitive (TDD)
**What to do**:
- RED first: `on-turn-end.test.ts` with 3 scenarios: (a) fires at end of mover's turn, (b) does NOT fire at end of opponent's turn for this piece, (c) fires BEFORE next color's on-turn-start (order assertion)
- GREEN: mirror `on-turn-start.ts`. paramsSchema = `{ color: 'white' | 'black' | 'both', primitives: [...] }`. Write `OnTurnEndHooks` attr. seedsAttrs, longDescription, examples.
- Register in index.ts + types.ts union.
**Must NOT do**: merge with on-turn-start (separate attr + separate dispatcher); fire on non-matching color turns
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2, parallel with T5, T7–T11, T16, T17. Blocks T12, T21. Blocked by T1, T2, T3.
**References**:
- `packages/chess/src/modifiers/primitives/on-turn-start.ts` — mirror entirely (differs only in attr name + semantics)
- `packages/chess/src/modifiers/triggers.ts:fireOnTurnStartHooks` — shows matching-color filter pattern
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-turn-end.test.ts` → PASS (≥3 scenarios)
- [ ] `PRIMITIVE_REGISTRY.get('on-turn-end')` returns non-undefined
**QA Scenarios**:
```
Scenario: Fires at mover's turn end, not opponent's
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-turn-end.test.ts 2>&1 | tee .sisyphus/evidence/task-6-on-turn-end.txt
2. Assert: Scenario "fires on matching color" passes; scenario "skips non-matching color" passes
Expected: 3/3 pass
Evidence: .sisyphus/evidence/task-6-on-turn-end.txt
Scenario: Order — on-turn-end(W) before on-turn-start(B)
Tool: Bash (bun test with order assertion)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-turn-end.test.ts -t "before on-turn-start"
2. Assert: Test uses ordered event log; on-turn-end entry precedes on-turn-start entry
Expected: Order lock confirmed
Evidence: .sisyphus/evidence/task-6-on-turn-end-order.txt
```
**Evidence**: task-6-on-turn-end.txt, task-6-on-turn-end-order.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-turn-end primitive`. Files: on-turn-end.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 7. `packages/chess/src/modifiers/primitives/on-promotion.ts` + `.test.ts`: Add `on-promotion` primitive with old+new PieceType event (TDD)
**What to do**:
- RED first: test that (a) fires when pawn promotes to queen — event carries `{promotedFrom: 'pawn', promotedTo: 'queen'}`, (b) nested primitive `add-to-attribute Hp +1` runs with new PieceType visible via ctx.event, (c) does NOT fire when promotion is disabled by `override-promotion` with disabled target, (d) fires AFTER PieceType fact flip
- GREEN: paramsSchema `{ primitives: [...] }`. apply() writes `OnPromotionHooks`. Uses `PrimitiveEvent` from T1's context.ts carrying `{promotedFrom, promotedTo}`.
- Nested primitives receive `ctx.event = { promotedFrom: PieceType, promotedTo: PieceType }`
**Must NOT do**: fire on non-promotion moves; fire when override-promotion disabled; expose old type AFTER fact flip (it's preserved only in event)
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2. Blocks T12, T21. Blocked by T1, T2, T3, T4.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — structural mirror
- `packages/chess/src/engine.ts:1350-1376` — promotion resolution in applyMove (event capture point)
- `packages/chess/src/modifiers/primitives/override-promotion.ts` — check if `PromotionOverride === 'disabled'` to suppress firing
- T1 `primitives/context.ts` — PrimitiveEvent type carries promotedFrom + promotedTo
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-promotion.test.ts` → PASS (≥4 scenarios)
- [ ] event.promotedFrom is always 'pawn'; event.promotedTo is the new type
- [ ] Does NOT fire when PromotionOverride = 'disabled'
**QA Scenarios**:
```
Scenario: Promotion event carries both types
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-promotion.test.ts -t "event carries types" 2>&1 | tee .sisyphus/evidence/task-7-on-promotion-event.txt
2. Assert: event.promotedFrom === 'pawn', event.promotedTo === 'queen'
Expected: Event context populated correctly
Evidence: .sisyphus/evidence/task-7-on-promotion-event.txt
Scenario: Does not fire when promotion disabled
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-promotion.test.ts -t "disabled" 2>&1 | tee .sisyphus/evidence/task-7-on-promotion-disabled.txt
2. Assert: Hook dispatched 0 times when override-promotion:disabled is active
Expected: No false firing
Evidence: .sisyphus/evidence/task-7-on-promotion-disabled.txt
```
**Evidence**: task-7-on-promotion-event.txt, task-7-on-promotion-disabled.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-promotion primitive with old+new type event`. Files: on-promotion.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 8. `packages/chess/src/modifiers/primitives/on-check-received.ts` + `.test.ts`: Edge-triggered check-received (TDD)
**What to do**:
- RED first: (a) fires ONCE when royal transitions from not-in-check → in-check, (b) does NOT fire again on subsequent turn if still in check (edge-only), (c) fires again after exiting + re-entering check, (d) only fires on royal pieces (king, or royal knight with knightmate preset)
- GREEN: paramsSchema `{ primitives: [...] }`. apply() writes `OnCheckReceivedHooks`. Evaluator (T12) uses T4's PRE_MOVE_CHECK_STATE_SNAPSHOTS to diff pre vs post — fires only on not-in-check→in-check transition.
**Must NOT do**: fire on non-royal pieces; fire level-triggered (every turn in check); fire when piece was already in check
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2. Blocks T12, T21. Blocked by T1, T2, T3, T4.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — structural mirror
- T4 PRE_MOVE_CHECK_STATE_SNAPSHOTS for edge detection
- `packages/chess/src/presets/active-set.ts` — `getRoyalPieces` hook API; piece must be royal to receive hook
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-check-received.test.ts` → PASS (≥4 scenarios)
- [ ] Edge-only firing confirmed via multi-turn test
**QA Scenarios**:
```
Scenario: Edge-only firing over 3 turns
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-check-received.test.ts -t "edge" 2>&1 | tee .sisyphus/evidence/task-8-edge-firing.txt
2. Assert: Hook fires exactly 1 time across 3 turns where king stays in check
Expected: 1 firing, not 3
Evidence: .sisyphus/evidence/task-8-edge-firing.txt
Scenario: Non-royal pieces do not receive hook
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-check-received.test.ts -t "non-royal"
2. Assert: Rook with on-check-received hook, attacked rook, hook NOT dispatched
Expected: Hook ignored on non-royal
Evidence: .sisyphus/evidence/task-8-non-royal.txt
```
**Evidence**: task-8-edge-firing.txt, task-8-non-royal.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-check-received primitive (edge-triggered)`. Files: on-check-received.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 9. `packages/chess/src/modifiers/primitives/on-check-delivered.ts` + `.test.ts`: Attacker-attached check-delivered with discovered check handling (TDD)
**What to do**:
- RED first: (a) fires on direct check — attacking piece moves and attacks royal, (b) fires on discovered check — piece B (not the mover) newly attacks royal after piece A moves, hook on B fires (NOT on A), (c) does NOT fire if piece already attacked royal pre-move (edge-triggered), (d) fires on double check on both attackers
- GREEN: paramsSchema `{ primitives: [...] }`. apply() writes `OnCheckDeliveredHooks`. Evaluator iterates all pieces post-move, checks if piece attacks royal in post-state but NOT in T4's pre-move snapshot.
**Must NOT do**: attribute discovered check to the mover; fire on pieces that already attacked pre-move; fire on own royal
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2. Blocks T12, T21. Blocked by T1, T2, T3, T4.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — structural mirror
- T4 PRE_MOVE_CHECK_STATE_SNAPSHOTS for diff detection
- `packages/chess/src/rules/` — attack-ray detection helpers
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-check-delivered.test.ts` → PASS (≥4 scenarios)
- [ ] Discovered-check case: hook on B (not A)
**QA Scenarios**:
```
Scenario: Discovered check attributes correctly
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-check-delivered.test.ts -t "discovered" 2>&1 | tee .sisyphus/evidence/task-9-discovered-check.txt
2. Setup: Bishop C behind pawn P. P moves, revealing C's check on king. Hook on C fires, hook on P does NOT.
3. Assert: Dispatch log shows C's hook fired; P's hook not fired
Expected: Discovered check correctly attributes to revealing attacker
Evidence: .sisyphus/evidence/task-9-discovered-check.txt
Scenario: Double check fires both attackers
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-check-delivered.test.ts -t "double"
2. Assert: Both pieces newly attacking royal receive hook
Expected: 2 hooks fired on double check
Evidence: .sisyphus/evidence/task-9-double-check.txt
```
**Evidence**: task-9-discovered-check.txt, task-9-double-check.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-check-delivered primitive`. Files: on-check-delivered.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 10. `packages/chess/src/modifiers/primitives/on-moved-onto-square.ts` + `.test.ts`: Discriminated union predicate (TDD)
**What to do**:
- RED first: (a) squares-list variant fires when piece ends on d4 from squares: ['d4','d5'], (b) predicate variant fires when piece ends on file='d' regardless of rank, (c) predicate variant fires when rank=8 regardless of file, (d) does NOT fire when move does not change piece's square
- GREEN: paramsSchema — Zod discriminated union: `z.discriminatedUnion('kind', [z.object({kind: z.literal('squares'), squares: z.array(SquareSchema)}), z.object({kind: z.literal('predicate'), file: FileSchema.optional(), rank: RankSchema.optional(), primitives: [...]})])` — OR simpler: `z.union([ {squares, primitives}, {file?, rank?, primitives} ])` using filter kind inferred from fields present
- Write `OnMovedOntoSquareHooks` attr entries = `{ filter: SquareFilter, primitives: [...] }`
**Must NOT do**: add a `color` field to predicate; allow empty predicate (file AND rank both absent) — must have at least one constraint
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2. Blocks T12, T21. Blocked by T1, T2, T3.
**References**:
- `packages/chess/src/modifiers/primitives/on-capture.ts` — structural mirror
- `packages/chess/src/schema.ts` — Square type, `SquareFilter` type added in T2
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts` → PASS (≥4 scenarios)
- [ ] Schema rejects empty predicate `{}` (must have file, rank, or squares)
**QA Scenarios**:
```
Scenario: Squares list variant matches explicit squares
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts -t "squares list" 2>&1 | tee .sisyphus/evidence/task-10-squares.txt
2. Assert: Piece moves to d4 with filter {squares:['d4','d5','e4','e5']} → hook fires; moves to a1 → hook does not fire
Expected: Square filter precise match
Evidence: .sisyphus/evidence/task-10-squares.txt
Scenario: Predicate rejects empty constraint
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-moved-onto-square.test.ts -t "reject empty predicate"
2. Assert: params `{kind:'predicate', primitives:[]}` fails Zod validation
Expected: Validator error surfaces "at least one of file, rank, or squares required"
Evidence: .sisyphus/evidence/task-10-empty-predicate.txt
```
**Evidence**: task-10-squares.txt, task-10-empty-predicate.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-moved-onto-square primitive`. Files: on-moved-onto-square.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 11. `packages/chess/src/modifiers/primitives/on-captured.ts` + `.test.ts`: Death-rattle with target redirection (TDD)
**What to do**:
- RED first: (a) fires when piece takes lethal damage (HP → 0 OR captured in non-HP rules), (b) fires BEFORE retract so nested primitives can still access defender attrs, (c) with `target: {relation: 'ally'}` nested `add-to-attribute Hp +1` buffs all friendly pieces, (d) with `target: 'attacker'` nested `add-to-attribute Hp -1` damages the attacker, (e) does NOT fire on non-lethal damage (HP still > 0)
- GREEN: paramsSchema `{ target?: TargetResolver, primitives: [...] }` — target is per-hook override, defaults to self. apply() writes `OnCapturedHooks` with target embedded. Evaluator (T12) fires hook with `ctx.target = hook.target`, `ctx.event = { attackerId, defenderId }`.
**Must NOT do**: fire AFTER piece retraction (facts already gone); fire on non-lethal damage; allow unrestricted access to opponent royal without target filter
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 2. Blocks T12, T21. Blocked by T1, T2, T3.
**References**:
- `packages/chess/src/modifiers/primitives/on-damaged.ts` — similar structure; on-damaged fires on any damage, on-captured only on lethal
- T1 TargetResolver + `resolveTargets()`
- `packages/chess/src/modifiers/primitives/manifest.ts:getEngineSeedManifest` — zombie cleanup happens AFTER hook fires (confirm ordering)
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/primitives/on-captured.test.ts` → PASS (≥5 scenarios)
- [ ] `target: 'attacker'` correctly damages attacker via nested primitive
- [ ] `target: {relation: 'ally'}` buffs all allies (not self, not enemies)
- [ ] Hook fires BEFORE zombie-fact cleanup (assertable via attr access in nested primitive)
**QA Scenarios**:
```
Scenario: Death-rattle damages attacker
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-captured.test.ts -t "target attacker" 2>&1 | tee .sisyphus/evidence/task-11-attacker.txt
2. Setup: Defender D with on-captured {target:'attacker', primitives: [add-to-attribute Hp -1]}. Attacker A captures D lethally.
3. Assert: A's Hp reduced by 1 after move
Expected: Death-rattle pattern works
Evidence: .sisyphus/evidence/task-11-attacker.txt
Scenario: Buffs allies (not self, not enemies)
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/primitives/on-captured.test.ts -t "target ally"
2. Setup: Defender D with on-captured {target:{relation:'ally'}, primitives:[add-to-attribute Hp +1]}. Multiple allies + enemies on board.
3. Assert: All ally HP +1; D's HP unchanged (already dead); enemy HP unchanged
Expected: Ally-buff semantics precise
Evidence: .sisyphus/evidence/task-11-ally.txt
```
**Evidence**: task-11-attacker.txt, task-11-ally.txt
**Commit**: YES. Message: `feat(chess/primitives): add on-captured primitive with target redirection`. Files: on-captured.{ts,test.ts}, index.ts, types.ts. Pre-commit: `bun run check`
- [x] 12. `packages/chess/src/modifiers/triggers.ts`: Add 7 new `fire*Hooks` evaluator functions
**What to do**:
- Add evaluators mirroring `fireOnCaptureHooks` / `fireOnTurnStartHooks` pattern:
- `fireOnMoveHooks(engine, movedPieceIds: readonly EntityId[])` — iterates hooks, runs nested primitives
- `fireOnTurnEndHooks(engine, endedColor)` — color-filtered (matches hook's `color: 'white'|'black'|'both'`)
- `fireOnPromotionHooks(engine, promotedPieceId, promotedFrom: PieceType, promotedTo: PieceType)` — populates ctx.event
- `fireOnCheckReceivedHooks(engine, royalId)` — edge-filtered via T4 snapshot diff
- `fireOnCheckDeliveredHooks(engine, newlyAttackingPieceIds)` — edge-filtered
- `fireOnMovedOntoSquareHooks(engine, movedPieceId, destSquare)` — filter matches SquareFilter
- `fireOnCapturedHooks(engine, capturedPieceId, attackerId)` — fires BEFORE retraction, resolves hook.target
**Must NOT do**: modify existing 4 fire*Hooks functions; use any state not sourced from engine + T4 snapshots; mutate session outside of nested primitive apply calls
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 3. Blocks T21. Blocked by T5–T11.
**References**:
- `packages/chess/src/modifiers/triggers.ts:fireOnCaptureHooks` (lines 149-180) — canonical pattern
- `packages/chess/src/modifiers/primitives/context.ts` (T1) — for `resolveTargets()` call
- T4 PRE_MOVE_CHECK_STATE_SNAPSHOTS for edge-triggered evaluators
**Acceptance Criteria**:
- [ ] `grep -c "^export function fire.*Hooks" packages/chess/src/modifiers/triggers.ts` → ≥ 11 (4 existing + 7 new)
- [ ] `bun test packages/chess/src/modifiers/triggers.test.ts` → PASS with ≥7 new scenarios covering each evaluator
**QA Scenarios**:
```
Scenario: All 11 evaluators exported
Tool: Bash
Steps:
1. Run: grep -c "^export function fire.*Hooks" /home/joey/Projects/rules/packages/chess/src/modifiers/triggers.ts
2. Assert: ≥ 11
Evidence: .sisyphus/evidence/task-12-evaluator-count.txt
Scenario: Edge-triggered check evaluator uses snapshot diff
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/triggers.test.ts -t "check-received edge" 2>&1 | tee .sisyphus/evidence/task-12-check-edge.txt
2. Assert: Scenario with king in check across 2 turns → evaluator dispatched 1x total
Evidence: .sisyphus/evidence/task-12-check-edge.txt
```
**Evidence**: task-12-evaluator-count.txt, task-12-check-edge.txt
**Commit**: YES. Message: `feat(chess/modifiers): add 7 fire*Hooks evaluators in triggers.ts`. Files: `triggers.ts`, `triggers.test.ts`. Pre-commit: `bun run check`
- [x] 13. `packages/server/src/wire/custom-modifier.ts` (or equivalent): Mirror 7 new primitive kinds in server wire schema + parity fixture
**What to do**:
- Locate server wire schema for CustomModifierDescriptor validation (likely in `packages/server/src/wire/` or `packages/server/src/routes/`)
- Mirror new `PrimitiveKind` additions (OR confirm schema uses `z.string()` structural match + runs against PRIMITIVE_REGISTRY via shared import — in which case no wire change needed, just docs)
- Update cross-package parity fixture: add descriptor JSON using each new primitive, verify it round-trips through both client + server schemas
**Must NOT do**: diverge client and server validation; change descriptor shape; skip parity test (Q4.2 cross-package test)
**Recommended Agent Profile**: `unspecified-high`. Skills: []
**Parallelization**: Wave 1, parallel with others. Blocks T20. Blocked by: none (can define fixtures independently).
**References**:
- `packages/server/PROTOCOL.md` — wire format docs
- `packages/server/src/` — locate CustomModifierDescriptor validator (grep for `primitives` + `custom` schema)
- `packages/chess/src/modifiers/custom/schema.ts` — client schema (source of truth for parity)
**Acceptance Criteria**:
- [ ] Cross-package parity test PASS with descriptor using each new kind
- [ ] `bun test packages/server` → PASS
- [ ] Server rejects descriptors violating `MAX_PRIMITIVE_COUNT = 50` (unchanged)
**QA Scenarios**:
```
Scenario: Server accepts all 7 new primitive kinds in wire schema
Tool: Bash (bun test)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/server 2>&1 | tee .sisyphus/evidence/task-13-server-wire.txt
2. Assert: Descriptor fixtures using each new kind parse successfully via server wire schema
Expected: All new kinds accepted
Evidence: .sisyphus/evidence/task-13-server-wire.txt
Scenario: Cross-package parity holds
Tool: Bash (bun test parity test if exists, else write one)
Steps:
1. Run: bun test --grep "parity" 2>&1 | tee .sisyphus/evidence/task-13-parity.txt
2. Assert: No drift between client + server schemas
Expected: Parity green
Evidence: .sisyphus/evidence/task-13-parity.txt
```
**Evidence**: task-13-server-wire.txt, task-13-parity.txt
**Commit**: YES. Message: `feat(chess/server): mirror 7 new primitive kinds in wire schema + parity fixtures`. Files: `packages/server/**`, parity test file. Pre-commit: `bun run check`
- [x] 14. `packages/chess/src/ui/ParamField.tsx` + `.test.tsx`: Extract `PrimitiveInspector` param-rendering logic from `CustomModifierEditor.tsx` (PURE REFACTOR)
**What to do**:
- BEFORE touching code: capture golden snapshot of current inspector output for every kind's paramsSchema. Write `PrimitiveInspector.snapshot.test.tsx` that renders current component with 14 fixture descriptors, snapshots DOM.
- Extract lines 625-872 of `CustomModifierEditor.tsx` into new `ParamField.tsx` preserving Zod-introspection patterns verbatim
- `ParamField` props: `{ node: EffectPrimitiveNode, primitive: EffectPrimitive | undefined, allPrimitives: readonly EffectPrimitiveNode[], onChange: (params: unknown) => void }`
- Re-import into `CustomModifierEditor.tsx`, replace inline component — ZERO behavior change
- Snapshot test MUST match byte-for-byte after extraction
**Must NOT do**: change any field rendering behavior; rename any test IDs; change props shape; add features
**Recommended Agent Profile**: `artistry`. Skills: []
- Reason: Pure refactor with snapshot-preserving discipline; unconventional in that zero new features are acceptable — must resist any "improvement"
**Parallelization**: Wave 1. Blocks T16, T22. Blocked by: none.
**References**:
- `packages/chess/src/ui/CustomModifierEditor.tsx:625-872` — the `PrimitiveInspector` function to extract
- `packages/chess/src/ui/AttrCombobox.tsx` — internal dep kept by import
- `packages/chess/src/ui/attr-suggestions.ts` — type helpers used by inspector
- `packages/chess/e2e/custom-modifiers.spec.ts` — e2e tests exercising inspector — must all pass unchanged
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/ParamField.snapshot.test.tsx` → PASS
- [ ] Playwright: `bun x playwright test packages/chess/e2e/custom-modifiers.spec.ts` → PASS unchanged (prove no regression)
- [ ] `git diff --stat` shows only file moves + 1-2 import line changes in CustomModifierEditor.tsx
- [ ] Line count of extracted file matches original (within ±10 lines for import cleanup)
**QA Scenarios**:
```
Scenario: Golden snapshots match before and after extraction
Tool: Bash (bun test with snapshot update)
Steps:
1. Before extraction: run snapshot test, commit golden files
2. After extraction: re-run snapshot test without --update
3. Assert: 0 snapshot diffs
Expected: Byte-identical DOM output
Evidence: .sisyphus/evidence/task-14-snapshot-match.txt
Scenario: E2E tests pass after extraction
Tool: Bash (playwright)
Steps:
1. Run: cd /home/joey/Projects/rules/packages/chess && bun x playwright test e2e/custom-modifiers.spec.ts 2>&1 | tee .sisyphus/evidence/task-14-e2e.txt
2. Assert: 100% pass rate
Expected: No e2e regressions
Evidence: .sisyphus/evidence/task-14-e2e.txt
```
**Evidence**: task-14-snapshot-match.txt, task-14-e2e.txt
**Commit**: YES. Message: `refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change)`. Files: `ParamField.tsx`, `ParamField.snapshot.test.tsx`, `CustomModifierEditor.tsx` (imports only). Pre-commit: `bun run check`
- [x] 15. `packages/chess/src/ui/narrate.ts` + `.test.ts`: Pure descriptor → English narrative module
**What to do**:
- Pure module. Zero session/engine access. Input: `CustomModifierDescriptor` or `readonly EffectPrimitiveNode[]`. Output: `string` (English narrative)
- `narrate(descriptor): string` — walks tree, composes sentences: "When this piece captures, gain 1 HP. At turn start (matching 'white'), if HP < 3, heal 1."
- Per-primitive narrator functions: `narrateSeed(params)`, `narrateAddTo(params)`, etc. — one per kind (21 total after new primitives)
- Cycle guard: use visited-Set to prevent infinite loop on in-edit malformed trees
- Length cap: if result > 4000 chars, truncate with "... and N more primitives"
- Golden test file `narrate.test.ts` — table-driven, one scenario per primitive kind + 3 nested-combination scenarios
**Must NOT do**: import engine/session; call PRIMITIVE_REGISTRY.get (use static kind→narrator map); add i18n; use template literals with escape-heavy HTML (plain English only)
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 1. Blocks T17 (PreviewPane reads narrate output). Blocked by: none.
**References**:
- `packages/chess/src/modifiers/primitives/types.ts:PrimitiveKind` — kinds to cover
- `packages/chess/src/modifiers/primitives/manifest.ts:collectDynamicSeedsInTree` — tree-walk pattern with visited guard
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/narrate.test.ts` → PASS (≥24 scenarios: 21 kinds + 3 nested)
- [ ] narrate() runs < 1ms on 50-node descriptor (microbenchmark test)
- [ ] Cyclic reference input returns bounded output without hanging
**QA Scenarios**:
```
Scenario: Golden output per primitive kind
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/ui/narrate.test.ts 2>&1 | tee .sisyphus/evidence/task-15-golden.txt
2. Assert: All 24+ snapshot cases pass
Expected: Deterministic narration per kind
Evidence: .sisyphus/evidence/task-15-golden.txt
Scenario: 50-node perf
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/ui/narrate.test.ts -t "perf"
2. Assert: 50-node narrate completes under 1ms (averaged over 100 runs)
Expected: Live-preview friendly perf
Evidence: .sisyphus/evidence/task-15-perf.txt
```
**Evidence**: task-15-golden.txt, task-15-perf.txt
**Commit**: YES. Message: `feat(chess/ui): add narrate.ts pure module for descriptor → English`. Files: `narrate.ts`, `narrate.test.ts`. Pre-commit: `bun run check`
- [x] 16. `packages/chess/src/ui/visual-builder/BlockCard.tsx` + `.test.tsx`: Single primitive block renderer
**What to do**:
- Presentational component rendering ONE primitive node as a colored block card with: kind label, compact param summary (first 60 chars of JSON), remove button, expand/collapse for nested triggers (on-* kinds with child primitives)
- Color-code by category: State (blue), Mechanic (emerald), Advanced/Trigger (violet)
- Accessibility: `role="article"`, `aria-label="<kind> block"`, keyboard focusable, Enter to expand
- Props: `{ node: EffectPrimitiveNode, index: number, isSelected: boolean, onSelect: () => void, onRemove: () => void, depth: number }` — NO dnd-kit yet (pure rendering)
- Uses `ParamField` from T14 in the inspector-overlay (only when expanded/selected)
- Nested triggers render child BlockCards recursively with `depth + 1` for indentation
**Must NOT do**: handle DnD logic here (T18); mutate descriptor directly (call onChange); exceed depth-3 indentation visually (clamp style)
**Recommended Agent Profile**: `visual-engineering`. Skills: [`interface-design`, `frontend-ui-ux`]
- `interface-design`: direct domain match for authoring UI affordances
- `frontend-ui-ux`: color system + a11y patterns
**Parallelization**: Wave 2. Blocks T18. Blocked by T14, T15.
**References**:
- `packages/chess/src/ui/CustomModifierEditor.tsx:339-388` — existing tree-node rendering pattern (color-coded rows)
- `packages/chess/src/ui/ParamField.tsx` (from T14) — for selected state
- Tailwind color palette: blue-50/blue-500 (State), emerald-50/emerald-500 (Mechanic), violet-50/violet-500 (Trigger)
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/visual-builder/BlockCard.test.tsx` → PASS (≥5 scenarios: render, select, remove, expand nested, depth-clamp at 3)
- [ ] Keyboard-focusable via Tab; Enter activates expand
- [ ] No DnD code imported
**QA Scenarios**:
```
Scenario: Render + select + remove flow
Tool: Bash (vitest)
Steps:
1. Run: cd /home/joey/Projects/rules && bun test packages/chess/src/ui/visual-builder/BlockCard.test.tsx 2>&1 | tee .sisyphus/evidence/task-16-blockcard.txt
2. Assert: All scenarios pass
Evidence: .sisyphus/evidence/task-16-blockcard.txt
Scenario: Nested trigger renders child blocks
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/BlockCard.test.tsx -t "nested"
2. Assert: on-capture with child add-to-attribute renders both blocks; child at depth+1
Evidence: .sisyphus/evidence/task-16-nested.txt
```
**Evidence**: task-16-blockcard.txt, task-16-nested.txt
**Commit**: YES. Message: `feat(chess/ui): add BlockCard component`. Files: `visual-builder/BlockCard.tsx`, `.test.tsx`. Pre-commit: `bun run check`
- [x] 17. `packages/chess/src/ui/visual-builder/preview/`: PreviewPane + 3 sub-views (NarrativeView, JsonView, BoardDiagramView)
**What to do**:
- `PreviewPane.tsx`: tab UI (3 tabs: Narrative / JSON / Board). Props `{ descriptor: CustomModifierDescriptor }`.
- `NarrativeView.tsx`: calls `narrate(descriptor)` from T15, renders in a scrollable `<pre>` with semantic `role="region" aria-label="narrative"`.
- `JsonView.tsx`: syntax-highlighted JSON (use minimal custom highlighter or accept plain `<pre>`). Collapsible by top-level keys. Copy-to-clipboard button.
- `BoardDiagramView.tsx`: 8×8 SVG chessboard (200×200). Highlights:
- Squares from `on-moved-onto-square` filters (yellow overlay)
- Aura radius (concentric ring)
- Poisoned/affected squares (from existing integration)
- If descriptor has no positional primitives, render "No board effect" placeholder
- All 3 views memoized on descriptor identity.
**Must NOT do**: run engine simulation; show dynamic game state; add editor-level controls here (preview-only); use react-syntax-highlighter (size bloat — roll simple regex highlighter or pre-format)
**Recommended Agent Profile**: `visual-engineering`. Skills: [`interface-design`, `frontend-ui-ux`]
**Parallelization**: Wave 2. Blocks T19. Blocked by T15.
**References**:
- `packages/chess/src/ui/Board.tsx` — existing 8×8 SVG board for coordinate math reference
- `packages/chess/src/coord.ts` — square-to-coords helpers
- `packages/chess/src/ui/narrate.ts` (from T15)
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/visual-builder/preview/PreviewPane.test.tsx` → PASS (tabs switch, each view mounts)
- [ ] `bun test packages/chess/src/ui/visual-builder/preview/BoardDiagramView.test.tsx` → PASS (poisoned squares highlighted; aura renders ring; empty case shows placeholder)
- [ ] Bundle size delta for preview subtree < 8kb gz (check via `bun run build`)
**QA Scenarios**:
```
Scenario: Tab switching preserves descriptor
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/preview 2>&1 | tee .sisyphus/evidence/task-17-preview.txt
2. Assert: All tab tests PASS; descriptor not mutated across tab switches
Evidence: .sisyphus/evidence/task-17-preview.txt
Scenario: Board diagram renders aura
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/preview/BoardDiagramView.test.tsx -t "aura"
2. Assert: Descriptor with add-aura{radius:2} renders concentric ring in SVG
Evidence: .sisyphus/evidence/task-17-aura.txt
```
**Evidence**: task-17-preview.txt, task-17-aura.txt
**Commit**: YES. Message: `feat(chess/ui): add PreviewPane (Narrative/JSON/Board tabs)`. Files: `visual-builder/preview/*.tsx`, tests. Pre-commit: `bun run check`
- [x] 18. `packages/chess/src/ui/visual-builder/BlockList.tsx` + `.test.tsx`: dnd-kit SortableContext tree with nested triggers
**What to do**:
- `BlockList.tsx`: top-level SortableContext wraps root-level blocks. Each trigger block that has child primitives wraps its children in a nested SortableContext.
- Sensors: `PointerSensor` + `KeyboardSensor` with `sortableKeyboardCoordinates`.
- `DragOverlay` component for drag preview (prevents layout jank during drag).
- `onDragEnd`: calls `onReorder(activeId, overId, isNested: boolean)` — parent component updates descriptor tree structurally.
- Announcements: custom `announcements` prop surfacing "Moved <kind> block from position X to Y" for screen readers.
- Nested drop: dropping a block inside a trigger's children SortableContext moves it INTO that trigger's `primitives[]`.
**Must NOT do**: allow drag across different descriptor levels that would exceed depth 3 (block with validation fallback); enable multi-select; persist drag state to localStorage
**Recommended Agent Profile**: `visual-engineering`. Skills: [`interface-design`, `context7`]
- `context7`: dnd-kit has specific v6 API patterns; official docs via Context7 to avoid guesses
**Parallelization**: Wave 3. Blocks T19. Blocked by T16.
**References**:
- `packages/chess/src/ui/LayoutEditor.tsx` — existing HTML5 drag pattern (orthogonal reference for behavior parity)
- `@dnd-kit/sortable` docs — SortableContext, `arrayMove`, `rectSortingStrategy`
- `packages/chess/src/ui/visual-builder/BlockCard.tsx` (T16) — integrates via `useSortable`
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/visual-builder/BlockList.test.tsx` → PASS (render, prop-driven reorder via direct callback; SKIP PointerEvent tests in happy-dom, cover via e2e T25)
- [ ] Keyboard reorder test: simulate Space → ArrowDown → Space, assert descriptor reorders (use KeyboardSensor explicit invocation)
- [ ] Depth-4 drop is rejected with error toast/no-op
**QA Scenarios**:
```
Scenario: Keyboard reorder swaps blocks
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/BlockList.test.tsx -t "keyboard" 2>&1 | tee .sisyphus/evidence/task-18-keyboard.txt
2. Assert: After Space+ArrowDown+Space, descriptor primitives[0] and primitives[1] swapped
Evidence: .sisyphus/evidence/task-18-keyboard.txt
Scenario: Depth-4 drop rejected
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/BlockList.test.tsx -t "depth limit"
2. Assert: Drop attempt into 4th-level children → no mutation; validation toast triggered (mock)
Evidence: .sisyphus/evidence/task-18-depth-limit.txt
```
**Evidence**: task-18-keyboard.txt, task-18-depth-limit.txt
**Commit**: YES. Message: `feat(chess/ui): add BlockList with dnd-kit sortable (nested)`. Files: `BlockList.tsx`, `.test.tsx`. Pre-commit: `bun run check`
- [x] 19. `packages/chess/src/ui/visual-builder/VisualBuilderPane.tsx`: Composition shell — BlockList + Palette + PreviewPane
**What to do**:
- Layout: left sidebar (palette from existing editor, reused verbatim), center (BlockList), right (PreviewPane). Replaces CustomModifierEditor's center+right panels when mode=visual.
- Props: `{ descriptor, onChange, validationResult }`.
- Handles tree mutations: add (from palette), remove, reorder (via BlockList callback).
- Shows invalid-descriptor fallback: if `validationResult.ok === false` AND descriptor has cyclic or malformed structure, render a banner "Fix errors in Form mode to use Visual mode" with button to switch back.
**Must NOT do**: reimplement palette (import from CustomModifierEditor or extract earlier); duplicate save/load UI (that stays in CustomModifierEditor header); introduce new state beyond descriptor sync
**Recommended Agent Profile**: `visual-engineering`. Skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks T22. Blocked by T17, T18.
**References**:
- `packages/chess/src/ui/CustomModifierEditor.tsx:272-326` — palette component (extract or reuse)
- `packages/chess/src/ui/visual-builder/BlockList.tsx` (T18)
- `packages/chess/src/ui/visual-builder/preview/PreviewPane.tsx` (T17)
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/visual-builder/VisualBuilderPane.test.tsx` → PASS (≥4 scenarios: render, add from palette, remove, invalid-fallback)
- [ ] Fallback banner renders when descriptor has Zod-invalid shape
**QA Scenarios**:
```
Scenario: Palette → BlockList integration
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/VisualBuilderPane.test.tsx 2>&1 | tee .sisyphus/evidence/task-19-pane.txt
2. Assert: Clicking a palette item adds to descriptor.primitives; block appears in list
Evidence: .sisyphus/evidence/task-19-pane.txt
Scenario: Invalid descriptor fallback
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/visual-builder/VisualBuilderPane.test.tsx -t "invalid fallback"
2. Assert: descriptor with depth-4 structure → fallback banner + "switch to Form" button
Evidence: .sisyphus/evidence/task-19-fallback.txt
```
**Evidence**: task-19-pane.txt, task-19-fallback.txt
**Commit**: YES. Message: `feat(chess/ui): add VisualBuilderPane composing blocks + preview`. Files: `VisualBuilderPane.tsx`, `.test.tsx`. Pre-commit: `bun run check`
- [x] 20. `packages/chess/src/modifiers/custom/__fixtures__/legacy-descriptor.json` + round-trip test
**What to do**:
- Create fixture JSON containing a descriptor using ONLY the 15 pre-existing primitive kinds (no new ones from T5–T11)
- Write test `legacy-descriptor.test.ts`: (a) parses via `parseCustomModifierDescriptor` without error, (b) validates via `validateCustomDescriptor` → `{ok: true}`, (c) applies via `applyCustomDescriptor` — resulting session WMEs match an expected snapshot, (d) serializes back to JSON — deep-equal to input
- Fixture must exercise at least 5 different kinds including 2 nested triggers
**Must NOT do**: introduce any new kinds in this fixture (proves backward compat); update fixture when adding new primitives
**Recommended Agent Profile**: `unspecified-high`. Skills: []
**Parallelization**: Wave 3. Blocks T25. Blocked by T13.
**References**:
- `packages/chess/src/modifiers/custom/recipes.ts` — source of legacy-only descriptor examples
- `packages/chess/src/modifiers/custom/schema.ts:parseCustomModifierDescriptor`
- `packages/chess/src/modifiers/custom/validate.ts`
- `packages/chess/src/modifiers/custom/apply.ts:applyCustomDescriptor`
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/custom/legacy-descriptor.test.ts` → PASS (4 scenarios: parse, validate, apply, serialize)
- [ ] JSON deep-equal round-trip
**QA Scenarios**:
```
Scenario: Legacy descriptor full round-trip
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/custom/legacy-descriptor.test.ts 2>&1 | tee .sisyphus/evidence/task-20-legacy.txt
2. Assert: All 4 scenarios pass; JSON.stringify(parsed) === original JSON
Expected: Byte-equal round-trip
Evidence: .sisyphus/evidence/task-20-legacy.txt
Scenario: Apply produces expected WMEs
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/custom/legacy-descriptor.test.ts -t "apply"
2. Assert: Session WMEs after apply match snapshot of legacy-baseline WMEs
Evidence: .sisyphus/evidence/task-20-apply.txt
```
**Evidence**: task-20-legacy.txt, task-20-apply.txt
**Commit**: YES. Message: `test(chess): add legacy descriptor backward-compat fixture + round-trip test`. Files: `__fixtures__/legacy-descriptor.json`, `legacy-descriptor.test.ts`. Pre-commit: `bun run check`
- [x] 21. `packages/chess/src/modifiers/apply.ts`: Wire 7 new trigger dispatchers into onAfterMove with strict ordering + ordering test
**What to do**:
- In `onAfterMove` integration preset hook, add calls in Metis-locked order:
1. `computeAuraFacts` (existing)
2. `fireOnDamagedHooks` (existing)
3. `fireOnCaptureHooks` (existing)
4. `fireOnCapturedHooks(engine, capturedIds, attackerId)` — BEFORE retraction (T11)
5. `fireOnPromotionHooks(engine, ...)` — uses T4 flag
6. `fireOnMoveHooks(engine, movedPieceIds)` — via Position diff
7. `fireOnMovedOntoSquareHooks(engine, movedPieceId, destSquare)`
8. `fireOnCheckReceivedHooks(engine, royalId)` — T4 snapshot diff
9. `fireOnCheckDeliveredHooks(engine, newlyAttackingIds)` — T4 snapshot diff
10. `fireConditionalHooks` (existing)
11. `fireOnTurnEndHooks(engine, endedColor)` — for mover
12. `fireOnTurnStartHooks(engine, newColor)` (existing) — for next color
- Clear T4 snapshots at end of onAfterMove
- Write ORDERING TEST: `apply.test.ts` registers a dispatch-order-spy descriptor on a test piece with all 11 trigger types, runs 1 move that triggers all, asserts dispatch order matches locked sequence
**Must NOT do**: change existing dispatcher internals; introduce try/catch that swallows errors; skip snapshot cleanup
**Recommended Agent Profile**: `deep`. Skills: []
**Parallelization**: Wave 3. Blocks T25. Blocked by T12.
**References**:
- `packages/chess/src/modifiers/apply.ts:665-683` — existing onAfterMove dispatch order
- T4 PRE_MOVE_CHECK_STATE_SNAPSHOTS cleanup point
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/apply.test.ts -t "dispatch order"` → PASS with exact order assertion
- [ ] After move resolves, no pre-move snapshots remain (WeakMap.has() false for test engine)
- [ ] All 21 primitive tests + 11 dispatcher tests still PASS
**QA Scenarios**:
```
Scenario: Dispatch order locked
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/apply.test.ts -t "dispatch order" 2>&1 | tee .sisyphus/evidence/task-21-order.txt
2. Assert: Event log shows exact 12-step order from Metis spec
Expected: Byte-locked order
Evidence: .sisyphus/evidence/task-21-order.txt
Scenario: Full integration test — descriptor with all trigger types runs end-to-end
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/apply.test.ts -t "all triggers"
2. Assert: Descriptor with on-move + on-capture + on-captured + on-check-received + on-turn-end runs without error across a 3-move sequence
Evidence: .sisyphus/evidence/task-21-all-triggers.txt
```
**Evidence**: task-21-order.txt, task-21-all-triggers.txt
**Commit**: YES. Message: `feat(chess/modifiers): wire 7 new triggers in apply.ts onAfterMove + dispatch order test`. Files: `apply.ts`, `apply.test.ts`. Pre-commit: `bun run check`
- [x] 22. `packages/chess/src/ui/CustomModifierEditor.tsx`: Add Form/Visual mode toggle + localStorage persistence
**What to do**:
- Add `mode: 'form' | 'visual'` state in CustomModifierEditor. Default: read from localStorage key `houserules:custom-modifier-editor-mode:v1`, fallback `'form'`.
- Add toggle buttons in header area next to Templates/Load/Save buttons: `[Form] [Visual]` with visual mode indicator (aria-pressed).
- When `mode === 'form'`: render existing center (tree view) + right (inspector) — unchanged.
- When `mode === 'visual'`: render `<VisualBuilderPane descriptor={descriptor} onChange={setDescriptor} validationResult={validationResult} />`, which replaces center+right.
- Persist mode change to localStorage on toggle.
- Add data-testid: `custom-modifier-editor-mode-toggle`, `custom-modifier-editor-mode-form`, `custom-modifier-editor-mode-visual`.
**Must NOT do**: touch existing form-mode rendering logic beyond adding the conditional branch; change save/load/share handlers; add undo/redo
**Recommended Agent Profile**: `visual-engineering`. Skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks T23, T25. Blocked by T14, T19.
**References**:
- `packages/chess/src/ui/CustomModifierEditor.tsx:119-267` — header structure to extend
- `packages/chess/src/ui/visual-builder/VisualBuilderPane.tsx` (T19)
- `packages/chess/src/persist/autosave.ts` — localStorage key-versioning pattern
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/CustomModifierEditor.test.tsx` → PASS with new mode-toggle scenarios
- [ ] Playwright `custom-modifiers.spec.ts` still PASS (no regressions)
- [ ] localStorage `houserules:custom-modifier-editor-mode:v1` updates on toggle
- [ ] Default is 'form' on fresh install (no key in storage)
**QA Scenarios**:
```
Scenario: Mode toggle persists across reload
Tool: Bash (vitest or playwright)
Steps:
1. Run: bun test packages/chess/src/ui/CustomModifierEditor.test.tsx -t "mode persistence" 2>&1 | tee .sisyphus/evidence/task-22-mode-persist.txt
2. Assert: After toggle to visual, localStorage contains "visual"; next mount reads it back
Evidence: .sisyphus/evidence/task-22-mode-persist.txt
Scenario: Existing form mode preserved
Tool: Bash (playwright)
Steps:
1. Run: cd packages/chess && bun x playwright test e2e/custom-modifiers.spec.ts
2. Assert: 100% pass rate (existing form-mode flows unchanged)
Evidence: .sisyphus/evidence/task-22-e2e-unchanged.txt
```
**Evidence**: task-22-mode-persist.txt, task-22-e2e-unchanged.txt
**Commit**: YES. Message: `feat(chess/ui): add Form/Visual mode toggle to CustomModifierEditor`. Files: `CustomModifierEditor.tsx`. Pre-commit: `bun run check`
- [x] 23. `packages/chess/src/ui/CustomModifierEditor.mode-roundtrip.test.tsx`: Form↔Visual byte-equal round-trip test
**What to do**:
- Write test: render CustomModifierEditor with fixture descriptor (5 primitives including a nested trigger). Simulate toggle form→visual→form. After each toggle, call `JSON.stringify` on current descriptor and assert it equals the original.
- Extend coverage: load descriptor from library, toggle modes 5 times, assert no drift.
**Must NOT do**: test DnD behavior here (that's T25); assert visual rendering (T22 already covers rendering)
**Recommended Agent Profile**: `quick`. Skills: []
**Parallelization**: Wave 4. Blocks F3. Blocked by T22.
**References**:
- `packages/chess/src/ui/CustomModifierEditor.tsx` (T22 updated)
- Fixture descriptor from T20 legacy fixture
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/ui/CustomModifierEditor.mode-roundtrip.test.tsx` → PASS
- [ ] 5-toggle drift test passes
**QA Scenarios**:
```
Scenario: Form → Visual → Form byte-equal
Tool: Bash (vitest)
Steps:
1. Run: bun test packages/chess/src/ui/CustomModifierEditor.mode-roundtrip.test.tsx 2>&1 | tee .sisyphus/evidence/task-23-roundtrip.txt
2. Assert: JSON.stringify(descriptor_after) === JSON.stringify(descriptor_before)
Expected: No drift
Evidence: .sisyphus/evidence/task-23-roundtrip.txt
```
**Evidence**: task-23-roundtrip.txt
**Commit**: YES. Message: `test(chess/ui): add mode toggle round-trip byte-equal test`. Files: `CustomModifierEditor.mode-roundtrip.test.tsx`. Pre-commit: `bun run check`
- [x] 24. `packages/chess/src/modifiers/custom/validate.test.ts` (extend): Depth-3 composition test + depth-4 rejection
**What to do**:
- Add test scenarios:
- Depth-3 valid: conditional → on-move → add-to-attribute (exactly 3 levels) → `validateCustomDescriptor` returns `{ok: true}`
- Depth-4 invalid: conditional → on-move → conditional → add-to-attribute → returns `{ok: false}` with error code `descriptor.primitives.depth.exceeded`
- Mixed new + old kinds at depth 3 still valid: on-captured{target:ally} → conditional → add-aura
- 50-node cap still enforced (just verify existing test still green)
**Must NOT do**: raise depth cap; change existing depth test
**Recommended Agent Profile**: `quick`. Skills: []
**Parallelization**: Wave 4. Blocks F1. Blocked by T21.
**References**:
- `packages/chess/src/modifiers/custom/validate.ts:MAX_RECURSION_DEPTH`
- `packages/chess/src/modifiers/custom/validate.test.ts` — existing test file to extend
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/custom/validate.test.ts` → PASS with new scenarios
- [ ] Depth-3-valid and depth-4-invalid both demonstrated
**QA Scenarios**:
```
Scenario: Depth-3 trigger composition accepted, depth-4 rejected
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/custom/validate.test.ts -t "depth" 2>&1 | tee .sisyphus/evidence/task-24-depth.txt
2. Assert: Depth-3 test: {ok: true}. Depth-4 test: {ok: false} with code "descriptor.primitives.depth.exceeded"
Expected: Cap enforced precisely
Evidence: .sisyphus/evidence/task-24-depth.txt
```
**Evidence**: task-24-depth.txt
**Commit**: YES. Message: `test(chess/modifiers): add depth-3 trigger composition test + depth-4 rejection test`. Files: `validate.test.ts`. Pre-commit: `bun run check`
- [x] 25. `packages/chess/e2e/custom-modifier-visual.spec.ts`: Playwright e2e visual authoring flow
**What to do**:
- New Playwright spec. Flow: (1) open chess app, start solo, open rules drawer, open custom modifier editor. (2) Toggle to Visual mode — assert palette + block list + preview visible. (3) Drag (or click-to-add) `on-move` primitive from palette. (4) Inside the on-move block's children zone, add `add-to-attribute` with Hp +1. (5) Verify preview narrative shows "When this piece moves, add 1 to Hp". (6) Save to library. (7) Reload page. (8) Open library, reload saved descriptor. (9) Verify Visual mode renders the saved structure identically.
- Include a negative: (10) attempt to drop a 4th-level block — assert toast error or no-op.
- Screenshots at each major step to `.sisyphus/evidence/task-25-*.png`.
**Must NOT do**: mock the engine; stub dnd-kit (use real events); skip negative scenarios
**Recommended Agent Profile**: `unspecified-high`. Skills: [`playwright`]
**Parallelization**: Wave 4. Blocks F3. Blocked by T20, T21, T22.
**References**:
- `packages/chess/e2e/custom-modifiers.spec.ts` — pattern for localStorage seeding and flow
- `packages/chess/playwright.config.ts` — test runner config
**Acceptance Criteria**:
- [ ] `cd packages/chess && bun x playwright test e2e/custom-modifier-visual.spec.ts` → all scenarios PASS
- [ ] Evidence screenshots saved at each major step
- [ ] At least 10 assertions covering happy path + 1 negative
**QA Scenarios**:
```
Scenario: Full visual authoring + save + reload round-trip
Tool: Playwright
Preconditions: Chess app buildable (`bun run build` passing in packages/chess)
Steps:
1. Run: cd /home/joey/Projects/rules/packages/chess && bun x playwright test e2e/custom-modifier-visual.spec.ts --reporter=list 2>&1 | tee ../../.sisyphus/evidence/task-25-e2e.txt
2. Navigate: localhost dev → Play Solo → Rules Drawer → Custom Modifier Editor → Visual mode toggle
3. Click palette item 'on-move' → assert block appears (selector: [data-testid="block-on-move"])
4. Within block, click '+' to add child → select 'add-to-attribute' → set attr='Hp', delta=1
5. Assert preview narrative (selector: [role="region"][aria-label="narrative"]) contains "When this piece moves, add 1 to Hp"
6. Click Save to Library
7. Reload page (page.reload())
8. Open editor → Load → select saved descriptor
9. Assert Visual mode still active + block structure rendered identically
Expected Result: Descriptor round-trips through save/reload with no data loss
Failure Indicators: Any step fails; any selector not found
Evidence: task-25-e2e.txt + screenshots at each step
Scenario: Depth-4 drop rejected with user feedback
Tool: Playwright
Steps:
1. Build descriptor with conditional → on-move → conditional structure (depth 3)
2. Attempt to drop add-to-attribute into the innermost conditional (would be depth 4)
3. Assert: toast message "Maximum nesting depth exceeded" visible; descriptor unchanged
Expected Result: Depth-4 drop rejected
Evidence: .sisyphus/evidence/task-25-depth-reject.png + task-25-depth-reject.txt
```
**Evidence**: task-25-e2e.txt, task-25-*.png screenshots, task-25-depth-reject.{txt,png}
**Commit**: YES. Message: `test(chess/e2e): add Playwright visual-mode authoring flow`. Files: `e2e/custom-modifier-visual.spec.ts`. Pre-commit: `bun run check`
- [x] 26. `packages/chess/src/modifiers/custom/recipes.ts`: Add 2+ new recipes showcasing new triggers
**What to do**:
- Add at least 2 new entries to `CUSTOM_MODIFIER_RECIPES`:
- **"Kamikaze Knight"** — on-captured with `target: 'attacker'` → add-to-attribute Hp -2. Death-rattle that damages whoever captures this piece.
- **"Berserker Pawn"** — on-move → add-to-attribute attack-bonus +1 (stacking rage). At each move, the piece grows stronger.
- Optional third: "Promotion Feast" — on-promotion → seed-attribute Hp = 5, capitalizing on promotedTo from event
- Each recipe validates via `validateCustomDescriptor` → `{ok: true}` (verified by `recipes.test.ts`)
**Must NOT do**: use kinds not yet implemented; skip recipe tests; introduce recipe IDs that collide with existing
**Recommended Agent Profile**: `quick`. Skills: []
**Parallelization**: Wave 4. Blocks F1. Blocked by T21.
**References**:
- `packages/chess/src/modifiers/custom/recipes.ts` — existing recipe format
- `packages/chess/src/modifiers/custom/recipes.test.ts` — existing recipe validation test
**Acceptance Criteria**:
- [ ] `bun test packages/chess/src/modifiers/custom/recipes.test.ts` → PASS with ≥ 7 recipes (5 existing + 2 new minimum)
- [ ] Each new recipe validates via `validateCustomDescriptor`
**QA Scenarios**:
```
Scenario: New recipes validate + render
Tool: Bash (bun test)
Steps:
1. Run: bun test packages/chess/src/modifiers/custom/recipes.test.ts 2>&1 | tee .sisyphus/evidence/task-26-recipes.txt
2. Assert: 7+ recipes total; each validates cleanly
Evidence: .sisyphus/evidence/task-26-recipes.txt
```
**Evidence**: task-26-recipes.txt
**Commit**: YES. Message: `feat(chess/modifiers): add recipes showcasing on-captured + on-move triggers`. Files: `recipes.ts`, `recipes.test.ts`. Pre-commit: `bun run check`
- [x] 27. `packages/chess/RULES.md` + `packages/chess/docs/PRESET-API.md`: Document new trigger semantics + target redirection
**What to do**:
- Add section to `RULES.md` or a new `packages/chess/docs/TRIGGERS.md`: documentation for all 11 triggers (4 existing + 7 new) with exact firing semantics locked in the interview:
- `on-move`: "fires whenever this piece's Position WME changes"
- `on-turn-end`: "fires at end of matching-color turn, BEFORE opponent's on-turn-start"
- `on-promotion`: "fires AFTER PieceType flip; event context carries `{promotedFrom, promotedTo}`"
- `on-check-received`: "edge-triggered; fires only on transition into check; royal pieces only"
- `on-check-delivered`: "fires on the piece whose attack-line newly threatens opponent royal; discovered check attributes to the revealing piece"
- `on-moved-onto-square`: "predicate is discriminated union of `{squares: Square[]}` or `{file?, rank?}`"
- `on-captured`: "fires on lethal damage BEFORE retraction; use `target` to redirect nested primitives"
- Document `target` field and TargetResolver semantics in `PRESET-API.md`
- Document dispatch order (Metis-locked)
**Must NOT do**: document internal implementation details users don't need; skip examples
**Recommended Agent Profile**: `writing`. Skills: []
**Parallelization**: Wave 4. Blocks F1. Blocked by T21.
**References**:
- `packages/chess/RULES.md` — existing docs structure to extend
- `packages/chess/docs/PRESET-API.md` — hook reference to extend
**Acceptance Criteria**:
- [ ] `RULES.md` (or new TRIGGERS.md) contains sections for all 7 new triggers with firing rules
- [ ] `PRESET-API.md` updated with `TargetResolver` + `PrimitiveEvent` context fields
- [ ] Dispatch order from Metis documented
**QA Scenarios**:
```
Scenario: Docs contain all 7 new trigger entries
Tool: Bash
Steps:
1. Run: grep -c "^### on-" /home/joey/Projects/rules/packages/chess/RULES.md /home/joey/Projects/rules/packages/chess/docs/*.md 2>&1 | tee .sisyphus/evidence/task-27-docs-count.txt
2. Assert: Total on-* sections ≥ 11
Expected: Each trigger has a dedicated section
Evidence: .sisyphus/evidence/task-27-docs-count.txt
Scenario: Firing-rule language matches user decisions
Tool: Bash (grep)
Steps:
1. Run: grep -i "edge-triggered" /home/joey/Projects/rules/packages/chess/RULES.md /home/joey/Projects/rules/packages/chess/docs/*.md
2. Assert: on-check-received documented as edge-triggered
Evidence: .sisyphus/evidence/task-27-docs-semantics.txt
```
**Evidence**: task-27-docs-count.txt, task-27-docs-semantics.txt
**Commit**: YES. Message: `docs(chess): document new trigger semantics in RULES.md + PRESET-API.md`. Files: `RULES.md`, `docs/PRESET-API.md`. Pre-commit: `bun run check`
- [x] 28. `packages/chess`: Add dnd-kit deps + verify bundle size delta + registry count assertion
**What to do**:
- `bun add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities` in packages/chess
- Run `bun run build`, measure `dist/` output bytes (gz via `gzip | wc -c` or vite's built-in size report)
- Compare against baseline (capture baseline size via `git stash` trick or pre-task measurement); assert delta ≤ 25kb gz
- Assert `PRIMITIVE_REGISTRY.list().length === 21` via a one-off script: `bun -e "import {PRIMITIVE_REGISTRY} from './packages/chess/src/modifiers/primitives/index.js'; console.log(PRIMITIVE_REGISTRY.list().length)"`
**Must NOT do**: add other deps; disable Vite tree-shaking; skip delta measurement
**Recommended Agent Profile**: `quick`. Skills: []
**Parallelization**: Wave 4. Blocks F2. Blocked by T22.
**References**:
- `packages/chess/package.json` — add deps
- `packages/chess/vite.config.ts` — build config
**Acceptance Criteria**:
- [ ] `bun run build` in packages/chess → exits 0
- [ ] Bundle delta ≤ 25kb gz
- [ ] `PRIMITIVE_REGISTRY.list().length === 21`
- [ ] `@dnd-kit/*` listed in `dependencies` (not devDependencies)
**QA Scenarios**:
```
Scenario: Bundle builds and stays under budget
Tool: Bash
Steps:
1. Run: cd /home/joey/Projects/rules/packages/chess && bun run build 2>&1 | tee ../../.sisyphus/evidence/task-28-build.txt
2. Run: ls -la dist/ | awk '{sum+=$5} END {print sum}' (capture total bundle size)
3. Assert: Delta from pre-task baseline ≤ 25600 bytes (gz); if exact baseline unrecorded, total bundle size is within repository's documented ceiling
Evidence: .sisyphus/evidence/task-28-build.txt, task-28-size.txt
Scenario: Registry count locked at 21
Tool: Bash
Steps:
1. Run: cd /home/joey/Projects/rules && bun -e "import('./packages/chess/src/modifiers/primitives/index.js').then(m => console.log(require('./packages/chess/src/modifiers/primitives/registry.js').PRIMITIVE_REGISTRY.list().length))" 2>&1 | tee .sisyphus/evidence/task-28-registry.txt
2. Assert: Output === "21"
Evidence: .sisyphus/evidence/task-28-registry.txt
```
**Evidence**: task-28-build.txt, task-28-size.txt, task-28-registry.txt
**Commit**: YES. Message: `chore(chess): add dnd-kit deps + verify bundle size delta`. Files: `package.json`, `bun.lock`. Pre-commit: `bun run check`
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing.
>
> **Do NOT auto-proceed after verification. Wait for user's explicit approval before marking work complete.**
> **Never mark F1-F4 as checked before getting user's okay.** Rejection or user feedback → fix → re-run → present again → wait for okay.
- [x] F1. **Plan Compliance Audit** — `oracle`
Read this plan end-to-end. For each "Must Have": verify implementation exists by inspecting the cited file:line and running the cited command. For each "Must NOT Have": grep the codebase for forbidden patterns and reject with file:line if found (e.g., `grep -r "MAX_RECURSION_DEPTH" packages/chess/src/modifiers/` must show only `= 3`). Check every evidence file exists in `.sisyphus/evidence/`. Compare delivered files to "Concrete Deliverables" list — every path must exist.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | Evidence files [N/N present] | VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review** — `unspecified-high`
Run `cd /home/joey/Projects/rules && bun run check` — must be green. Review all changed files via `git diff main...HEAD` for: `as any`, `@ts-ignore`, empty catches, `console.log`, commented-out code, unused imports, generic names (data/result/item/temp). Check AI slop: excessive comments, over-abstraction, extracted helpers with one consumer. Check bundle size delta stays within 25kb gz.
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | Bundle delta [Xkb] | VERDICT`
- [x] F3. **Real Manual QA** — `unspecified-high` + `playwright` skill
Start from clean state (clear localStorage). Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence screenshots + HAR. Test cross-task integration: (a) author a modifier in Form mode with 3 old kinds, toggle to Visual, verify renders; (b) author using new `on-move` + nested `add-to-attribute`, save, reload page, verify persists; (c) save via "Share with Room" broadcast, verify server accepts (mock/real); (d) depth-3 nested trigger-in-conditional-in-trigger validates. Edge cases: empty descriptor in Visual mode, 50-node descriptor narrates <1ms, mode toggle with invalid JSON in form shows fallback. Save all evidence to `.sisyphus/evidence/final-qa/`.
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
- [x] F4. **Scope Fidelity Check** — `deep`
For each task T1–T28: read "What to do", read actual diff via `git log --oneline main..HEAD` + `git diff main...HEAD -- <paths>`. Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance per task. Detect cross-task contamination: Task N touching Task M's files inappropriately (e.g., T5 on-move primitive accidentally editing `CustomModifierEditor.tsx`). Flag unaccounted changes (files modified that aren't in any task deliverable list).
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
---
## Commit Strategy
Atomic commits. Each commit passes `bun run check` on its own.
- **T1**: `feat(chess/modifiers): extend PrimitiveApplyContext with target resolver + event field`
- Files: `packages/chess/src/modifiers/primitives/{types.ts,context.ts}`, tests
- **T2**: `feat(chess/schema): add 7 new hook attrs to ChessAttrMap`
- Files: `packages/chess/src/schema.ts`
- **T3**: `feat(chess/modifiers): register 7 new attr consumers`
- Files: `packages/chess/src/modifiers/apply.ts`
- **T4**: `feat(chess/modifiers): add pre-move check/promotion state snapshots`
- Files: `packages/chess/src/modifiers/apply.ts`
- **T5–T11**: `feat(chess/primitives): add <kind> primitive` (one per commit, TDD)
- **T12**: `feat(chess/modifiers): add 7 fire*Hooks evaluators in triggers.ts`
- **T13**: `feat(chess/server): mirror 7 new primitive kinds in wire schema + parity fixtures`
- **T14**: `refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change)`
- **T15**: `feat(chess/ui): add narrate.ts pure module for descriptor → English`
- **T16**: `feat(chess/ui): add BlockCard component`
- **T17**: `feat(chess/ui): add PreviewPane (Narrative/JSON/Board tabs)`
- **T18**: `feat(chess/ui): add BlockList with dnd-kit sortable (nested)`
- **T19**: `feat(chess/ui): add VisualBuilderPane composing blocks + preview`
- **T20**: `test(chess): add legacy descriptor backward-compat fixture + round-trip test`
- **T21**: `feat(chess/modifiers): wire 7 new triggers in apply.ts onAfterMove + dispatch order test`
- **T22**: `feat(chess/ui): add Form/Visual mode toggle to CustomModifierEditor`
- **T23**: `test(chess/ui): add mode toggle round-trip byte-equal test`
- **T24**: `test(chess/modifiers): add depth-3 trigger composition test + depth-4 rejection test`
- **T25**: `test(chess/e2e): add Playwright visual-mode authoring flow`
- **T26**: `feat(chess/modifiers): add recipes showcasing on-captured + on-move triggers`
- **T27**: `docs(chess): document new trigger semantics in RULES.md + PRESET-API.md`
- **T28**: `chore(chess): add dnd-kit deps + verify bundle size delta`
Pre-commit hook: `bun run check` (lint + typecheck + tests). Never `--no-verify`.
---
## Success Criteria
### Verification Commands
```bash
# From repo root
bun run check # PASS (full repo lint + typecheck + test)
bun test packages/chess # PASS (all chess unit tests)
bun test packages/chess/src/modifiers/primitives/registry.test.ts # 22 primitives registered
bun test packages/chess/src/modifiers/custom/validate.test.ts # depth/count/cycle validators pass
bun test packages/chess/src/modifiers/apply.test.ts # dispatch ordering locked in
bun test packages/chess/src/ui/narrate.test.ts # golden outputs match
bun test packages/chess/src/ui/visual-builder # block editor unit tests pass
cd packages/chess && bun x playwright test e2e/custom-modifier-visual.spec.ts # e2e pass
bun run build # bundle delta ≤ 25kb gz
```
### Final Checklist
- [ ] All "Must Have" items present (verified via F1)
- [ ] All "Must NOT Have" items absent (verified via F2 + F4)
- [ ] All 28 implementation tasks (T1–T28) complete and commits atomic
- [ ] F1 + F2 + F3 + F4 all APPROVE
- [ ] User explicitly okayed the verification results
- [ ] Draft file `.sisyphus/drafts/visual-modifier-builder.md` deleted after plan delivery