Commit Graph

75 Commits

Author SHA1 Message Date
josh 31f8ede9ac feat(02-05): wire compost beat toast (Plan 02-04 deferral)
- src/ui/settings/compost-toast.tsx: thin transient toast (D-07,
  GARD-04). Reads a rotating line from uiStrings[1].post_harvest_beat
  on each compost dispatch; fades out after 3.5s. Co-located with
  PersistenceToast as the plan specified ('folded into the persistence-
  toast UI surface').
- src/store/session-slice.ts: compostBeatTick monotonic counter +
  bumpCompostBeat action. Counter (vs boolean) ensures consecutive
  composts re-fire the toast without dedup.
- src/game/scenes/Garden.ts: handleTilePointerDown's compost branch
  calls bumpCompostBeat after enqueueCommand.
- src/App.tsx: mounts <CompostToast />.
- 4 new compost-toast tests; 312/312 vitest green; e2e still 1.6s
  green; npm run ci exits 0.

Implementation choice (per plan 'surfaced in SUMMARY'): minimum-viable
toast surface chosen over the Ink runtime path. The Ink-authored
compost-acknowledgements.ink content remains compiled + runtime-
loadable via loadInkStory + InkRenderer, so a future plan can swap
this component for the richer voice without touching sim or store.
2026-05-09 11:07:43 -04:00
josh dd486969a9 test(02-05): playwright e2e for PIPE-07 — full Phase-2 loop
- tests/e2e/season1-loop.spec.ts: PIPE-07 smoke covering load → Begin
  → plant rosemary → fast-forward FakeClock 3min → harvest →
  fragment-reveal modal → close → journal-icon visible → open journal
  → fragment present → reload page → fragment persists. Sidesteps
  Phaser canvas pixel-clicking via window.__tlgStore command dispatch
  (test-only window slot, production-guarded by import.meta.env.PROD).
- playwright.config.ts: bumped webServer timeout 30s → 60s for cold
  Vite startup; pinned port 5273 + --strictPort to avoid collisions
  with other dev servers on the user's machine; reuseExistingServer
  false so the spec always starts a fresh Vite against this project.
- package.json: added test:e2e script (npx playwright test). Not
  added to npm run ci — Playwright is slower than Vitest; manual run
  before /gsd-verify-work + future v1 release pipeline.
- src/content/loader.ts (Rule 3 — Blocking): replaced gray-matter
  with a 15-line parseFrontmatter helper. gray-matter pulls in Node's
  Buffer global which is undefined in Vite's browser bundle; the
  build emits a 'Module "buffer" externalized' warning that masks
  the runtime ReferenceError. Surfaced under Vite dev mode while
  running the e2e — Plan 02-03's Markdown loader path (lura-first-
  letter.md + winter-rose-night.md) was effectively broken in real
  browsers since shipping. parseFrontmatter handles the strict
  '---<yaml>---<body>' shape the .md fragments use; bundle dropped
  from 2.2MB to 1.9MB as a side effect of dropping the unused dep.
- deferred-items.md: tracks the gray-matter package.json cleanup
  (the dep is now unused but kept in package.json for now, scoped
  out of this plan).
- npx playwright test exits 0 (1 spec, 1.5s test runtime); npm run
  ci exits 0; 308/308 vitest still green. PIPE-07 satisfied
  end-to-end.
2026-05-09 11:04:32 -04:00
josh 5d58d6cc7b feat(02-05): letter overlay + settings UI + boot save lifecycle + clock injection
- src/save/payload.ts (W2): shared buildPayloadFromStore (state, nowMs)
  + hydrateStoreFromPayload (state, payload). Two-arg signature unifies
  Settings.tsx (passes Date.now()) and PhaserGame.tsx saveSync (passes
  clock.now()). BLOCKER 3 — lastTickAt is wall-clock ms, owned by the
  application layer; the sim never writes it.
- src/ui/letter/Letter.tsx + test: D-20 full-screen overlay (UX-02);
  loads compiled letter Ink, binds plants_bloomed/fragment_titles/
  lura_was_here slots from session.pendingLetterEventBlock, dismisses
  via Tend the garden button or backdrop click. Pitfall 9 — dismiss
  calls bootstrapAudioContext synchronously inside the click handler.
- src/ui/settings/Settings.tsx + test: D-28 save-management UI
  (Export/Import/Restore). BLOCKER 2 — Import pipeline is
  importFromBase64 -> unwrap (CRC verify) -> migrate -> hydrate. No
  audio sliders, no a11y polish (Phase 8 owns those).
- src/ui/settings/persistence-toast.tsx: D-30 one-time soft toast in
  voice when navigator.storage.persist() denies. Reads
  showPersistenceToast from session slice; sets persistenceToastShown
  after the timeout fires.
- src/PhaserGame.tsx: full boot path rewrite. Clock selection
  (?devtime=fake URL flag, production-guarded by import.meta.env.PROD),
  save load (BLOCKER 1 — unwrap then migrate), silent offline catchup
  via drainTicks(silent=true), letter overlay open at >=5min absence,
  requestPersistence + showPersistenceToast wiring, Phaser start AFTER
  hydration, registerSaveLifecycleHooks with synchronous LocalStorage
  saveSync (Pitfall 7) + best-effort IDB write. W5 — lifecycle handle
  held in ref so outer cleanup detaches.
- src/store/session-slice.ts: showPersistenceToast transient flag +
  setShowPersistenceToast action.
- src/ui/journal/journal-icon.tsx: 'j' hotkey listener via
  tlg:toggle-journal CustomEvent (D-29).
- src/game/scenes/Garden.ts: formalized clock read via readClockSlot()
  helper; falls back to wallClock if window.__tlgClock missing.
- src/App.tsx: mount Letter, Settings, PersistenceToast, SettingsIcon
  (corner button); D-29 keyboard shortcuts (',' toggles Settings, 'j'
  toggles Journal via window event).
- 308/308 tests green (was 295; +13 new — 7 Letter + 6 Settings).
  npm run ci exits 0; Vite emits letter Ink as a separate lazy chunk.
2026-05-09 10:57:09 -04:00
josh 26eb77a216 feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer
- src/sim/offline/: OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS
  + aggregateOfflineEvent pure aggregator (D-19); 14 tests green
- src/sim/garden/auto-harvest.ts: autoHarvestReadyPlants silent-mode
  branch (D-10); reuses harvest() pipeline so selector + Pitfall 10
  unlocks + STRY-10 Lura gate all run identically; BLOCKER 3 invariant
  preserved (no lastTickAt writes); 7 tests green
- simulateOneTick: ctx.silent triggers auto-harvest sweep before tick
  increment; active-play path unchanged (silent defaults false)
- content/dialogue/season1/letter-from-the-garden.ink: authored skeleton
  with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18;
  bible voice, anti-FOMO compliant, 24h cap silent in voice (D-11)
