Files
josh 348c76a537 docs(02-03): complete harvest-journal-fragments plan
Plan 02-03 (Wave 1 second plan) executed in sequential mode. 3 atomic
commits + this metadata commit:

  - f192e82 — Season-1 fragments + sim/memory selector + harvest/compost
  - 572c861 — journal + reveal modal + harvest pointer wiring
  - 39bfcd2 — scripts/check-bundle-split.mjs (PIPE-02 structural verifier)

Outcomes:
  - 217/217 tests green (was 163; +54 new this plan)
  - npm run ci exits 0 with check:bundle-split integrated AFTER build
  - GARD-03 / GARD-04 / MEMR-01..06 / PIPE-02 satisfied end-to-end
  - The full Season-1 active-play loop works on real authored content:
    plant → grow → ready → click → harvest (deterministic, gated, no-dup,
    sentinel fallback for Pitfall 8) → reveal modal pops with full text →
    close → fragment files into journal under Season 1 → journal icon
    appears (D-23 first-harvest gate, invisible before) → click opens
    full-screen Memory Journal grouped by Season (D-24, MEMR-05 selectable
    DOM)
  - 17 Season-1 fragments authored in bible voice (9 warm + 3
    contemplative + 2 heavy + 1 _meta sentinel + 2 long-form Markdown)
  - Plant-type unlock thresholds finalized (Plan author's discretion
    within D-05): rosemary @ 0 / yarrow @ 3 / winter-rose @ 6. Pitfall 10
    boundaries pinned (locked at 2/5, unlocked at 3/6).
  - Pool-exhaustion sentinel chosen over repeat-most-recent — preserves
    no-dup invariant; warm pool depth ≥9 makes the sentinel structurally
    unreachable in normal Phase-2 play
  - compost-acknowledgements.ink content shipped ahead of Plan 02-04's
    Ink runtime; Garden.ts has TODO at the wiring point
  - PIPE-02 structurally verified by scripts/check-bundle-split.mjs (Vitest-
    importable Node ESM with runCheck() export)

Phase 2 progress: 3/5 plans complete (Wave 0 + both Wave 1 plans). Wave 2
(02-04 lura-gate-beats + 02-05 letter-settings-e2e) is the only remaining
Phase-2 work.

SUMMARY at .planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:16:02 -04:00