- ink-loader: loadInkStory union extended with letter-from-the-garden;
  separate letterStoryGlob for lazy code-split chunk; INK_VARIABLE_MAP
  gains plants_bloomed / fragment_titles / lura_was_here slots reading
  from session.pendingLetterEventBlock
- src/ui/letter/letter-renderer.ts: pure buildLetterSlots helper —
  prefers fragment first-sentence body for tonal weight, slugified-id
  fallback; 10 tests green
- npm run compile:ink emits 5 .ink.json files (was 4); Vite emits the
  letter as a separate lazy chunk (letter-from-the-garden.ink-*.js)
- 295/295 tests green (was 264; +31 new); npm run ci exits 0
2026-05-09 10:49:59 -04:00
josh de3f55b1c4 docs(02-04): complete lura-gate-beats plan
- 02-04-lura-gate-beats-SUMMARY.md created. Documents:
  - 3 Lura Ink beats authored in bible voice (warmth anchor, contrast not co-griever)
  - Build-time inklecate compile pipeline with bundled binary (BLOCKER 4 mitigated)
  - RESEARCH Assumption A6 verified first-try on Windows
  - 47 new Vitest cases (264/264 total green); npm run ci exits 0
  - sim/narrative gating pure-state; STRY-10 mechanically defended
  - sim/* contains zero inkjs imports; ESLint sim-purity rule still green
  - 4 lazy code-split chunks emitted for compiled Ink JSON
  - Compost-toast UI deferred to Plan 02-05 (folded into persistence-toast surface)
  - 5 auto-fix deviations documented (Rule 1 + Rule 3); 2 tightenings; 0 architectural changes
- STATE.md updated: progress 18% → 19%; Phase 2 plans 3/5 → 4/5; 217 → 264 tests;
  per-phase metrics updated (Phase 2 4/5 plans, ~66min, ~16min/plan).
- ROADMAP.md: Plan 02-04 marked complete with duration; progress table updated.
- REQUIREMENTS.md: STRY-01 / STRY-06 / STRY-07 / STRY-10 marked complete with
  full traceability annotations.

Plan 02-05 (offline catchup + letter + Settings + Playwright e2e) is the only
remaining Phase-2 work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:41:24 -04:00
josh 661f990e9a feat(02-04): Lura dialogue overlay + Ink runtime + gate visual + Garden scene wiring
- src/ui/dialogue/ink-runtime.ts: thin wrapper around inkjs Story —
  nextLine() with text-message cadence (1500ms base + 20ms/char, capped
  at 4000ms), skipDelay() for tap-to-advance, choice surface forwarded
  to ChooseChoiceIndex. Constants exported for Plan 02-05's UX-05
  reduced-motion hook + playtest tuning.
- src/ui/dialogue/ink-runtime.test.ts: 7 cases pinning the cadence
  bounds, skipDelay one-shot semantics, choice forwarding (uses
  vi.useFakeTimers() to validate timing without wall-clock waits).
- src/ui/dialogue/ink-renderer.tsx: drips lines into the DOM as the
  runtime yields them; userSelect: 'text' for MEMR-05 copy-paste;
  click-anywhere skip; choice buttons stop event propagation.
- src/ui/dialogue/LuraDialogue.tsx: D-15 — full-screen DOM overlay
  driven by dialogueOverlayOpen + luraBeatProgress.pending. Loads the
  compiled Ink JSON via loadInkStory, binds variables from store
  snapshot, ChoosePathString into the named knot ('arrival'/'mid'/
  'farewell'), then runs InkRenderer. Close button calls
  resolvePendingLuraBeat to mark visited and clear pending.
- src/ui/dialogue/LuraDialogue.test.tsx: 6 cases — closed-state null,
  dialog renders on open+pending, Close fires resolvePendingLuraBeat
  for all three beats, loadInkStory called with correct beat name +
  knot. Mocks the loadInkStory + ink-runtime layer to keep happy-dom
  out of inkjs internals (Plan 02-05 e2e exercises the live path).
- src/render/garden/gate-renderer.ts: drawGate() + updateGateIndicator()
  — Phaser primitive gate (body / glow / hit) at canvas (880, 384).
  Glow alpha-pulses via Sine-yoyo tween when isPending=true; idempotent.
- src/game/scenes/Garden.ts: gate added in create(); pointerdown
  dispatches setDialogueOverlayOpen(true) only when a beat is pending.
  storeUnsubscribe also drives updateGateIndicator on luraBeatProgress
  changes. update() loop now calls simAdapter.applyLuraProgress when
  the sim's luraBeatProgress differs from the store's so harvests
  trigger the gate indicator. destroy() cleans up the gate's tween.
- src/App.tsx: <LuraDialogue /> mounted as DOM sibling of PhaserGame.
- src/ui/index.ts + src/render/garden/index.ts: re-exports.

13 new tests across dialogue layer; 264/264 total green; npm run ci
exits 0; Vite emits 4 lazy ink-*.js chunks (compiled JSON code-split
per file); ESLint sim-purity rule still green (sim/narrative imports
no inkjs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:33:22 -04:00
josh 7b79d11584 feat(02-04): sim/narrative — Lura beat gating (1/4/8 harvest, STRY-10)
- src/sim/narrative/beat-queue.ts: LuraBeatId / LuraBeatProgress contracts
  matching V1Payload.luraBeatProgress + NarrativeSlice; INITIAL frozen.
- src/sim/narrative/lura-gate.ts: LURA_BEAT_THRESHOLDS = {1: arrival,
  4: mid, 8: farewell}; advanceLuraBeatProgress / resolvePendingLuraBeat /
  isLuraBeatPending — pure, no inkjs import, no Date.now (sim-purity rule
  green). The gate counts harvest events, never wall-clock time, so STRY-10
  holds.
- src/sim/narrative/lura-gate.test.ts: 17 cases including the load-bearing
  STRY-10 case (24 hours of FakeClock advance with 0 harvests leaves all
  flags + pending false). Pitfall 10 boundaries pinned at 3/4/5 and 7/8/9.
  pending-set-already + already-visited carry-throughs covered.
- src/sim/garden/commands.ts: harvest() now calls advanceLuraBeatProgress
  AFTER the harvest commit (Pitfall 10 — same-tick boundary). The new
  luraBeatProgress field flows through the returned SimState and into the
  store via the existing Garden.update() path.
- src/sim/garden/commands.test.ts: +5 cases pinning the harvest → beat
  gate edges (1st→arrival, 4th→mid, 8th→farewell, between-threshold
  no-fire, pending preservation when player hasn't visited).
- src/sim/index.ts: re-export ./narrative.

67/67 sim tests green; npm run lint + build exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:27:06 -04:00
josh c90f8f1e5c feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader
- scripts/compile-ink.mjs: build-time inklecate runner using bundled binary
  (BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings).
  Assumption A6 verified first-try on Windows; the same binary path resolution
  works on macOS + Linux per the wrapper's own getInklecatePath convention.
- scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs +
  emits valid JSON with inkVersion. wipe=false for the test path so it can
  run in parallel with the ink-loader test without racing on the wipe step.
- 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper
  for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink,
  compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into
  VAR-driven branch shape consumable by the runtime).
- src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk +
  INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8
  BOM stripped before Story instantiation.
- src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4
  beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent
  skip for stories missing declared vars.
- package.json: build now runs compile:ink first; ci chain runs compile:ink
  before test so ink-loader.test.ts's precondition check passes.
- .gitignore: src/content/compiled-ink/ excluded (regenerated on every build).

npm run ci exits 0; 11 new tests green (228 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:24:40 -04:00
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
josh 39bfcd2032 chore(02-03): scripts/check-bundle-split.mjs (PIPE-02 structural verification)
Task 3 of Plan 02-03: ship the PIPE-02 structural assertion that Season-1
content reaches the build output. Three structural checks (any one
sufficient): chunk filename slug match (fragments / season1 / 01-soil),
chunk-contents reference to /content/seasons/01-soil/ source path or to
known fragment ids, and a future-extension hook for index.html manifest
inspection.

Phase 2 ships eager-corpus loading alongside the lazy
loadSeasonFragments surface, so currently chunkContentMatch=true via
inlined ?raw content. When Plan 02-04+ switches consumers to lazy-only
and Vite emits a separate Season-1 chunk, chunkNameMatch will also
start passing — at which point either path satisfies the assertion. The
plumbing is structurally proven now; the chunk-naming side is documented
as the path of least resistance for the Phase-4 Season-2 onboarding.

scripts/check-bundle-split.mjs:
- Refactored body into export function runCheck() returning a structured
  result; the CLI invocation guard wraps process.exit so Vitest can
  import the module without termination (verified by the test file).

scripts/check-bundle-split.test.mjs:
- 3 cases: file exists, parses + imports without process.exit firing,
  runCheck() returns the documented {ok, message, chunkNameMatch,
  chunkContentMatch, files} shape. The on-disk dist/-required happy path
  fires via the package.json scripts.ci chain (`npm run build &&
  npm run check:bundle-split`).

package.json:
- New `check:bundle-split` script.
- `ci` chain extended: lint → test → validate:assets → build →
  check:bundle-split. dist/ is populated by build before the bundle-split
  assertion runs.

`npm run ci` exits 0 end-to-end. 217/217 tests green (was 214; +3 new
this task). The PIPE-02 verification step now refuses any future change
that breaks the lazy-content plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:07:36 -04:00
josh 572c86192f feat(02-03): journal + reveal modal + harvest pointer wiring
Task 2 of Plan 02-03: ship the Memory Journal UI surfaces (D-23/D-24/D-25)
and wire harvest + compost pointer events through the Garden scene to the
sim → store → React reveal flow.

src/ui/journal/ (new module):
- Journal.tsx — full-screen modal (D-24); fragments grouped by Season;
  DOM-rendered text with userSelect: text per MEMR-05; reads
  harvestedFragmentIds from the store; resolves ids against the eager
  `fragments` corpus (defensive — unresolvable ids skip silently).
- FragmentRevealModal.tsx — D-25 active-play reveal modal; backdrop click
  + inner Close button dismiss; event.stopPropagation on the article
  body so clicking inside the text doesn't dismiss; defensive silent
  dismiss on unresolvable id.
- journal-icon.tsx — D-23 + D-29 corner affordance; gated by
  selectJournalRevealed (`harvestedFragmentIds.length > 0`); local open
  state (no store pollution); 'j' hotkey deferred to Plan 02-05.
- index.ts — barrel.
- 16 new Vitest cases across 3 test files (Journal: 7 / FragmentRevealModal:
  6 / journal-icon: 3); all green.

src/App.tsx — mount FragmentRevealModal + JournalIcon as DOM siblings of
PhaserGame.

src/ui/index.ts — re-export ./journal.

src/game/scenes/Garden.ts — harvest/compost pointer flow:
- create() builds a SimContext from the eager `fragments` corpus filtered
  to Season 1; passed to every simulateOneTick call (Phase 4+ should swap
  to await loadSeasonFragments(currentSeason) when Season transitions land).
- handleTilePointerDown branches on tile state: empty → seed picker
  event; ready plant → enqueue 'harvest' command; immature plant → enqueue
  'compost' command (TODO Plan 02-04: render the Ink-authored compost
  acknowledgement beat from compost-acknowledgements.ink).
- update() detects newly-appended harvestedFragmentIds and sets
  fragmentRevealId so the reveal modal pops with the new fragment text.
- BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt.

content/dialogue/season1/compost-acknowledgements.ink — authored content
for the GARD-04 + D-07 compost beat. 6 short lines in the gardener-keeper
voice (NOT Lura — she's the warmth anchor; the garden's voice is the
contrast). Plan 02-04 wires the inkjs runtime; Plan 02-03 ships the
content so the writer can iterate independently.

214/214 tests green (was 163; +51 new this plan); npm run lint exits 0;
npm run ci exits 0; npm run build exits 0 with the expected
INEFFECTIVE_DYNAMIC_IMPORT warnings (eager `fragments` export still
imports the Season-1 yaml/md statically alongside the lazy
loadSeasonFragments path — documented in 02-02-SUMMARY.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:05:45 -04:00
josh f192e8298c feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands
Task 1 of Plan 02-03: ship Season-1 authored content + the deterministic
fragment selector + extend sim/garden/commands.ts with harvest + compost.

Content (≥17 Season-1 fragments under /content/seasons/01-soil/):
- 14 in fragments.yaml (9 warm / 3 contemplative / 2 heavy + 1 _meta sentinel)
- 2 long-form Markdown fragments (lura-first-letter.md, winter-rose-night.md)
- Pool depth (W6): warm pool ≥9 satisfies the worst-case all-rosemary
  playthrough at the 8th-harvest Lura threshold (CONTEXT D-14)
- All ids match /^season1\.[a-z0-9._-]+$/ (FragmentSchema regex; CLAUDE.md
  stable-string-ID rule); bible voice maintained throughout

FragmentSchema extension (back-compat — tags is optional):
- Optional `tags: z.array(z.string()).optional()` for tonal-register gating
- Reserved tag `_meta` excludes the exhaustion sentinel from the normal pool

src/sim/memory/ (new module):
- pool.ts — filterPool() pure helper (Season + tonal-register + no-dup gates)
- selector.ts — selectFragment() deterministic + mulberry32 PRNG +
  EXHAUSTION_FALLBACK_ID for Pitfall 8 fallback
- selector.test.ts — 16 tests covering gating / no-dup / determinism /
  sentinel-fallback / sentinel-exclusion-from-normal-pool
- index.ts — barrel; src/sim/index.ts re-exports

src/sim/garden/commands.ts (extended):
- harvest() pure command — empties tile, appends one fragment id,
  re-computes unlockedPlantTypes (Pitfall 10: thresholds checked AFTER
  the harvest commit). Refuses immature plants and OOR indices.
- compost() pure command — empties tile regardless of stage; no fragment
  yield (D-07); no resource refund (D-04 = infinite seeds).
- SimContext interface — application-layer-injected (fragments, currentSeason)
- simulateOneTick() takes optional ctx (default empty pool); harvest/compost
  branches added to the kind switch.
- BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt.

Plant-type unlock thresholds (CONTEXT D-05, plan author's discretion):
- rosemary @ count 0 (start)
- yarrow @ count 3 (third harvest)
- winter-rose @ count 6 (sixth harvest)

commands.test.ts: +18 new cases (harvest / compost / Pitfall 10 boundary
on yarrow + winter-rose / sentinel fallback / immutability). 65/65 tests
green across src/sim/memory + src/sim/garden + src/content; lint exits 0;
build green (Vite parses all 17 fragments without schema violation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:00:38 -04:00
josh d052a35478 docs(02-02): complete begin-plant-grow plan
- Add 02-02-begin-plant-grow-SUMMARY.md (3 atomic commits, 35 new tests, BLOCKER 3 invariant honored, RESEARCH Assumption A5 verified, deviations documented)
- STATE.md — advance to Plan 02-02 complete (2/5 plans in Phase 2; 9 total plans complete)
- ROADMAP.md — mark 02-02 plan complete; phase progress 2/5
- REQUIREMENTS.md — mark GARD-01, GARD-02, AEST-07, UX-01 complete (with traceability comments)
2026-05-09 09:50:05 -04:00
josh 414a554549 feat(02-02): begin screen + seed picker + ui-strings + lazy content split
- content/seasons/01-soil/ui-strings.yaml: player-visible Phase-2 copy externalized per CLAUDE.md (Begin / seed picker / post-harvest beat / journal / settings / plant display names); voice reviewed against bible + anti-fomo-doctrine.md
- content/seasons/01-soil/fragments.yaml: placeholder Season-1 fragment file (Plan 02-03 expands to ≥10 authored)
- content/seasons/00-demo/: deleted (Phase-1 demo replaced)
- src/content/schemas/ui-strings.ts: UiStringsSchema (Zod) — validates structure of every season's ui-strings.yaml at load time
- src/content/schemas/index.ts + src/content/index.ts: re-export UiStringsSchema/UiStrings
- src/content/loader.ts: eager `uiStrings` glob + PIPE-02 lazy `loadSeasonFragments(seasonId)` (Plan 02-03+ exploit)
- src/ui/begin/use-audio-bootstrap.ts: bootstrapAudioContext() lazy-creates + resumes (RESEARCH Pattern 9; Pitfall 5 mitigation — context construction inside the gesture for iOS Safari) + installFirstInteractionGestureHandler() one-shot for D-22 returning players + __resetAudioBootstrapForTest()
- src/ui/begin/BeginScreen.tsx: D-21 typographic Begin screen — title + subtitle + CTA from uiStrings[1].begin; onClick calls bootstrapAudioContext synchronously inside the click event then dismisses the session gate (D-22)
- src/ui/begin/BeginScreen.test.tsx: 4 tests — render / D-22 skip / click bootstraps + dismisses / subtitle string
- src/ui/garden/SeedPicker.tsx: D-02 inline DOM popover; subscribes to 'tile-clicked-coords'; renders one button per unlocked plant type from uiStrings[1].plants; click enqueues plantSeed command via store.enqueueCommand
- src/ui/garden/SeedPicker.test.tsx: 6 tests — initial-null / coords-positioned / unlocked-only / enqueue / dismiss / multi-plant; mocks game/event-bus to avoid Phaser canvas init under happy-dom (deviation Rule 3)
- src/ui/{begin,garden,index}.ts: barrels
- src/App.tsx: mount BeginScreen + SeedPicker as overlay siblings to PhaserGame
- src/PhaserGame.tsx: bootstrap unlockedPlantTypes=['rosemary'] for first-run; install gesture handler + scene-ready listener
- npm run ci exits 0; 163/163 tests pass (10 new this commit + 25 from Task 1 + 128 baseline)
2026-05-09 09:43:47 -04:00
josh 537016b48f feat(02-02): render layer + Garden scene + scheduler integration
- src/render/garden/tile-coords.ts: GRID_LAYOUT (96px tiles + 16px gap, centered in 1024×768) + tileTopLeftCanvas / tileCenterCanvas / tileCenterToDom helpers (RESEARCH Pattern 4 + Assumption A5: handles Phaser.Scale.FIT scaling via canvas getBoundingClientRect)
- src/render/garden/tile-renderer.ts: drawTiles(scene) — 16 outlined rounded rectangles with hover state (D-06)
- src/render/garden/plant-renderer.ts: drawPlant(scene, idx, tile, stage) — primitive shapes per stage (sprout dot / mature stem / ready bloom) tinted by plant type (D-26); destroyPlant cleanup
- src/render/garden/ready-pulse.ts: applyReadyPulse alpha-cycle tween for ready stage (D-27)
- src/render/garden/index.ts + src/render/index.ts: barrels
- src/game/scenes/Garden.ts: Phaser Garden scene wires scheduler ↔ store ↔ render. update() loop drains commands → simulateOneTick → simAdapter.applyTilesAndUnlocks. appStore.subscribe drives reactive plant repaint (Pitfall 6 mitigation: subscribe, not read-once-in-create). pointerdown on empty tile emits 'tile-clicked-coords' for the React seed picker. BLOCKER 3 invariant: lastTickAt is read-through from store (NOT seeded with this.currentTick).
- src/game/main.ts: scene registry now [Boot, Garden]
- src/game/scenes/Boot.ts: create() transitions to Garden
- Lint clean; build clean; 153/153 tests pass (Task-1 sim/garden tests + all baseline)
2026-05-09 09:36:09 -04:00
josh e82a11b988 feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed
- src/sim/garden/types.ts: Tile/PlantInstance/PlantType/PlantTypeId/GrowthStage interfaces; tileIdx(row,col) + tileCoords(idx) + emptyTiles() helpers (RESEARCH Pitfall 2 canonical row*4+col encoding)
- src/sim/garden/plants.ts: 3 Season-1 plants per D-03 (rosemary 600t / yarrow 900t / winter-rose 1500t — D-08/D-09 2–5min band) with placeholder tints (D-26)
- src/sim/garden/growth.ts: advanceGrowth() pure function — Sprout (0%) → Mature (33%) → Ready (100%); Math.max clamp on negative deltas defends Pitfall 1 system-clock rewind
- src/sim/garden/commands.ts: plantSeed (D-05 unlock-gate + occupied silent no-op + immutability) + simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt) + tileGrowthStage helper
- src/sim/garden/index.ts: barrel
- src/sim/index.ts: re-export garden barrel
- 25 new tests (11 growth + 14 commands) — all green; lint clean; build green
- ESLint sim-purity rule from Plan 02-01 confirms zero Date.now/setInterval call sites under src/sim/garden/
2026-05-09 09:32:59 -04:00
josh 38535bac73 docs(02-01): complete foundations plan
- 02-01-foundations-SUMMARY.md authored with frontmatter dependency
  graph, key-files manifest, decisions log, patterns established,
  test-count breakdown (72 new tests), TICK_MS=200 (no drift), and
  ESLint sim-purity rule landed (defended-option clause did not trigger)
- STATE.md: Phase 2 progress 1/5 plans (Wave 0 complete);
  velocity table updated with Plan 02-01 ~12min entry; decisions log
  cites BLOCKER 3 split, V1Payload extension, ESLint rule
- ROADMAP.md: Phase 2 row updated to 1/5; 02-01 plan marked [x] with
  duration + summary backlink
- REQUIREMENTS.md: CORE-02, CORE-03, CORE-11, UX-10, UX-11 marked
  complete with annotations; traceability table updated

Plan execution metrics:
- 3 atomic commits (58db532, fe99058, 2a8d354)
- 72 new tests across 9 test files (cushion above plan estimate of 54)
- Total test count: 128/128 green
- npm run ci exits 0
- Duration: ~12 min (sequential mode)
2026-05-09 09:26:37 -04:00
josh 2a8d354b58 chore(02-01): eslint sim-purity rule + Date.now violator fixture
- eslint.config.js block 3: no-restricted-syntax bans Date.now() and
  setInterval() inside src/sim/**, with src/sim/scheduler/clock.ts as
  the single allowed wall-clock owner (CONTEXT D-33, RESEARCH Pitfall 1)
- date-now-violator.ts deliberate-violation fixture (excluded from
  default lint by Block 1's top-level ignores; the programmatic ESLint
  test passes ignore: false to override)
- lint-firewall.test.ts gains 2 new cases: positive (rule fires on
  violator) + negative (rule does NOT fire on clock.ts the one exception)
- Existing CORE-10 firewall test left untouched and remains green
2026-05-09 09:20:44 -04:00
josh fe99058040 feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks
- Zustand 5 vanilla createStore composes 4 slices (garden / memory /
  narrative / session); useAppStore React hook re-renders on selector
  change; getState() works without React (Phaser ↔ React bridge per D-32)
- simAdapter exposes drainCommands / applyTilesAndUnlocks /
  applyHarvestedFragments / applyLuraProgress / applyTickCount; sim
  never imports the store (CORE-10)
- V1Payload extended in place per D-34: tickCount (BLOCKER 3 monotonic
  counter), unlockedPlantTypes, luraBeatProgress, offlineEvents,
  settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays 1, no
  migrations[2] sneaked in (regression-defense test pins this)
- migrations[1] populates all new field defaults; tickCount: 0 means
  fresh sims always start at sim-tick 0
- registerSaveLifecycleHooks (UX-10): visibilitychange→hidden,
  beforeunload, plus saveOnSeasonTransition() — Vitest covers all three
- Phaser EventBus singleton seeded per the Phaser 4 React-template pattern
- Install @testing-library/react as devDep so the React-hook test can
  exercise the real renderHook surface
- 27 new tests across store / migrations / lifecycle all green; full
  npm run ci is 126/126
2026-05-09 09:18:43 -04:00
josh 58db53227c feat(02-01): BigQty + scheduler + sim foundations
- Install zustand@^5.0.0 + break_eternity.js@^2.1.3 as dependencies
- BigQty immutable wrapper around Decimal (D-31): factories,
  arithmetic, comparison, JSON round-trip, saturating coercion
- formatHumanReadable for K/M/B/T/scientific HUD readouts (UX-11)
- Clock interface + wallClock + FakeClock — only file in src/sim/
  allowed to read Date.now() (D-33)
- drainTicks fixed-timestep accumulator: refuses negative deltas
  (CORE-11), clamps at MAX_OFFLINE_MS=24h (CORE-03), TICK_MS=200
- computeOfflineCatchup pure descriptor for offline boundaries
- SimState root shape with BLOCKER 3 split: lastTickAt
  (wall-clock, app-layer-only) + tickCount (sim-internal monotonic)
- 52 tests across big-qty / format / clock / tick / catchup all green
2026-05-09 09:14:10 -04:00
josh 5ddaabcdc1 docs(02): cite D-12, D-16, D-32 in plan must_haves + record planning complete
ci / lint + test + validate-assets + build (push) Successful in 9m39s
Decision-coverage gate found three CONTEXT.md decisions structurally
implemented but not literally cited by their D-NN tags. Added one-line
must_haves entries citing each:

- D-12 (Lura as discrete gate visits, 3 beats this Season) → 02-04
- D-16 (all Lura dialogue authored in Ink, runtime via inkjs) → 02-04
- D-32 (Zustand 5 store as the Phaser↔React bridge; sim never imports
  store, CORE-10 enforced) → 02-01

STATE.md flipped from in_progress (context gathered) to ready_to_execute
with the planning summary in stopped_at.

All 24 REQ-IDs + 34 D-XX decisions now covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:19:44 -04:00
josh a641056364 fix(02): plan revision iter 3 — BLOCKER 3 cross-plan regression + W1/W2
BLOCKER 3 — cross-plan regression: Plans 02-03 and 02-05 BOTH re-author
src/sim/garden/commands.ts but had reverted simulateOneTick to the old
defective return shape (`return { ...next, lastTickAt: currentTick };`).
Wave 1's execution of 02-03 would overwrite 02-02's correct version,
breaking the invariant for the entire phase.

  - 02-03: simulateOneTick return now matches 02-02 line 457 exactly:
    `return { ...next, tickCount: next.tickCount + 1 };`
  - 02-05: same fix for the silent-mode update (Step 6).
  - 02-03 acceptance_criteria: add negative grep
    (`! grep -E "lastTickAt:\s*(this|currentTick)" src/sim/garden/commands.ts`)
    and positive grep (`grep -q "tickCount: next.tickCount" ...`).
  - 02-05 acceptance_criteria: add the same two greps for commands.ts so
    02-05's silent-mode edits cannot silently re-introduce the regression.

W1 — App.tsx import: 02-05 Step 11 used `useEffect` without importing it.
Combined `import { useState }` and `import { useRef }` into a single
`import { useState, useEffect, useRef } from 'react';` line.

W2 — helper arity divergence: Settings.tsx (one-arg, Date.now() inline)
and PhaserGame.tsx (two-arg, clock.now() injected) had two parallel
definitions of buildPayloadFromStore / hydrateStoreFromPayload. Fix:

  - New Step 3.5 introduces `src/save/payload.ts` with the unified
    two-arg signature: `buildPayloadFromStore(state, nowMs)` and
    `hydrateStoreFromPayload(state, payload)`.
  - `src/save/index.ts` re-exports both.
  - Settings.tsx imports from save barrel; passes Date.now() at the
    call site (no clock injection on hand).
  - PhaserGame.tsx imports from save barrel; passes clock.now() (the
    injected wallClock or FakeClock).
  - Inline duplicate definitions in both files removed; replaced with
    a comment pointing to the shared module.
  - files_modified updated to include src/save/payload.ts.
  - acceptance_criteria asserts: shared file exists, both helpers
    exported, both consumers import from save barrel, no inline
    duplicate definitions remain.

VALIDATION.md not updated — no `<automated>` verify command changed;
the new greps live inside `<acceptance_criteria>` (executor-checked
per task), and VALIDATION.md is not present in the phase dir.

All iteration-1 + iteration-2 fixes preserved; no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:15:39 -04:00
josh d065922cad revise(02): mark Open Questions RESOLVED + tidy GrowthStage import order
- W1: 02-RESEARCH.md Open Questions section now flagged (RESOLVED) and each
  Recommendation prefixed with RESOLVED + a pointer to the artifact that
  codified the resolution.
- W8: 02-02 Plan example moves `import type { GrowthStage }` to the top of
  commands.ts (alongside the other type-only imports) and drops the trailing
  parenthetical apology — the executor doesn't need to fix anything.
2026-05-09 03:05:51 -04:00
josh e5c55b0aae revise(02): BLOCKER 3 — split lastTickAt (wall-clock) from tickCount (sim counter)
Two distinct fields with strict separation:
  - lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
    the application layer. The sim NEVER writes this field.
    computeOfflineCatchup uses it as the wall-clock anchor.
  - tickCount: monotonic sim-internal counter (one per simulate() call).
    Used for STRY-10 narrative gating that must be immune to wall-clock
    manipulation. The sim writes this field; the application layer reads
    it via simAdapter.applyTickCount.

Changes:
  02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
  defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
  simAdapter exposes applyTickCount; tests assert the round-trip.
  02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
  currentTick); Garden scene's SimState snapshot reads lastTickAt
  through from store and writes tickCount: this.currentTick locally;
  acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
  02-05: buildPayloadFromStore now persists tickCount (from store);
  hydrateStoreFromPayload restores it via state.setTickCount.

This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
2026-05-09 03:04:45 -04:00
josh a9f190ed27 revise(02-05): fix migrate() bypass in boot+import paths + lifecycle leak + hotkey
- BLOCKER 1: PhaserGame.tsx boot path now runs unwrap(env) → migrate(raw, env.schemaVersion).
  Casting unwrap(record.envelope) directly to V1Payload silently accepted any
  future-shape payload as the current shape; only migrate() walks the schema
  version chain.
- BLOCKER 2: Settings.tsx onImport now correctly orders importFromBase64 →
  unwrap (CRC verify) → migrate. Previous code discarded migrate's result
  and then read v1.payload as if unwrap returned an envelope rather than
  the payload itself — runtime crash on every import.
- BLOCKER 3: documented the lastTickAt invariant as wall-clock milliseconds,
  written ONLY at saveSync time (never by the sim). Added acceptance_criteria
  greps proving (a) saveSync writes clock.now(), (b) Garden scene does not
  overwrite lastTickAt with a tick counter, (c) sim/garden/Garden.ts (if it
  exists; the Garden scene actually lives at src/game/scenes/Garden.ts)
  contains no lastTickAt: this.* writes.
- W2: D-29 keyboard shortcut wired in App.tsx — comma toggles Settings,
  'j' dispatches a window CustomEvent the JournalIcon picks up.
- W5: lifecycle handle now stored in useRef and detached in the OUTER
  useLayoutEffect cleanup (the previous IIFE-internal return was a closure
  return, never reaching React's effect cleanup contract).
2026-05-09 03:01:27 -04:00
josh 953784ae93 revise(02-03): bump warm-pool fragment count + journal hotkey listener
- W6: warm-tagged pool depth raised to ≥9 (8th-harvest threshold + 1 buffer)
  so a worst-case all-rosemary playthrough never exhausts. Total per-pool
  targets: ≥9 warm, ≥3 contemplative, ≥3 heavy, plus the sentinel.
- W2: JournalIcon now listens for the 'tlg:toggle-journal' window event so
  App.tsx can wire a 'j' hotkey without lifting open/close state into the
  store. Hotkey is gated on the same revealed selector as the icon itself.
2026-05-09 03:01:12 -04:00
josh f6bef061c3 revise(02-04): replace in-test compileAllInk() call with precondition check
Per W9: invoking the compiler from inside ink-loader.test.ts's beforeAll
creates a filesystem race against other concurrent tests because the
script wipes src/content/compiled-ink/ at start. Compile is already part
of the npm run ci chain (via npm run build); the test should only verify
the artefact exists and fail loudly with a fix-it message otherwise.
2026-05-09 02:57:15 -04:00
josh f7428da299 revise(02-04): fix inklecate binary path + drop unused .ink + add last_fragment_title slot
- BLOCKER 4: inklecateBinary() now resolves node_modules/inklecate/bin/inklecate{,.exe};
  the previous path (inklecate-windows/, inklecate-mac/, inklecate-linux/) does not
  exist in the package — fallback verification of Assumption A6 would have masked
  the wrapper-API failure.
- W3: removed lura-greeting-template.ink from files_modified (never authored).
- W4: added last_fragment_title to INK_VARIABLE_MAP (extracts first sentence of the
  most-recently-harvested fragment) so the must_haves slot promise is fulfilled.
2026-05-09 02:56:50 -04:00
josh 63d2d8d5f7 docs(02): create phase 2 plan — 5 plans across 3 waves
Phase 2 (Season 1 Vertical Slice — Soil) plan set:
- 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule)
- 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice
- 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification
- 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10)
- 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e

All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:45:56 -04:00
josh 5bc98ba4ac docs(02): map phase 2 file targets to existing analogs
54 file targets classified (49 new, 5 modified) with 87% analog coverage.
Key patterns: V1Payload extension (not v1→v2 migration), per-layer
public barrel pattern, test colocation, Zustand vanilla store + Phaser
EventBus singleton as the dual sim↔React bridge, ESLint sim-purity rule
proposed as a defended option (not auto-locked, per minimum-viable bias).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:11:01 -04:00
josh 01e02dcdb8 docs(phase-02): add validation strategy
Nyquist VALIDATION.md scaffold for Phase 2. Defines test infrastructure
(Vitest + Playwright already wired by Phase 1), sampling rates (npm test
after each commit, npm run ci after each wave), Wave-0 dependency surface
(BigQty + scheduler + Zustand store + V1Payload extension), and three
manual-only verifications (AudioContext cross-browser, letter voice review,
cozy-pace playtest). The per-task verification map is intentionally empty —
the planner fills it during plan generation; nyquist_compliant flips to
true once it's complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:03:17 -04:00
josh c4589a56b4 docs(02): research phase 2 vertical slice — 24 REQ-IDs mapped
5-plan MVP slice proposal across 3 waves: Wave 0 lands the three
deferred foundations (BigQty wrapper around break_eternity.js, Zustand
5 store wiring, tick scheduler / monotonic clock). Wave 1 ships two
parallel vertical slices (Begin+Plant+Grow, Harvest+Journal+Fragments).
Wave 2 ships the Lura gate-visit slice and the offline-letter slice
including Playwright PIPE-07 e2e. All 24 REQ-IDs addressed in the
coverage map; 10 architectural patterns enumerated; tick rate locked at
5Hz with 24h offline cap; AudioContext.resume() bootstrap pattern
documented for first-run + returning-player paths; V1Payload extension
shape locked per CONTEXT D-34 (no migrate_v1_to_v2 added). 10
assumptions logged, 8 are LOW risk; 2 are MEDIUM (canvas-DOM coord
mapping under FIT scale, inklecate Windows binary invocation) and
flagged for early verification in Plans 02-02 and 02-04.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:22 -04:00
josh 350e976fed docs(state): record phase 2 context session
Phase 2 discuss-phase complete; STATE.md now reflects context-gathered
status, updated stopped_at narrative, and next-action pointer to
/gsd-plan-phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:39:52 -04:00
josh 69964ba17f docs(02): capture phase context
Phase 2 (Season 1 Vertical Slice — Soil) discuss-phase output. 02-CONTEXT.md
captures 34 implementation decisions across 8 gray areas (garden geometry &
input · time density · Lura's arc · letter composition · Begin screen ·
Memory Journal · plant placeholders · Phase-2 Settings UI scope). Locks the
4×4 grid + click+inline seed picker, 2–5min growth band per plant with
auto-harvest-while-offline, 3 Lura gate visits gated by 1st/4th/8th harvest,
authored Ink letter skeleton with templated slots, full-screen letter on
≥5min absence, tasteful placeholder Begin screen (Phase 3 paints), full-
screen Journal modal revealing after first harvest, simple Phaser-primitive
plants with subtle ready-state pulse, save-management-only Settings.
Phase 2's first commits: BigQty wrapper, Zustand 5 store, tick scheduler.
Save schema is a v1 *extension*, not v1→v2.

02-DISCUSSION-LOG.md preserves the alternatives considered for human review.

Next: /gsd-plan-phase 2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:39:48 -04:00
josh 49ba411a00 verify(01): phase 1 verification — all 16 REQ-IDs PASS, CI green
ci / lint + test + validate-assets + build (push) Successful in 9m38s
All 16 Phase-1 requirements verified against the live codebase:
- CORE-01, CORE-04..CORE-10: scaffold builds; save layer (IDB + fallback,
  envelope, migrations, snapshots, persist, codec) all green (53 tests / 12 files)
- PIPE-01: Vite-native content pipeline; schema violation fails build
- PIPE-03: asset provenance gate; refused-sample fixture proves gate structure
- PIPE-05: both doctrine docs authored + 8 doc-lint assertions green
- PIPE-06: ci.yml runs npm run ci on every push + PR
- AEST-08: ProvenanceSchema 6 fields; CI gate on every commit
- AEST-09: human curation gate in place; IOU records Path C deferral to Phase 5
- STRY-09: /content/ convention established (vacuously satisfied in Phase 1)
- UX-13: anti-fomo-doctrine.md authored + review-enforced

Deferred (non-blocking): 10-20 real north-star images (AEST-09 Task 2) — Phase 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:17:20 -04:00
josh d3410e207d feat(01-05): commit 2 placeholder north-star assets + IOU (Path C deferral)
User invoked planning-doctrine pushback principle on the 10-20 north-star
curation step. Two 1x1 transparent-PNG placeholders ship with provenance
sidecars marked model_id: 'placeholder' so the validator exercises at >0
assets. Full deferral rationale and resolution path in
.planning/phases/01-foundations-and-doctrine/01-05-IOU.md — to be revisited
at Phase 5 entry (curate then, or amend CONTEXT D-01 if still ceremonial).
2026-05-09 00:06:27 -04:00
josh 8ace3db7b4 docs(01-07): complete ci-workflow plan + Phase 1 closure metadata
- 01-07-ci-workflow-SUMMARY.md: structural enforcement map, Phase 2/8 handoff notes, threat T-01-08 mitigation confirmed
- 01-VALIDATION.md: per-task table populated (12/13 green, 01-05-T2 partial — checkpoint:human-verify awaiting north-star image curation); status flipped to executed
- ROADMAP.md: progress table marks Phase 1 as 7/7 with 01-05 partial annotation
- STATE.md: position advanced to Plan 7 of 7 complete; performance trend; Plan 01-05 Task 2 explicitly tracked as the only outstanding deliverable; next action = human curation pass then /gsd-verify-work
- REQUIREMENTS.md: PIPE-06 marked complete (CI workflow runs Vitest on every push/PR)
2026-05-09 00:00:23 -04:00
josh 609d58231d ci(01-07): minimum-viable GitHub Actions workflow running npm run ci on push + PR (PIPE-06)
- Single-job workflow at .github/workflows/ci.yml (~49 lines including load-bearing comments)
- runs-on: ubuntu-latest, timeout-minutes: 10
- Uses actions/setup-node@v4 with cache: 'npm' (per RESEARCH CI Pitfall A — never cache node_modules/)
- Node 22 (per RESEARCH § Environment Availability)
- Triggers on push to main and pull_request to main
- Steps: checkout → setup-node → npm ci (lockfile-strict) → npm run ci (lint + test + validate-assets + build)
- Per CONTEXT user pushback: NO matrix, NO test reporters, NO Codecov, NO release automation
- Local npm run ci exits 0 (53 tests passing across 12 files); workflow will be green on push
- Structurally enforces every Phase 1 success criterion on every commit going forward
2026-05-08 23:54:30 -04:00
josh bbaa2c6905 fix(01): remove unused eslint-disable in save/db.ts
01-02's flat config does not enable no-console, so 01-03's directive
landed as a max-warnings=0 violation after parallel-wave merge.
2026-05-08 23:50:16 -04:00
josh 0f192ca3c0 chore: merge executor worktree (01-06 doctrine-docs) 2026-05-08 23:48:35 -04:00
josh 2aa61d030e chore: merge executor worktree (01-05 asset-provenance, partial — Task 2 awaits human curation) 2026-05-08 23:48:30 -04:00
josh 4cc3d8dbd2 chore: merge executor worktree (01-03 save-layer) 2026-05-08 23:48:25 -04:00
josh c3289440d6 chore: merge executor worktree (01-04 content-pipeline) 2026-05-08 23:48:15 -04:00
josh 161be69d7b chore: merge executor worktree (01-02 eslint-firewall) 2026-05-08 23:48:11 -04:00
josh 13139547f7 docs(01-03): complete save layer plan
7 commits across 3 TDD tasks (RED + GREEN per task) + .gitkeep cleanup;
36 Vitest tests across 7 test files green; npm run build clean under
TypeScript strict; all 6 CORE requirements (CORE-04 through CORE-09)
covered by at least one assertion.

Key structural decision documented in SUMMARY: SaveDB is a single
common-contract interface, not a union of IDBPDatabase | LocalStorageDBAdapter.
The union shape failed TypeScript-strict at the build gate; the interface
refactor isolates the type-system cast to one location at openSaveDB().
2026-05-08 23:47:01 -04:00
josh d4c519c38d chore(01-03): remove src/save/.gitkeep (firewall marker no longer needed)
src/save/ now contains 7 production files + 7 test files. The .gitkeep
firewall marker exists only to make empty directories trackable in git;
it can be retired once the directory has real content (per Plan 01-01
SUMMARY's pattern — 'firewall-as-directory pattern').
2026-05-08 23:42:13 -04:00
josh 2761bcc1e0 feat(01-03): Base64 codec + DoS-capped import + index re-exports + SaveDB interface refactor [GREEN]
- codec.ts: exportToBase64 / importFromBase64 via lz-string with
  MAX_IMPORT_BYTES=50MB DoS cap (T-01-02 in plan threat model); import
  validates against SaveEnvelopeSchema before returning. lz-string sync
  caveat documented per RESEARCH Pitfall 5 (Web Worker mitigation deferred
  to Phase 8 per CONTEXT D-09)
- index.ts: 14 public re-exports — the only entry point Phase 2 should
  import from. Includes the LocalStorageDBAdapter class so consumers can
  type-check the fallback path explicitly if needed

[Rule 1 - Bug] Build was failing because the original SaveDB type was a
union (IDBPDatabase<SaveDBSchema> | LocalStorageDBAdapter) — TypeScript
cannot resolve method calls through a union when each branch has
differently-shaped overloads ('no compatible signature' on every db.put).
Fixed by:
  - Defining SaveDB as a single common-contract interface that both
    backends MUST satisfy (get/put/delete/getAll/transaction with
    conditional-type RecordOf<S> return values)
  - Hoisting the canonical SavedRecord/SnapshotRecord/StoreName types
    into db-localstorage-adapter.ts (lower-level module) and re-exporting
    them from db.ts to avoid a circular import
  - Casting the idb-returned IDBPDatabase to SaveDB at the open-call
    boundary (the casts are isolated to openSaveDB; Phase 2 only sees
    the SaveDB interface)
  - Promoting SnapshotEntry to a type-alias of SnapshotRecord so
    snapshots.ts no longer redeclares the shape and can rely on
    canonical types

Tests: 36/36 pass under 'npx vitest run src/save/' (full suite incl
sentinel: 37/37). 'npm run build' exits 0 under TypeScript strict.
'npm run lint' is not invoked here because Plan 02 (eslint-firewall) has
not landed yet — the lint script will fail until it does, by design per
the Plan 01-01 SUMMARY ('Plan 02 owns it').
2026-05-08 23:42:00 -04:00
josh de39c1b7c3 docs(01-02): complete eslint-firewall plan
SUMMARY documents the ESLint flat config, the 9 element types, the
single CORE-10 rule, the deliberate-violation fixture, the Vitest test
that runs ESLint programmatically against the violator, and the four
auto-fixed deviations (typescript-eslint parser-only integration,
real render target file for the violator import, eslint-import-resolver-
typescript wiring, tsconfig.app.json test-file exclusion).

Verifies: npm run lint -> 0 errors / 0 warnings; npm test -> 2/2 pass;
npm run build -> green; eslint --no-ignore on violator -> exits 1.

Self-Check: PASSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:38:19 -04:00
josh bec0df1dc2 test(01-03): add failing tests for Base64 codec + full round-trip [RED]
- round-trip.test.ts (3 tests): full pipeline EXPORT -> IMPORT -> MIGRATE
  -> WRAP -> UNWRAP -> IDB PUT -> IDB GET exercising every save layer
  file end-to-end (CORE-09 + CORE-04 + CORE-06 + CORE-07); plus DoS-cap
  rejection at MAX_IMPORT_BYTES + 1; plus malformed-Base64 rejection

RED phase per TDD plan-level gate. Tests fail because codec.ts does not
exist yet.
2026-05-08 23:37:13 -04:00
josh 0b1425d4f6 feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + persist API [GREEN]
- db.ts: openSaveDB() opens IndexedDB ('tlg-save', v1) with two object
  stores (saves singleton + save_snapshots keyed); on openDB rejection
  (private mode, blocked, quota exceeded) falls back to LocalStorageDBAdapter
  per CORE-04 contract
- db-localstorage-adapter.ts: ~110-LoC adapter exposing the same minimal
  get/put/delete/getAll/transaction surface as idb's IDBPDatabase, namespaced
  under tlg.saves.<id> and tlg.save_snapshots.<id>; transaction() shim
  proxies straight through (localStorage has no real transactions)
- snapshots.ts: snapshot(envelope) writes to save_snapshots and prunes to
  RETAIN=3 newest by savedAt descending (CORE-08); listSnapshots() returns
  newest-first; entropy suffix on snapshot IDs avoids same-ms collisions
- persist.ts: requestPersistence() returns {granted, apiAvailable} for all
  4 navigator.storage scenarios per CORE-05 + RESEARCH Pitfall 2

Test infra fixes: snapshots.test.ts and db.test.ts cannot deleteDatabase
between tests because openSaveDB leaves an open connection that idb caches
(deleteDatabase blocks indefinitely). beforeEach instead clears store
contents directly. The fallback test calls vi.resetModules() BEFORE
vi.doMock('idb') so the freshly-imported db.ts picks up the rejecting
openDB stub, and re-imports LocalStorageDBAdapter from the same module
graph so instanceof checks against the same class identity.

Tests: 12/12 pass (npx vitest run src/save/db.test.ts
src/save/snapshots.test.ts src/save/persist.test.ts).
Full save suite: 33/33 pass (Task 1 + Task 2 combined).
TypeScript-strict; no 'any' in production code (CLAUDE.md).
2026-05-08 23:36:20 -04:00