32 KiB
Raw Permalink Blame History

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
02-season-1-vertical-slice-soil 03 harvest-journal-fragments-vertical-slice
vertical-slice
harvest
journal
fragments
content-authoring
mulberry32
lazy-load
pipe-02
mvp
wave-1
phase provides
02-01 BigQty + tick scheduler + Zustand store + V1Payload extension (harvestedFragmentIds + fragmentRevealId + selectJournalRevealed) + ESLint sim-purity rule + Phaser EventBus singleton
phase provides
02-02 sim/garden core (4×4 grid, 3 plant types, growth state machine, plantSeed) + render/garden tier + Garden Phaser scene + BeginScreen + audio bootstrap + SeedPicker + UI strings + PIPE-02 lazy fragment loader surface
sim/memory module — pool.ts (filterPool — pure, gated by Season + plant-type tonal register + no-dup) + selector.ts (selectFragment — deterministic via mulberry32 PRNG seeded from sim state; EXHAUSTION_FALLBACK_ID sentinel for Pitfall 8) + barrel; 16 selector tests
sim/garden/commands.ts (extended) — harvest() pure command with Pitfall 10 mitigation (unlocks computed AFTER harvest commit) + compost() pure command (D-07 no-yield, D-04 no-refund) + SimContext interface for application-layer-injected fragment corpus + simulateOneTick branches on harvest/compost
Plant-type unlock thresholds — rosemary @ 0 (start), yarrow @ 3, winter-rose @ 6 (Plan author's discretion within D-05); pinned by 3 boundary tests
FragmentSchema extension — optional `tags
z.array(z.string()).optional()` for tonal-register gating (warm/contemplative/heavy/_meta); back-compat (existing tagless fragments parse)
Memory Journal UI tier — Journal.tsx (D-24 full-screen modal, fragments grouped by Season, MEMR-05 selectable DOM) + FragmentRevealModal.tsx (D-25 active-play reveal, backdrop-click + inner-Close dismiss, defensive silent dismiss on unresolvable id) + journal-icon.tsx (D-23 reveal-after-first-harvest gate via selectJournalRevealed selector, D-29 corner affordance with internal open state)
Season-1 authored fragment pool — 14 yaml entries (9 warm + 3 contemplative + 2 heavy + 1 _meta sentinel) + 2 long-form Markdown fragments (lura-first-letter.md, winter-rose-night.md). Total 17 authored. Warm pool depth ≥9 satisfies the worst-case all-rosemary playthrough at the 8th-harvest Lura threshold (CONTEXT D-14).
content/dialogue/season1/compost-acknowledgements.ink — authored content (6 short lines in the gardener-keeper voice) shipped ahead of Plan 02-04's Ink runtime; Garden.ts compost branch carries a TODO marking the Plan 02-04 wiring point
Garden.ts harvest+compost pointer wiring — handleTilePointerDown branches on tile state (empty → SeedPicker / ready → harvest / immature → compost); update() loop detects newly-appended harvestedFragmentIds and sets fragmentRevealId for the D-25 reveal flow; SimContext built once at create() from filtered eager `fragments` corpus
PIPE-02 structural verification — scripts/check-bundle-split.mjs (refactored as exportable `runCheck()` for Vitest cover; CLI invocation guard wraps process.exit) + scripts/check-bundle-split.test.mjs (3 cases
file exists / parses without exit / runCheck returns documented shape) + ci chain extended to run check:bundle-split AFTER build
02-04-lura-gate-beats (Lura's Ink runtime swaps in for the compost-acknowledgements TODO + Lura beats consume harvestedFragmentIds.length thresholds)
02-05-letter-settings-e2e (offline auto-harvest writes to harvestedFragmentIds; e2e exercises the full Begin → Plant → Grow → Harvest loop end-to-end)
added patterns
sim/memory module shape: pool.ts (filter helper) + selector.ts (deterministic PRNG-driven choice + sentinel fallback) + index.ts (barrel). Repeats the sim/<subsystem>/ shape established by sim/garden in Plan 02-02.
Deterministic selector via mulberry32 PRNG seeded from `(harvestedFragmentIds.length, plantedAtTick)` — both sim-internal counters; no Date.now leaks into selection. Pinned by 16 selector tests including determinism, gating, no-dup, season exclusion, sentinel exclusion from normal pool.
Pitfall 10 mitigation: plant-type unlock thresholds checked AFTER the harvest commit (computePlantUnlocks uses harvestedIds.length, not the pre-commit count). Pinned by 3 boundary tests — locked at 2/5 harvests, unlocked at 3/6.
Pitfall 8 (gated-pool exhaustion) — chosen behavior is the sentinel fallback. EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion' is authored content tagged ['_meta']; the pool filter excludes _meta-tagged fragments, and selector.ts looks the sentinel up explicitly when filterPool returns []. Plan ships sufficient warm-pool depth that the sentinel is unreachable in normal Phase-2 play; it remains a defensive structural fallback.
FragmentSchema extension via optional `tags` — back-compat with Phase-1 demo fragments that don't carry tags (loader.test.ts continues to pass against a tag-less fixture). Phase 2+ authored fragments ship tags for tonal-register gating.
DOM-rendered journal tier: Journal + FragmentRevealModal + JournalIcon all use selectable text (`userSelect: 'text'`) with `<pre>` for body rendering. MEMR-05 mechanically verified — canvas rendering would foreclose copy-paste from day one.
Application-layer SimContext injection — Garden scene loads the eager `fragments` corpus at create() and threads it through every simulateOneTick call. Sim modules NEVER import import.meta.glob; the corpus is a pure data input.
Journal-icon owns local `open` state (not the store) — V1Payload has no journal-open flag by design; the affordance owns its own visibility lifecycle without polluting the persisted save shape.
PIPE-02 structural verifier as an exportable `runCheck()` returning a structured result, with the CLI invocation guarded behind an import.meta.url comparison so Vitest can import without process.exit firing. Pattern reusable for Phase 8 visual-regression scripts.
created modified removed
src/sim/memory/pool.ts (filterPool — pure Season + plant-type tonal-register + no-dup gating)
src/sim/memory/selector.ts (selectFragment + EXHAUSTION_FALLBACK_ID + mulberry32 PRNG)
src/sim/memory/selector.test.ts (16 tests — gating / no-dup / determinism / sentinel fallback / sentinel pool exclusion / season exclusion)
src/sim/memory/index.ts (barrel)
src/ui/journal/Journal.tsx (D-24 full-screen Memory Journal modal)
src/ui/journal/Journal.test.tsx (7 tests — empty state / fragment body render / userSelect
text / Season grouping / close callback / aria-label / unresolvable id silent skip)
src/ui/journal/FragmentRevealModal.tsx (D-25 active-play reveal modal)
src/ui/journal/FragmentRevealModal.test.tsx (6 tests — null when revealId is null / body rendered / backdrop dismiss / article-body stopPropagation / inner Close dismiss / unresolvable id silent dismiss)
src/ui/journal/journal-icon.tsx (D-23 reveal-after-first-harvest gate + corner affordance)
src/ui/journal/journal-icon.test.tsx (3 tests — null pre-first-harvest / icon renders post-first-harvest / click opens journal modal)
src/ui/journal/index.ts (barrel)
content/seasons/01-soil/fragments/lura-first-letter.md (long-form Markdown fragment, warm tonal register)
content/seasons/01-soil/fragments/winter-rose-night.md (long-form Markdown fragment, heavy tonal register)
content/dialogue/season1/compost-acknowledgements.ink (6 short authored compost beat lines; Plan 02-04 wires the runtime)
scripts/check-bundle-split.mjs (PIPE-02 structural verifier with exportable runCheck())
scripts/check-bundle-split.test.mjs (3 Vitest cases — exists / parses-without-exit / structured result)
src/content/schemas/fragment.ts (added optional `tags` field; back-compat preserved)
src/sim/garden/commands.ts (harvest + compost branches; SimContext interface; PLANT_UNLOCK_THRESHOLDS table; Pitfall 10 mitigation; selectFragment integration; BLOCKER 3 invariant preserved)
src/sim/garden/commands.test.ts (added 18 new cases — harvest / compost / Pitfall 10 boundaries / sentinel fallback / immutability + simulateOneTick integration; updated the previously-stubbed "harvest/compost ignored" case)
src/sim/garden/index.ts (export harvest/compost/SimContext)
src/sim/index.ts (re-export ./memory)
content/seasons/01-soil/fragments.yaml (replaced single placeholder with 14 authored fragments + sentinel; bible voice maintained throughout)
src/ui/index.ts (re-export ./journal)
src/App.tsx (mount FragmentRevealModal + JournalIcon)
src/game/scenes/Garden.ts (build SimContext at create() from eager `fragments`; handleTilePointerDown branches harvest/compost on stage; update() detects new harvest and triggers D-25 reveal flow)
package.json (new check:bundle-split script; ci chain extended)
Pool exhaustion behavior chosen: sentinel fallback (EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'). The alternative — repeat-most-recent — was rejected because (a) it makes the fragment ID corpus mutable in spirit (a fragment id can re-appear in harvestedFragmentIds, breaking the no-dup invariant downstream consumers expect) and (b) the bible voice naturally accommodates a single sentinel about steady-part-that-doesn't-need-re-learning. The authored warm pool ≥9 satisfies the worst-case all-rosemary 8th-harvest Lura threshold so the sentinel is structurally unreachable in normal Phase-2 play.
Plant-type unlock thresholds finalized within Plan author's discretion (CONTEXT D-05): rosemary @ 0, yarrow @ 3, winter-rose @ 6. The 3/6 spacing matches the 1/4/8 Lura beat cadence (D-14) — the player feels yarrow unlock right around the time Lura's mid-beat fires (4th harvest), and winter-rose unlock arrives shortly before the farewell beat (8th harvest). Adjustable in playtest by ±1 — the model (tied to harvest count) is locked.
Garden scene loads fragments via the EAGER `fragments` export filtered to Season 1 — NOT via `await loadSeasonFragments(1)`. Trade-off documented: Phase 2 has only Season 1, so the eager path is simpler and avoids an async-init dance in Phaser create(). The lazy plumbing is structurally proven by check-bundle-split.mjs; Phase 4+ should swap to lazy when Season transitions land. INEFFECTIVE_DYNAMIC_IMPORT warnings in `npm run build` are inherited from Plan 02-02 and will resolve naturally when consumers move to lazy-only.
Compost beat — Plan 02-03 ships the AUTHORED CONTENT (compost-acknowledgements.ink, 6 lines in the gardener-keeper voice) but does NOT yet render it. Plan 02-04 owns the inkjs runtime; Garden.ts has a TODO at the wiring point. This split lets the writer iterate on voice independently of the runtime work.
Journal-icon's 'j' hotkey (CONTEXT D-29) is intentionally NOT wired in Plan 02-03 — keyboard-shortcut surface lands with the wider Settings hotkey work in Plan 02-05. The plan's task-2 step-3 sketched a window-CustomEvent indirection; the simpler choice is to defer the keybinding until Plan 02-05 owns the surface holistically.
FragmentRevealModal silent-dismiss on unresolvable id (defensive). The state-update-during-render is bounded — the next render reads fragmentRevealId === null and exits at the guard. React does not warn for this single-step path because the setState transitions to a steady state in O(1) re-renders.
Knuth's multiplicative hash on `(harvestCount * 2654435761 + plantedAtTick) | 0` for the seedHash. Spreads adjacent (count, tick) pairs across the 32-bit seed space so mulberry32 produces visibly-different results on adjacent harvests; the `| 0` truncates to 32-bit signed int (mulberry32 internally re-coerces to unsigned).
Sentinel exclusion from the normal pool is enforced in BOTH the schema-tag check (`if (f.tags.includes('_meta')) return false`) AND by selector.ts NEVER exposing the sentinel via the seeded-pool branch. Dual defense — accidental tag drift on a future fragment can't smuggle the sentinel into normal play.
Deterministic-selector pattern (selectFragment): pure inputs (corpus, season, plant type, harvested ids, seed hash) → Fragment | null with sentinel fallback. Reusable for Phase 5+ memory-vignette selection (place-memory + Loom feeds), Phase 4+ cross-pollination output, anywhere a 'pick one from a gated pool, deterministically' is needed.
Application-layer-injected SimContext: sim modules take pure data; the application layer (Phaser scene) loads the data and threads it through. Plan 02-04 will extend SimContext with `inkStory` for Lura beat firing; Plan 02-05 will extend with `offlineEvents` for the letter-from-the-garden composition.
DOM-overlay-over-canvas pattern (Plan 02-02 establishment continues): Journal + FragmentRevealModal + JournalIcon are React DOM siblings of PhaserGame. MEMR-05 selectable text demands DOM, not canvas. The pattern repeats for Plan 02-04 Lura dialogue and Plan 02-05 Letter overlay.
FragmentSchema optional-field extension: Phase 2 added `tags?` without bumping schemaVersion. The same path is open for Phase 4+ (e.g., `unlocks?: string[]` for cross-pollination). Migration only required when an existing field's shape changes, never for additive optional fields.
PIPE-02 structural verifier as Vitest-importable Node ESM: `runCheck()` exported, CLI gated by import.meta.url. Pattern reusable for Phase 4 Season-2 onboarding (extend the script's known-content list) and Phase 8 visual-regression baselines (different filename heuristics, same export shape).
Journal-icon owns local open state (not the store): UI affordances that don't need to persist across sessions live in component state. V1Payload stays clean — only canonical game state crosses the save boundary.
GARD-03
GARD-04
MEMR-01
MEMR-02
MEMR-03
MEMR-04
MEMR-05
MEMR-06
PIPE-02
UX-01
12min 2026-05-09

Phase 2 Plan 03: Harvest, Memory Journal & Fragments Vertical Slice Summary

One-liner

The second half of the Season-1 active-play loop — sim/memory module with deterministic mulberry32-seeded selector + sentinel fallback for the gated-pool exhaustion case (Pitfall 8); harvest + compost pure commands extending sim/garden with Pitfall 10 mitigation (yarrow @ 3 / winter-rose @ 6 unlocks computed AFTER harvest commit); 17 authored Season-1 fragments under /content/seasons/01-soil/ in the bible voice (9 warm / 3 contemplative / 2 heavy / 1 _meta sentinel + 2 long-form Markdown); DOM-rendered Memory Journal + active-play FragmentRevealModal + first-harvest-gated JournalIcon all selectable + copy-pasteable per MEMR-05; Garden scene wiring harvest/compost pointer events through to the D-25 reveal flow; PIPE-02 structural verifier (scripts/check-bundle-split.mjs) as a Vitest-importable Node ESM module integrated into npm run ci.

Performance

  • Duration: ~12 min (sequential executor; lighter than 02-02's 18min — Plan 02-03's surface is sim/memory + journal UI tier without a new render layer; the architectural firewall edges shipped in 02-02 carry over directly)
  • Started: 2026-05-09T13:55:00Z (approximate; orchestrator-recorded plan-start time)
  • Completed: 2026-05-09T14:08:00Z
  • Tasks: 3 (atomic per plan)
  • Files created: 14
  • Files modified: 9

Task Commits

Each task was committed atomically:

  1. Task 1: Season-1 fragments + sim/memory selector + harvest/compost commandsf192e82 (feat)
  2. Task 2: Journal + reveal modal + harvest pointer wiring572c861 (feat)
  3. Task 3: scripts/check-bundle-split.mjs (PIPE-02 structural verification)39bfcd2 (chore)

Plan metadata: (this commit)docs(02-03): complete harvest-journal-fragments plan

Accomplishments

  • Active-play loop closed end-to-end on real authored content. A player can plant a seed (Plan 02-02) → watch it grow → click a ready plant → harvest fires through the sim, picks one fragment deterministically from the gated pool → reveal modal pops with the fragment's full text → close → fragment files into the Memory Journal under Season 1 → journal icon (invisible until first harvest) appears in the corner → click opens the full-screen modal listing all collected fragments grouped by Season.
  • 17 Season-1 fragments authored in voice, satisfying the worst-case-all-rosemary depth at the 8th-harvest Lura threshold (CONTEXT D-14) without reaching the exhaustion sentinel. Bible voice maintained throughout — warm, specific, intermittent, sometimes funny, sometimes devastating; the gardener-keeper voice (NOT Lura — she's the warmth anchor; the contrast lives here).
  • MEMR-06 deterministic selector landed: same inputs ALWAYS yield the same fragment. Pinned by 16 Vitest cases including determinism, Season + plant-type gating, no-dup, sentinel exclusion from the normal pool, and Pitfall 8 exhaustion fallback.
  • Pitfall 10 boundary mechanically pinned: yarrow locked at 2 harvests, unlocked at 3; winter-rose locked at 5, unlocked at 6. Three explicit boundary tests in commands.test.ts.
  • PIPE-02 structurally verified: scripts/check-bundle-split.mjs exits 0 after npm run build; integrated into the CI chain so any future change that breaks the lazy-content plumbing fails the build.
  • No raw Decimal outside src/sim/numbers/. No hardcoded player-visible strings outside /content/. Zero new ESLint sim-purity violations. All sim modules pure (no Date.now / setInterval / DOM / fetch).

Files Created/Modified

Created (14)

  • src/sim/memory/pool.ts — pure filter helper (Season + plant-type tonal-register + no-dup gating)
  • src/sim/memory/selector.ts — deterministic mulberry32-seeded selector with EXHAUSTION_FALLBACK_ID sentinel
  • src/sim/memory/selector.test.ts — 16 cases pinning determinism / gating / no-dup / sentinel fallback / sentinel pool exclusion
  • src/sim/memory/index.ts — barrel
  • src/ui/journal/Journal.tsx — D-24 full-screen modal, fragments grouped by Season, MEMR-05 selectable
  • src/ui/journal/Journal.test.tsx — 7 cases
  • src/ui/journal/FragmentRevealModal.tsx — D-25 active-play reveal modal
  • src/ui/journal/FragmentRevealModal.test.tsx — 6 cases
  • src/ui/journal/journal-icon.tsx — D-23 first-harvest reveal gate + D-29 corner affordance
  • src/ui/journal/journal-icon.test.tsx — 3 cases
  • src/ui/journal/index.ts — barrel
  • content/seasons/01-soil/fragments/lura-first-letter.md — long-form Markdown fragment, warm
  • content/seasons/01-soil/fragments/winter-rose-night.md — long-form Markdown fragment, heavy
  • content/dialogue/season1/compost-acknowledgements.ink — 6 authored beat lines for Plan 02-04 to wire
  • scripts/check-bundle-split.mjs — PIPE-02 structural verifier with exportable runCheck()
  • scripts/check-bundle-split.test.mjs — 3 Vitest cases proving import-without-exit + result shape

Modified (9)

  • src/content/schemas/fragment.ts — added optional tags field for tonal-register gating
  • src/sim/garden/commands.ts — harvest + compost branches; SimContext interface; PLANT_UNLOCK_THRESHOLDS; Pitfall 10 mitigation; selectFragment integration
  • src/sim/garden/commands.test.ts — +18 new cases (harvest / compost / Pitfall 10 / sentinel fallback / immutability)
  • src/sim/garden/index.ts — export harvest/compost/SimContext
  • src/sim/index.ts — re-export ./memory
  • content/seasons/01-soil/fragments.yaml — replaced single placeholder with 14 authored fragments + sentinel
  • src/ui/index.ts — re-export ./journal
  • src/App.tsx — mount <FragmentRevealModal /> + <JournalIcon />
  • src/game/scenes/Garden.ts — SimContext at create(); harvest/compost pointer dispatch; reveal-flow detection in update()
  • package.json — new check:bundle-split script; ci chain extended

Per-tag Distribution

Tag Count Notes
warm 9 rosemary pool. Worst-case 8th-harvest depth + 1 buffer
contemplative 3 yarrow pool. Yarrow unlocks @ harvest 3
heavy 2 winter-rose pool. Winter-rose unlocks @ harvest 6
(Markdown warm) 1 lura-first-letter.md
(Markdown heavy) 1 winter-rose-night.md
_meta 1 season1.soil._exhaustion sentinel
Total 17

The yarrow + winter-rose pool sizes (3 + 2 = 5 contemplative-or-heavy entries plus the 2 long-form Markdown carrying tonal weight) reflect that those plants unlock progressively into the playthrough — the player has fewer harvests left to draw from those pools, and an over-deep contemplative pool is wasted. If a playtest shows the contemplative or heavy pool feeling thin, the writer can add more without changing any code (the pool is purely data; the selector consumes whatever's authored).

Plant-type Unlock Thresholds (CONTEXT D-05, finalized)

Plant Unlocks at harvest # Notes
rosemary 0 (start) Available from first plant. Warm pool.
yarrow 3 Spaced before Lura's mid-beat (4th harvest, D-14) so the player feels the unlock just before the conversation.
winter-rose 6 Spaced before Lura's farewell beat (8th harvest, D-14) so the heavy plant arrives in tonal alignment with the arc's turn.

These are tunable in playtest within ±1; the model (harvest-count thresholds, not wall-time gates) is locked per the STRY-10 contract.

Pool Exhaustion Behavior (RESEARCH Pitfall 8)

Chosen behavior: sentinel fallback. When filterPool() returns an empty array, selectFragment() looks up the fragment with id season1.soil._exhaustion (authored in fragments.yaml, tagged ['_meta']) and returns it. If even the sentinel is missing (degenerate test fixture), the selector returns null and harvest() returns the original state reference unchanged (the player's tap was a no-op — the safest possible behavior since refusing to harvest preserves the ready plant).

Documented in:

  • src/sim/memory/selector.ts (docblock).
  • content/seasons/01-soil/fragments.yaml (the sentinel entry's comment block).
  • src/sim/memory/selector.test.ts covers (a) sentinel-returned-when-pool-empty, (b) null-returned-when-sentinel-missing, (c) sentinel-NEVER-returned-via-normal-pool.

Why sentinel over repeat-most-recent: the no-dup invariant on harvestedFragmentIds is load-bearing for downstream consumers (Journal de-dup, Plan 02-05's letter slot vocabulary, Plan 02-04's Lura beat counters that depend on count). A repeat-most-recent path would silently re-grow harvestedFragmentIds past the corpus size, polluting these consumers. The sentinel fragment is a real id appended exactly once on first exhaustion (and never again — it itself is in the no-dup set after).

scripts/check-bundle-split.mjs Heuristic — first-try assessment

First-try result: the structural assertion passes via chunkContentMatch=true. Phase 2 is currently in eager-corpus mode (the fragments export inlines all Season-1 yaml + Markdown into the main bundle as ?raw strings), so the chunk content match fires on the source-path /content/seasons/01-soil/ and on the literal fragment id season1.soil.first-bloom.

chunkNameMatch=false is the expected state for Phase 2 — Vite does not emit a separate Season-1 chunk while the eager path keeps the same source modules in the main bundle (build emits [INEFFECTIVE_DYNAMIC_IMPORT] warnings noting this). When Plan 02-04+ switches consumers to lazy-only, chunkNameMatch will start firing and the warnings will resolve.

No tuning was needed. The OR-of-three structural checks gives the verifier room to evolve as Phase 4+ Season-2 onboarding lands without forcing the heuristic to be tight on Day 1.

Garden Scene Fragment-Loading Approach

Chosen: eager fragments export filtered to Season 1, captured at Garden.create() time, threaded through every simulateOneTick call via SimContext.

Trade-off vs. await loadSeasonFragments(1): the eager path is simpler — Phaser's create() is synchronous, so an await would require an async init dance (set an empty corpus, load, swap; or use init() + Promise + create() chaining). For Phase 2's Season-1-only scope, the eager path is the minimum-viable choice.

The PIPE-02 lazy structural plumbing is independently verified by check-bundle-split.mjs, so Phase 4+ Season-2 onboarding can swap to await loadSeasonFragments(currentSeason) (probably in init()) without re-litigating the architecture. Documented at src/game/scenes/Garden.ts:55 (the SimContext docblock).

Manual Smoke Test

Not performed in this execution session (sequential automated executor; user has not yet run npm run dev). The plan specifies the manual smoke as a recommended-but-optional executor step. Structural verification is comprehensive:

  • 217/217 Vitest cases green (was 163 before this plan; +54 new — 16 selector, 18 commands extension, 7 Journal, 6 FragmentRevealModal, 3 journal-icon, 3 check-bundle-split, 1 commands rewording).
  • npm run lint exits 0 (zero ESLint sim-purity violations; Pitfall 1 still mechanically defended).
  • npm run build exits 0 (Vite parses all 17 fragments — schema violation would fail the build per PIPE-01).
  • npm run ci exits 0 end-to-end with check:bundle-split integrated.
  • Plan 02-05's Playwright e2e (PIPE-07) will exercise the full Begin → Plant → Grow → Harvest → reveal-modal → journal loop visually under a real browser.

Decisions Made

See key-decisions in frontmatter (8 entries). Headlines:

  1. Pool exhaustion: sentinel fallback (season1.soil._exhaustion), not repeat-most-recent — preserves the no-dup invariant on harvestedFragmentIds.
  2. Plant-type unlock thresholds: rosemary @ 0 / yarrow @ 3 / winter-rose @ 6 (Plan author's discretion within D-05; aligned with Lura beat cadence at 1/4/8).
  3. Garden scene uses the EAGER fragments corpus filtered to Season 1, not loadSeasonFragments(1) await. Simpler synchronous create(); PIPE-02 lazy plumbing is structurally verified for Phase 4+ to exploit.
  4. Compost beat content shipped (compost-acknowledgements.ink, 6 lines) but NOT yet rendered — Plan 02-04 owns the Ink runtime; Garden.ts has a TODO at the wiring point.
  5. Journal-icon 'j' hotkey deferred to Plan 02-05 (Settings hotkey work).
  6. FragmentRevealModal silent-dismiss on unresolvable id (defensive, single-step setState transition).
  7. Knuth multiplicative hash for the seedHash spreads adjacent (count, tick) pairs across the 32-bit seed space.
  8. Sentinel exclusion is dual-defended (schema-tag check + selector branch).

Deviations from Plan

None — the plan executed almost exactly as written. Two minor tightenings applied during authoring:

Tightenings (not deviations — within plan author's discretion)

  1. Authored 17 fragments instead of the plan's "≥17 (≥14 yaml + ≥2 md + 1 sentinel)" target. Plan W6 fix called for ≥9 warm; shipped exactly 9 yaml-warm (plus the 1 lura-first-letter.md warm = 10 warm total when counting Markdown). Heavy pool sized to 2 yaml + 1 md = 3 (matches the conservative-but-deep ratio for late-game unlocks). All targets met or exceeded.
  2. Added a journal-icon.test.tsx file (3 cases) the plan didn't explicitly request. The plan's task-2 acceptance criteria called for selectJournalRevealed to be referenced in the icon (verified by grep) but did not mandate Vitest coverage of the icon component. Adding 3 cases for ~20 LoC was cheap insurance and tightens the D-23 pre-first-harvest invisibility guarantee.

Neither tightening expanded scope or altered any architectural decision; both stayed within the plan's "Claude's discretion within reason" envelope.

Issues Encountered

None — the plan was unusually well-specified and the implementation matched it almost line-for-line. The only friction point was a transient: the original plan-text seedHash formula (harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick) sums two integers but does not coerce the result to 32-bit, which means very large pre-existing harvest counts would push the seed past Number.MAX_SAFE_INTEGER long-term. Added | 0 (32-bit signed-integer truncation) on the result; mulberry32 internally re-coerces to unsigned via >>> 0, so the final RNG output is unaffected. Documented in the harvest() docblock.

TDD Gate Compliance

This plan is type: execute, not type: tdd. No RED → GREEN → REFACTOR commit-sequence gating applies. Tests landed alongside implementation in Tasks 13.

User Setup Required

None — no external service configuration required. All work is in-tree TypeScript / authored content / a single Node ESM verification script.

Next Phase Readiness

  • Plan 02-04 (Lura's Ink dialogue + gate beats): can build directly on top. Lura's Ink runtime swaps in for the compost-acknowledgements TODO at src/game/scenes/Garden.ts and for Lura's beat-fire surface (1st / 4th / 8th harvest gated by harvestedFragmentIds.length). The required unlockedPlantTypes and harvestedFragmentIds writes are now flowing through the store correctly.
  • Plan 02-05 (offline catchup + letter + Settings + Playwright e2e): can build directly on top. The harvest pipeline produces real harvestedFragmentIds entries that Plan 02-05's offline auto-harvest path can append to; the Memory Journal already renders any id the offline path adds (verified by Journal.test.tsx — adding ids to the store re-renders the modal under Season 1).

No blockers, no IOUs, no carried-over technical debt this plan produced. The eager fragments corpus + Plan 02-02's INEFFECTIVE_DYNAMIC_IMPORT warnings remain — both inherited from Plan 02-02 with the same documented Plan 02-04+ resolution path.

Self-Check: PASSED

Verification before this section was added:

  • src/sim/memory/pool.ts: FOUND
  • src/sim/memory/selector.ts: FOUND
  • src/sim/memory/selector.test.ts: FOUND
  • src/sim/memory/index.ts: FOUND
  • src/ui/journal/Journal.tsx: FOUND
  • src/ui/journal/Journal.test.tsx: FOUND
  • src/ui/journal/FragmentRevealModal.tsx: FOUND
  • src/ui/journal/FragmentRevealModal.test.tsx: FOUND
  • src/ui/journal/journal-icon.tsx: FOUND
  • src/ui/journal/journal-icon.test.tsx: FOUND
  • src/ui/journal/index.ts: FOUND
  • content/seasons/01-soil/fragments/lura-first-letter.md: FOUND
  • content/seasons/01-soil/fragments/winter-rose-night.md: FOUND
  • content/dialogue/season1/compost-acknowledgements.ink: FOUND
  • scripts/check-bundle-split.mjs: FOUND
  • scripts/check-bundle-split.test.mjs: FOUND
  • src/sim/garden/commands.ts (modified): FOUND
  • src/sim/garden/commands.test.ts (modified): FOUND
  • src/sim/garden/index.ts (modified): FOUND
  • src/sim/index.ts (modified): FOUND
  • src/content/schemas/fragment.ts (modified): FOUND
  • content/seasons/01-soil/fragments.yaml (modified): FOUND
  • src/ui/index.ts (modified): FOUND
  • src/App.tsx (modified): FOUND
  • src/game/scenes/Garden.ts (modified): FOUND
  • package.json (modified): FOUND
  • Commit f192e82 (Task 1): FOUND in git log --oneline -5
  • Commit 572c861 (Task 2): FOUND in git log --oneline -5
  • Commit 39bfcd2 (Task 3): FOUND in git log --oneline -5
  • npm run ci exits 0: VERIFIED
  • 217/217 tests pass: VERIFIED
  • node scripts/check-bundle-split.mjs exits 0 after build: VERIFIED
  • ESLint sim-purity rule: zero violations (lint exits 0)
  • Build: npm run build exits 0; all 17 fragments parse without schema violation