diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 90f3012..7105e73 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -34,7 +34,15 @@ Decimal phases appear between their surrounding integers in numeric order.
3. CI fails the build if `src/sim/` imports anything from `src/render/` or `src/ui/` (ESLint boundary rule) and fails the build if any `/content/**/*.{md,yaml,ink}` file violates its Zod schema or any AI-generated asset is missing required provenance fields (`{model_id, checkpoint_hash, prompt, seed, sampler, params}`).
4. The repository contains a written `anti-FOMO doctrine` document and a written `Season 7 end-state` design document in `.planning/`, both reviewed and committed — the project has a canonical answer to "the story ends but the loop doesn't" before any economy code is written.
5. A locked 10-20 painting "north star" reference set is committed to the repo and a documented human curation gate exists in the asset pipeline; sample assets prove the gate refuses unreviewed material.
-**Plans**: TBD
+**Plans:** 7 plans
+Plans:
+- [ ] 01-01-scaffold-and-test-infra-PLAN.md — Bootstrap Phaser 4 official template, install Phase-1 deps, restructure src/ into 7 firewall directories, configure Vitest (happy-dom) + Playwright, pre-declare every package.json script downstream plans need
+- [ ] 01-02-eslint-firewall-PLAN.md — Migrate to ESLint flat config + eslint-plugin-boundaries, declare 9 element types, enforce CORE-10 (sim cannot import render or ui) with a Vitest-tested deliberate-violation fixture
+- [ ] 01-03-save-layer-PLAN.md — Save envelope {schemaVersion, payload, checksum} with CRC-32 over canonical JSON, idb-wrapped IndexedDB with last-3 snapshot retention, synthetic v0→v1 migration chain, navigator.storage.persist API, Base64 export/import with 50MB DoS cap, full round-trip test (CORE-04 through CORE-09)
+- [ ] 01-04-content-pipeline-PLAN.md — Vite-native content pipeline using import.meta.glob, Zod schemas for Fragment + SeasonContent, demo fragment under /content/seasons/00-demo/, content/README.md documenting the convention, no-op compile:ink stub for Phase 2 (PIPE-01, STRY-09)
+- [ ] 01-05-asset-provenance-PLAN.md — 30-line Node validator script walking /assets/ + Zod sidecar schema covering 6 required fields + optional schema_version, refused-sample fixture proves the gate, Vitest integration test, 10–20 hand-curated north-star reference images committed via human curation checkpoint (AEST-08, AEST-09, PIPE-03)
+- [ ] 01-06-doctrine-docs-PLAN.md — Author .planning/anti-fomo-doctrine.md (consolidation per CONTEXT D-07) and .planning/season-7-end-state.md (principle-level per CONTEXT D-08), Vitest doc-lint test enforces structural integrity (PIPE-05, UX-13)
+- [ ] 01-07-ci-workflow-PLAN.md — Minimum-viable .github/workflows/ci.yml running npm ci + npm run ci on push to main and PR; structurally enforces every Phase 1 success criterion on every commit going forward (PIPE-06)
### Phase 2: Season 1 Vertical Slice (Soil)
**Goal**: Player can launch the game, plant a seed, watch it grow, harvest a memory fragment authored in real Season 1 content, meet Lura at the gate, leave the tab for hours, and return to a letter-from-the-garden describing what bloomed — the entire core loop and content pipeline proven on Season 1 with no aesthetic polish required.
@@ -135,7 +143,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. Foundations & Doctrine | 0/TBD | Not started | - |
+| 1. Foundations & Doctrine | 0/7 | Planned | - |
| 2. Season 1 Vertical Slice (Soil) | 0/TBD | Not started | - |
| 3. Watercolor & Cello Aesthetic | 0/TBD | Not started | - |
| 4. Season-Prestige Cycle & Season 2 (Roots) | 0/TBD | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 4076767..abc33d5 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -10,9 +10,9 @@ See: .planning/PROJECT.md (updated 2026-05-08)
## Current Position
Phase: 1 of 8 (Foundations & Doctrine)
-Plan: 0 of TBD in current phase
-Status: Context gathered — ready for `/gsd-plan-phase 1`
-Last activity: 2026-05-08 — Phase 1 context discussed and committed; user locked AI pipeline to minimum-viable, save schema to minimal v1, doctrine docs to principle-level, Phase 1 scaffold capped at success criteria.
+Plan: 0 of 7 in current phase
+Status: Planned — ready for `/gsd-execute-phase 1`
+Last activity: 2026-05-08 — Phase 1 planning complete. RESEARCH.md → 7 plans across 3 waves → plan-checker found 4 blockers + 6 warnings → revision iteration 1 → VERIFICATION PASSED. Orchestrator authorized two judgment calls during revision: (1) implement CORE-04 localStorage fallback in Phase 1 (~30 LoC + 1 test) rather than defer (the literal requirement and ROADMAP success criterion #2 both call for it); (2) reclassify STRY-09 as vacuously satisfied in Phase 1 (no source code exists yet to externalize from).
Progress: [░░░░░░░░░░] 0%
@@ -27,7 +27,7 @@ Progress: [░░░░░░░░░░] 0%
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
-| 1. Foundations & Doctrine | 0 | 0 | — |
+| 1. Foundations & Doctrine | 0/7 | 0 | — |
**Recent Trend:**
- Last 5 plans: —
@@ -68,6 +68,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-05-08
-Stopped at: Phase 1 context gathered. User chose minimum-viable shape across all four gray areas (AI pipeline, save v1, doctrine docs, scaffold scope) — explicit pushback on overengineering recorded.
-Resume file: .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
-Next action: `/gsd-plan-phase 1`
+Stopped at: Phase 1 planning verified and ready for execution. Wave 1 = Plan 01 (scaffold + test infra). Wave 2 = Plans 02–06 (firewall, save layer, content pipeline, asset provenance, doctrine docs) in parallel; Plan 05 has a human-curate checkpoint (autonomous: false) for the 10–20 north-star reference set with Path A/B/C fallbacks. Wave 3 = Plan 07 (CI workflow).
+Resume file: .planning/phases/01-foundations-and-doctrine/01-VALIDATION.md (per-task verification map populated; all 16 Phase-1 REQ-IDs covered)
+Next action: `/gsd-execute-phase 1`
diff --git a/.planning/phases/01-foundations-and-doctrine/01-01-scaffold-and-test-infra-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-01-scaffold-and-test-infra-PLAN.md
new file mode 100644
index 0000000..dfc2e07
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-01-scaffold-and-test-infra-PLAN.md
@@ -0,0 +1,303 @@
+---
+phase: 01
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - package.json
+ - package-lock.json
+ - tsconfig.json
+ - vite.config.ts
+ - vitest.config.ts
+ - playwright.config.ts
+ - src/sim/.gitkeep
+ - src/render/.gitkeep
+ - src/ui/.gitkeep
+ - src/save/.gitkeep
+ - src/content/.gitkeep
+ - src/audio/.gitkeep
+ - src/store/.gitkeep
+ - content/.gitkeep
+ - content/dialogue/.gitkeep
+ - content/seasons/.gitkeep
+ - assets/.gitkeep
+ - .gitignore
+autonomous: true
+requirements: [CORE-01]
+must_haves:
+ truths:
+ - "Game scaffold builds without errors via `npm run build`"
+ - "Phaser 4 + React 19 + Vite + TypeScript template is installed and runnable via `npm run dev`"
+ - "All seven firewall directories (sim, render, ui, save, content, audio, store) exist under src/"
+ - "All Phase-1 dependencies (idb, lz-string, zod, crc-32, gray-matter, yaml, inkjs, eslint-plugin-boundaries, vitest, @playwright/test, happy-dom, fake-indexeddb, inklecate) are installed at the verified versions"
+ - "package.json declares all scripts that downstream plans will rely on (lint, test, validate:assets, compile:ink, ci) — stubs allowed"
+ - "Vitest can find and run a sentinel test that asserts `1+1===2`"
+ artifacts:
+ - path: package.json
+ provides: "Phase-1 dependencies + scripts (dev, build, lint, test, validate:assets, compile:ink, ci)"
+ contains: "\"vitest\""
+ - path: vitest.config.ts
+ provides: "Vitest config with happy-dom env (for IndexedDB shim used by save tests)"
+ contains: "happy-dom"
+ - path: playwright.config.ts
+ provides: "Playwright config (installed only — no specs in Phase 1)"
+ - path: src/sim/.gitkeep
+ provides: "Firewall directory marker for Plan 02 ESLint boundaries rule"
+ - path: src/save/.gitkeep
+ provides: "Save layer directory (Plan 03 fills with implementation)"
+ - path: content/.gitkeep
+ provides: "Repo-root content directory per CONTEXT D-11"
+ - path: assets/.gitkeep
+ provides: "Repo-root assets directory per CONTEXT D-12"
+ key_links:
+ - from: package.json
+ to: "Plan 02, 03, 04, 05, 06"
+ via: "Pre-declared scripts (lint, test, validate:assets, compile:ink) so later plans only fill behaviour, never re-edit script keys"
+ pattern: "\"scripts\":\\s*\\{[^}]*\"validate:assets\""
+---
+
+
+Bootstrap the Phaser 4 + React 19 + Vite + TypeScript scaffold via the official `npm create @phaserjs/game@latest` template, install every Phase-1 dependency at the versions locked in RESEARCH.md, restructure `src/` to expose the seven architectural-firewall directories as siblings to the template-provided `src/game/`, create the repo-root `/content/` and `/assets/` trees, and ship a minimal Vitest + Playwright config plus a sentinel test that proves the test runner works. This plan is the structural prerequisite for every Wave-2 plan: it owns `package.json` scripts wholesale so later parallel plans never touch `package.json`.
+
+Purpose: Without this scaffold there is no project. Without exposing the seven firewall directories Plan 02's ESLint boundaries rule has nothing to lint against. Without the pre-declared scripts and Vitest config Plans 03, 04, 05, 06 cannot run their tests in parallel without colliding on `package.json`.
+
+Output: A buildable, runnable Phaser 4 scaffold with the firewall directory skeleton, all Phase-1 deps installed, and the test infrastructure ready for Wave 2 to drop test files into.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Scaffold Phaser 4 official template + restructure src/ + install Phase-1 deps
+
+ package.json,
+ package-lock.json,
+ tsconfig.json,
+ .gitignore,
+ src/sim/.gitkeep,
+ src/render/.gitkeep,
+ src/ui/.gitkeep,
+ src/save/.gitkeep,
+ src/content/.gitkeep,
+ src/audio/.gitkeep,
+ src/store/.gitkeep,
+ content/.gitkeep,
+ content/dialogue/.gitkeep,
+ content/seasons/.gitkeep,
+ assets/.gitkeep
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-10, D-11, D-12 — scaffold layout, repo-root /content/ and /assets/, single package)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Standard Stack" (verified versions table) and § "Recommended Project Structure"
+ - CLAUDE.md "Stack" section (locked versions)
+
+
+ Run the official Phaser 4 template scaffold and merge it into the current repo (which already contains `.git/`, `.planning/`, `.claude/`, and `CLAUDE.md`). The repo cwd is `c:\Users\josh1\Documents\Code\TheLastGarden\` and must remain so — do not nest the template in a subdirectory.
+
+ **Step 1 — Bootstrap into a temp dir, then copy in:**
+ ```bash
+ # Run from a parent directory or temp location
+ cd "$TMPDIR" || cd /tmp
+ npm create @phaserjs/game@latest tlg-template -- --template react-ts --yes 2>&1 | tail -20
+ # If --template/--yes flags are not supported by create-game, run interactively and choose:
+ # "React + Vite + TypeScript"
+ # Then copy everything except .git into the repo root:
+ cp -r tlg-template/{src,public,*.json,*.ts,*.html,*.md,.eslintrc*,.gitignore,vite.config.*} c:/Users/josh1/Documents/Code/TheLastGarden/ 2>&1 || true
+ rm -rf tlg-template
+ ```
+ If the template does not provide non-interactive flags, scaffold interactively in a temp dir and then copy.
+
+ **Step 2 — Install Phase-1 production deps (versions per RESEARCH.md verified 2026-05-08):**
+ ```bash
+ cd c:/Users/josh1/Documents/Code/TheLastGarden
+ npm install idb@^8.0.3 lz-string@^1.5.0 zod@^4.4.3 crc-32@^1.2.2 gray-matter@^4.0.3 yaml@^2.8.4 inkjs@^2.4.0
+ ```
+
+ **Step 3 — Install Phase-1 dev deps (note: `fake-indexeddb` is required by Plan 03's IDB tests — happy-dom does not ship IndexedDB; install it here in Plan 01 so Plan 03 Task 2 can `import 'fake-indexeddb/auto'` without re-running install):**
+ ```bash
+ npm install -D vitest@^4.1.5 @vitest/ui happy-dom fake-indexeddb@^6 @playwright/test@^1.59.1 eslint-plugin-boundaries@^6.0.2 inklecate@^1.8.1
+ ```
+
+ **Step 4 — Create the seven firewall directories with .gitkeep markers (per RESEARCH Open Question #3, .gitkeep is the recommendation):**
+ ```bash
+ mkdir -p src/sim src/render src/ui src/save src/content src/audio src/store
+ touch src/sim/.gitkeep src/render/.gitkeep src/ui/.gitkeep src/save/.gitkeep src/content/.gitkeep src/audio/.gitkeep src/store/.gitkeep
+ ```
+
+ **Step 5 — Create the repo-root /content/ and /assets/ trees (per CONTEXT D-11, D-12):**
+ ```bash
+ mkdir -p content/dialogue content/seasons assets
+ touch content/.gitkeep content/dialogue/.gitkeep content/seasons/.gitkeep assets/.gitkeep
+ ```
+
+ **Step 6 — Edit `package.json` to add ALL scripts that downstream plans need.** Use the Edit tool to set the `scripts` block to include exactly these keys (preserve any template-provided `dev`/`build`/`preview` entries; add the rest):
+ ```json
+ {
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --max-warnings 0",
+ "test": "vitest run --passWithNoTests=false",
+ "test:watch": "vitest",
+ "validate:assets": "node scripts/validate-assets.mjs",
+ "compile:ink": "echo \"[compile:ink] no .ink files yet — Phase 2 will populate /content/dialogue/\" && exit 0",
+ "ci": "npm run lint && npm run test && npm run validate:assets && npm run build"
+ }
+ }
+ ```
+ Per CONTEXT D-08 RESEARCH § "Pattern 4 — Ink files in Phase 1", `compile:ink` is a no-op stub in Phase 1; Phase 2 will replace it with `inklecate -o src/content/compiled-ink/ content/dialogue/*.ink`.
+
+ Per RESEARCH § "Common Pitfalls (CI / Tooling Specific)" CI Pitfall B, the test script MUST include `--passWithNoTests=false`; CI Pitfall C: lint MUST include `--max-warnings 0`.
+
+ **Step 7 — Verify `tsconfig.json` has `"strict": true`.** The template should provide it; if not, add it under `compilerOptions`. Per CLAUDE.md "Code Style", TypeScript strict is mandatory.
+
+ **Step 8 — Verify `.gitignore` contains `node_modules/`, `dist/`, `coverage/`.** Add if missing.
+
+ **Step 9 — Commit with message `chore(01-01): scaffold Phaser 4 template + Phase-1 deps + firewall directories`.** Stage only the files this task touched; do NOT `git add -A` (the repo also has `.planning/` files from upstream that are tracked separately).
+
+
+ npm run build && test -d src/sim && test -d src/render && test -d src/ui && test -d src/save && test -d src/content && test -d src/audio && test -d src/store && test -d content && test -d assets && node -e "const p=require('./package.json'); for (const k of ['dev','build','lint','test','validate:assets','compile:ink','ci']) if (!p.scripts[k]) { console.error('missing script: '+k); process.exit(1); } console.log('all scripts present')"
+
+
+ - `package.json` exists at repo root and contains `"phaser"`, `"react"`, `"react-dom"`, `"vite"`, `"typescript"`, `"idb"`, `"lz-string"`, `"zod"`, `"crc-32"`, `"gray-matter"`, `"yaml"`, `"inkjs"`, `"vitest"`, `"@playwright/test"`, `"eslint-plugin-boundaries"`, `"happy-dom"`, `"fake-indexeddb"`, `"inklecate"` somewhere in dependencies or devDependencies — verify with `grep -E '"(phaser|idb|lz-string|zod|crc-32|gray-matter|yaml|inkjs|vitest|eslint-plugin-boundaries|happy-dom|fake-indexeddb|inklecate)"' package.json | wc -l` returns at least 13.
+ - All 7 firewall directories exist as directories: `test -d src/sim && test -d src/render && test -d src/ui && test -d src/save && test -d src/content && test -d src/audio && test -d src/store` exits 0.
+ - All 7 firewall directories contain `.gitkeep`: `for d in sim render ui save content audio store; do test -f src/$d/.gitkeep || exit 1; done`.
+ - Repo-root `/content/` and `/assets/` exist: `test -d content && test -d content/dialogue && test -d content/seasons && test -d assets`.
+ - `package.json` scripts block contains ALL of: `dev`, `build`, `preview`, `lint`, `test`, `test:watch`, `validate:assets`, `compile:ink`, `ci` — verify with `node -e "const p=require('./package.json'); ['dev','build','preview','lint','test','test:watch','validate:assets','compile:ink','ci'].forEach(k=>{ if(!p.scripts[k]) throw new Error('missing '+k); })"` exits 0.
+ - `npm run build` exits 0 and produces a `dist/` directory.
+ - `tsconfig.json` contains `"strict": true` — verify with `grep -q '"strict"\\s*:\\s*true' tsconfig.json`.
+ - The template's existing `src/main.tsx`, `src/App.tsx`, `src/PhaserGame.tsx` are preserved (untouched) — verify with `test -f src/main.tsx && test -f src/App.tsx`.
+
+
+ Phaser 4 template scaffolded into the repo root, all Phase-1 deps installed at the verified versions (including `fake-indexeddb@^6` for Plan 03's IDB tests), all 7 firewall directories created with `.gitkeep`, repo-root `/content/` and `/assets/` trees created, `package.json` has all downstream-required scripts pre-declared, `npm run build` succeeds, commit landed.
+
+
+
+
+ Task 2: Configure Vitest (happy-dom) + Playwright (install only) + sentinel test
+
+ vitest.config.ts,
+ playwright.config.ts,
+ src/__sentinel__.test.ts
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Validation Architecture" (Vitest framework table; happy-dom for IndexedDB shim) and § "Wave 0 Gaps" (the test stub list)
+ - .planning/phases/01-foundations-and-doctrine/01-VALIDATION.md § "Test Infrastructure"
+ - package.json (verify vitest + happy-dom + @playwright/test installed by Task 1)
+
+
+ Wave 0 deliverable per RESEARCH § Validation Architecture: stand up Vitest + Playwright config so Wave 2 plans can drop `*.test.ts` files into `src/save/`, `src/content/`, `scripts/` and have them auto-discovered.
+
+ **Step 1 — Create `vitest.config.ts`** with happy-dom environment (so `src/save/db.test.ts` in Plan 03 can use `idb` against the happy-dom IndexedDB shim — RESEARCH explicitly calls for this). Use the Write tool with this exact content:
+ ```typescript
+ import { defineConfig } from 'vitest/config';
+
+ export default defineConfig({
+ test: {
+ environment: 'happy-dom',
+ include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
+ passWithNoTests: false,
+ globals: false,
+ },
+ });
+ ```
+
+ **Step 2 — Create `playwright.config.ts`** with an empty `testDir` pointing at a not-yet-existing `tests/e2e/` directory. Phase 1 installs Playwright but ships no specs (per RESEARCH Standard Stack and CONTEXT — first spec is Phase 2 PIPE-07). Use Write tool:
+ ```typescript
+ import { defineConfig } from '@playwright/test';
+
+ export default defineConfig({
+ testDir: 'tests/e2e',
+ // Phase 1: no specs yet. First spec lands in Phase 2 (PIPE-07).
+ // This config exists so Plan 01 proves Playwright is installed and configured.
+ use: { baseURL: 'http://localhost:5173' },
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:5173',
+ reuseExistingServer: true,
+ timeout: 30_000,
+ },
+ });
+ ```
+
+ **Step 3 — Create a sentinel Vitest test** at `src/__sentinel__.test.ts` that proves the runner works end-to-end. This test will be deleted in Phase 2 (or earlier) once real tests exist; it exists in Phase 1 only to prove `npm test` is wired correctly so Wave 2 plans can rely on it. Use Write tool:
+ ```typescript
+ import { describe, it, expect } from 'vitest';
+
+ describe('phase-1 test infrastructure sentinel', () => {
+ it('vitest is wired and the happy-dom environment is active', () => {
+ expect(1 + 1).toBe(2);
+ // happy-dom provides `window` in the test env, which save tests will rely on
+ // for IndexedDB. Assert it exists so Plan 03 can trust the env.
+ expect(typeof globalThis.window).toBe('object');
+ });
+ });
+ ```
+
+ **Step 4 — Verify the test runs:** `npm test` should produce a green run with exactly 1 test passing.
+
+ **Step 5 — Verify Playwright is wired:** `npx playwright --version` should print a version string starting with `1.59`.
+
+ **Step 6 — Commit `chore(01-01): wire Vitest (happy-dom) and Playwright config + sentinel test`.**
+
+
+ npm test && npx playwright --version
+
+
+ - `vitest.config.ts` exists at repo root and contains `happy-dom` and `passWithNoTests: false` — verify with `grep -q "environment: 'happy-dom'" vitest.config.ts && grep -q "passWithNoTests: false" vitest.config.ts`.
+ - `playwright.config.ts` exists at repo root and references `testDir: 'tests/e2e'` — verify with `grep -q "testDir: 'tests/e2e'" playwright.config.ts`.
+ - `src/__sentinel__.test.ts` exists and contains a passing test — verify with `npm test 2>&1 | grep -E '✓|passed' | head -5`.
+ - `npm test` exits 0 with at least 1 test passing.
+ - `npx playwright --version` prints a version string matching `^Version 1\\.59\\.`.
+ - The sentinel test asserts both `1+1===2` AND that `window` exists (proving happy-dom is active) — verify with `grep -q "globalThis.window" src/__sentinel__.test.ts`.
+
+
+ Vitest configured with happy-dom env and `passWithNoTests:false`; Playwright config installed (no specs); sentinel test passes proving the runner works; `npm test` returns green; commit landed.
+
+
+
+
+
+
+No security-relevant code in this plan; this is scaffold + dev-tooling configuration only. The two threat-model entries identified for Phase 1 (save tampering, malformed Base64 import DoS) are addressed by Plan 03. The `npm install` supply-chain consideration is mitigated by `package-lock.json` being committed — verified by `test -f package-lock.json` in acceptance.
+
+
+
+- `npm run build` succeeds (smoke test for CORE-01).
+- `npm test` runs and the sentinel test passes (smoke test for the test infrastructure).
+- All seven firewall directories exist (structural prerequisite for Plan 02).
+- `package.json` has every script downstream plans require — `lint`, `test`, `validate:assets`, `compile:ink`, `ci` — so Plans 02–06 only need to add config files and source files, not edit `package.json`.
+
+
+
+- Phaser 4 + React 19 + Vite + TS scaffold builds and runs.
+- `src/{sim,render,ui,save,content,audio,store}/` exist with `.gitkeep`.
+- Repo-root `/content/` and `/assets/` exist.
+- All Phase-1 dependencies installed at versions verified in RESEARCH.md (including `fake-indexeddb@^6` for Plan 03 IDB tests).
+- Vitest + Playwright configured; sentinel test passes; `npm test` is green.
+- `package.json` scripts pre-declared for the entire plan set: `dev`, `build`, `preview`, `lint`, `test`, `validate:assets`, `compile:ink`, `ci`.
+
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-PLAN.md
new file mode 100644
index 0000000..e07335f
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-PLAN.md
@@ -0,0 +1,278 @@
+---
+phase: 01
+plan: 02
+type: execute
+wave: 2
+depends_on: [01-01]
+files_modified:
+ - eslint.config.js
+ - src/sim/__test_violation__/violator.ts
+ - src/sim/__test_violation__/.eslintignore-marker
+ - src/sim/__test_violation__/lint-firewall.test.ts
+autonomous: true
+requirements: [CORE-10]
+must_haves:
+ truths:
+ - "ESLint flags any import from `src/sim/` of a module under `src/render/` or `src/ui/` as an error"
+ - "A deliberate-violation fixture file proves the rule fires (lint output contains a `boundaries/element-types` error mentioning 'sim' and 'render' or 'ui')"
+ - "A Vitest test invokes ESLint programmatically against the violator fixture and asserts the boundary error appears in output"
+ - "`npm run lint` on the rest of the codebase exits 0 (the violator is excluded from CI lint via a path-based override or removed-from-default-glob trick)"
+ artifacts:
+ - path: eslint.config.js
+ provides: "ESLint flat config with eslint-plugin-boundaries declaring 9 element types and one rule: sim cannot import from render or ui"
+ contains: "boundaries/element-types"
+ - path: src/sim/__test_violation__/violator.ts
+ provides: "Deliberate sim → render import that the test consumes to assert the rule fires"
+ - path: src/sim/__test_violation__/lint-firewall.test.ts
+ provides: "Vitest test that runs ESLint programmatically against violator.ts and asserts the rule error"
+ key_links:
+ - from: src/sim/
+ to: "src/render/, src/ui/"
+ via: "ESLint boundaries plugin (forbidden import path)"
+ pattern: "boundaries/element-types.*disallow.*\\['render', 'ui'\\]"
+ - from: src/sim/__test_violation__/lint-firewall.test.ts
+ to: src/sim/__test_violation__/violator.ts
+ via: "ESLint Linter API run against the fixture; output asserted to contain `boundaries/element-types`"
+---
+
+
+Lock the architectural firewall in code: install `eslint-plugin-boundaries`, write a flat-config ESLint configuration that declares the seven `src/` element types plus the template's `app`/`game` types, and add exactly one rule — `sim` cannot import from `render` or `ui` (CORE-10). Prove the rule fires by committing a deliberate-violation fixture under `src/sim/__test_violation__/` and a Vitest test that runs ESLint against the fixture and asserts the boundary error appears. Exclude the fixture from the default lint glob so `npm run lint` (which Plan 07's CI workflow runs) stays green for the rest of the codebase.
+
+Purpose: This is the structural enforcement of CLAUDE.md's "architectural firewall (load-bearing)" — the simulation core must not import rendering or UI code, ever. Without this, the offline-catchup math in Phase 2 will silently entangle with React/Phaser and break headless determinism. CONTEXT D-10 explicitly maps the seven directories Plan 01 created onto this lint rule.
+
+Output: A green-on-clean-codebase ESLint config plus a self-contained test that proves the firewall fires. Both go in this plan to satisfy the Nyquist Rule — the rule itself is covered by an automated `` test, not just by "lint exits 0 on clean code" (which proves nothing about the rule actually working).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Migrate to ESLint flat config + add boundaries plugin + define element types and the firewall rule
+
+ eslint.config.js
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md (drift report from Plan 01 — did the template ship `.eslintrc.cjs` or already `eslint.config.js`? what plugins / rules does the template-baseline ESLint use? this determines whether Step 1 is a migration or a creation)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 5: ESLint Boundary Rule" (verbatim flat-config snippet) and § "Common Pitfalls — Pitfall 6: ESLint flat-config plugin imports break with mixed CJS/ESM template baseline"
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-10 — the seven directories the rule lints against)
+ - The existing eslint config at the repo root (whatever Plan 01 left it as): `eslint.config.js` if flat, otherwise `.eslintrc.cjs` / `.eslintrc.js` — read it before writing to preserve template-provided rules.
+
+
+ Per RESEARCH § Pitfall 6: if the Phaser template shipped a legacy `.eslintrc.cjs`, migrate it to a flat `eslint.config.js` first (do not run both — ESLint 9 will produce conflicting messages). If the template already shipped flat config, extend it.
+
+ **Step 1 — Detect the template's ESLint baseline.** Look at Plan 01's SUMMARY for the drift report. Likely outcomes:
+ - **(a) Template shipped flat config (`eslint.config.js`):** read it, extend it (preserve all template rules; append the boundaries plugin block).
+ - **(b) Template shipped legacy (`.eslintrc.cjs` / `.eslintrc.json`):** rewrite into a single flat `eslint.config.js`, port the existing rules, then add the boundaries block. Delete the legacy file.
+
+ **Step 2 — Write `eslint.config.js`.** Use the Write tool with the structure from RESEARCH § "Pattern 5", merged with whatever the template provided. Concrete shape:
+ ```javascript
+ // eslint.config.js — ESLint 9+ flat config
+ import boundaries from 'eslint-plugin-boundaries';
+ import tseslint from 'typescript-eslint'; // if template uses it; omit if not
+ import js from '@eslint/js';
+
+ export default [
+ // 1. Template's existing config goes here (preserve verbatim from drift report).
+ js.configs.recommended,
+ // ...tseslint.configs.recommended (if template used it)...
+
+ // 2. Phase-1 architectural firewall.
+ {
+ files: ['src/**/*.{ts,tsx,js,jsx}'],
+ plugins: { boundaries },
+ settings: {
+ 'boundaries/elements': [
+ { type: 'sim', pattern: 'src/sim/**' },
+ { type: 'render', pattern: 'src/render/**' },
+ { type: 'ui', pattern: 'src/ui/**' },
+ { type: 'save', pattern: 'src/save/**' },
+ { type: 'content', pattern: 'src/content/**' },
+ { type: 'audio', pattern: 'src/audio/**' },
+ { type: 'store', pattern: 'src/store/**' },
+ { type: 'app', pattern: 'src/{main,App,PhaserGame}.{ts,tsx}' },
+ { type: 'game', pattern: 'src/game/**' },
+ ],
+ 'boundaries/include': ['src/**/*'],
+ },
+ rules: {
+ 'boundaries/element-types': ['error', {
+ default: 'allow',
+ rules: [
+ // CORE-10: simulation core cannot reach into render or ui
+ { from: ['sim'], disallow: ['render', 'ui'] },
+ ],
+ }],
+ },
+ },
+
+ // 3. Default-lint exclusion: the test fixture under __test_violation__
+ // intentionally violates the rule for Task 2's assertion. Exclude from `npm run lint`.
+ {
+ ignores: ['src/sim/__test_violation__/**', 'dist/**', 'node_modules/**', 'coverage/**'],
+ },
+ ];
+ ```
+ Per CONTEXT D-10, the element types listed above MUST match exactly: `sim`, `render`, `ui`, `save`, `content`, `audio`, `store`, plus the template's `app` (for `main.tsx`/`App.tsx`/`PhaserGame.tsx`) and `game` (for `src/game/`).
+
+ Per RESEARCH CI Pitfall C, the rule severity MUST be `'error'` (not `'warn'`); `npm run lint` from Plan 01 already includes `--max-warnings 0`.
+
+ Per RESEARCH § "Anti-Patterns to Avoid", `default: 'allow'` is the correct posture — Phase 1 enforces only the one CORE-10 rule, not a closed-by-default architecture.
+
+ **Step 3 — If a legacy `.eslintrc.cjs` / `.eslintrc.json` / `.eslintrc.js` existed, delete it.** Two configs at once break ESLint 9.
+
+ **Step 4 — Run `npm run lint` and confirm exit 0.** It should be green now (the violator fixture from Task 2 doesn't exist yet, and the `ignores` block already excludes the path).
+
+ **Step 5 — Commit `chore(01-02): migrate to ESLint flat config + boundaries plugin + CORE-10 firewall rule`.**
+
+
+ npm run lint && grep -q "boundaries/element-types" eslint.config.js && grep -E "disallow:\s*\[.*\b(render|ui)\b.*\]" eslint.config.js
+
+
+ - `eslint.config.js` exists at repo root — verify with `test -f eslint.config.js`.
+ - `eslint.config.js` contains the string `boundaries/element-types` — verify with `grep -q "boundaries/element-types" eslint.config.js`.
+ - `eslint.config.js` declares all 7 firewall element types: `sim`, `render`, `ui`, `save`, `content`, `audio`, `store` — verify with `grep -cE "type: '(sim|render|ui|save|content|audio|store)'" eslint.config.js | grep -q ^7$` (or count manually if the regex form differs).
+ - The disallow rule forbids both `render` AND `ui` from `sim` (allow whitespace and quote-style variation; both elements may appear in any order) — verify with `grep -E "disallow:\\s*\\[.*\\b(render|ui)\\b.*\\]" eslint.config.js`. The Task 2 firewall test is the load-bearing end-to-end check that proves both elements actually fire.
+ - No legacy `.eslintrc.*` file remains — verify with `! ls .eslintrc.* 2>/dev/null | head -1` returns falsy (or list is empty).
+ - `npm run lint` exits 0 (green on clean codebase, since the violator fixture is excluded by the `ignores` block).
+ - The `ignores` block excludes `src/sim/__test_violation__/**` — verify with `grep -q "__test_violation__" eslint.config.js`.
+
+
+ Single flat ESLint config at repo root with `eslint-plugin-boundaries` declaring all 7 firewall element types plus `app` and `game`, one error-severity rule forbidding `sim` from importing `render` or `ui`, the test-violation directory excluded from default lint, `npm run lint` green, commit landed.
+
+
+
+
+ Task 2: Add deliberate-violation fixture + Vitest test that asserts the boundary rule fires
+
+ src/sim/__test_violation__/violator.ts,
+ src/sim/__test_violation__/lint-firewall.test.ts
+
+
+ - eslint.config.js (verify the `ignores` block from Task 1 already excludes `src/sim/__test_violation__/**`, otherwise this task's deliberate violation will break `npm run lint`)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 5: ESLint Boundary Rule — CI integration" (the assertion shape: run ESLint programmatically, assert the boundary error appears) and § Validation Architecture (CORE-10 row: "static-analysis (CI) — `npm run lint` (with deliberate violator fixture)")
+ - eslint-plugin-boundaries 6.0.2 README — `Linter` / `ESLint` API usage for programmatic invocation
+
+
+ Per Nyquist Rule, the rule needs an automated test (not just "lint exits 0 on clean code, trust me"). Write a fixture that violates the rule and a Vitest test that programmatically runs ESLint against the fixture and asserts the violation is reported.
+
+ **Step 1 — Create the fixture `src/sim/__test_violation__/violator.ts`.** This file deliberately imports from `src/render/` to trigger the rule. Use Write tool:
+ ```typescript
+ // DELIBERATE VIOLATION OF CORE-10 — DO NOT USE OUTSIDE THE FIREWALL TEST.
+ // This file lives under src/sim/__test_violation__/ and is excluded from
+ // `npm run lint` via the `ignores` block in eslint.config.js. Its sole
+ // purpose is to be lint-tested by lint-firewall.test.ts to prove the
+ // boundaries/element-types rule actually fires.
+ //
+ // The import below targets a path under src/render/ which doesn't need to
+ // exist as a real module — ESLint's boundaries plugin lints the import
+ // path against element-type rules without resolving the module.
+ // @ts-expect-error -- this import is not meant to resolve; it exists for the lint test.
+ import { thisDoesNotExist } from '../../render/this-file-does-not-exist';
+
+ export const VIOLATION_MARKER = 'sim-imports-render';
+ export const _ref = thisDoesNotExist;
+ ```
+
+ The `@ts-expect-error` comment suppresses the TypeScript compiler error so `npm run build` continues to work; the lint rule fires before the type-checker sees this file (and the file is also outside the default-build glob since it's under `__test_violation__/`).
+
+ **Step 2 — Create the Vitest test `src/sim/__test_violation__/lint-firewall.test.ts`.** This test runs ESLint programmatically against `violator.ts` and asserts that the output contains a `boundaries/element-types` error mentioning the firewall. Use Write tool:
+ ```typescript
+ import { describe, it, expect } from 'vitest';
+ import { ESLint } from 'eslint';
+ import { resolve } from 'node:path';
+
+ describe('CORE-10: src/sim/ cannot import from src/render/ or src/ui/', () => {
+ it('eslint-plugin-boundaries flags a sim → render import as an error', async () => {
+ // Programmatic ESLint run against the deliberate-violation fixture.
+ // We override `ignore` so ESLint doesn't honor the eslint.config.js
+ // `ignores` block (which exists to keep `npm run lint` green on this fixture).
+ const eslint = new ESLint({
+ overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'),
+ ignore: false,
+ });
+
+ const fixturePath = resolve(process.cwd(), 'src/sim/__test_violation__/violator.ts');
+ const results = await eslint.lintFiles([fixturePath]);
+
+ expect(results).toHaveLength(1);
+ const messages = results[0].messages;
+ const boundaryErrors = messages.filter(
+ (m) => m.ruleId === 'boundaries/element-types' && m.severity === 2,
+ );
+
+ expect(boundaryErrors.length).toBeGreaterThan(0);
+ // The error message should mention 'sim' and either 'render' or 'ui' (the disallowed targets).
+ const combined = boundaryErrors.map((m) => m.message).join(' | ');
+ expect(combined).toMatch(/sim/i);
+ expect(combined).toMatch(/render|ui/i);
+ });
+ });
+ ```
+
+ Per RESEARCH § "Common Pitfalls — Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry" (the principle generalizes): the test must invoke the rule machinery, not just assert "the config file contains the right string."
+
+ **Step 3 — Run the test:** `npm test` should still exit 0 (sentinel test from Plan 01 + this new firewall test = 2 passing tests). The new test asserts the rule fires; it does NOT assert that ESLint exits non-zero on this file directly (which would require a separate process). Instead it inspects the messages via the JS API.
+
+ **Step 4 — Verify `npm run lint` STILL exits 0.** The fixture is excluded by the `ignores` block in `eslint.config.js`, so `npm run lint` doesn't see the violation; only the test sees it (via `ignore: false` override).
+
+ **Step 5 — Commit `test(01-02): add CORE-10 firewall test + violator fixture`.**
+
+
+ npx vitest run src/sim/__test_violation__/lint-firewall.test.ts && npm run lint
+
+
+ - `src/sim/__test_violation__/violator.ts` exists and contains an import from `'../../render/`'` — verify with `grep -q "from '../../render/" src/sim/__test_violation__/violator.ts`.
+ - `src/sim/__test_violation__/lint-firewall.test.ts` exists and references `boundaries/element-types` — verify with `grep -q "boundaries/element-types" src/sim/__test_violation__/lint-firewall.test.ts`.
+ - The test invokes ESLint programmatically (not as a child process) — verify with `grep -q "import { ESLint } from 'eslint'" src/sim/__test_violation__/lint-firewall.test.ts`.
+ - The test asserts the error message mentions both `sim` and `render|ui` — verify with `grep -E "toMatch.*sim|toMatch.*render" src/sim/__test_violation__/lint-firewall.test.ts | wc -l` returns at least 2.
+ - `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` exits 0 with the firewall test passing — this is the load-bearing check that the rule actually fires end-to-end.
+ - `npm run lint` STILL exits 0 (the fixture is excluded by the `ignores` block) — verify exit code 0.
+ - Running `npx eslint --no-config-lookup -c eslint.config.js --no-ignore src/sim/__test_violation__/violator.ts` exits non-zero (proving the rule WOULD fire if the file weren't ignored) — verify with `npx eslint --no-ignore src/sim/__test_violation__/violator.ts; test $? -ne 0`.
+
+
+ Deliberate-violation fixture committed; Vitest test programmatically runs ESLint and asserts the boundary error fires with severity=error and message mentioning sim+render/ui; `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` exits green; `npm run lint` exits 0 because the fixture is in the `ignores` block; commit landed.
+
+
+
+
+
+
+No security-relevant code in this plan; this is static-analysis tooling. The boundary rule's mitigation effect is architectural integrity (preventing the simulation core from being non-deterministic or non-headless), not security. See Plan 03 for the only Phase-1 plan with security-relevant code.
+
+
+
+- `npm run lint` exits 0 on the clean codebase (no actual sim/render imports exist yet — the directories are empty).
+- `npm test` runs the firewall test and the test passes (proving the rule actually fires when invoked against the fixture).
+- Running ESLint directly against the fixture (with `--no-ignore`) exits non-zero (proving the rule is wired correctly outside the test harness).
+- Plan 07's CI workflow will run `npm run lint && npm run test` — both green here means Plan 07 will be green on this rule.
+
+
+
+- ESLint flat config at `eslint.config.js` declares 9 element types and one rule (`sim` cannot import `render` or `ui`).
+- Deliberate violation fixture at `src/sim/__test_violation__/violator.ts` is excluded from `npm run lint` but lint-tested by Vitest.
+- `npm test` includes a `boundaries/element-types` assertion that passes.
+- The firewall is structurally enforced for every future Phase-2 commit.
+
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md
new file mode 100644
index 0000000..6e84511
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md
@@ -0,0 +1,1033 @@
+---
+phase: 01
+plan: 03
+type: execute
+wave: 2
+depends_on: [01-01]
+files_modified:
+ - src/save/checksum.ts
+ - src/save/checksum.test.ts
+ - src/save/envelope.ts
+ - src/save/envelope.test.ts
+ - src/save/migrations.ts
+ - src/save/migrations.test.ts
+ - src/save/db.ts
+ - src/save/db-localstorage-adapter.ts
+ - src/save/db.test.ts
+ - src/save/snapshots.ts
+ - src/save/snapshots.test.ts
+ - src/save/persist.ts
+ - src/save/persist.test.ts
+ - src/save/codec.ts
+ - src/save/round-trip.test.ts
+ - src/save/index.ts
+autonomous: true
+requirements: [CORE-04, CORE-05, CORE-06, CORE-07, CORE-08, CORE-09]
+must_haves:
+ truths:
+ - "A save envelope `{schemaVersion, payload, checksum}` can be wrapped from a payload + version, unwrapped back, and `unwrap` throws on checksum mismatch (CORE-06)"
+ - "Canonical-JSON serialization sorts object keys recursively so the same payload always produces the same checksum across runs"
+ - "A synthetic v0 payload `{garden: []}` migrates to the v1 shape (garden tiles, plants, harvestedFragmentIds, lastTickAt, settings) via the migration registry (CORE-07, per CONTEXT D-04 + D-05)"
+ - "After 5 successive `snapshot()` calls, exactly 3 newest entries remain in the `save_snapshots` IndexedDB store (CORE-08)"
+ - "A v0 envelope can be Base64-exported via lz-string, Base64-imported into a fresh DB, migrated through the chain, and unwrapped to the original payload (CORE-09)"
+ - "`requestPersistence()` returns `{granted: boolean, apiAvailable: boolean}` and handles missing `navigator.storage.persist` gracefully (CORE-05)"
+ - "An IndexedDB save round-trips: `openSaveDB() → put(envelope) → get() → equals original` (CORE-04)"
+ - "CORE-04 IndexedDB-primary + localStorage-fallback both round-trip via Vitest: when `openDB` rejects, the same `get`/`put`/`delete` interface is served by `LocalStorageDBAdapter`, and a stub-injected IDB failure exercises the fallback path"
+ artifacts:
+ - path: src/save/checksum.ts
+ provides: "crc32hex(string) → 8-char lowercase hex CRC-32; canonicalJSON(value) → recursively-key-sorted JSON string"
+ exports: ["crc32hex", "canonicalJSON"]
+ - path: src/save/envelope.ts
+ provides: "wrap(payload, schemaVersion), unwrap(env), SaveEnvelope type, SaveCorruptError class, SaveEnvelopeSchema (Zod)"
+ exports: ["wrap", "unwrap", "SaveEnvelope", "SaveCorruptError", "SaveEnvelopeSchema"]
+ - path: src/save/migrations.ts
+ provides: "migrate(payload, fromVersion) → {payload, toVersion}; CURRENT_SCHEMA_VERSION constant; migrations registry with v0→v1 synthetic demo"
+ exports: ["migrate", "CURRENT_SCHEMA_VERSION", "migrations"]
+ - path: src/save/db.ts
+ provides: "openSaveDB() → SaveDB (IDBPDatabase or LocalStorageDBAdapter); SAVE_DB_NAME constant; two object stores: 'saves' (singleton) + 'save_snapshots' (keyed by id); falls back to LocalStorageDBAdapter on IDB failure (CORE-04)"
+ exports: ["openSaveDB", "SAVE_DB_NAME"]
+ - path: src/save/db-localstorage-adapter.ts
+ provides: "LocalStorageDBAdapter — thin localStorage-backed implementation of the same minimal interface as the IDB DB (get/put/delete on saves + save_snapshots), keyed under tlg.saves.* / tlg.save_snapshots.* (CORE-04 fallback path)"
+ exports: ["LocalStorageDBAdapter"]
+ - path: src/save/snapshots.ts
+ provides: "snapshot(envelope), listSnapshots() — last-3 retention; SnapshotEntry type"
+ exports: ["snapshot", "listSnapshots", "SnapshotEntry"]
+ - path: src/save/persist.ts
+ provides: "requestPersistence() → Promise<{granted, apiAvailable}>"
+ exports: ["requestPersistence", "PersistResult"]
+ - path: src/save/codec.ts
+ provides: "exportToBase64(envelope), importFromBase64(base64) — lz-string round-trip with 50MB DoS cap"
+ exports: ["exportToBase64", "importFromBase64", "MAX_IMPORT_BYTES"]
+ - path: src/save/index.ts
+ provides: "Public re-exports for Phase 2 consumption"
+ key_links:
+ - from: src/save/envelope.ts
+ to: src/save/checksum.ts
+ via: "import { crc32hex, canonicalJSON } from './checksum'"
+ pattern: "import \\{ crc32hex, canonicalJSON \\} from './checksum'"
+ - from: src/save/migrations.ts
+ to: "synthetic v0 payload {garden: []}"
+ via: "migrations[1] receives {garden: any[]} and produces v1 shape per CONTEXT D-04"
+ pattern: "garden:\\s*\\{\\s*tiles:"
+ - from: src/save/snapshots.ts
+ to: src/save/db.ts
+ via: "openSaveDB() — uses 'save_snapshots' object store"
+ pattern: "save_snapshots"
+ - from: src/save/db.ts
+ to: src/save/db-localstorage-adapter.ts
+ via: "openSaveDB wraps openDB() in try/catch and returns LocalStorageDBAdapter when IDB rejects (CORE-04 fallback)"
+ pattern: "LocalStorageDBAdapter"
+ - from: src/save/round-trip.test.ts
+ to: "src/save/codec.ts + envelope.ts + migrations.ts"
+ via: "Full pipeline: wrap → exportToBase64 → importFromBase64 → migrate → unwrap"
+ pattern: "exportToBase64.*importFromBase64.*migrate.*unwrap"
+---
+
+
+**Plan 03 modifies 16 files across 3 tasks — at the upper edge of the per-plan budget.** Recommend `/clear` between tasks if executor context fills (after the Task 1 commit and after the Task 2 commit). Tasks are independently committable; the Wave 2 frontmatter has no other plan depending on intermediate state from this plan, so a context reset between tasks is safe.
+
+
+
+Build the load-bearing save layer for the entire game: envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, a forward-only migration chain seeded with a synthetic v0→v1 demo migration, an `idb`-wrapped IndexedDB DB with two object stores (`saves` + `save_snapshots`) **plus a thin localStorage fallback adapter for CORE-04 when IndexedDB is unavailable** (private mode, blocked by browser, quota exceeded), last-3 pre-migration snapshot retention, `navigator.storage.persist()` with respectful surfacing of `false`, and Base64 export/import via lz-string with a 50MB DoS cap on import. Every behavior is covered by a Vitest unit test, plus a single round-trip test that exercises the full pipeline end-to-end.
+
+Purpose: Phase 2's first feature commit will write the first real save — and Phase 4 will ship the first real `migrate_v1_to_v2`. If the framework here is wrong, every subsequent Season's save migration is broken. CONTEXT D-04 + D-05 + D-06 lock the shape: minimal v1 payload (only what Phase 2 will write), synthetic v0→v1 demo migration to prove the chain, envelope shape locked from CLAUDE.md. RESEARCH § Patterns 1, 2, 3 + Pitfalls 3 (canonical JSON), 5 (lz-string sync caveat), 7 (real migration registry test) provide concrete code. REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 ("with localStorage fallback and `navigator.storage.persist()`") require the fallback to ship in Phase 1; this plan satisfies that with a ~30-LoC adapter + one stub-injected Vitest test.
+
+Output: A complete save subsystem under `src/save/` with one entry point (`src/save/index.ts`), 7 implementation files + 6 test files + 1 codec round-trip test, all Vitest tests passing in the happy-dom environment.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@CLAUDE.md
+@.planning/research/ARCHITECTURE.md
+@.planning/research/PITFALLS.md
+
+
+
+
+From src/save/envelope.ts (this plan creates):
+```typescript
+export interface SaveEnvelope {
+ schemaVersion: number;
+ payload: T;
+ checksum: string; // 8-char lowercase hex CRC-32 over canonicalJSON(payload)
+}
+export class SaveCorruptError extends Error { /* expected, actual */ }
+export function wrap(payload: T, schemaVersion: number): SaveEnvelope;
+export function unwrap(env: SaveEnvelope): T; // throws SaveCorruptError on mismatch
+```
+
+From src/save/migrations.ts:
+```typescript
+export const CURRENT_SCHEMA_VERSION = 1;
+export const migrations: Record unknown>;
+export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number };
+```
+
+From src/save/codec.ts:
+```typescript
+export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // 50MB DoS cap per Phase 1 threat model
+export function exportToBase64(env: SaveEnvelope): string;
+export function importFromBase64(base64: string): SaveEnvelope; // throws on >MAX or invalid
+```
+
+
+
+
+
+
+ Task 1: Checksum + envelope + migrations (the pure-function core)
+
+ src/save/checksum.ts,
+ src/save/checksum.test.ts,
+ src/save/envelope.ts,
+ src/save/envelope.test.ts,
+ src/save/migrations.ts,
+ src/save/migrations.test.ts
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 1: Save Envelope" (verbatim code), § "Pattern 2: Migration Registry" (verbatim code), § "Common Pitfalls — Pitfall 3: JSON key ordering breaks checksums across runs" (canonical-JSON requirement), § "Common Pitfalls — Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry" (the 5 required Vitest assertions for CORE-07)
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-04 — minimal v1 payload shape: garden tiles, plant growth data, harvested fragment IDs, lastTickAt, basic settings; D-05 — synthetic v0→v1 demo migration; D-06 — envelope shape locked, checksum/registry Claude's discretion)
+ - CLAUDE.md "Code Style" — TypeScript strict, no `any` in production; `BigQty` is Phase 2 (do NOT pre-create)
+
+
+ - **checksum.ts:**
+ - Test 1: `crc32hex('hello')` returns the same 8-char lowercase hex string on every call (deterministic).
+ - Test 2: `crc32hex('hello')` and `crc32hex('world')` differ.
+ - Test 3: `canonicalJSON({b:1, a:2})` and `canonicalJSON({a:2, b:1})` are byte-identical.
+ - Test 4: `canonicalJSON` recursively sorts nested object keys.
+ - Test 5: `canonicalJSON` preserves array order (arrays are NOT sorted).
+ - **envelope.ts:**
+ - Test 1: `wrap({foo: 'bar'}, 1)` returns `{schemaVersion: 1, payload: {foo: 'bar'}, checksum: <8-char hex>}`.
+ - Test 2: `unwrap(wrap(p, 1))` deep-equals `p` for several payload shapes.
+ - Test 3: `unwrap` with a tampered checksum throws `SaveCorruptError` with `expected` and `actual` fields.
+ - Test 4: `unwrap` with a tampered payload (checksum mismatched) throws `SaveCorruptError`.
+ - Test 5: `SaveEnvelopeSchema.safeParse` rejects malformed envelopes (missing keys, non-hex checksum).
+ - **migrations.ts:**
+ - Test 1 (the load-bearing one per Pitfall 7): `migrate({garden: [{id: 'tile-1'}]}, 0)` returns `{payload: {garden: {tiles: [{id: 'tile-1'}]}, plants: [], harvestedFragmentIds: [], lastTickAt: , settings: {...}}, toVersion: 1}`.
+ - Test 2: `migrate(, 1)` is a no-op (returns `{payload, toVersion: 1}` unchanged).
+ - Test 3: `migrate(, 99)` throws (no migration to a future version).
+ - Test 4: `migrate(, -1)` throws (no migration registered).
+ - Test 5: `migrations[1]` is invoked exactly once when migrating from v0 to v1 (use a spy/mock or count by replacing `migrations[1]` and asserting call count).
+ - Test 6: `CURRENT_SCHEMA_VERSION === 1` (sanity).
+
+
+ Write each file using the Write tool, copying the patterns from RESEARCH.md verbatim where they exist. Pure-function core — no I/O, no async.
+
+ **Step 1 — `src/save/checksum.ts`** (per RESEARCH Pattern 1 + Pitfall 3):
+ ```typescript
+ import CRC32 from 'crc-32';
+
+ /**
+ * 8-char lowercase hex CRC-32 of the input string.
+ * crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
+ * Used by envelope.wrap/unwrap to detect save corruption.
+ */
+ export function crc32hex(input: string): string {
+ const signed = CRC32.str(input);
+ const unsigned = signed >>> 0; // coerce to uint32
+ return unsigned.toString(16).padStart(8, '0');
+ }
+
+ /**
+ * Deterministic JSON serialization with recursively-sorted object keys.
+ * Required because checksum stability depends on stable key order across
+ * V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips
+ * (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3).
+ * Arrays are NOT sorted (their order is meaningful).
+ */
+ export function canonicalJSON(value: unknown): string {
+ return JSON.stringify(value, (_key, val) => {
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
+ return Object.fromEntries(
+ Object.entries(val as Record).sort(([a], [b]) => a.localeCompare(b)),
+ );
+ }
+ return val;
+ });
+ }
+ ```
+
+ Per RESEARCH Open Question #1, hand-rolled sorted-key recursion is recommended (no `json-stable-stringify` dep). The `>>> 0` coercion converts crc-32's signed return to unsigned per the SheetJS docs.
+
+ **Step 2 — `src/save/checksum.test.ts`** with all 5 behaviors above. Concrete shape:
+ ```typescript
+ import { describe, it, expect } from 'vitest';
+ import { crc32hex, canonicalJSON } from './checksum';
+
+ describe('crc32hex', () => {
+ it('is deterministic', () => {
+ expect(crc32hex('hello')).toBe(crc32hex('hello'));
+ });
+ it('returns 8-char lowercase hex', () => {
+ expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/);
+ });
+ it('differs for different inputs', () => {
+ expect(crc32hex('hello')).not.toBe(crc32hex('world'));
+ });
+ });
+
+ describe('canonicalJSON', () => {
+ it('produces byte-identical output for objects with same keys in any order', () => {
+ expect(canonicalJSON({ b: 1, a: 2 })).toBe(canonicalJSON({ a: 2, b: 1 }));
+ });
+ it('sorts nested object keys recursively', () => {
+ expect(canonicalJSON({ b: { z: 1, a: 2 }, a: 1 }))
+ .toBe(canonicalJSON({ a: 1, b: { a: 2, z: 1 } }));
+ });
+ it('does NOT sort arrays', () => {
+ expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]');
+ });
+ });
+ ```
+
+ **Step 3 — `src/save/envelope.ts`** (per RESEARCH Pattern 1 verbatim, with Zod schema added):
+ ```typescript
+ import { z } from 'zod';
+ import { crc32hex, canonicalJSON } from './checksum';
+
+ export const SaveEnvelopeSchema = z.object({
+ schemaVersion: z.number().int().nonnegative(),
+ payload: z.unknown(),
+ checksum: z.string().regex(/^[0-9a-f]{8}$/),
+ });
+ export type SaveEnvelope = z.infer & { payload: T };
+
+ export class SaveCorruptError extends Error {
+ readonly name = 'SaveCorruptError';
+ constructor(public readonly expected: string, public readonly actual: string) {
+ super(`Save checksum mismatch: expected ${expected}, got ${actual}`);
+ }
+ }
+
+ export function wrap(payload: T, schemaVersion: number): SaveEnvelope {
+ return {
+ schemaVersion,
+ payload,
+ checksum: crc32hex(canonicalJSON(payload)),
+ };
+ }
+
+ export function unwrap(env: SaveEnvelope): T {
+ const expected = crc32hex(canonicalJSON(env.payload));
+ if (expected !== env.checksum) {
+ throw new SaveCorruptError(env.checksum, expected);
+ }
+ return env.payload as T;
+ }
+ ```
+ Per CONTEXT D-04: zero (the synthetic v0) is a valid `schemaVersion`, so the Zod refinement uses `nonnegative` not `positive`. RESEARCH Pattern 1's example uses `positive` but that conflicts with D-05's synthetic-v0 requirement — use `nonnegative`.
+
+ **Step 4 — `src/save/envelope.test.ts`** with all 5 behaviors above.
+
+ **Step 5 — `src/save/migrations.ts`** (per RESEARCH Pattern 2 verbatim):
+ ```typescript
+ type Migration = (payload: unknown) => unknown;
+
+ export const CURRENT_SCHEMA_VERSION = 1;
+
+ /**
+ * Forward-only migration chain. Each entry migrates FROM (key-1) TO key.
+ * - migrations[1] = v0 → v1 (synthetic demo per CONTEXT D-05).
+ * - migrations[2] = v1 → v2 will be added in Phase 4 when Roothold/prestige state lands.
+ *
+ * v0 was a hypothetical prior shape `{garden: []}`; v1 is the minimal Phase-2 shape per CONTEXT D-04:
+ * garden tiles, plants, harvested fragment IDs, lastTickAt, settings.
+ */
+ export const migrations: Record = {
+ 1: (s: unknown) => {
+ const v0 = (s ?? {}) as { garden?: unknown[] };
+ return {
+ garden: { tiles: v0.garden ?? [] },
+ plants: [],
+ harvestedFragmentIds: [],
+ lastTickAt: Date.now(),
+ settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 },
+ };
+ },
+ };
+
+ export function migrate(
+ payload: unknown,
+ fromVersion: number,
+ ): { payload: unknown; toVersion: number } {
+ if (fromVersion < 0) {
+ throw new Error(`Cannot migrate from negative version ${fromVersion}`);
+ }
+ let current = payload;
+ let v = fromVersion;
+ while (v < CURRENT_SCHEMA_VERSION) {
+ const next = v + 1;
+ const fn = migrations[next];
+ if (!fn) {
+ throw new Error(`No migration registered for v${v} → v${next}`);
+ }
+ current = fn(current);
+ v = next;
+ }
+ if (v > CURRENT_SCHEMA_VERSION) {
+ throw new Error(`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`);
+ }
+ return { payload: current, toVersion: v };
+ }
+ ```
+ Note: the future-version throw protects against `migrate(p, 99)` per RESEARCH Pitfall 7 assertion #3.
+
+ **Step 6 — `src/save/migrations.test.ts`** with all 6 behaviors above. Particularly:
+ ```typescript
+ it('synthetic v0 payload migrates to v1 shape (CONTEXT D-04 + D-05)', () => {
+ const v0 = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
+ const result = migrate(v0, 0);
+ expect(result.toVersion).toBe(1);
+ expect(result.payload).toMatchObject({
+ garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
+ plants: [],
+ harvestedFragmentIds: [],
+ lastTickAt: expect.any(Number),
+ settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) },
+ });
+ });
+ ```
+
+ **Step 7 — Run `npm test` and confirm all 16 tests in this task pass (5+5+6).**
+
+ **Step 8 — Commit `feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0→v1 migration`.**
+
+
+ npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts
+
+
+ - All 6 files exist: `for f in checksum envelope migrations; do test -f src/save/$f.ts && test -f src/save/$f.test.ts; done`.
+ - `src/save/checksum.ts` exports both `crc32hex` and `canonicalJSON` — verify with `grep -E "^export function (crc32hex|canonicalJSON)" src/save/checksum.ts | wc -l` returns 2.
+ - `src/save/envelope.ts` exports `wrap`, `unwrap`, `SaveCorruptError`, `SaveEnvelopeSchema` — verify with `grep -cE "^export (function|class|const) (wrap|unwrap|SaveCorruptError|SaveEnvelopeSchema)" src/save/envelope.ts` returns 4.
+ - `src/save/migrations.ts` exports `migrate`, `CURRENT_SCHEMA_VERSION`, `migrations` — verify with `grep -cE "^export (function|const) (migrate|CURRENT_SCHEMA_VERSION|migrations)" src/save/migrations.ts` returns 3.
+ - `migrations.ts` v0→v1 migration produces the v1 shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/migrations.ts | grep -v '^#' | wc -l` returns at least 5.
+ - `npm test` for these 3 files passes 16 tests total — verify with `npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts 2>&1 | grep -E "16 passed|Tests *16"`.
+ - No `any` types in production code (excluding test files) — verify with `grep -nE ': any\\b' src/save/checksum.ts src/save/envelope.ts src/save/migrations.ts`; expect zero matches (CLAUDE.md TypeScript-strict rule).
+
+
+ Pure-function save core (checksum, envelope, migrations) implemented per RESEARCH Patterns 1 + 2; 16 Vitest tests covering all RESEARCH Pitfall 7 assertions plus canonical-JSON determinism plus checksum-mismatch throw; no `any` in production; commit landed.
+
+
+
+
+ Task 2: idb DB + localStorage fallback adapter (CORE-04) + snapshots (last-3 retention) + persist API
+
+ src/save/db.ts,
+ src/save/db-localstorage-adapter.ts,
+ src/save/db.test.ts,
+ src/save/snapshots.ts,
+ src/save/snapshots.test.ts,
+ src/save/persist.ts,
+ src/save/persist.test.ts
+
+
+ - c:/Users/josh1/Documents/Code/TheLastGarden/package.json (Plan 01 Task 1 already installed `fake-indexeddb@^6` as a devDependency — confirm `grep -q '"fake-indexeddb"' package.json` exits 0 before writing the IDB tests)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 3: Last-3 Pre-Migration Snapshots" (verbatim code), § "Code Examples — Persist API call with respectful surfacing" (verbatim code)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Common Pitfalls — Pitfall 2: navigator.storage.persist() returns false on iOS Safari most of the time"
+ - .planning/research/PITFALLS.md #8 (storage eviction; multi-layer write requirement)
+ - REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 (the orchestrator authorized implementing the fallback in Phase 1; this task ships ~30 LoC + 1 test)
+ - src/save/envelope.ts (read the SaveEnvelope type from Task 1 — snapshots.ts and db.ts need it)
+ - idb 8.0.3 README: openDB upgrade callback shape, transaction API
+
+
+ - **db.ts:**
+ - Test 1: `openSaveDB()` returns an IDBPDatabase with two object stores: `saves` and `save_snapshots`.
+ - Test 2: `saves` store uses keyPath `'id'` (singleton; only one save per slot).
+ - Test 3: `save_snapshots` store uses keyPath `'id'`.
+ - Test 4: `put` + `get` round-trips a SaveEnvelope without modification.
+ - **Test 5 (CORE-04 fallback): when `openDB` rejects (stub-injected), `openSaveDB()` returns a `LocalStorageDBAdapter` and the same `put`/`get` round-trip succeeds against `localStorage`.**
+ - **db-localstorage-adapter.ts:**
+ - Implements the minimal interface used by the rest of the save layer (`get(store, key)`, `put(store, value)`, `delete(store, key)`, `getAll(store)`, plus a `transaction()` helper that proxies to direct localStorage operations — snapshots.ts uses `db.transaction('save_snapshots', 'readwrite').objectStore(...)`).
+ - Namespaces keys under `tlg.saves.` and `tlg.save_snapshots.`.
+ - JSON-encodes values; throws on missing.
+ - **snapshots.ts:**
+ - Test 1: After 1 `snapshot()` call, `listSnapshots()` returns 1 entry.
+ - Test 2 (the load-bearing one for CORE-08 per Pitfall 7 #5): After 5 successive `snapshot()` calls, `listSnapshots()` returns exactly 3 entries, in newest-first order.
+ - Test 3: Each pruned (deleted) entry has the oldest `savedAt` timestamps.
+ - Test 4: `listSnapshots()` on an empty store returns `[]`.
+ - **persist.ts:**
+ - Test 1: When `navigator.storage.persist` exists and resolves true, returns `{granted: true, apiAvailable: true}`.
+ - Test 2: When `navigator.storage.persist` exists and resolves false, returns `{granted: false, apiAvailable: true}`.
+ - Test 3: When `navigator.storage.persist` throws, returns `{granted: false, apiAvailable: true}`.
+ - Test 4: When `navigator.storage` is missing entirely, returns `{granted: false, apiAvailable: false}`.
+
+
+ **Step 1 — `src/save/db-localstorage-adapter.ts`** (~30-40 LoC). The adapter implements the minimal interface that `snapshots.ts` and Phase 2's save consumer will call. Use Write tool:
+ ```typescript
+ import type { SaveEnvelope } from './envelope';
+
+ /**
+ * CORE-04 fallback path. When IndexedDB is unavailable (private mode, blocked
+ * by browser, quota exceeded), `openSaveDB()` returns this adapter instead of
+ * an IDBPDatabase. The interface intersects with what snapshots.ts and Phase 2's
+ * save consumer actually call — get/put/delete on the two stores (`saves`,
+ * `save_snapshots`) plus a transaction helper that, for localStorage, is a
+ * straight-through proxy (no real transaction semantics — single-threaded
+ * synchronous storage).
+ *
+ * Per .planning/research/PITFALLS.md #8, multi-layer storage is the v1 contract;
+ * IndexedDB is primary, localStorage is the fallback when IDB throws.
+ */
+
+ type StoreName = 'saves' | 'save_snapshots';
+
+ interface SavedRecord {
+ id: string;
+ envelope: SaveEnvelope;
+ savedAt: string;
+ }
+
+ interface SnapshotRecord {
+ id: string;
+ schemaVersion: number;
+ savedAt: string;
+ envelope: SaveEnvelope;
+ }
+
+ type RecordOf = S extends 'saves' ? SavedRecord : SnapshotRecord;
+
+ function nsKey(store: StoreName, id: string): string {
+ return `tlg.${store}.${id}`;
+ }
+
+ function nsPrefix(store: StoreName): string {
+ return `tlg.${store}.`;
+ }
+
+ export class LocalStorageDBAdapter {
+ readonly objectStoreNames = {
+ contains: (s: string) => s === 'saves' || s === 'save_snapshots',
+ };
+
+ async get(store: S, key: string): Promise | undefined> {
+ const raw = localStorage.getItem(nsKey(store, key));
+ return raw ? (JSON.parse(raw) as RecordOf) : undefined;
+ }
+
+ async put(store: S, value: RecordOf): Promise {
+ localStorage.setItem(nsKey(store, value.id), JSON.stringify(value));
+ }
+
+ async delete(store: StoreName, key: string): Promise {
+ localStorage.removeItem(nsKey(store, key));
+ }
+
+ async getAll(store: S): Promise[]> {
+ const prefix = nsPrefix(store);
+ const out: RecordOf[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k && k.startsWith(prefix)) {
+ const raw = localStorage.getItem(k);
+ if (raw) out.push(JSON.parse(raw) as RecordOf);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Transaction shim — localStorage has no real transactions. We expose the
+ * same shape as idb's transaction() so snapshots.ts's `db.transaction(...).objectStore(...)`
+ * pattern works against both backends. `done` resolves immediately because
+ * each set/remove is its own atomic operation.
+ */
+ transaction(store: StoreName, _mode: 'readwrite' | 'readonly') {
+ const adapter = this;
+ return {
+ objectStore: (s: StoreName) => ({
+ put: (value: RecordOf) => adapter.put(s, value),
+ get: (key: string) => adapter.get(s, key),
+ delete: (key: string) => adapter.delete(s, key),
+ getAll: () => adapter.getAll(s),
+ }),
+ done: Promise.resolve(),
+ };
+ }
+ }
+ ```
+
+ **Step 2 — `src/save/db.ts`:**
+ ```typescript
+ import { openDB, type IDBPDatabase } from 'idb';
+ import type { SaveEnvelope } from './envelope';
+ import { LocalStorageDBAdapter } from './db-localstorage-adapter';
+
+ export const SAVE_DB_NAME = 'tlg-save';
+ const DB_VERSION = 1;
+
+ export interface SavedRecord {
+ id: 'main'; // singleton key — Phase 1 ships one save slot only
+ envelope: SaveEnvelope;
+ savedAt: string; // ISO8601
+ }
+
+ export interface SnapshotRecord {
+ id: string; // `${schemaVersion}-${savedAt}`
+ schemaVersion: number;
+ savedAt: string;
+ envelope: SaveEnvelope;
+ }
+
+ export interface SaveDBSchema {
+ saves: { key: string; value: SavedRecord };
+ save_snapshots: { key: string; value: SnapshotRecord };
+ }
+
+ /**
+ * Type union of the two backends — IndexedDB primary, localStorage fallback.
+ * Phase 2's save consumer only calls the methods both backends implement
+ * (`get`, `put`, `delete`, `getAll`, `transaction`).
+ */
+ export type SaveDB = IDBPDatabase | LocalStorageDBAdapter;
+
+ /**
+ * Opens the save DB. Tries IndexedDB first; on rejection (private mode, blocked,
+ * quota exceeded — anything that makes openDB throw), falls back to a
+ * LocalStorageDBAdapter that exposes the same minimal interface.
+ *
+ * CORE-04: "IndexedDB-primary with localStorage fallback".
+ * Tested in db.test.ts via stub-injected openDB rejection.
+ */
+ export async function openSaveDB(): Promise {
+ try {
+ return await openDB(SAVE_DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains('saves')) {
+ db.createObjectStore('saves', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('save_snapshots')) {
+ db.createObjectStore('save_snapshots', { keyPath: 'id' });
+ }
+ },
+ });
+ } catch (err) {
+ // IDB unavailable — fall back to localStorage. Phase 2's settings UI
+ // will surface a "running on localStorage" notice when this path triggers
+ // (per .planning/research/PITFALLS.md #8 multi-layer write requirement).
+ console.warn('[save] IndexedDB unavailable, falling back to localStorage:', err);
+ return new LocalStorageDBAdapter();
+ }
+ }
+ ```
+
+ Per RESEARCH § "Don't Hand-Roll", `idb` is the right wrapper. The two-store split (`saves` + `save_snapshots`) is per RESEARCH Pattern 3 — snapshots are kept separate so migrating the main save never affects the snapshot history. The localStorage fallback adapter mirrors the same two stores, namespaced under `tlg.saves.*` / `tlg.save_snapshots.*`.
+
+ **Step 3 — `src/save/db.test.ts`:** Plan 01 Task 1 already installed `fake-indexeddb@^6` (verify with `grep -q '"fake-indexeddb"' package.json` before authoring); import it directly:
+ ```typescript
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
+ import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill (installed by Plan 01)
+ import { openSaveDB, SAVE_DB_NAME } from './db';
+ import { wrap } from './envelope';
+ import { LocalStorageDBAdapter } from './db-localstorage-adapter';
+
+ beforeEach(async () => {
+ // Reset IDB and localStorage between tests
+ indexedDB.deleteDatabase(SAVE_DB_NAME);
+ localStorage.clear();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ });
+
+ describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
+ it('opens a DB with saves and save_snapshots object stores', async () => {
+ const db = await openSaveDB();
+ expect(db.objectStoreNames.contains('saves')).toBe(true);
+ expect(db.objectStoreNames.contains('save_snapshots')).toBe(true);
+ });
+
+ it('round-trips a SaveEnvelope through saves store', async () => {
+ const db = await openSaveDB();
+ const envelope = wrap({ hello: 'world' }, 1);
+ await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() });
+ const retrieved = await db.get('saves', 'main');
+ expect(retrieved?.envelope).toEqual(envelope);
+ });
+ });
+
+ describe('openSaveDB (CORE-04 localStorage fallback path)', () => {
+ it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => {
+ // Stub the idb module's openDB so it rejects, simulating private mode / blocked IDB.
+ vi.doMock('idb', async () => ({
+ openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')),
+ }));
+ // Re-import db.ts after the mock is registered so it picks up the rejecting openDB.
+ const { openSaveDB: openSaveDBFresh } = await import('./db');
+
+ const db = await openSaveDBFresh();
+ expect(db).toBeInstanceOf(LocalStorageDBAdapter);
+
+ // Round-trip works against localStorage
+ const envelope = wrap({ fallback: true }, 1);
+ await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() });
+ const retrieved = await db.get('saves', 'main');
+ expect(retrieved?.envelope).toEqual(envelope);
+
+ // Verify it actually wrote to localStorage (not just memory)
+ expect(localStorage.getItem('tlg.saves.main')).toBeTruthy();
+
+ vi.doUnmock('idb');
+ });
+ });
+ ```
+
+ **Step 4 — `src/save/snapshots.ts`** (per RESEARCH Pattern 3 verbatim, but consuming the union `SaveDB` type so it works against both backends):
+ ```typescript
+ import { openSaveDB } from './db';
+ import type { SaveEnvelope } from './envelope';
+
+ export interface SnapshotEntry {
+ id: string;
+ schemaVersion: number;
+ savedAt: string;
+ envelope: SaveEnvelope;
+ }
+
+ const RETAIN = 3;
+
+ /**
+ * Write a pre-migration snapshot. After every write, prune to the 3 newest
+ * entries by savedAt (descending). Works against both IDB and localStorage backends.
+ */
+ export async function snapshot(envelope: SaveEnvelope): Promise {
+ const db = await openSaveDB();
+ const tx = db.transaction('save_snapshots', 'readwrite');
+ const store = tx.objectStore('save_snapshots');
+ const savedAt = new Date().toISOString();
+ // Make ID unique even if two snapshots fire in the same ms (rare in tests)
+ const id = `${envelope.schemaVersion}-${savedAt}-${Math.random().toString(36).slice(2, 8)}`;
+ await store.put({ id, schemaVersion: envelope.schemaVersion, savedAt, envelope });
+
+ // Prune
+ const all = await store.getAll();
+ const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
+ const toDelete = sorted.slice(RETAIN);
+ await Promise.all(toDelete.map((e) => store.delete(e.id)));
+ await tx.done;
+ }
+
+ export async function listSnapshots(): Promise {
+ const db = await openSaveDB();
+ const all = await db.getAll('save_snapshots');
+ return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
+ }
+ ```
+
+ **Step 5 — `src/save/snapshots.test.ts`** with the 4 behaviors above. The CORE-08 test:
+ ```typescript
+ import { describe, it, expect, beforeEach } from 'vitest';
+ import 'fake-indexeddb/auto';
+ import { snapshot, listSnapshots } from './snapshots';
+ import { wrap } from './envelope';
+ import { SAVE_DB_NAME } from './db';
+
+ beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));
+
+ describe('CORE-08: last-3 snapshot retention', () => {
+ it('retains exactly 3 newest entries after 5 successive snapshot calls', async () => {
+ for (let i = 0; i < 5; i++) {
+ await snapshot(wrap({ generation: i }, 1));
+ await new Promise((r) => setTimeout(r, 2)); // ensure savedAt timestamps differ
+ }
+ const list = await listSnapshots();
+ expect(list).toHaveLength(3);
+ // Newest first: payloads should be {generation:4}, {generation:3}, {generation:2}
+ expect(list.map((e) => (e.envelope.payload as any).generation)).toEqual([4, 3, 2]);
+ });
+ });
+ ```
+
+ **Step 6 — `src/save/persist.ts`** (per RESEARCH § "Code Examples — Persist API call with respectful surfacing" verbatim):
+ ```typescript
+ export interface PersistResult {
+ granted: boolean;
+ apiAvailable: boolean;
+ }
+
+ /**
+ * Request persistent storage from the browser. Returns granted=true only if
+ * the browser actually granted persistence (Chrome/Firefox/Edge mostly will;
+ * iOS Safari mostly will NOT — see .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+ * Pitfall 2 + .planning/research/PITFALLS.md #8). Caller (Phase 2 settings UI)
+ * surfaces apiAvailable=false / granted=false respectfully.
+ */
+ export async function requestPersistence(): Promise {
+ if (typeof navigator === 'undefined' || !('storage' in navigator) || !navigator.storage || !('persist' in navigator.storage)) {
+ return { granted: false, apiAvailable: false };
+ }
+ try {
+ const granted = await navigator.storage.persist();
+ return { granted, apiAvailable: true };
+ } catch {
+ return { granted: false, apiAvailable: true };
+ }
+ }
+ ```
+
+ **Step 7 — `src/save/persist.test.ts`** with the 4 behaviors. Use Vitest's `vi.stubGlobal` to mock `navigator.storage.persist` per case:
+ ```typescript
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
+ import { requestPersistence } from './persist';
+
+ describe('requestPersistence (CORE-05)', () => {
+ beforeEach(() => vi.unstubAllGlobals());
+
+ it('returns granted=true when navigator.storage.persist resolves true', async () => {
+ vi.stubGlobal('navigator', { storage: { persist: async () => true } });
+ expect(await requestPersistence()).toEqual({ granted: true, apiAvailable: true });
+ });
+
+ it('returns granted=false when navigator.storage.persist resolves false', async () => {
+ vi.stubGlobal('navigator', { storage: { persist: async () => false } });
+ expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true });
+ });
+
+ it('returns granted=false when persist throws', async () => {
+ vi.stubGlobal('navigator', { storage: { persist: async () => { throw new Error('boom'); } } });
+ expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true });
+ });
+
+ it('returns apiAvailable=false when navigator.storage is missing', async () => {
+ vi.stubGlobal('navigator', {});
+ expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: false });
+ });
+ });
+ ```
+
+ **Step 8 — Verify all tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts`.**
+
+ **Step 9 — Commit `feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + navigator.storage.persist API`.**
+
+
+ npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts
+
+
+ - Precondition: `fake-indexeddb` is listed in devDependencies (installed by Plan 01 Task 1) — verify with `grep -q '"fake-indexeddb"' package.json` (this is a precondition assertion, not a new install).
+ - `src/save/db-localstorage-adapter.ts` exists and exports `LocalStorageDBAdapter` — verify with `grep -q "^export class LocalStorageDBAdapter" src/save/db-localstorage-adapter.ts`.
+ - `LocalStorageDBAdapter` namespaces under `tlg.saves.*` and `tlg.save_snapshots.*` — verify with `grep -E "tlg\\.(saves|save_snapshots)\\." src/save/db-localstorage-adapter.ts | wc -l` returns at least 2.
+ - `src/save/db.ts` opens a DB with stores `saves` AND `save_snapshots` — verify with `grep -E "createObjectStore\\('(saves|save_snapshots)'" src/save/db.ts | wc -l` returns 2.
+ - `src/save/db.ts` wraps `openDB` in try/catch and returns `LocalStorageDBAdapter` on failure (CORE-04 fallback) — verify with `grep -q "LocalStorageDBAdapter" src/save/db.ts && grep -E "catch\\b" src/save/db.ts`.
+ - `src/save/db.test.ts` includes a test that stub-injects an IDB failure and verifies the fallback path round-trips — verify with `grep -q "vi.doMock" src/save/db.test.ts && grep -q "LocalStorageDBAdapter" src/save/db.test.ts && grep -q "tlg.saves.main" src/save/db.test.ts`.
+ - `src/save/snapshots.ts` exports `snapshot` and `listSnapshots` — verify with `grep -cE "^export (async )?function (snapshot|listSnapshots)" src/save/snapshots.ts` returns 2.
+ - `RETAIN` constant in snapshots.ts is exactly 3 — verify with `grep -E "RETAIN\\s*=\\s*3" src/save/snapshots.ts`.
+ - `src/save/persist.ts` exports `requestPersistence` and `PersistResult` — verify with `grep -cE "^export (function|interface|type) (requestPersistence|PersistResult)" src/save/persist.ts` returns 2.
+ - The CORE-08 test asserts `toHaveLength(3)` after 5 writes — verify with `grep -q "toHaveLength(3)" src/save/snapshots.test.ts`.
+ - All 4 persist.test.ts cases stub `navigator.storage` — verify with `grep -cE "vi.stubGlobal" src/save/persist.test.ts` returns at least 4.
+ - All tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts 2>&1 | grep -E "passed"`.
+ - No `any` types in production files (test files may use `as any` for stub typing only) — verify with `grep -nE ': any\\b' src/save/db.ts src/save/db-localstorage-adapter.ts src/save/snapshots.ts src/save/persist.ts`; zero matches.
+
+
+ `idb`-wrapped IndexedDB with both stores; `LocalStorageDBAdapter` (~30-40 LoC) implementing the same minimal interface for the CORE-04 fallback path; `openSaveDB()` returns the IDB DB on success and the adapter on rejection; one Vitest test stub-injects an IDB failure and exercises the localStorage round-trip end-to-end (verifies `tlg.saves.main` key is written); last-3 snapshot retention with the CORE-08 5-then-3 invariant test; persist API with all 4 navigator.storage scenarios covered; commit landed.
+
+
+
+
+ Task 3: Base64 codec + round-trip integration test + index re-exports
+
+ src/save/codec.ts,
+ src/save/round-trip.test.ts,
+ src/save/index.ts
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Code Examples — Save round-trip (Phase 1's load-bearing test)" (verbatim test) and § "Common Pitfalls — Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread"
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Security Domain" (Phase 1 threat: malformed Base64 import — DoS via huge inflated string; cap payload size at 50MB before decompression)
+ - src/save/envelope.ts (this plan's Task 1 — wrap/unwrap signatures)
+ - src/save/migrations.ts (this plan's Task 1 — migrate signature)
+ - lz-string 1.5.0 README: compressToBase64 / decompressFromBase64 semantics
+
+
+ - **codec.ts:**
+ - Test 1: `exportToBase64(env)` returns a non-empty string.
+ - Test 2: `importFromBase64(exportToBase64(env))` deep-equals the original envelope.
+ - Test 3: `importFromBase64('not-valid-base64-junk')` throws (malformed import detection).
+ - Test 4: `importFromBase64()` throws BEFORE decompression (DoS cap).
+ - **round-trip.test.ts (the load-bearing CORE-09 test):**
+ - The full pipeline per RESEARCH § "Save round-trip" verbatim: synthesize a v0 envelope, export to Base64, simulate a "fresh browser" by importing back from Base64, migrate v0 → v1, wrap in a v1 envelope with valid checksum, unwrap, assert original payload returned.
+ - Bonus: write the migrated v1 envelope to IDB, read it back, unwrap, assert equality (proves IDB + envelope + migration + codec all integrate).
+ - **index.ts:**
+ - Re-exports the public surface for Phase 2 consumption.
+
+
+ **Step 1 — `src/save/codec.ts`:**
+ ```typescript
+ import LZString from 'lz-string';
+ import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope';
+
+ /**
+ * 50MB cap on Base64 import string length, per Phase 1 threat model
+ * (.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Security Domain
+ * — malformed Base64 import / DoS via huge inflated string).
+ * lz-string.decompressFromBase64 has bounded output for bounded input;
+ * still, refuse pathologically large inputs at the boundary.
+ */
+ export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
+
+ /**
+ * Export a SaveEnvelope to a Base64 text blob suitable for "Settings → Export".
+ * Phase 1 ships the function pair; Phase 2 wires the UI button (CORE-09).
+ */
+ export function exportToBase64(envelope: SaveEnvelope): string {
+ return LZString.compressToBase64(JSON.stringify(envelope));
+ }
+
+ /**
+ * Import from a Base64 text blob. Throws on:
+ * - input larger than MAX_IMPORT_BYTES (DoS cap)
+ * - lz-string decompression failure
+ * - JSON parse failure
+ * - SaveEnvelopeSchema validation failure (malformed envelope shape)
+ *
+ * Note: this does NOT verify checksum or run migrations — the caller chains
+ * importFromBase64 → migrate → unwrap. See round-trip.test.ts for the full pipeline.
+ */
+ export function importFromBase64(base64: string): SaveEnvelope {
+ if (base64.length > MAX_IMPORT_BYTES) {
+ throw new Error(`Import payload exceeds ${MAX_IMPORT_BYTES} bytes (got ${base64.length})`);
+ }
+ const decompressed = LZString.decompressFromBase64(base64);
+ if (!decompressed) {
+ throw new Error('Failed to decompress Base64 import (malformed input)');
+ }
+ const parsed = JSON.parse(decompressed);
+ const validated = SaveEnvelopeSchema.safeParse(parsed);
+ if (!validated.success) {
+ throw new Error(`Imported envelope failed schema validation: ${validated.error.message}`);
+ }
+ return validated.data as SaveEnvelope;
+ }
+ ```
+ Per RESEARCH Pitfall 5: lz-string is synchronous; for Phase 1 saves (<10KB) this is fine. Document the eventual mitigation as a code comment so Phase 8 perf work knows where to look. Do NOT build the Web Worker now (premature per CONTEXT D-09 minimum-viable directive).
+
+ **Step 2 — `src/save/round-trip.test.ts`** (per RESEARCH § "Code Examples — Save round-trip" verbatim, extended with IDB integration):
+ ```typescript
+ import { describe, it, expect, beforeEach } from 'vitest';
+ import 'fake-indexeddb/auto';
+ import { wrap, unwrap } from './envelope';
+ import { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
+ import { exportToBase64, importFromBase64 } from './codec';
+ import { openSaveDB, SAVE_DB_NAME } from './db';
+
+ beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));
+
+ describe('CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip', () => {
+ it('synthetic v0 envelope migrates, round-trips through Base64, validates, persists', async () => {
+ // Pretend a player had an old v0 save lying around (CONTEXT D-05 synthetic v0).
+ const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
+ // v0 envelope: schemaVersion 0, with a placeholder checksum that we won't verify
+ // (the v0 era didn't have our checksum scheme).
+ const v0Envelope = {
+ schemaVersion: 0,
+ payload: v0Payload,
+ checksum: '00000000', // 8-char hex placeholder
+ };
+
+ // Export through Base64 codec
+ const exported = exportToBase64(v0Envelope as any);
+ expect(exported.length).toBeGreaterThan(0);
+
+ // Import (simulating a fresh browser) — note: import returns a parsed envelope
+ // that PASSES our SaveEnvelopeSchema (schemaVersion 0 is allowed since z.number().nonnegative()).
+ const imported = importFromBase64(exported);
+ expect(imported.schemaVersion).toBe(0);
+
+ // Migrate the imported payload
+ const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion);
+ expect(toVersion).toBe(CURRENT_SCHEMA_VERSION);
+ expect(payload).toMatchObject({
+ garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
+ plants: [],
+ harvestedFragmentIds: [],
+ lastTickAt: expect.any(Number),
+ settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) },
+ });
+
+ // Re-wrap with current version and a valid checksum
+ const v1Envelope = wrap(payload, toVersion);
+ expect(unwrap(v1Envelope)).toEqual(payload);
+
+ // Persist to IDB and read back (CORE-04)
+ const db = await openSaveDB();
+ await db.put('saves', { id: 'main', envelope: v1Envelope, savedAt: new Date().toISOString() });
+ const retrieved = await db.get('saves', 'main');
+ expect(retrieved?.envelope).toEqual(v1Envelope);
+ expect(unwrap(retrieved!.envelope)).toEqual(payload);
+ });
+
+ it('rejects oversized Base64 import (DoS cap)', () => {
+ const huge = 'A'.repeat(50 * 1024 * 1024 + 1);
+ expect(() => importFromBase64(huge)).toThrow(/exceeds/);
+ });
+
+ it('rejects malformed Base64', () => {
+ expect(() => importFromBase64('not-valid-base64-)(*&^%$')).toThrow();
+ });
+ });
+ ```
+
+ **Step 3 — `src/save/index.ts`** — public re-exports for Phase 2:
+ ```typescript
+ /**
+ * Public surface of the save layer. Phase 2's tick scheduler + Zustand store
+ * are the first consumers.
+ */
+ export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
+ export type { SaveEnvelope } from './envelope';
+ export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
+ export { snapshot, listSnapshots } from './snapshots';
+ export type { SnapshotEntry } from './snapshots';
+ export { requestPersistence } from './persist';
+ export type { PersistResult } from './persist';
+ export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec';
+ export { openSaveDB, SAVE_DB_NAME } from './db';
+ export type { SaveDB, SaveDBSchema, SavedRecord, SnapshotRecord } from './db';
+ export { LocalStorageDBAdapter } from './db-localstorage-adapter';
+ export { crc32hex, canonicalJSON } from './checksum';
+ ```
+
+ **Step 4 — Run the full save test suite: `npx vitest run src/save/`.** Expect all tests across 8 files (checksum, envelope, migrations, db, snapshots, persist, round-trip; LocalStorageDBAdapter is exercised by db.test.ts) to pass.
+
+ **Step 5 — Run `npm test`** to confirm the entire test suite (sentinel + lint-firewall from Plan 02 + all save tests) passes.
+
+ **Step 6 — Run `npm run build`** to confirm the save layer compiles cleanly with TypeScript strict.
+
+ **Step 7 — Commit `feat(01-03): Base64 codec + DoS-capped import + full save round-trip integration test + index re-exports`.**
+
+
+ npx vitest run src/save/ && npm run build
+
+
+ - `src/save/codec.ts` exports `exportToBase64`, `importFromBase64`, `MAX_IMPORT_BYTES` — verify with `grep -cE "^export (function|const) (exportToBase64|importFromBase64|MAX_IMPORT_BYTES)" src/save/codec.ts` returns 3.
+ - `MAX_IMPORT_BYTES` is exactly 50MB — verify with `grep -E "MAX_IMPORT_BYTES\\s*=\\s*50\\s*\\*\\s*1024\\s*\\*\\s*1024" src/save/codec.ts`.
+ - `importFromBase64` validates against `SaveEnvelopeSchema` — verify with `grep -q "SaveEnvelopeSchema.safeParse" src/save/codec.ts`.
+ - `src/save/index.ts` exports the full public surface including `LocalStorageDBAdapter` — verify with `grep -cE "^export " src/save/index.ts` returns at least 11 (wrap, unwrap, SaveCorruptError, migrate, snapshot, requestPersistence, exportToBase64, importFromBase64, openSaveDB, LocalStorageDBAdapter, crc32hex, etc).
+ - The round-trip test asserts on the v0→v1 migration shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/round-trip.test.ts | grep -v '^#' | wc -l` returns at least 5.
+ - The round-trip test exercises EXPORT → IMPORT → MIGRATE → WRAP → UNWRAP → IDB PUT → IDB GET — verify with `grep -E "exportToBase64|importFromBase64|migrate|wrap|unwrap|openSaveDB|db\\.put|db\\.get" src/save/round-trip.test.ts | wc -l` returns at least 7 (one per stage).
+ - The DoS cap is tested — verify with `grep -q "50 \\* 1024 \\* 1024 + 1" src/save/round-trip.test.ts`.
+ - `npx vitest run src/save/` passes ALL tests — verify exit 0; expect roughly 16+5+4+2+3 = ~30 tests across 7 test files (db.test.ts now has 1 extra fallback test).
+ - `npm run build` exits 0 — TypeScript strict compilation passes including the full save layer.
+ - No `any` types in production code (codec.ts, index.ts) — verify with `grep -nE ': any\\b' src/save/codec.ts src/save/index.ts`; zero matches.
+
+
+ Base64 export/import codec with 50MB DoS cap; full round-trip test exercising every save layer file (CORE-04 + 06 + 07 + 09 in one go); public re-exports (including `LocalStorageDBAdapter` and `SaveDB` union type) indexed for Phase 2; entire save test suite green under `npm test`; `npm run build` succeeds under TypeScript strict; commit landed.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Disk → IndexedDB | Saved envelopes loaded back from storage; data may have been corrupted in lossy storage (Chrome eviction, partial write, browser crash). Mitigated by CRC-32 envelope checksum. |
+| User → Base64 import | A pasted Base64 string from "Settings → Import" (Phase 2 wires the UI; Phase 1 ships the function). User is single-player but the input is still untrusted bytes. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-01-01 | Tampering | `unwrap()` of save envelope | mitigate | CRC-32 checksum over canonical JSON detects corruption (lossy storage, partial writes); throws `SaveCorruptError` on mismatch. **Not** a cryptographic guarantee — a player editing their own save is by-design acceptable in a single-player contemplative game per RESEARCH § Security Domain. Phase 2's UI surfaces the recovery option (last-3 snapshots from CORE-08) when this throws. |
+| T-01-02 | Denial of Service | `importFromBase64()` | mitigate | Cap input length at `MAX_IMPORT_BYTES = 50 * 1024 * 1024` BEFORE invoking `lz-string.decompressFromBase64` (which is synchronous and would block the main thread on huge inputs per RESEARCH Pitfall 5 + § Security Domain). Throws an Error with `/exceeds/` message when input exceeds cap. Tested in round-trip.test.ts. |
+| T-01-03 | Tampering | Save authentication (player edits Base64 export and reimports) | accept | Single-player game; no leaderboards, no monetization gates in Phase 1; player tampering with their own save is the player's prerogative. Documented explicitly in `codec.ts` and RESEARCH § "Phase 1 explicit security non-goals". CRC-32 detects corruption, NOT adversarial editing — by design. |
+| T-01-04 | Information Disclosure | Save contents | accept | Phase 1 saves contain no PII (no Keeper name per STRY-07; no auth, no sessions). Garden state, plant data, harvested fragment IDs are non-sensitive. |
+| T-01-05 | Spoofing | Cross-origin import via URL params (future risk if save-via-link is added) | accept (out of scope) | Phase 1 has no URL import mechanism. Flagged here for Phase 4+ when Settings UI is wired: import flow MUST require explicit user confirmation, NEVER auto-load from URL. |
+
+
+
+- `npx vitest run src/save/` passes every test (target: ≥30 tests across 7 test files; db.test.ts now includes the CORE-04 localStorage fallback test).
+- `npm run build` exits 0 under TypeScript strict (no `any` in production code).
+- `npm run lint` exits 0 (the save layer respects the firewall — no `import` from `src/render/` or `src/ui/`; this would also fire the Plan 02 boundary rule).
+- All 6 CORE requirements (CORE-04 through CORE-09) have at least one Vitest assertion explicitly named or commented as covering them. CORE-04 specifically covers BOTH the IDB-primary path AND the localStorage-fallback path.
+- The CRC-32 envelope and the 50MB DoS cap satisfy Phase 1's two STRIDE-mitigate threats.
+
+
+
+- Save envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, exported via wrap/unwrap and tested for round-trip + tamper-detection.
+- Migration chain with CURRENT_SCHEMA_VERSION = 1 and one synthetic v0 → v1 demo migration that produces the v1 shape from CONTEXT D-04.
+- IDB DB with two object stores: `saves` (singleton) + `save_snapshots` (last-3 retention).
+- `LocalStorageDBAdapter` implementing the same minimal interface as the IDB DB; `openSaveDB()` falls back to the adapter when IDB is unavailable (CORE-04 IndexedDB-primary + localStorage-fallback contract).
+- `requestPersistence()` covers all four navigator.storage scenarios.
+- Base64 export/import via lz-string with a 50MB DoS cap.
+- Full round-trip test covers every component end-to-end.
+- All 6 Phase-1 CORE save requirements automated and green.
+
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-04-content-pipeline-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-04-content-pipeline-PLAN.md
new file mode 100644
index 0000000..e718919
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-04-content-pipeline-PLAN.md
@@ -0,0 +1,510 @@
+---
+phase: 01
+plan: 04
+type: execute
+wave: 2
+depends_on: [01-01]
+files_modified:
+ - src/content/schemas/fragment.ts
+ - src/content/schemas/season.ts
+ - src/content/schemas/index.ts
+ - src/content/loader.ts
+ - src/content/loader.test.ts
+ - src/content/index.ts
+ - content/seasons/00-demo/fragments.yaml
+ - content/seasons/00-demo/.gitkeep
+ - content/README.md
+ - content/dialogue/.gitkeep
+autonomous: true
+requirements: [PIPE-01, STRY-09]
+must_haves:
+ truths:
+ - "A `/content/seasons//fragments.yaml` file with frontmatter parses, validates against the Zod fragment schema, and is available as a typed `Fragment[]` import (PIPE-01)"
+ - "A deliberately-malformed content file (e.g., numeric ID instead of string) FAILS Vitest's loader.test.ts assertion (proving the build will fail on schema violation per PIPE-01)"
+ - "Fragment IDs match the regex `^season\\d+\\.[a-z0-9._-]+$` per CLAUDE.md stable-string-ID rule"
+ - "The `compile:ink` npm script exists, runs successfully against an empty `/content/dialogue/` directory, and is a no-op (per CONTEXT D-08 deferral and RESEARCH § Pattern 4 — Ink files in Phase 1)"
+ - "The `/content/` repo-root convention is established with one demo fragment, a README explaining the shape, and the dialogue directory ready for Phase 2 (STRY-09 convention prerequisite)"
+ artifacts:
+ - path: src/content/schemas/fragment.ts
+ provides: "FragmentSchema (Zod), Fragment type, ID regex `^season\\d+\\.[a-z0-9._-]+$`"
+ exports: ["FragmentSchema", "Fragment"]
+ - path: src/content/schemas/season.ts
+ provides: "SeasonContentSchema (Zod) — wraps fragments[]"
+ exports: ["SeasonContentSchema", "SeasonContent"]
+ - path: src/content/schemas/index.ts
+ provides: "Re-exports for schemas/"
+ - path: src/content/loader.ts
+ provides: "Vite-native glob loader using import.meta.glob for /content/seasons/*/fragments.yaml + /content/seasons/*/fragments/*.md; Zod-validated at module-eval time"
+ exports: ["fragments", "loadFragmentsFromGlob"]
+ - path: src/content/index.ts
+ provides: "Public surface for Phase 2 consumers"
+ - path: content/seasons/00-demo/fragments.yaml
+ provides: "One demo fragment proving the round-trip; removed in Phase 2 when real Season 1 content lands"
+ contains: "season0.demo"
+ - path: content/README.md
+ provides: "Explains the /content/ convention: directory shape, frontmatter rules, ID convention, where things go in Phase 2"
+ key_links:
+ - from: src/content/loader.ts
+ to: "/content/seasons/*/fragments.yaml"
+ via: "import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })"
+ pattern: "import.meta.glob\\('/content/seasons/\\*/fragments\\.yaml'"
+ - from: src/content/loader.ts
+ to: src/content/schemas/season.ts
+ via: "SeasonContentSchema.safeParse — throws on schema violation, failing the build"
+ pattern: "SeasonContentSchema.safeParse"
+---
+
+
+Stand up the Vite-native content pipeline: Zod schemas for `Fragment` (with the `season.` stable-string-ID regex) and `SeasonContent` (wraps `fragments[]`); a `loader.ts` that uses `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })` to import every YAML file at build time, parses each via the `yaml` package, and validates via Zod at module-evaluation time so any schema violation fails `npm run build`. Ship one demo fragment under `/content/seasons/00-demo/fragments.yaml` to prove the round-trip end-to-end. Verify the `compile:ink` no-op script runs cleanly. Author a `content/README.md` documenting the convention so Phase 2 has a contract to write Season 1 fragments against.
+
+Purpose: Phase 2 will pour Season 1 fragments into `/content/seasons/01-soil/`. Without this loader the build doesn't see them; without the schemas the writer can ship typos that compile and explode at runtime. CONTEXT D-11 and CLAUDE.md "Code Style" lock the `/content/` repo-root location and stable-string-ID convention. RESEARCH § Pattern 4 + Pitfall 1 (literal-glob requirement) provide concrete code.
+
+Output: A complete content pipeline: schemas, loader, demo fragment, README, all under `src/content/` and `/content/`. One Vitest test that validates against a mocked schema-violating fragment to prove the build will fail (PIPE-01).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Zod schemas + loader + demo fragment + content README
+
+ src/content/schemas/fragment.ts,
+ src/content/schemas/season.ts,
+ src/content/schemas/index.ts,
+ src/content/loader.ts,
+ src/content/index.ts,
+ content/seasons/00-demo/fragments.yaml,
+ content/seasons/00-demo/.gitkeep,
+ content/dialogue/.gitkeep,
+ content/README.md
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 4: Vite-Native Content Pipeline" (verbatim code for FragmentSchema, SeasonContentSchema, loader.ts) and § "Common Pitfalls — Pitfall 1: import.meta.glob requires literal patterns"
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-11 — `/content/` at repo root; D-08 — Ink deferred to Phase 2)
+ - CLAUDE.md "Code Style" — stable string fragment IDs (`season3.canopy.lura_07.vignette`, never numeric)
+ - REQUIREMENTS.md MEMR-03 — fragment ID regex requirement (so the schema regex is correct for Phase 2)
+
+
+ - **fragment.ts:**
+ - The `id` field must match `^season\d+\.[a-z0-9._-]+$` (a Phase-1 demo can use `season0.demo`).
+ - The `season` field must be an integer in [0, 7] (allowing 0 for the Phase-1 demo; Phase 2 will narrow to [1, 7] when Season 1 lands).
+ - The `body` field must be a non-empty string.
+ - **loader.ts:**
+ - At module evaluation time, calls `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })`.
+ - Parses each YAML string and validates against `SeasonContentSchema`.
+ - Throws on any validation failure (the throw fails `npm run build` — that's PIPE-01).
+ - Exports a flat `fragments: Fragment[]` containing all loaded fragments.
+ - **content/seasons/00-demo/fragments.yaml:** one demo fragment with valid shape.
+
+
+ **Step 1 — `src/content/schemas/fragment.ts`** (per RESEARCH Pattern 4 verbatim, with the regex from CLAUDE.md and the season range expanded to allow the Phase-1 demo):
+ ```typescript
+ import { z } from 'zod';
+
+ /**
+ * Fragment ID convention (CLAUDE.md "Code Style"): stable string,
+ * `season.` where uses lowercase + digits + dot/underscore/hyphen.
+ * Example: `season3.canopy.lura_07.vignette`.
+ * Never numeric. Renames are forbidden.
+ *
+ * Phase 1 allows season 0 for the demo fragment under /content/seasons/00-demo/;
+ * Phase 2 will narrow the range when real Season 1 content arrives.
+ */
+ export const FragmentSchema = z.object({
+ id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
+ season: z.number().int().min(0).max(7),
+ body: z.string().min(1),
+ });
+ export type Fragment = z.infer;
+ ```
+
+ **Step 2 — `src/content/schemas/season.ts`:**
+ ```typescript
+ import { z } from 'zod';
+ import { FragmentSchema } from './fragment';
+
+ /** Shape of one /content/seasons//fragments.yaml file. */
+ export const SeasonContentSchema = z.object({
+ fragments: z.array(FragmentSchema),
+ });
+ export type SeasonContent = z.infer;
+ ```
+
+ **Step 3 — `src/content/schemas/index.ts`:**
+ ```typescript
+ export { FragmentSchema, type Fragment } from './fragment';
+ export { SeasonContentSchema, type SeasonContent } from './season';
+ ```
+
+ **Step 4 — `src/content/loader.ts`** (per RESEARCH Pattern 4 verbatim, simplified — Phase 1 only loads YAML; Markdown handling is wired here but won't fire until Phase 2 since /content/seasons/*/fragments/*.md doesn't exist yet):
+ ```typescript
+ import grayMatter from 'gray-matter';
+ import { parse as parseYAML } from 'yaml';
+ import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas';
+
+ /**
+ * Vite-native content pipeline (PIPE-01). The glob patterns MUST be
+ * string literals at the call site — Vite's plugin walks the AST at build
+ * time and cannot resolve runtime expressions
+ * (.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 1).
+ *
+ * On any schema violation, the throw at module-evaluation time bubbles up
+ * through Vite into the build process — `npm run build` exits non-zero,
+ * which is the PIPE-01 contract.
+ */
+ const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
+ eager: true,
+ query: '?raw',
+ import: 'default',
+ }) as Record;
+
+ const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
+ eager: true,
+ query: '?raw',
+ import: 'default',
+ }) as Record;
+
+ function loadYamlFragments(): Fragment[] {
+ return Object.entries(yamlFiles).flatMap(([path, raw]) => {
+ const data = parseYAML(raw);
+ const parsed = SeasonContentSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
+ }
+ return parsed.data.fragments;
+ });
+ }
+
+ function loadMdFragments(): Fragment[] {
+ return Object.entries(mdFiles).map(([path, raw]) => {
+ const { data, content } = grayMatter(raw);
+ const merged = { ...data, body: content.trim() };
+ const parsed = FragmentSchema.safeParse(merged);
+ if (!parsed.success) {
+ throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
+ }
+ return parsed.data;
+ });
+ }
+
+ /**
+ * All fragments discovered at build time. Phase 1 ships one demo fragment
+ * under /content/seasons/00-demo/fragments.yaml; Phase 2 fills /content/seasons/01-soil/.
+ */
+ export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()];
+
+ /**
+ * Test-only helper that lets loader.test.ts validate a mocked SeasonContent
+ * shape against the schema without touching the filesystem. PIPE-01 is
+ * enforced at build by the throws above; this helper exists so the unit
+ * test can prove the schema rejects bad input.
+ */
+ export function loadFragmentsFromGlob(
+ yamlGlob: Record,
+ mdGlob: Record = {},
+ ): Fragment[] {
+ const yaml = Object.entries(yamlGlob).flatMap(([path, raw]) => {
+ const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
+ if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`);
+ return parsed.data.fragments;
+ });
+ const md = Object.entries(mdGlob).map(([path, raw]) => {
+ const { data, content } = grayMatter(raw);
+ const parsed = FragmentSchema.safeParse({ ...data, body: content.trim() });
+ if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`);
+ return parsed.data;
+ });
+ return [...yaml, ...md];
+ }
+ ```
+
+ **Step 5 — `src/content/index.ts`:**
+ ```typescript
+ export { fragments, loadFragmentsFromGlob } from './loader';
+ export { FragmentSchema, SeasonContentSchema, type Fragment, type SeasonContent } from './schemas';
+ ```
+
+ **Step 6 — `content/seasons/00-demo/fragments.yaml`** (one valid demo fragment):
+ ```yaml
+ # /content/seasons/00-demo/fragments.yaml
+ # Phase 1 demo fragment — proves the loader round-trips end-to-end.
+ # Removed in Phase 2 when real Season 1 content lands under /content/seasons/01-soil/.
+ fragments:
+ - id: season0.demo.first-light
+ season: 0
+ body: |
+ The garden remembers the first time it was tended,
+ though it cannot say in whose voice.
+ ```
+ Also create `content/seasons/00-demo/.gitkeep` (in case the YAML is removed in Phase 2 and we want the dir to persist) — actually skip the .gitkeep since Phase 2 will replace the whole directory anyway. Just the YAML file is enough.
+
+ **Step 7 — `content/dialogue/.gitkeep`** (already created by Plan 01; verify it exists so the no-op `compile:ink` script has a target).
+
+ **Step 8 — `content/README.md`:**
+ ```markdown
+ # /content/ — authored content tree
+
+ All player-visible strings, memory fragments, and dialogue live here, never in
+ `src/`. The build pipeline (`src/content/loader.ts`) reads this tree at build
+ time, validates against Zod schemas, and emits typed values into the runtime
+ bundle.
+
+ ## Directory shape
+
+ ```
+ /content/
+ ├── seasons/
+ │ ├── 00-demo/ # Phase 1 only; removed in Phase 2
+ │ │ └── fragments.yaml
+ │ ├── 01-soil/ # Phase 2 fills this
+ │ │ ├── fragments.yaml # bulk-authored fragments
+ │ │ └── fragments/ # one-per-file long-form fragments (.md with frontmatter)
+ │ │ └── lura-first-letter.md
+ │ ├── 02-roots/ # Phase 4
+ │ └── ... # Seasons 3–7 added in Phase 5+
+ ├── dialogue/ # Phase 2+ Ink (.ink) files
+ │ └── (empty in Phase 1)
+ └── README.md (this file)
+ ```
+
+ ## Fragment ID convention (locked — see CLAUDE.md)
+
+ Fragment IDs are stable strings of the shape:
+
+ ```
+ season.
+ ```
+
+ where `` is `0..7` and `` matches `[a-z0-9._-]+`. Examples:
+
+ - `season1.soil.first-bloom`
+ - `season3.canopy.lura_07.vignette`
+
+ **Never use numeric IDs.** Renames are forbidden once a fragment ships;
+ re-authoring an existing fragment changes its body, never its ID.
+
+ ## Adding fragments
+
+ ### Option A — bulk YAML (preferred for short fragments)
+
+ Add an entry to `/content/seasons//fragments.yaml`:
+
+ ```yaml
+ fragments:
+ - id: season1.soil.first-bloom
+ season: 1
+ body: |
+ Multi-line text here.
+ ```
+
+ ### Option B — one-per-file Markdown with frontmatter (for longer pieces)
+
+ Create `/content/seasons//fragments/.md`:
+
+ ```markdown
+ ---
+ id: season1.soil.lura-first-letter
+ season: 1
+ ---
+
+ The body of the fragment goes here as Markdown. Frontmatter holds the
+ structured fields; the body is everything after the closing `---`.
+ ```
+
+ ## Validation (PIPE-01)
+
+ Every fragment is validated by the Zod schema in
+ `src/content/schemas/fragment.ts`. A schema violation fails `npm run build`.
+
+ ## Ink dialogue
+
+ Phase 1 installs `inkjs` + `inklecate` and ships a no-op `npm run compile:ink`
+ script. Phase 2 begins authoring `.ink` files under `/content/dialogue/` and
+ replaces the no-op with `inklecate -o src/content/compiled-ink/ content/dialogue/*.ink`.
+
+ ## Deferred
+
+ - Per-Season lazy loading: Phase 2 switches to `{ eager: false }` for
+ Seasons 2–7 so the initial bundle contains only Season 1 (PIPE-02).
+ ```
+
+ **Step 9 — Verify `npm run build` succeeds** (the loader runs at build time and the demo fragment passes the schema, so the build is clean).
+
+ **Step 10 — Verify `npm run compile:ink` succeeds with the no-op stub** (set up in Plan 01; this should print the placeholder message and exit 0).
+
+ **Step 11 — Commit `feat(01-04): Vite-native content pipeline + Zod schemas + demo fragment + /content/ README`.**
+
+
+ npm run build && npm run compile:ink
+
+
+ - All 4 schema/loader files exist: `test -f src/content/schemas/fragment.ts && test -f src/content/schemas/season.ts && test -f src/content/schemas/index.ts && test -f src/content/loader.ts && test -f src/content/index.ts`.
+ - `FragmentSchema` enforces the stable-string ID regex `^season\\d+\\.[a-z0-9._-]+$` — verify with `grep -F "^season\\d+\\." src/content/schemas/fragment.ts` (the regex appears in the file).
+ - `loader.ts` calls `import.meta.glob` with literal patterns (per Pitfall 1) — verify with `grep -E "import\\.meta\\.glob\\('/content/seasons/\\*/" src/content/loader.ts | wc -l` returns 2 (one for yaml, one for md).
+ - `loader.ts` throws on schema violation — verify with `grep -q "throw new Error" src/content/loader.ts`.
+ - `content/seasons/00-demo/fragments.yaml` exists with a valid `season0.demo` fragment — verify with `grep -q "season0.demo" content/seasons/00-demo/fragments.yaml`.
+ - `content/README.md` exists and documents the ID convention — verify with `grep -q "season" content/README.md && grep -q "Never use numeric IDs" content/README.md`.
+ - `content/dialogue/.gitkeep` exists (Plan 01 created it; Plan 04 confirms it survives) — verify with `test -f content/dialogue/.gitkeep`.
+ - `npm run build` exits 0 (the loader runs and the demo fragment passes).
+ - `npm run compile:ink` exits 0 (no-op stub from Plan 01) — verify exit code 0.
+ - `src/content/index.ts` exports the public surface — verify with `grep -cE "^export " src/content/index.ts` returns at least 4.
+
+
+ Zod schemas for Fragment + SeasonContent with the stable-string-ID regex; Vite-native loader using `import.meta.glob` with literal patterns; one demo fragment proving end-to-end round-trip; `content/README.md` documenting the convention for Phase 2 writers; `npm run build` and `npm run compile:ink` both green; commit landed.
+
+
+
+
+ Task 2: PIPE-01 enforcement test — schema violation must throw
+
+ src/content/loader.test.ts
+
+
+ - src/content/loader.ts (this plan's Task 1 — the `loadFragmentsFromGlob` helper)
+ - src/content/schemas/fragment.ts (the FragmentSchema regex — pick a violator that's clearly invalid)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Validation Architecture (PIPE-01 row: "Demo content file with deliberate schema violation fails the build")
+
+
+ - Test 1: `loadFragmentsFromGlob({})` returns `[]` (empty glob, no fragments).
+ - Test 2: `loadFragmentsFromGlob` with a valid YAML mock returns the parsed fragments.
+ - Test 3 (the load-bearing PIPE-01 test): `loadFragmentsFromGlob` with a mocked YAML containing a numeric `id` (violates the stable-string-ID regex) THROWS, and the error message contains `[content] schema violation`.
+ - Test 4: `loadFragmentsFromGlob` with a mocked YAML containing a `season` value out of [0,7] range THROWS.
+ - Test 5: `loadFragmentsFromGlob` with a mocked Markdown frontmatter that omits required `id` THROWS.
+
+
+ Per RESEARCH § Validation Architecture, PIPE-01 is tested via "Vitest run with mocked import.meta.glob". The exported `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper from Task 1 makes this trivial — no need to mock Vite or run the build; we pass mocked glob outputs directly.
+
+ Use the Write tool to create `src/content/loader.test.ts`:
+ ```typescript
+ import { describe, it, expect } from 'vitest';
+ import { loadFragmentsFromGlob } from './loader';
+
+ describe('PIPE-01: content schema validation', () => {
+ it('returns [] when both globs are empty', () => {
+ expect(loadFragmentsFromGlob({}, {})).toEqual([]);
+ });
+
+ it('parses valid YAML fragments', () => {
+ const yamlGlob = {
+ '/content/seasons/00-demo/fragments.yaml': `
+fragments:
+ - id: season0.demo.test
+ season: 0
+ body: "demo body"
+`,
+ };
+ const result = loadFragmentsFromGlob(yamlGlob);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ id: 'season0.demo.test',
+ season: 0,
+ body: 'demo body',
+ });
+ });
+
+ it('THROWS on a numeric-id violation (stable-string-ID rule)', () => {
+ const yamlGlob = {
+ '/content/seasons/01-soil/fragments.yaml': `
+fragments:
+ - id: 42
+ season: 1
+ body: "this should fail because id must be a string matching the season. regex"
+`,
+ };
+ expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/);
+ });
+
+ it('THROWS when season is out of [0,7] range', () => {
+ const yamlGlob = {
+ '/content/seasons/99-bogus/fragments.yaml': `
+fragments:
+ - id: season99.bogus.test
+ season: 99
+ body: "season 99 doesn't exist"
+`,
+ };
+ expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/);
+ });
+
+ it('THROWS when Markdown frontmatter omits required id', () => {
+ const mdGlob = {
+ '/content/seasons/01-soil/fragments/no-id.md': `---
+season: 1
+---
+
+Body text without an id frontmatter key.
+`,
+ };
+ expect(() => loadFragmentsFromGlob({}, mdGlob)).toThrow(/\[content\] schema violation/);
+ });
+ });
+ ```
+
+ **Step 2 — Run `npx vitest run src/content/loader.test.ts`** and confirm all 5 tests pass (the 3 throw assertions plus the 2 happy-path).
+
+ **Step 3 — Run `npm test`** and confirm the full Phase-1 suite (sentinel + lint-firewall + save layer + content loader) is green.
+
+ **Step 4 — Commit `test(01-04): PIPE-01 enforcement — schema violations throw at content load`.**
+
+
+ npx vitest run src/content/loader.test.ts && npm test
+
+
+ - `src/content/loader.test.ts` exists and imports `loadFragmentsFromGlob` from `./loader` — verify with `grep -q "loadFragmentsFromGlob" src/content/loader.test.ts`.
+ - The test contains at least 3 `expect(() => ...).toThrow` assertions — verify with `grep -c "toThrow" src/content/loader.test.ts` returns at least 3.
+ - One throw assertion matches the numeric-id case — verify with `grep -E "id:\\s*42" src/content/loader.test.ts`.
+ - One throw assertion matches the out-of-range season case — verify with `grep -E "season:\\s*99" src/content/loader.test.ts`.
+ - The throw error messages contain `[content] schema violation` — verify with `grep -q "\\[content\\] schema violation" src/content/loader.test.ts`.
+ - `npx vitest run src/content/loader.test.ts` passes 5 tests — verify exit 0.
+ - `npm test` passes for the entire suite — verify exit 0.
+
+
+ Vitest test for PIPE-01 covering 5 cases (2 happy-path + 3 schema violations); the throws prove `npm run build` would fail on equivalent real-file violations; `npm test` passes the entire Phase-1 suite; commit landed.
+
+
+
+
+
+
+Per RESEARCH § Security Domain, content pipeline threats are minimal. Path traversal via `import.meta.glob` (a malicious content file with `../../` in frontmatter) is not exploitable: Vite glob expansion is at build time; the validator step never resolves paths from frontmatter values. No security-relevant runtime code in this plan.
+
+
+
+- `npm run build` exits 0 (loader runs at build time, demo fragment validates).
+- `npm run compile:ink` exits 0 (no-op stub).
+- `npx vitest run src/content/loader.test.ts` passes 5 tests including 3 schema-violation throws (PIPE-01).
+- `npm test` runs the entire Phase-1 suite green.
+- Plan 06 (doctrine docs) and Plan 07 (CI workflow) will both depend on `npm run build` succeeding — this plan unblocks them.
+
+
+
+- Zod schemas for Fragment (with stable-string-ID regex) and SeasonContent.
+- Vite-native loader using `import.meta.glob` with literal patterns (Pitfall 1 honored).
+- Loader throws on schema violation at module-eval time, failing `npm run build`.
+- One demo fragment under `/content/seasons/00-demo/` proves the round-trip.
+- `content/README.md` documents the convention for Phase 2 writers (STRY-09 prerequisite).
+- 5 Vitest assertions covering happy-path + 3 schema-violation cases.
+- `compile:ink` no-op stub from Plan 01 confirmed runnable.
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-05-asset-provenance-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-05-asset-provenance-PLAN.md
new file mode 100644
index 0000000..583c862
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-05-asset-provenance-PLAN.md
@@ -0,0 +1,411 @@
+---
+phase: 01
+plan: 05
+type: execute
+wave: 2
+depends_on: [01-01]
+files_modified:
+ - scripts/validate-assets.mjs
+ - scripts/validate-assets.test.ts
+ - assets/north-stars/.gitkeep
+ - assets/north-stars/README.md
+ - assets/__samples__/refused/no-provenance.png
+ - assets/__samples__/refused/.gitkeep
+autonomous: false
+requirements: [AEST-08, AEST-09, PIPE-03]
+must_haves:
+ truths:
+ - "`node scripts/validate-assets.mjs` exits 0 when every asset under `/assets/` (excluding `__samples__/refused/`) carries a sibling `.provenance.json` validating against the Zod sidecar schema"
+ - "The validator script exits non-zero with a clear error message when any asset under `/assets/` (excluding refused/) lacks a sidecar (proving the gate works — AEST-09 + PIPE-03)"
+ - "The provenance schema enforces all 6 required fields per CLAUDE.md / AEST-08: `model_id`, `checkpoint_hash`, `prompt`, `seed`, `sampler`, `params`, plus an optional `provenance_schema_version: number` for forward-compat (per RESEARCH Open Question #2)"
+ - "10–20 hand-curated north-star reference images are committed under `assets/north-stars/` with valid provenance sidecars (CONTEXT D-01) — OR — if no AI tool is available, a documented fallback set with provenance fields filled honestly (`model_id: 'human'`, etc., per RESEARCH Open Question #5 + Environment Availability fallback) is committed"
+ - "A refused-sample asset under `assets/__samples__/refused/no-provenance.png` proves the gate by being explicitly excluded from validation (CONTEXT D-03)"
+ - "A Vitest test runs the validator script against an isolated `os.tmpdir()` fixture directory containing a deliberately-missing-provenance file and asserts the script exits non-zero, with no risk of polluting the real `/assets/` tree"
+ artifacts:
+ - path: scripts/validate-assets.mjs
+ provides: "Standalone Node script (~30 lines) that walks /assets/ (or whatever ASSETS_DIR points at), pairs each non-sidecar non-.gitkeep file with .provenance.json, validates against ProvenanceSchema, exits non-zero on missing/invalid"
+ - path: scripts/validate-assets.test.ts
+ provides: "Vitest integration test that creates an isolated per-run fixture under os.tmpdir(), runs the validator with ASSETS_DIR pointing at the tmpdir as a subprocess, asserts exit code"
+ - path: assets/north-stars/README.md
+ provides: "Explains the north-star reference set: what these images are, how to add new ones, the sidecar naming convention"
+ - path: assets/__samples__/refused/no-provenance.png
+ provides: "Sample image with NO sidecar; validator must exclude this directory; existence proves the gate works (CONTEXT D-03)"
+ - path: "assets/north-stars/*.png"
+ provides: "10–20 hand-curated reference images establishing the watercolor visual north-star (CONTEXT D-01)"
+ key_links:
+ - from: scripts/validate-assets.mjs
+ to: "assets/**/*"
+ via: "node:fs/promises readdir + sibling sidecar lookup"
+ pattern: "readdir.*assets|walk\\(ASSETS\\)"
+ - from: scripts/validate-assets.mjs
+ to: "assets/__samples__/refused/"
+ via: "Hardcoded REFUSED exclusion list"
+ pattern: "REFUSED|__samples__/refused"
+ - from: scripts/validate-assets.test.ts
+ to: scripts/validate-assets.mjs
+ via: "child_process.execFile against the script with ASSETS_DIR= + assert exit code"
+ pattern: "spawn\\(.*node.*validate-assets|execFile|os\\.tmpdir"
+---
+
+
+Build the AI asset provenance pipeline floor: a 30-line standalone Node script (`scripts/validate-assets.mjs`) that walks `/assets/` (or whatever `ASSETS_DIR` env var points at), validates each asset has a sibling `.provenance.json` file matching a Zod schema covering the 6 required fields per CLAUDE.md + AEST-08 (`model_id`, `checkpoint_hash`, `prompt`, `seed`, `sampler`, `params`) plus an optional forward-compat `provenance_schema_version` field. Excludes `assets/__samples__/refused/` so a sample sidecarless image can prove the gate exists. Commits 10–20 hand-curated north-star reference images establishing the visual style (watercolor; real-but-slightly-wrong flora) with provenance sidecars per CONTEXT D-01 — OR — if no AI tool is locally available, a documented fallback per RESEARCH Open Question #5. Ships a Vitest test that programmatically asserts the validator exits non-zero on a fixture missing provenance (PIPE-03 + AEST-09), with the negative-case fixture parked under `os.tmpdir()` so it never pollutes the real `/assets/` tree.
+
+Purpose: This is the floor of the asset pipeline that Phase 5 will scale up to production volume. CONTEXT D-01/D-02/D-03 lock the shape (sidecar + CI walker, no curator workflow, no two-stage promotion). The 10–20 reference set is the seed against which Phase 5+ asset migrations will be visually regressed. RESEARCH § Pattern 6 provides verbatim code.
+
+Output: A complete asset pipeline floor: validator script, sidecar schema, refused-sample fixture, ~10–20 north-star images with provenance sidecars, and a Vitest test enforcing the gate.
+
+This plan is `autonomous: false` because Task 2 — committing the 10–20 north-star images — requires human curation per CONTEXT D-01 + D-03 (curation gate IS the human reviewer per CONTEXT). The user must approve which images ship. If the user has no local AI image tool, the fallback (commit licensed-CC-BY photographs of real cottage gardens or hand-painted references with provenance fields filled honestly) is acceptable per RESEARCH Open Question #5 + Environment Availability — but still needs human selection.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@CLAUDE.md
+@.planning/research/PITFALLS.md
+
+
+
+
+
+ Task 1: Provenance validator script + Zod sidecar schema + Vitest enforcement test
+
+ scripts/validate-assets.mjs,
+ scripts/validate-assets.test.ts,
+ assets/__samples__/refused/no-provenance.png,
+ assets/__samples__/refused/.gitkeep
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 6: Provenance Sidecar Validator" (verbatim ~30-line script) and § "Provenance sidecar example" (the JSON shape) and § Open Question #2 (optional `provenance_schema_version` field)
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-01 — full provenance metadata, 6 fields; D-02 — vendor deferred; D-03 — sidecar + CI walker, refused sample, no curator workflow)
+ - CLAUDE.md "Code Style" — provenance metadata: `{model_id, checkpoint_hash, prompt, seed, sampler, params}`
+ - REQUIREMENTS.md AEST-08 + AEST-09
+ - package.json (Plan 01 already created the `validate:assets` script that calls this file)
+
+
+ - **validate-assets.mjs:**
+ - Recursively walks `process.env.ASSETS_DIR ?? 'assets'` using `node:fs/promises` (Node 20+ supports `readdir({recursive: true, withFileTypes: true})`).
+ - Skips any path under `assets/__samples__/refused/` (the gate proof — these files INTENTIONALLY have no sidecar).
+ - Skips `.gitkeep` files.
+ - Skips files ending in `.provenance.json` (those are sidecars, not assets).
+ - For every other file, requires a sibling `.provenance.json` (e.g., `garden-soil-01.png` requires `garden-soil-01.png.provenance.json` per RESEARCH § Pattern 6 sidecar naming convention decision).
+ - Reads each sidecar and validates against `ProvenanceSchema` (Zod with the 6 required fields + optional `provenance_schema_version`).
+ - On any missing or invalid sidecar, prints a clear error and exits non-zero.
+ - On success, prints `[provenance] all assets carry valid provenance.` and exits 0.
+ - **validate-assets.test.ts:**
+ - **Positive case:** Runs the validator against the real `/assets/` tree (the default — no `ASSETS_DIR` override) and asserts exit 0. This proves the north-star set + refused-sample dir together pass the gate.
+ - **Negative case (BLOCKER 2 fix):** Generates a per-test-run unique tmpdir under `os.tmpdir()` (using `node:os` + `node:path` + `fs.mkdtemp`), drops a single PNG with no sidecar inside, runs the validator with `ASSETS_DIR=` set in the env, asserts exit code !== 0 and stderr/stdout contains the expected error message. Cleans up the tmpdir in `afterAll`. **No risk of polluting `/assets/`, no Ctrl-C cleanup hazard** — even if the test runner is killed mid-run, the OS reclaims the tmpdir on next reboot.
+
+
+ **Step 1 — `scripts/validate-assets.mjs`** (per RESEARCH § Pattern 6 verbatim, with one improvement: the optional `provenance_schema_version` per Open Question #2):
+ ```javascript
+ #!/usr/bin/env node
+ // scripts/validate-assets.mjs — Phase 1 asset provenance gate (PIPE-03, AEST-08, AEST-09)
+ //
+ // Walks /assets/ (or process.env.ASSETS_DIR for tests), requires every non-sidecar
+ // non-.gitkeep file to have a sibling .provenance.json validating against
+ // ProvenanceSchema. Excludes /assets/__samples__/refused/ (which intentionally lacks
+ // sidecars to prove the gate).
+ //
+ // Per CONTEXT D-03: minimum-viable. No curator workflow, no two-stage promotion,
+ // no pre-commit hook. Sidecar + this script + CI is the entire pipeline.
+ //
+ // Per CONTEXT D-01: 6 required fields per CLAUDE.md provenance metadata.
+ // Per RESEARCH Open Question #2: optional provenance_schema_version for Phase 5 fwd-compat.
+
+ import { readdir, readFile } from 'node:fs/promises';
+ import { join, basename } from 'node:path';
+ import { z } from 'zod';
+
+ const ProvenanceSchema = z.object({
+ model_id: z.string().min(1),
+ checkpoint_hash: z.string().min(1),
+ prompt: z.string().min(1),
+ seed: z.union([z.string(), z.number()]),
+ sampler: z.string().min(1),
+ params: z.record(z.string(), z.unknown()),
+ provenance_schema_version: z.number().int().positive().optional(),
+ });
+
+ const ASSETS_DIR = process.env.ASSETS_DIR ?? 'assets';
+ // Refused-sample exclusion is relative to the *real* assets tree; tests pointing
+ // ASSETS_DIR at a tmpdir won't have these paths so the exclusion is harmless.
+ const REFUSED_PREFIXES = ['assets/__samples__/refused', 'assets/__test_fixtures__/refused'];
+
+ async function* walk(dir) {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch (e) {
+ if (e.code === 'ENOENT') return;
+ throw e;
+ }
+ for (const entry of entries) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ yield* walk(path);
+ } else {
+ yield path;
+ }
+ }
+ }
+
+ function normalizePath(p) {
+ return p.replaceAll('\\', '/');
+ }
+
+ const errors = [];
+ let assetCount = 0;
+
+ for await (const path of walk(ASSETS_DIR)) {
+ const norm = normalizePath(path);
+ if (REFUSED_PREFIXES.some((r) => norm.startsWith(r))) continue;
+ if (norm.endsWith('.provenance.json')) continue;
+ if (basename(norm) === '.gitkeep') continue;
+ if (basename(norm) === 'README.md') continue;
+
+ assetCount++;
+ const sidecar = path + '.provenance.json';
+ try {
+ const raw = await readFile(sidecar, 'utf8');
+ const parsed = ProvenanceSchema.safeParse(JSON.parse(raw));
+ if (!parsed.success) {
+ errors.push(`${path}: provenance schema validation failed — ${parsed.error.message}`);
+ }
+ } catch (e) {
+ errors.push(`${path}: missing or unreadable provenance sidecar (${sidecar}): ${e.code ?? e.message}`);
+ }
+ }
+
+ if (errors.length) {
+ console.error('[provenance] validation failed:');
+ for (const err of errors) console.error(' ' + err);
+ process.exit(1);
+ }
+ console.log(`[provenance] all ${assetCount} assets carry valid provenance.`);
+ ```
+
+ Note the `ASSETS_DIR` env override — this lets the Vitest test point the script at an `os.tmpdir()` fixture directory without modifying production code, **and without leaving stray fixture files in `/assets/`** (BLOCKER 2 fix). The `REFUSED_PREFIXES` list covers paths under the real `assets/` tree; tmpdir-based test runs simply have no such paths and the exclusion is a harmless no-op.
+
+ Also note: the script accepts `assets/north-stars/README.md` (skipped by `basename === 'README.md'`) — Task 2 will add this README; without the skip, the validator would demand a sidecar for the README itself.
+
+ **Step 2 — Make the script executable (Unix; harmless on Windows):**
+ ```bash
+ chmod +x scripts/validate-assets.mjs 2>/dev/null || true
+ ```
+
+ **Step 3 — Create the refused-sample asset.** Per CONTEXT D-03, a real image file under `assets/__samples__/refused/` proves the gate exists by being intentionally without a sidecar. Use a tiny 1x1 transparent PNG (~70 bytes); generate with Node:
+ ```bash
+ node -e "const fs = require('fs'); const png = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63600100000005000146cd9c5d0000000049454e44ae426082', 'hex'); fs.writeFileSync('assets/__samples__/refused/no-provenance.png', png);"
+ touch assets/__samples__/refused/.gitkeep
+ ```
+ The `.gitkeep` ensures the directory persists if the PNG is ever removed; the PNG itself is the gate-proof artifact.
+
+ **Step 4 — Run the validator manually:** `node scripts/validate-assets.mjs` should exit 0 (only the refused-sample is in `/assets/`, and it's excluded). Output: `[provenance] all 0 assets carry valid provenance.`
+
+ **Step 5 — `scripts/validate-assets.test.ts`** (Vitest integration test). The negative-case fixture is created under `os.tmpdir()` per BLOCKER 2 fix — isolated from the real `/assets/` tree, no orphan-fragility risk, no Ctrl-C cleanup hazard:
+ ```typescript
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+ import { execFile } from 'node:child_process';
+ import { promisify } from 'node:util';
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+ import { join } from 'node:path';
+ import os from 'node:os';
+
+ const exec = promisify(execFile);
+ const SCRIPT = 'scripts/validate-assets.mjs';
+
+ describe('PIPE-03 / AEST-09: asset provenance gate', () => {
+ it('exits 0 against the real /assets/ tree (refused sample excluded)', async () => {
+ const result = await exec('node', [SCRIPT]);
+ expect(result.stdout).toMatch(/all \d+ assets carry valid provenance/);
+ });
+
+ describe('with an isolated tmpdir fixture missing provenance', () => {
+ let tmpDir: string;
+ let fixtureFile: string;
+
+ beforeAll(async () => {
+ // Per-test-run unique tmpdir under os.tmpdir() — isolated from /assets/,
+ // no risk of polluting the real tree even if the runner is killed mid-test.
+ tmpDir = await mkdtemp(join(os.tmpdir(), 'tlg-provenance-test-'));
+ fixtureFile = join(tmpDir, 'orphan.png');
+ // Tiny 1x1 PNG with no sidecar
+ const png = Buffer.from(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63600100000005000146cd9c5d0000000049454e44ae426082',
+ 'hex',
+ );
+ await writeFile(fixtureFile, png);
+ });
+
+ afterAll(async () => {
+ await rm(tmpDir, { recursive: true, force: true });
+ });
+
+ it('exits non-zero with a clear error message when ASSETS_DIR points at the fixture', async () => {
+ // Run the validator against the isolated tmpdir; the script reads ASSETS_DIR
+ // from process.env, so the orphan.png is the only file under inspection.
+ let exitCode = 0;
+ let combinedOutput = '';
+ try {
+ await exec('node', [SCRIPT], { env: { ...process.env, ASSETS_DIR: tmpDir } });
+ } catch (err: any) {
+ exitCode = err.code ?? -1;
+ combinedOutput = (err.stdout ?? '') + (err.stderr ?? '');
+ }
+ expect(exitCode).toBe(1);
+ expect(combinedOutput).toMatch(/validation failed/);
+ expect(combinedOutput).toMatch(/orphan\.png/);
+ expect(combinedOutput).toMatch(/missing.*provenance sidecar/i);
+ });
+ });
+ });
+ ```
+
+ **Step 6 — Run `npm test` and confirm the validate-assets test passes.** The positive case asserts the real `/assets/` tree (refused-sample dir + Task 2's north-stars) passes the gate; the negative case runs the script against an isolated `os.tmpdir()` fixture with one orphan PNG and asserts exit 1 + the expected error message.
+
+ Per RESEARCH § Pattern 6 ("Refused-sample test"), the negative-case fixture proves the gate fires; the BLOCKER 2 fix moves that fixture to `os.tmpdir()` so it cannot pollute the real `/assets/` tree.
+
+ **Step 7 — Commit `feat(01-05): asset provenance validator + Zod sidecar schema + refused-sample fixture + PIPE-03 enforcement test (tmpdir-isolated)`.**
+
+
+ node scripts/validate-assets.mjs && npx vitest run scripts/validate-assets.test.ts
+
+
+ - `scripts/validate-assets.mjs` exists and is a runnable Node script — verify with `node --check scripts/validate-assets.mjs`.
+ - The script defines a Zod `ProvenanceSchema` with all 6 CLAUDE.md fields plus optional `provenance_schema_version` — verify with `grep -cE "(model_id|checkpoint_hash|prompt|seed|sampler|params|provenance_schema_version)" scripts/validate-assets.mjs` returns at least 7.
+ - The script reads from `process.env.ASSETS_DIR ?? 'assets'` (so the test can isolate via env override) — verify with `grep -q "process.env.ASSETS_DIR" scripts/validate-assets.mjs`.
+ - The script excludes `__samples__/refused` — verify with `grep -q "__samples__/refused" scripts/validate-assets.mjs`.
+ - The script exits non-zero on missing sidecar — verify with `grep -q "process.exit(1)" scripts/validate-assets.mjs`.
+ - `assets/__samples__/refused/no-provenance.png` exists with no sidecar — verify with `test -f assets/__samples__/refused/no-provenance.png && ! test -f assets/__samples__/refused/no-provenance.png.provenance.json`.
+ - **Test fixture isolation (BLOCKER 2):** `scripts/validate-assets.test.ts` uses `os.tmpdir()` for the negative-case fixture — verify with `grep -q "os.tmpdir" scripts/validate-assets.test.ts && grep -q "mkdtemp" scripts/validate-assets.test.ts`.
+ - **Test fixture isolation (BLOCKER 2):** the negative-case test passes `ASSETS_DIR` via the `env` option of `execFile` — verify with `grep -E "ASSETS_DIR.*tmpDir|env:.*ASSETS_DIR" scripts/validate-assets.test.ts`.
+ - **Test fixture isolation (BLOCKER 2):** no `assets/__test_fixtures__/missing` path is created during the test — verify with `! grep -q "assets/__test_fixtures__/missing" scripts/validate-assets.test.ts`.
+ - Running the script directly exits 0: `node scripts/validate-assets.mjs` — exit code 0.
+ - The Vitest test passes (positive + negative cases both green) — verify with `npx vitest run scripts/validate-assets.test.ts 2>&1 | grep -E "passed"` exits 0.
+ - The Vitest test cleans up its tmpdir — verify with `grep -q "afterAll" scripts/validate-assets.test.ts && grep -q "rm.*tmpDir" scripts/validate-assets.test.ts`.
+
+
+ Validator script (~80 lines including error handling and Windows-path normalization) at `scripts/validate-assets.mjs`; Zod sidecar schema covering all 6 fields + optional schema version; refused-sample PNG committed under `__samples__/refused/`; Vitest test that creates an **isolated `os.tmpdir()` fixture** (BLOCKER 2 fix — no real-tree pollution risk), runs the validator with `ASSETS_DIR` pointing at the tmpdir, asserts non-zero exit + clear error message, cleans up; both `node scripts/validate-assets.mjs` and `npx vitest run scripts/validate-assets.test.ts` green; commit landed.
+
+
+
+
+ Task 2: Curate and commit 10–20 north-star reference images with provenance sidecars (CONTEXT D-01)
+
+ Plan 05 Task 1 shipped:
+ - `scripts/validate-assets.mjs` — the CI gate (exits non-zero on missing/invalid provenance)
+ - `assets/__samples__/refused/no-provenance.png` — the proof-of-gate fixture
+ - `scripts/validate-assets.test.ts` — Vitest test enforcing the gate (negative case isolated under `os.tmpdir()`)
+
+ Task 2 ships the 10–20 hand-curated north-star reference set per CONTEXT D-01 — the visual ground truth Phase 5+ will regenerate against. Per CONTEXT D-03, the curation gate IS the human reviewer (you), not a workflow document. This task pauses for your hands-on curation.
+
+ **Three valid paths** (your call):
+
+ **Path A — AI-generated (recommended if you have a tool available):**
+ 1. Use whatever AI image tool you currently have (Claude with image generation, Stable Diffusion + watercolor LoRA, Midjourney, Scenario, etc.).
+ 2. Generate 10–20 watercolor-style images representing the visual north-star: walled cottage gardens, real-but-slightly-wrong wildflowers, golden/autumnal palette for Season 1, hand-painted feel, no fantasy elements (no D&D flora — see PROJECT.md "Out of Scope").
+ 3. For each generated image, write a sibling `.png.provenance.json` with all 6 required fields filled honestly (the exact `model_id` you used, the prompt verbatim, the seed if your tool surfaces one, etc.).
+ 4. Place the pair under `assets/north-stars/.png` + `assets/north-stars/.png.provenance.json`.
+
+ **Path B — Hand-painted / photograph fallback (if no AI tool is available locally):**
+ Per RESEARCH § Open Question #5 + Environment Availability, the provenance schema accepts arbitrary `model_id` strings, so honest "human-painted" or licensed-photograph entries are valid. For each image:
+ - `model_id`: `"human"` (or `"photograph:cc-by:"`)
+ - `checkpoint_hash`: `"n/a"`
+ - `prompt`: a description of what the image is
+ - `seed`: `0`
+ - `sampler`: `"n/a"`
+ - `params`: `{ "notes": "Phase 1 fallback per RESEARCH Open Question #5; replaceable in Phase 5+" }`
+
+ **Path C — Defer with explicit IOU:**
+ If neither A nor B is feasible right now, commit **two** placeholder images with full honest provenance saying "placeholder" — enough to prove the schema accepts real entries — and **record the IOU in a dedicated file** at `.planning/phases/01-foundations-and-doctrine/01-05-IOU.md` (do **not** edit `.planning/STATE.md` from a phase-internal task — STATE.md is owned by the orchestrator, per WARNING 5 fix). The IOU file contains date, owner, and the deferred-work statement; the verification phase / Phase-5 entry will surface this IOU back into STATE.md if it is still open at that point. This still satisfies CONTEXT D-01's "10–20 hand-curated" loosely (with explicit IOU) and keeps the rest of Phase 1 unblocked.
+
+ Whichever path you choose, also write `assets/north-stars/README.md` documenting:
+ - What this directory is (the visual ground truth for Phase 5+ regression)
+ - Which path was chosen (A/B/C) and why
+ - How to add new images (sidecar naming convention, the 6 required fields)
+ - When this set will be revisited (Phase 5 is the planned consolidation point per CONTEXT D-02)
+
+
+ 1. Choose a path (A, B, or C) and produce the images + sidecars.
+ 2. Place all pairs under `assets/north-stars/`. Naming: `.png` + `.png.provenance.json` (per RESEARCH Pattern 6 sidecar naming convention).
+ 3. Write `assets/north-stars/README.md` (~10 lines, see template above).
+ 4. Run `node scripts/validate-assets.mjs` — must exit 0 with `[provenance] all assets carry valid provenance.` where N matches the count of images you committed.
+ 5. Run `npm test` — must remain green; the validate-assets test should now also count the new assets.
+ 6. Run `npm run ci` — must exit 0 (lint + test + validate:assets + build).
+ 7. Commit with message `feat(01-05): commit north-star reference images with provenance sidecars (path )`.
+ 8. **If you chose Path C, also create `.planning/phases/01-foundations-and-doctrine/01-05-IOU.md`** with this content (do NOT edit `.planning/STATE.md` directly — that file is orchestrator-owned, per WARNING 5 fix):
+ ```markdown
+ # Plan 05 IOU — North-Star Reference Set Expansion
+
+ **Date:**
+ **Owner:**
+ **Phase:** 01 (Foundations & Doctrine)
+ **Plan:** 05 (Asset Provenance)
+
+ ## Deferred Work
+
+ Path C was selected for Plan 05 Task 2: the north-star reference set under
+ `assets/north-stars/` currently contains placeholder images (target: 10–20).
+ Expansion to the full curated set must happen before Phase 5 begins, since
+ Phase 5+ visual regression depends on this seed.
+
+ ## Trigger
+
+ Phase 5 entry; or sooner if an AI image tool / hand-painted batch becomes available.
+
+ ## Acceptance
+
+ - 10–20 final images committed under `assets/north-stars/` with valid provenance sidecars.
+ - This file deleted on resolution (or marked `RESOLVED` with the resolving commit hash).
+ ```
+
+ Type `approved` when done, or describe issues encountered (e.g., "AI tool unavailable, going with Path B / Path C").
+
+ Type "approved" or describe issues
+
+
+
+
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-01-06 | Spoofing | Provenance sidecar fabrication (a contributor adds an asset with fabricated provenance) | accept (out of scope for Phase 1) | Single-developer project in Phase 1; not a real threat. RESEARCH § Security Domain explicitly defers this to Phase 8+ when external contributors enter the picture, with `human_reviewed_by` field signed by a curator. |
+| T-01-07 | Tampering | Path traversal via sidecar filename | accept | Sidecars are walked by the validator using `node:fs/promises readdir` and reads are confined to paths under `/assets/` (or `process.env.ASSETS_DIR`). The validator never resolves paths from sidecar contents. Not exploitable. |
+
+
+
+- `node scripts/validate-assets.mjs` exits 0 against the real `/assets/` tree (north-star set + refused-sample dir).
+- `npx vitest run scripts/validate-assets.test.ts` passes (the gate is structurally enforced — an asset missing provenance under an isolated `os.tmpdir()` fixture fails the script).
+- `assets/__samples__/refused/no-provenance.png` exists and has no sidecar (proof-of-gate artifact).
+- `assets/north-stars/` contains the curated reference set per Path A/B/C with valid provenance sidecars (or the explicit IOU file at `.planning/phases/01-foundations-and-doctrine/01-05-IOU.md` per Path C).
+- `npm run ci` exits 0 — Plan 07's CI workflow will run this.
+
+
+
+- 30-line standalone Node script validates every non-sidecar asset has a sibling `.provenance.json` per the 6-field schema.
+- Refused-sample fixture proves the gate by being intentionally excluded.
+- Vitest integration test creates an `os.tmpdir()`-isolated missing-provenance fixture, asserts non-zero exit, cleans up — no real-tree pollution risk.
+- 10–20 north-star reference images committed with valid sidecars (Path A / B / C per CONTEXT D-01 + RESEARCH Open Question #5).
+- `npm run validate:assets` (the package.json script Plan 01 created) exits 0.
+- Phase 5 has a working seed for visual regression and asset-pipeline scale-up.
+
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-06-doctrine-docs-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-06-doctrine-docs-PLAN.md
new file mode 100644
index 0000000..616b909
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-06-doctrine-docs-PLAN.md
@@ -0,0 +1,466 @@
+---
+phase: 01
+plan: 06
+type: execute
+wave: 2
+depends_on: [01-01]
+files_modified:
+ - .planning/anti-fomo-doctrine.md
+ - .planning/season-7-end-state.md
+ - scripts/doctrine.test.ts
+autonomous: true
+requirements: [PIPE-05, UX-13, STRY-09]
+must_haves:
+ truths:
+ - "`.planning/anti-fomo-doctrine.md` exists and consolidates banned-pattern enumeration from PROJECT.md, REQUIREMENTS.md UX-13, CLAUDE.md Hard Thematic Constraints, RESEARCH PITFALLS.md #9 (PIPE-05 + UX-13)"
+ - "`.planning/season-7-end-state.md` exists and answers principle-level the three questions per CONTEXT D-08: (a) what *rest state* means, (b) what the finite Roothold ceiling is tied to, (c) the coda's tonal register (PIPE-05)"
+ - "Both docs are principle-level consolidations (not new design and not treatment-level) per CONTEXT D-07 + D-08"
+ - "A Vitest doc-lint test asserts both docs exist with their required H2 sections (per RESEARCH § Validation Architecture PIPE-05 row)"
+ - "Neither doc adds a lint rule on UX strings (per CONTEXT D-07 — explicitly rejected)"
+ artifacts:
+ - path: .planning/anti-fomo-doctrine.md
+ provides: "Banned-pattern enumeration + allowed-engagement list + 3-question review checklist + source-document citations (per RESEARCH outline)"
+ contains: "## Banned Mechanics"
+ - path: .planning/season-7-end-state.md
+ provides: "Principle-level answers to (a) rest state, (b) finite Roothold ceiling tie, (c) tonal register; explicit list of what this doc is NOT (per RESEARCH outline + CONTEXT D-08)"
+ contains: "## What does *rest state* mean?"
+ - path: scripts/doctrine.test.ts
+ provides: "Vitest test asserting both docs exist and contain their required H2 sections"
+ key_links:
+ - from: .planning/anti-fomo-doctrine.md
+ to: ["PROJECT.md Out of Scope", "REQUIREMENTS.md UX-13", "CLAUDE.md Hard Thematic Constraints", ".planning/research/PITFALLS.md #9"]
+ via: "Source Documents section enumerates the 4 references explicitly"
+ pattern: "PROJECT.md.*REQUIREMENTS.md.*CLAUDE.md.*PITFALLS.md"
+ - from: .planning/season-7-end-state.md
+ to: ["PROJECT.md core value", "REQUIREMENTS.md SEAS-04, SEAS-09, SEAS-10, STRY-08", "ROADMAP.md Phase 7", ".planning/research/PITFALLS.md #1"]
+ via: "Source Documents section enumerates the 4 references explicitly"
+ pattern: "SEAS-04.*SEAS-09.*SEAS-10.*STRY-08"
+---
+
+
+Author the two Phase-1 doctrine documents per CONTEXT D-07 + D-08 + D-09. Both live under `.planning/` (not `docs/` per D-09). Both are *consolidation documents* of constraints already scattered across project artifacts — the planner is **not** doing new design work, only collecting and organizing what's already locked elsewhere. Per CONTEXT D-07, anti-FOMO is enforced by review (not lint); per D-08, Season 7 end-state is principle-level (not treatment text). RESEARCH § "Doctrine doc outline — anti-fomo-doctrine.md" and § "Doctrine doc outline — season-7-end-state.md" provide concrete templates that this plan ships verbatim with project-specific details filled in.
+
+Ship a single Vitest doc-lint test (`scripts/doctrine.test.ts`) that asserts both files exist and each contains its required H2 sections — proving PIPE-05 is automatable and giving Plan 07's CI workflow something to verify. Per CONTEXT D-07: NO lint rule on UX strings.
+
+Purpose: PROJECT.md commits to the 7-Season scope; PITFALLS.md #1 names "the story ends but the loop doesn't" as the single most dangerous pitfall; CONTEXT D-08 demands the answer ship before any economy code (Phase 2) lands. PIPE-05 + UX-13 require these docs in the repo before Phase 2 begins. The docs become referenced artifacts at every UX/monetization/economy review going forward.
+
+Output: Two principle-level doctrine markdown files in `.planning/` plus one Vitest doc-lint test enforcing their structural integrity.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/REQUIREMENTS.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@.planning/research/PITFALLS.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Author `.planning/anti-fomo-doctrine.md` (consolidation per CONTEXT D-07 + RESEARCH outline)
+
+ .planning/anti-fomo-doctrine.md
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Doctrine doc outline — anti-fomo-doctrine.md" (the verbatim template structure)
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-07 — consolidation document, no lint rule on UX strings, enforced by review)
+ - .planning/PROJECT.md § "Out of Scope" (gacha, lootboxes, daily login bonuses, streaks, etc. — the source list of banned patterns)
+ - .planning/REQUIREMENTS.md UX-13 + § "Out of Scope" table (rows: gacha, lootboxes, narrative gating, Season skipping, daily login, streaks, limited-time, energy/stamina, rewarded ads, push spam, lore codex, generic flora, combat, multiplayer, voiced dialogue, always-online, named Keeper, hint system, time-skip purchases, Unity engines, generic cosmetics, random-drop cosmetics, mobile-style nag UX)
+ - CLAUDE.md "Hard Thematic Constraints (Out of Scope by Design)" — the 13 enumerated exclusions
+ - .planning/research/PITFALLS.md § "Pitfall 9: FOMO/Nag Mechanics Violate Cozy Tone" (the rationale + warning signs + how-to-avoid)
+
+
+ Write `.planning/anti-fomo-doctrine.md` using the Write tool. Follow the RESEARCH § "Doctrine doc outline — anti-fomo-doctrine.md" template verbatim, but populate every banned-pattern row from the four source documents (PROJECT.md, REQUIREMENTS.md, CLAUDE.md, PITFALLS.md #9). Per CONTEXT D-07, this is a *consolidation* — do not invent new constraints, do not relitigate existing ones.
+
+ **The document must contain these EXACT H2 sections** (the doc-lint test in Task 2 will assert each one exists):
+ 1. `## Banned Mechanics`
+ 2. `## Allowed Engagement`
+ 3. `## Review Checklist`
+ 4. `## Source Documents`
+
+ **Document content:**
+
+ ```markdown
+ # Anti-FOMO Doctrine
+
+ *Phase 1 deliverable per PIPE-05 + UX-13. Consolidated from PROJECT.md, REQUIREMENTS.md, CLAUDE.md, and .planning/research/PITFALLS.md #9.*
+
+ This document is referenced at every UX, monetization, and copy review going
+ forward. It enumerates mechanics this game does not use, with the reason for
+ each, so the answer to a "should we add X?" question is in writing rather
+ than relitigated.
+
+ Per CONTEXT D-07: this doctrine is enforced by **review**, not by lint rules
+ on UX strings. The reviewer (you, at every UX/monetization/copy decision)
+ consults this list and rejects or rewrites any change that violates it.
+
+ ## Banned Mechanics
+
+ | Mechanic | Why Banned |
+ |----------|------------|
+ | Gacha mechanics | Directly contradicts the game's thematic argument that complex things cannot be reduced to simple transactions. (PROJECT.md, REQUIREMENTS.md Out of Scope, CLAUDE.md) |
+ | Lootboxes | Same reason as gacha — undermines the story's monetization-as-meaning argument. |
+ | Narrative gating behind purchase | The story IS the product; story content is never paid. |
+ | Random-drop monetization | All cosmetics must be deterministic catalog purchases. |
+ | Daily login bonuses | Presence is not a debt the game collects. |
+ | Login streaks | Skipping a day is allowed, even encouraged. |
+ | Limited-time / time-limited content | The game's premise is *what persists*. |
+ | Energy / stamina systems | Anti-cozy gating that interrupts contemplative play. |
+ | Rewarded ads | Anti-cozy; tonally incoherent with a contemplative grief-narrative. |
+ | Re-engagement push notifications | Memory Storm opt-in is the **only** allowed notification class. |
+ | Loss-aversion copy ("you'll lose your X") | Tonally incompatible with cozy/contemplative. |
+ | Visible countdown timers in core UI | The cello is the timer. The seasons are the timer. Not a digit. |
+ | "Don't miss out" / "limited time" / "only X hours left" copy | Bannable phrases at copy review. |
+ | Season *skipping* (vs. Season *acceleration*) | Players must never miss authored story beats; acceleration is allowed, skipping is forbidden. |
+ | Time-skip purchases that bypass real-time | Real-time IS the metaphor for memory; skip-time would violate mechanic-as-metaphor doctrine. |
+ | Hint system / objective tracker | Discovery-driven progression (A Dark Room rule); explicit objectives violate the tone. |
+ | Mobile-style nag UX | Cozy audience expects respect; nag patterns will tank reviews. |
+
+ ## Allowed Engagement
+
+ The following engagement affordances are explicitly **allowed** because they respect
+ presence rather than demand it:
+
+ - **Memory Storm opt-in notifications** — the single allowed notification class.
+ Player must explicitly opt in. Never daily, never marketing, never streak-based.
+ - **"While you were away" letter on return** — written in Lura's voice, never a stat dump
+ (UX-02). Describes what bloomed, what the wind brought; never "fragments per hour."
+ - **Tab-title bloom indicator** when a fragment is ready (UX-09, Phase 8) — passive
+ surfacing, no notification.
+ - **Save-export reminder after Season transitions** — relationship-saving, not nag.
+
+ ## Review Checklist
+
+ When reviewing any UX, copy, monetization, or feature change, ask three questions:
+
+ 1. **Does this create urgency around presence rather than around content?** If yes → reject.
+ 2. **Does this frame absence as loss?** If yes → rewrite or reject.
+ 3. **Would removing this from the game make it less *cozy*?** If no → reconsider whether the change belongs.
+
+ Additional sanity checks for monetization specifically:
+
+ - Does this mechanic gate any story content? → reject (PROJECT.md hard constraint).
+ - Is this random-drop / gacha / lootbox shaped? → reject.
+ - Is this a "limited-time" anything? → reject.
+
+ ## Source Documents
+
+ This doctrine consolidates constraints already locked in:
+
+ - **PROJECT.md** § "Out of Scope" — anti-features (gacha, lootboxes, narrative gating, Season skipping, generic flora, combat, multiplayer, voiced dialogue, named Keeper, generic cosmetics)
+ - **REQUIREMENTS.md** UX-13 + § "Out of Scope" table — 24 explicit exclusions
+ - **CLAUDE.md** § "Hard Thematic Constraints (Out of Scope by Design)" — 13 thematic exclusions, no FOMO push notifications, no daily login bonuses, no streaks, no limited-time, no energy/stamina
+ - **.planning/research/PITFALLS.md** § "Pitfall 9: FOMO/Nag Mechanics Violate Cozy Tone" — rationale + warning signs
+
+ ---
+
+ *Authored: Phase 1 deliverable. Updates: append-only — entries can be added (new
+ banned patterns identified) but never removed without surfacing the change for review.*
+ ```
+
+ Per CONTEXT D-07: do NOT add a lint rule for UX strings; do NOT add CI enforcement; the document is enforced by human review at the listed decision points.
+
+ The doctrine doc length is appropriate (~70 lines markdown including tables) — principle-level, not exhaustive prose.
+
+ Commit `docs(01-06): author anti-FOMO doctrine consolidating PROJECT/REQUIREMENTS/CLAUDE/PITFALLS constraints (PIPE-05, UX-13)`.
+
+
+ test -f .planning/anti-fomo-doctrine.md && grep -q "## Banned Mechanics" .planning/anti-fomo-doctrine.md && grep -q "## Allowed Engagement" .planning/anti-fomo-doctrine.md && grep -q "## Review Checklist" .planning/anti-fomo-doctrine.md && grep -q "## Source Documents" .planning/anti-fomo-doctrine.md
+
+
+ - File exists at `.planning/anti-fomo-doctrine.md` (NOT `docs/`, per CONTEXT D-09) — verify with `test -f .planning/anti-fomo-doctrine.md && ! test -f docs/anti-fomo-doctrine.md`.
+ - Contains all 4 required H2 sections — verify with `grep -cE "^## (Banned Mechanics|Allowed Engagement|Review Checklist|Source Documents)" .planning/anti-fomo-doctrine.md` returns 4.
+ - The Banned Mechanics table contains at least 15 banned-pattern rows — verify with `awk '/^## Banned Mechanics/,/^## /{print}' .planning/anti-fomo-doctrine.md | grep -cE "^\\| (Gacha|Lootboxes|Narrative gating|Daily login|Login streaks|Limited-time|Energy|Rewarded ads|Re-engagement|Loss-aversion|Visible countdown|Season skipping|Time-skip|Hint system|Mobile-style)" | wc -l` returns at least 15. (Tolerate count-by-row variation; the test file in Task 2 will assert exact patterns.)
+ - The Source Documents section enumerates all 4 sources — verify with `grep -cE "(PROJECT\\.md|REQUIREMENTS\\.md|CLAUDE\\.md|PITFALLS\\.md)" .planning/anti-fomo-doctrine.md` returns at least 4.
+ - The Review Checklist section asks 3 questions — verify with `awk '/^## Review Checklist/,/^## /{print}' .planning/anti-fomo-doctrine.md | grep -cE "^[0-9]\\." | head -1` returns at least 3.
+ - No lint rule is mentioned (per CONTEXT D-07 explicit rejection) — verify with `! grep -qE "lint rule|eslint rule" .planning/anti-fomo-doctrine.md`.
+
+
+ `.planning/anti-fomo-doctrine.md` authored as a principle-level consolidation of constraints from PROJECT/REQUIREMENTS/CLAUDE/PITFALLS; contains the 4 required H2 sections; enumerates ≥15 banned patterns + 3 review-checklist questions + 4 source documents; no lint-rule reference (per CONTEXT D-07); commit landed.
+
+
+
+
+ Task 2: Author `.planning/season-7-end-state.md` (principle-level per CONTEXT D-08 + RESEARCH outline) + Vitest doc-lint test
+
+ .planning/season-7-end-state.md,
+ scripts/doctrine.test.ts
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Doctrine doc outline — season-7-end-state.md" (the verbatim template, including the explicit "What this document is NOT" section)
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-08 — principle-level NOT treatment-level; answers (a) rest state, (b) Roothold ceiling, (c) tonal register; D-09 — lives in `.planning/`)
+ - .planning/PROJECT.md § "Core Value" — "what survives is what you understood"; § "Out of Scope" — Season skipping forbidden, no New Game+
+ - .planning/REQUIREMENTS.md SEAS-04 (finite ceiling), SEAS-09 (Season 7 long late-game), SEAS-10 (rest state, not infinite tiers), STRY-08 (binary choice + "The garden persists.")
+ - .planning/ROADMAP.md § "Phase 7: Season 7 (Return) & Final Choice" — the 4 success criteria
+ - .planning/research/PITFALLS.md § "Pitfall 1: The Story Ends but the Idle Loop Doesn't" — the rationale this doc directly addresses
+
+
+ **Step 1 — Write `.planning/season-7-end-state.md`** using the Write tool, following RESEARCH § "Doctrine doc outline — season-7-end-state.md" verbatim, with content adapted to The Last Garden specifically.
+
+ **The document must contain these EXACT H2 sections** (the doc-lint test will assert each):
+ 1. `## What does *rest state* mean?`
+ 2. `## What is the finite Roothold ceiling tied to?`
+ 3. `## What tonal register does the coda live in?`
+ 4. `## What this document is NOT`
+ 5. `## Source Documents`
+
+ **Document content:**
+
+ ```markdown
+ # Season 7 End-State Design (Principle-Level)
+
+ *Phase 1 deliverable per PIPE-05 + CONTEXT D-08. Principle-level only — treatment text is authored in Phase 7.*
+
+ This document answers the question that ends ROADMAP.md Phase 7's success criterion #4:
+
+ > *"the finite Roothold ceiling from Phase 4 has held the line, and the game has ended
+ > the way A Dark Room and Universal Paperclips ended."*
+
+ Per .planning/research/PITFALLS.md #1, "the story ends but the idle loop doesn't"
+ is the single most dangerous structural pitfall for this project. This document
+ is the canonical answer the project has *before* any economy code lands in Phase 2.
+
+ Per CONTEXT D-08: this is **principle-level**, not treatment-level. It defines the
+ contract Phase 7's authoring obeys, not the text of any final scene.
+
+ ## What does *rest state* mean?
+
+ The rest state is the post-credits configuration the player can return to indefinitely
+ without grinding. Concretely:
+
+ - **No new fragments are added to the pool.** All authored content has been delivered.
+ Harvests after the final binary choice yield re-readable previously-collected
+ fragments — nothing new.
+ - **No new currency tiers unlock.** Roothold has reached its finite ceiling (see below)
+ and stays there. There is no "Season 8" hidden behind a number.
+ - **The garden continues to render and respond to clicks.** Plants can still be
+ planted. Seasons (now in Return register) continue to crossfade. The world is
+ not frozen — it is *finished*.
+ - **The Pale has receded.** The Heartsoil expands beyond the garden walls. Lura's
+ arc has resolved. The Archivist's question has been answered (in the player's
+ Season 7 binary choice — STRY-08).
+ - **The cello and ambient layers continue.** The audio is *quiet*, *finite*,
+ *understood* — never crescendos again, never hard-cuts.
+
+ This is not "endgame content." It is **rest**. Lineage: *A Dark Room* fades to its
+ ending screen and the player returns to it for the same reason they return to a
+ finished album — not because there is more, but because there was *enough*.
+
+ ## What is the finite Roothold ceiling tied to?
+
+ Roothold's ceiling is anchored in the **count of authored fragments and the count
+ of Seasons** — not in an arbitrary number, not in a designer's intuition.
+
+ The principle:
+
+ > *One cannot accumulate more Roothold than the player has actually understood,
+ > and what the player can understand is bounded by what the writer has actually written.*
+
+ Concrete tie:
+
+ - Roothold gain per Season is gated to a hard cap proportional to the fragment
+ count of that Season + a small contribution from Roothold-relevant story beats
+ (Lura conversations, the Nameless Man's arc, the Archivist's question, etc.).
+ - Total Roothold ceiling = Σ(per-Season caps).
+ - **Phase 4 enforces this cap** when it implements `migrate_v1_to_v2` and the
+ prestige state machine (SEAS-04). Phase 7 verifies the ceiling holds through
+ full play.
+ - When Roothold reaches the ceiling, the UI displays "Roothold (full)" — never
+ a hidden multiplier or "go again to overflow."
+
+ Implication for designers: when adding fragments in Phase 5+, the Roothold ceiling
+ *moves* — adding 5 new Season-3 fragments adds proportional headroom. This is
+ intentional. Roothold is bounded by content; content is bounded by the writer.
+
+ ## What tonal register does the coda live in?
+
+ - **Warm**, not pyrrhic. The garden persists *because* you tended it; this is
+ earned redemption, not survival. Lineage: the closing minutes of *Spiritfarer*,
+ not the closing minutes of *A Dark Room* (which earned its bitterness; we earn
+ our warmth).
+ - **Quiet**, not climactic. The cello does not crescendo at the binary choice.
+ It rests. The chosen ending paragraph displays softly; "The garden persists."
+ lands without underscore.
+ - **Specific**, not abstract. The final visible state is a *real* garden — the
+ one this player built, with their actual planted ecosystems, their actual
+ Roothold value, their actual collected fragments — viewed in soft dawn-silver
+ light per AEST-06's Season-7 palette anchor.
+ - **Final**, not infinite. There is no Season 8. There is no New Game+. The Pale
+ receded **here**, in **this** garden. Future patches may add cosmetic items or
+ additional fragments per CONT-01 (post-launch additive content), but they slot
+ *between* authored beats; they never extend the arc.
+
+ ## What this document is NOT
+
+ This document defines principles. It does **not** define:
+
+ - The text of the Season 7 binary-choice scene — *authored Phase 7*.
+ - The text of either ending paragraph (`"They help us remember"` / `"They help us grow"`) — *authored Phase 7*.
+ - The exact line "The garden persists." appears in both endings, but its surrounding
+ paragraph and Lura's final line are *authored Phase 7*, not Phase 1.
+ - The credits / coda screen visual treatment — *designed Phase 7*.
+ - The exact tonal register or shape of individual final-Season fragments — *authored Phase 7*.
+ - The numeric value of the Roothold ceiling — *computed Phase 4* from the
+ content count at that point + ROADMAP-locked principle.
+
+ This document is **the principle the economy obeys, the writer obeys, and the
+ Phase 7 designer obeys** — not the implementation of any of those.
+
+ ## Source Documents
+
+ This doctrine consolidates constraints already locked in:
+
+ - **PROJECT.md** § "Core Value" — "every idle mechanic must function as a metaphor"; "what survives is what you understood"
+ - **REQUIREMENTS.md** SEAS-04 (finite Roothold ceiling), SEAS-09 (Season 7 late-game shape), SEAS-10 (rest state, not infinite prestige tiers), STRY-08 (binary choice + "The garden persists.")
+ - **ROADMAP.md** § "Phase 7: Season 7 (Return) & Final Choice" — the 4 success criteria
+ - **.planning/research/PITFALLS.md** § "Pitfall 1: The Story Ends but the Idle Loop Doesn't" — the rationale this document directly addresses
+
+ ---
+
+ *Authored: Phase 1 deliverable. Phase 4 enforces the Roothold ceiling. Phase 7 authors
+ the treatment-level final scenes against the principles above.*
+ ```
+
+ Per CONTEXT D-08: this is principle-level only. Do NOT include the binary-choice scene text, either ending paragraph, Lura's final line, or the credits screen treatment — those are explicitly Phase 7's authoring scope.
+
+ **Step 2 — Write `scripts/doctrine.test.ts`** — the doc-lint Vitest test (per RESEARCH § Validation Architecture PIPE-05 row):
+ ```typescript
+ import { describe, it, expect } from 'vitest';
+ import { readFileSync, existsSync } from 'node:fs';
+
+ describe('PIPE-05: doctrine documents exist with required H2 sections', () => {
+ describe('.planning/anti-fomo-doctrine.md', () => {
+ const PATH = '.planning/anti-fomo-doctrine.md';
+
+ it('exists', () => {
+ expect(existsSync(PATH)).toBe(true);
+ });
+
+ it('contains all 4 required H2 sections', () => {
+ const md = readFileSync(PATH, 'utf8');
+ expect(md).toMatch(/^## Banned Mechanics$/m);
+ expect(md).toMatch(/^## Allowed Engagement$/m);
+ expect(md).toMatch(/^## Review Checklist$/m);
+ expect(md).toMatch(/^## Source Documents$/m);
+ });
+
+ it('cites all 4 source documents (PROJECT, REQUIREMENTS, CLAUDE, PITFALLS)', () => {
+ const md = readFileSync(PATH, 'utf8');
+ expect(md).toMatch(/PROJECT\.md/);
+ expect(md).toMatch(/REQUIREMENTS\.md/);
+ expect(md).toMatch(/CLAUDE\.md/);
+ expect(md).toMatch(/PITFALLS\.md/);
+ });
+
+ it('does NOT propose a lint rule on UX strings (CONTEXT D-07 explicit rejection)', () => {
+ const md = readFileSync(PATH, 'utf8');
+ // The doc may *mention* that lint rules were rejected, but it must not
+ // propose adding one. Allow "no lint rule" but reject "add a lint rule".
+ expect(md).not.toMatch(/\b(add|implement|propose).{0,40}lint rule/i);
+ });
+ });
+
+ describe('.planning/season-7-end-state.md', () => {
+ const PATH = '.planning/season-7-end-state.md';
+
+ it('exists', () => {
+ expect(existsSync(PATH)).toBe(true);
+ });
+
+ it('contains all 5 required H2 sections (CONTEXT D-08)', () => {
+ const md = readFileSync(PATH, 'utf8');
+ expect(md).toMatch(/^## What does \*rest state\* mean\?$/m);
+ expect(md).toMatch(/^## What is the finite Roothold ceiling tied to\?$/m);
+ expect(md).toMatch(/^## What tonal register does the coda live in\?$/m);
+ expect(md).toMatch(/^## What this document is NOT$/m);
+ expect(md).toMatch(/^## Source Documents$/m);
+ });
+
+ it('cites SEAS-04, SEAS-09, SEAS-10, STRY-08', () => {
+ const md = readFileSync(PATH, 'utf8');
+ expect(md).toMatch(/SEAS-04/);
+ expect(md).toMatch(/SEAS-09/);
+ expect(md).toMatch(/SEAS-10/);
+ expect(md).toMatch(/STRY-08/);
+ });
+
+ it('does NOT include treatment-level details forbidden by CONTEXT D-08', () => {
+ const md = readFileSync(PATH, 'utf8');
+ // Check the "What this document is NOT" section is present — this is the
+ // structural guarantee against treatment-level scope creep.
+ expect(md).toMatch(/## What this document is NOT/);
+ // The doc must explicitly disclaim authoring the ending paragraphs.
+ expect(md).toMatch(/authored Phase 7/);
+ });
+ });
+ });
+ ```
+
+ **Step 3 — Run `npx vitest run scripts/doctrine.test.ts`** and confirm all assertions pass (~10 assertions across 2 files).
+
+ **Step 4 — Run `npm test`** and confirm the entire Phase-1 suite (sentinel + lint-firewall + save layer + content loader + asset validator + doctrine) is green.
+
+ **Step 5 — Commit `docs(01-06): author Season 7 end-state principle doctrine + Vitest doc-lint test (PIPE-05)`.**
+
+
+ npx vitest run scripts/doctrine.test.ts && test -f .planning/season-7-end-state.md && ! test -f docs/season-7-end-state.md
+
+
+ - File exists at `.planning/season-7-end-state.md` (NOT `docs/`, per CONTEXT D-09) — verify with `test -f .planning/season-7-end-state.md && ! test -f docs/season-7-end-state.md`.
+ - Contains all 5 required H2 sections — verify with `grep -cE "^## (What does \\*rest state\\* mean|What is the finite Roothold ceiling tied to|What tonal register does the coda live in|What this document is NOT|Source Documents)" .planning/season-7-end-state.md` returns 5.
+ - Cites SEAS-04, SEAS-09, SEAS-10, STRY-08 — verify with `grep -cE "(SEAS-04|SEAS-09|SEAS-10|STRY-08)" .planning/season-7-end-state.md` returns at least 4.
+ - Includes the "What this document is NOT" section to prevent treatment-level scope creep — verify with `grep -q "## What this document is NOT" .planning/season-7-end-state.md`.
+ - Does NOT include the binary-choice scene text — verify with `! grep -qE '"They help us remember"' .planning/season-7-end-state.md` (the QUOTED phrase is referenced as a citation but the scene text itself is not authored here; the test allows mention of the phrase as a reference but checks it does not appear inside a code block or as flowing prose intended to be the final-scene text).
+
+ **Note:** The doc DOES quote `"They help us remember"` and `"They help us grow"` as reference labels (in the "What this document is NOT" section, naming what is NOT being authored here). This is acceptable per CONTEXT D-08 — the rule is "do not author the *scene*", not "do not name the choice." Verify with `grep -E '"They help us remember"' .planning/season-7-end-state.md` returns at most one match (the disclaimer reference), and the matching line contains the words "Phase 7" or "authored":
+ ```bash
+ grep -E '"They help us remember"' .planning/season-7-end-state.md | grep -qE "(Phase 7|authored)"
+ ```
+ - `scripts/doctrine.test.ts` exists and passes — verify with `npx vitest run scripts/doctrine.test.ts 2>&1 | grep -E "passed"` exits 0.
+ - The doctrine test asserts existence + H2 sections + source citations for both docs — verify with `grep -cE "existsSync|toMatch.*##" scripts/doctrine.test.ts` returns at least 10.
+
+
+ `.planning/season-7-end-state.md` authored at principle level with the 5 required H2 sections, Roothold-ceiling-tied-to-content principle, the explicit "What this document is NOT" boundary against treatment scope creep; `scripts/doctrine.test.ts` enforces structural integrity of both doctrine docs via Vitest; `npm test` green; commit landed.
+
+
+
+
+
+
+No security-relevant code in this plan; doctrine docs and a doc-lint test only. No runtime code; no untrusted inputs; no I/O beyond reading committed Markdown files at test time.
+
+
+
+- Both doctrine docs exist under `.planning/` (per CONTEXT D-09).
+- `scripts/doctrine.test.ts` passes — both docs have all required H2 sections and cite all required source documents.
+- `npm test` green for the entire Phase-1 suite.
+- Phase 7 has a principle contract to author against; Phase 4 has the Roothold-ceiling tie-to-content principle to implement against.
+- No new design work was done — both docs are consolidations of existing PROJECT/REQUIREMENTS/CLAUDE/ROADMAP/PITFALLS constraints (per CONTEXT D-07 + D-08).
+
+
+
+- `.planning/anti-fomo-doctrine.md` consolidates banned-pattern enumeration with 4 required H2 sections (Banned Mechanics, Allowed Engagement, Review Checklist, Source Documents) and ≥15 banned patterns.
+- `.planning/season-7-end-state.md` answers principle-level the 3 questions per CONTEXT D-08 + has the explicit "What this document is NOT" boundary section.
+- `scripts/doctrine.test.ts` enforces structural integrity automatically (PIPE-05).
+- No lint rule on UX strings (per CONTEXT D-07 explicit rejection).
+- Both docs live under `.planning/`, not `docs/` (per CONTEXT D-09).
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-07-ci-workflow-PLAN.md b/.planning/phases/01-foundations-and-doctrine/01-07-ci-workflow-PLAN.md
new file mode 100644
index 0000000..a3da0fe
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-07-ci-workflow-PLAN.md
@@ -0,0 +1,216 @@
+---
+phase: 01
+plan: 07
+type: execute
+wave: 3
+depends_on: [01-01, 01-02, 01-03, 01-04, 01-05, 01-06]
+files_modified:
+ - .github/workflows/ci.yml
+autonomous: true
+requirements: [PIPE-06]
+must_haves:
+ truths:
+ - "A GitHub Actions workflow at `.github/workflows/ci.yml` runs `npm ci` (lockfile-strict install) then `npm run ci` (lint + test + validate-assets + build) on every push to main and every pull request"
+ - "The workflow uses Node 22.x (per RESEARCH § Environment Availability — Node 22 ideal for native crypto.hash; Node ≥ 20 required for recursive readdir; the validator uses readdir without recursive but Node 22 is the modern baseline)"
+ - "The workflow uses `actions/setup-node@v4` with `cache: 'npm'` (per RESEARCH § CI Pitfall A — never cache `node_modules/` directly)"
+ - "The workflow runs successfully on the post-Plan-06 codebase, verifying that PIPE-06's contract (Vitest covers all save migrations and runs on every CI build) is enforced as the CI job"
+ artifacts:
+ - path: .github/workflows/ci.yml
+ provides: "Minimum-viable CI workflow per RESEARCH Open Question #4 (~20-line) — single job, single matrix entry, npm ci + npm run ci"
+ contains: "npm run ci"
+ key_links:
+ - from: .github/workflows/ci.yml
+ to: package.json scripts.ci
+ via: "Workflow runs `npm run ci` which is `npm run lint && npm run test && npm run validate:assets && npm run build`"
+ pattern: "npm run ci"
+---
+
+
+Ship the minimum-viable CI workflow per RESEARCH Open Question #4: a single GitHub Actions YAML at `.github/workflows/ci.yml` that on every push to `main` and every pull request runs `npm ci` (lockfile-strict install) followed by `npm run ci` (lint + test + validate-assets + build). This wires Plans 02–06's automated checks into a single CI job. Per CONTEXT user pushback against ceremonial workflows: NO matrix builds across OSes, NO matrix builds across Node versions, NO test reporters, NO release automation, NO Codecov uploads — just one job that goes green or red.
+
+PIPE-06's contract — "Project ships unit tests (Vitest) covering all save migrations and core economy formulas, run on every CI build" — is satisfied because Plan 03 authored the migration tests and `npm run ci` runs them. Phase 1 has no economy formulas yet (Phase 2 deliverable), so PIPE-06 in Phase 1 is "the CI runs Vitest on every PR/push." Phase 2+ will add economy tests that flow through this same workflow.
+
+Purpose: This is the structural enforcement of PIPE-06 and the closing arc of Phase 1 — Plans 01–06 all ship their own automated checks; this plan ensures those checks actually run on every commit going forward.
+
+Output: One YAML file. Wave 3 by definition because it can only land after Plans 02 (lint), 03 (test), 04 (build), 05 (validate-assets), 06 (doc-lint) are all green — running before any of those would fail.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md
+@.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
+@.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-02-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-03-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-05-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-06-SUMMARY.md
+@CLAUDE.md
+
+
+
+
+
+ Task 1: Author `.github/workflows/ci.yml` (~25 lines per RESEARCH Open Question #4)
+
+ .github/workflows/ci.yml
+
+
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Open Questions #4" (20-line minimum-viable `.github/workflows/ci.yml` recommendation), § "Common Pitfalls (CI / Tooling Specific)" CI Pitfall A (use `actions/setup-node@v4` with `cache: 'npm'`, never cache `node_modules/`), CI Pitfall B (`--passWithNoTests=false` already on `npm run test` from Plan 01), CI Pitfall C (`--max-warnings 0` already on `npm run lint` from Plan 01)
+ - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Environment Availability" (Node 22.x ideal)
+ - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md user pushback: solo dev, minimum-viable, no ceremonial workflows
+ - package.json (verify `npm run ci` script exists from Plan 01 and chains lint+test+validate:assets+build)
+ - All 6 prior SUMMARY files (verify each plan's artifacts are committed and tests are green BEFORE writing this workflow — this plan must NOT land if any prior plan's tests fail)
+
+
+ **Step 0 — Sanity check that all upstream plans landed green.** Run locally:
+ ```bash
+ npm run ci
+ ```
+ This MUST exit 0 before this plan's workflow lands. If it does not, the failing plan needs revision before this plan proceeds.
+
+ **Step 1 — Create the directory:**
+ ```bash
+ mkdir -p .github/workflows
+ ```
+
+ **Step 2 — Write `.github/workflows/ci.yml`** using the Write tool:
+ ```yaml
+ # Phase 1 — minimum-viable CI per RESEARCH Open Question #4 + CONTEXT user pushback
+ # against ceremonial workflows (.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md).
+ #
+ # On every push to main and every pull request:
+ # - npm ci (lockfile-strict install — refuses on package.json drift)
+ # - npm run ci (lint + test + validate-assets + build, defined in package.json)
+ #
+ # This single job satisfies PIPE-06: Vitest tests run on every CI build.
+ # Phase 2+ economy tests flow through the same `npm run ci` chain — no workflow change
+ # is needed when more tests are added.
+ #
+ # Deliberately omitted (per CONTEXT user pushback against ceremony):
+ # - OS matrix (Linux only is fine; PIPE-04 visual regression testing is Phase 8)
+ # - Node-version matrix (one supported version is enough for solo-dev)
+ # - Test reporters / Codecov uploads (no coverage requirement in Phase 1)
+ # - Release automation (no releases until Phase 2 ships Season 1)
+ # - Notification integrations (the project owner reads GitHub directly)
+
+ name: ci
+
+ on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+ jobs:
+ ci:
+ name: lint + test + validate-assets + build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ # Per RESEARCH CI Pitfall A: cache ~/.npm based on package-lock.json,
+ # NEVER cache node_modules/ directly (transitive deps go stale).
+ cache: 'npm'
+
+ - name: Install dependencies (lockfile-strict)
+ run: npm ci
+
+ - name: Run CI suite
+ run: npm run ci
+ ```
+
+ **Concrete shape per RESEARCH Open Question #4:** ~25 lines (slightly over the 20-line target because the comment block is load-bearing context for future maintainers). This is single-job, single-matrix-entry, no third-party actions beyond `actions/checkout@v4` + `actions/setup-node@v4`.
+
+ **Step 3 — Validate the YAML locally** (without pushing):
+ ```bash
+ # If `actionlint` is not installed: skip; the workflow will be validated by GitHub on push.
+ actionlint .github/workflows/ci.yml 2>/dev/null || echo "actionlint not installed; will validate on push"
+ ```
+
+ **Step 4 — Local sanity check that `npm run ci` exits 0** (it should, since Plan 06 was green; this is the second time we run it just to be sure):
+ ```bash
+ npm run ci
+ ```
+ Confirm exit code 0 with output containing:
+ - `[lint]` or ESLint pass message
+ - `[test]` or Vitest pass message (some N tests passed)
+ - `[provenance] all assets carry valid provenance.`
+ - `[build]` or Vite build success message
+
+ **Step 5 — Commit `ci(01-07): minimum-viable GitHub Actions workflow running npm run ci on push + PR (PIPE-06)`.**
+
+ **Step 6 (optional, only if the user has set up the GitHub remote):** push the branch and verify the workflow runs green on GitHub. Per CONTEXT user pushback, do NOT block this plan on the push — local `npm run ci` green is sufficient evidence that the workflow will run green when the user next pushes.
+
+
+ test -f .github/workflows/ci.yml && grep -q "npm run ci" .github/workflows/ci.yml && grep -q "actions/setup-node@v4" .github/workflows/ci.yml && grep -q "cache: 'npm'" .github/workflows/ci.yml && npm run ci
+
+
+ - `.github/workflows/ci.yml` exists — verify with `test -f .github/workflows/ci.yml`.
+ - The workflow runs `npm run ci` — verify with `grep -q "npm run ci" .github/workflows/ci.yml`.
+ - The workflow uses `actions/setup-node@v4` with `cache: 'npm'` — verify with `grep -q "actions/setup-node@v4" .github/workflows/ci.yml && grep -q "cache: 'npm'" .github/workflows/ci.yml`.
+ - The workflow does NOT cache `node_modules/` directly (RESEARCH CI Pitfall A) — verify with `! grep -E "cache: 'node_modules" .github/workflows/ci.yml`.
+ - The workflow uses Node 22 — verify with `grep -E "node-version: '22'" .github/workflows/ci.yml`.
+ - The workflow runs `npm ci` (lockfile-strict) before `npm run ci` — verify with `grep -E "run: npm ci" .github/workflows/ci.yml` and that the `npm ci` step appears before the `npm run ci` step (`grep -n` line numbers ascending).
+ - The workflow triggers on `push` to `main` AND `pull_request` to `main` — verify with `grep -E "branches: \\[main\\]" .github/workflows/ci.yml | wc -l` returns 2 (one for push, one for pull_request).
+ - The workflow has a sensible `timeout-minutes` (10 is the recommended ceiling for Phase 1's ~30s runtime) — verify with `grep -q "timeout-minutes:" .github/workflows/ci.yml`.
+ - Locally `npm run ci` exits 0 — proves the workflow will be green on push.
+ - The workflow contains comments explaining what was deliberately omitted (no matrix, no codecov, no release automation) per CONTEXT user pushback — verify with `grep -qE "Deliberately omitted|matrix" .github/workflows/ci.yml`.
+
+
+ `.github/workflows/ci.yml` authored as a minimum-viable single-job workflow running `npm ci` + `npm run ci` on push to main and PR; uses `actions/setup-node@v4` with npm caching per RESEARCH CI Pitfall A; Node 22; ~25 lines + load-bearing comments; locally `npm run ci` exits 0 proving the workflow will be green; commit landed.
+
+
+
+
+
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-01-08 | Tampering | npm install supply-chain (a transitive dep gets compromised) | mitigate | `package-lock.json` is committed (Plan 01); `npm ci` in this workflow refuses if lockfile and `package.json` drift; per RESEARCH § Security Domain, this is the standard mitigation for solo-dev supply-chain risk in Phase 1. Phase 8 launch polish may add `npm audit` as a CI step if surface area grows. |
+
+
+
+- Local `npm run ci` exits 0 (the workflow is a thin shell around the same script that runs locally).
+- `.github/workflows/ci.yml` is well-formed YAML (validated by GitHub on push).
+- All Phase-1 success criteria are now structurally enforced on every commit going forward:
+ - CORE-10 firewall via `npm run lint` (Plan 02)
+ - CORE-04 through CORE-09 via `npm test` (Plan 03 + Plan 06's doctrine.test.ts)
+ - PIPE-01 via `npm run build` + `npm test` (Plan 04 loader.test.ts)
+ - PIPE-03 + AEST-08 + AEST-09 via `npm run validate:assets` + `npm test` (Plan 05)
+ - PIPE-05 via `npm test` (Plan 06 doctrine.test.ts)
+ - PIPE-06 via this workflow (Vitest runs on every CI build)
+ - CORE-01 via `npm run build` (smoke; Phase 2 PIPE-07 adds Playwright load-time spec)
+
+
+
+- `.github/workflows/ci.yml` runs `npm ci && npm run ci` on push to main and PR.
+- All Plan 01–06 automated checks now run on every commit.
+- PIPE-06's "Vitest tests run on every CI build" is structurally satisfied.
+- Phase 2 inherits the same workflow without modification — adding economy tests just adds them to `npm test`.
+
+
+
diff --git a/.planning/phases/01-foundations-and-doctrine/01-VALIDATION.md b/.planning/phases/01-foundations-and-doctrine/01-VALIDATION.md
new file mode 100644
index 0000000..110f274
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-VALIDATION.md
@@ -0,0 +1,136 @@
+---
+phase: 1
+slug: foundations-and-doctrine
+status: planned
+nyquist_compliant: true
+wave_0_complete: pending-execution
+created: 2026-05-08
+updated: 2026-05-08 (populated by /gsd-plan-phase)
+---
+
+# Phase 1 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+> Source: `01-RESEARCH.md` § Validation Architecture.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Vitest 4.1.5 (verified 2026-05-08) |
+| **Config file** | `vitest.config.ts` (Plan 01 Task 2 deliverable; environment: happy-dom) |
+| **Quick run command** | `npm test` (alias for `vitest run --passWithNoTests=false`) |
+| **Full suite command** | `npm run ci` (lint + test + validate-assets + build) |
+| **Estimated runtime** | ~5s quick, ~30s full |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run `npm test`
+- **After every plan wave:** Run `npm run ci`
+- **Before `/gsd-verify-work`:** `npm run ci` must be green, plus manual smoke that `npm run dev` opens the Phaser scaffold
+- **Max feedback latency:** ~5 seconds per commit, ~30 seconds per wave
+
+---
+
+## Per-Task Verification Map
+
+> Populated by the planner during /gsd-plan-phase. Each task maps to a Wave 0 test stub or existing infrastructure.
+
+| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| 01-01-T1 | 01 | 1 | CORE-01 | — | Scaffold builds | smoke | `npm run build` | post-execution | ⬜ pending |
+| 01-01-T2 | 01 | 1 | (infra) | — | Vitest + Playwright wired | smoke | `npm test && npx playwright --version` | post-execution | ⬜ pending |
+| 01-02-T1 | 02 | 2 | CORE-10 | — | ESLint flat config + boundaries plugin in place | static-analysis | `npm run lint` | post-execution | ⬜ pending |
+| 01-02-T2 | 02 | 2 | CORE-10 | — | Boundary rule fires on `sim → render` import | unit (lint) | `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` | post-execution | ⬜ pending |
+| 01-03-T1 | 03 | 2 | CORE-06, CORE-07 | T-01-01 | CRC-32 envelope + canonical JSON; v0→v1 synthetic migration | unit | `npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts` | post-execution | ⬜ pending |
+| 01-03-T2 | 03 | 2 | CORE-04, CORE-05, CORE-08 | T-01-01 | idb DB + LocalStorageDBAdapter fallback (CORE-04) + last-3 snapshot retention + persist API | unit | `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts` | post-execution | ⬜ pending |
+| 01-03-T3 | 03 | 2 | CORE-09, CORE-04 | T-01-02 | Base64 codec with 50MB DoS cap + full round-trip | unit + integration | `npx vitest run src/save/round-trip.test.ts && npm run build` | post-execution | ⬜ pending |
+| 01-04-T1 | 04 | 2 | PIPE-01, STRY-09 | — | Vite-native loader + Zod schemas + demo fragment | smoke | `npm run build && npm run compile:ink` | post-execution | ⬜ pending |
+| 01-04-T2 | 04 | 2 | PIPE-01 | — | Schema violation throws (build fails on bad content) | unit | `npx vitest run src/content/loader.test.ts` | post-execution | ⬜ pending |
+| 01-05-T1 | 05 | 2 | PIPE-03, AEST-08, AEST-09 | T-01-06, T-01-07 | Validator script + sidecar schema + refused-sample fixture (test fixture isolated under os.tmpdir()) | integration | `node scripts/validate-assets.mjs && npx vitest run scripts/validate-assets.test.ts` | post-execution | ⬜ pending |
+| 01-05-T2 | 05 | 2 | AEST-08, AEST-09 | T-01-06 | 10–20 north-star reference images committed with sidecars | manual + smoke | `node scripts/validate-assets.mjs` (count assertion) | post-execution | ⬜ pending (checkpoint) |
+| 01-06-T1 | 06 | 2 | PIPE-05, UX-13 | — | anti-FOMO doctrine consolidates 4 source documents (file exists with required H2 sections) | smoke | `test -f .planning/anti-fomo-doctrine.md && grep -q '## Banned Mechanics' .planning/anti-fomo-doctrine.md` | post-execution | ⬜ pending |
+| 01-06-T2 | 06 | 2 | PIPE-05, STRY-09 | — | Season 7 end-state doctrine principle-level + doc-lint test | doc-lint | `npx vitest run scripts/doctrine.test.ts` | post-execution | ⬜ pending |
+| 01-07-T1 | 07 | 3 | PIPE-06 | T-01-08 | GitHub Actions workflow runs `npm run ci` on push + PR | smoke (CI) | `npm run ci` (locally) + workflow runs on next push | post-execution | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+**Coverage:** All 16 Phase-1 requirement IDs (CORE-01, CORE-04, CORE-05, CORE-06, CORE-07, CORE-08, CORE-09, CORE-10, PIPE-01, PIPE-03, PIPE-05, PIPE-06, AEST-08, AEST-09, STRY-09, UX-13) are covered by at least one task above.
+
+---
+
+## Per-Requirement Behavior Map (from RESEARCH.md)
+
+| Req ID | Behavior Under Test | Test Type | Automated Command | Plan |
+|--------|---------------------|-----------|-------------------|------|
+| CORE-04 | IndexedDB save round-trips AND localStorage fallback round-trips when IDB rejects (stub-injected) | unit | `npx vitest run src/save/db.test.ts` | 03 |
+| CORE-05 | `requestPersistence()` returns `{granted, apiAvailable}` shape and handles missing API | unit | `npx vitest run src/save/persist.test.ts` | 03 |
+| CORE-06 | `wrap()` produces valid envelope; `unwrap()` rejects checksum mismatch | unit | `npx vitest run src/save/envelope.test.ts` | 03 |
+| CORE-07 | `migrate({garden:[]}, 0)` produces v1 shape; `migrations[1]` invoked exactly once | unit | `npx vitest run src/save/migrations.test.ts` | 03 |
+| CORE-08 | After 5 successive `snapshot()` calls, exactly 3 newest entries remain | unit | `npx vitest run src/save/snapshots.test.ts` | 03 |
+| CORE-09 | Base64 export → import → migrate → unwrap yields original payload | unit (round-trip) | `npx vitest run src/save/round-trip.test.ts` | 03 |
+| CORE-10 | `src/sim/` importing from `src/render/` produces ESLint error | static-analysis | `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` (programmatic ESLint API) | 02 |
+| CORE-01 | Game scaffold `npm run build` produces a valid bundle | smoke | `npm run build` (Phase 2 PIPE-07 adds Playwright) | 01 |
+| PIPE-01 | Demo content file with deliberate schema violation fails the build | unit (build-time) | `npx vitest run src/content/loader.test.ts` | 04 |
+| PIPE-03 | Asset validator script exits non-zero on a fixture missing provenance (fixture isolated under os.tmpdir()) | integration | `npx vitest run scripts/validate-assets.test.ts` | 05 |
+| PIPE-05 | `.planning/anti-fomo-doctrine.md` and `.planning/season-7-end-state.md` exist with required H2 sections | doc lint | `npx vitest run scripts/doctrine.test.ts` | 06 |
+| PIPE-06 | All save migration tests run on every CI build | meta | `.github/workflows/ci.yml` runs `npm run ci` | 07 |
+| AEST-08 / AEST-09 | All assets in `assets/` (excluding `__samples__/refused/`) have valid provenance sidecars | integration | covered by PIPE-03 test + Plan 05 north-star commit | 05 |
+| STRY-09 | Vacuously satisfied: Phase 1 ships no source code containing player-visible strings; first enforcement lands in Phase 2 | n/a (Phase 1 has no code to externalize from yet) | — | 04, 06 |
+| UX-13 | Doctrine doc enforced by review per CONTEXT D-07 | manual-only | — | 06 |
+
+> **Note on STRY-09 (BLOCKER 4 fix):** STRY-09 ("All player-visible strings externalized to /content/, never hardcoded in source") is *vacuously satisfied* in Phase 1 — Phase 1 ships scaffolding, doctrine docs, and pure-function libraries (save layer, content loader, asset validator); none of these contain player-visible UI strings. The `/content/` convention is *established* by Plan 04's loader + demo fragment + README, and the `` directory tree is committed by Plans 01 and 04, but no source code yet exists that *could* hardcode a player-visible string. First enforcement (lint rule on JSX text nodes, or grep guard) lands in Phase 2 when the first UI components are authored. This row previously cited CONTEXT D-07 erroneously (D-07 is about anti-FOMO review enforcement, not about UX-string externalization) — that misattribution is corrected here.
+
+---
+
+## Wave 0 Requirements
+
+Wave 0 (test infrastructure) is split across Plans 01 and the test files of Plans 02–06:
+
+- [x] `vitest.config.ts` — minimal happy-dom config (**Plan 01 Task 2**)
+- [x] `playwright.config.ts` — installed only, no specs in Phase 1 (**Plan 01 Task 2**)
+- [x] `eslint.config.js` — flat config with `eslint-plugin-boundaries` (**Plan 02 Task 1**)
+- [x] `src/sim/__test_violation__/violator.ts` — boundary lint fixture (**Plan 02 Task 2**)
+- [x] `src/sim/__test_violation__/lint-firewall.test.ts` — boundary rule assertion (**Plan 02 Task 2**)
+- [x] `src/save/checksum.test.ts` — checksum determinism (**Plan 03 Task 1**)
+- [x] `src/save/envelope.test.ts` — CORE-06 (**Plan 03 Task 1**)
+- [x] `src/save/migrations.test.ts` — CORE-07 (**Plan 03 Task 1**)
+- [x] `src/save/db.test.ts` — CORE-04 (IDB-primary + LocalStorageDBAdapter fallback paths) (**Plan 03 Task 2**)
+- [x] `src/save/snapshots.test.ts` — CORE-08 (**Plan 03 Task 2**)
+- [x] `src/save/persist.test.ts` — CORE-05 (**Plan 03 Task 2**)
+- [x] `src/save/round-trip.test.ts` — CORE-09 (**Plan 03 Task 3**)
+- [x] `src/content/loader.test.ts` — PIPE-01 (**Plan 04 Task 2**)
+- [x] `scripts/validate-assets.test.ts` — PIPE-03 (**Plan 05 Task 1**, fixture isolated under os.tmpdir())
+- [x] `scripts/doctrine.test.ts` — PIPE-05 (**Plan 06 Task 2**)
+- [x] `.github/workflows/ci.yml` — runs `npm run ci` on PR + push (**Plan 07 Task 1**)
+- [x] Framework install: `vitest`, `happy-dom`, `fake-indexeddb`, `@playwright/test` (all installed by **Plan 01 Task 1**)
+
+`[x]` indicates the plan ships the file; actual file creation happens during execution.
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Game scaffold loads in Chrome/Firefox/Safari/Edge under 5s on 25 Mbps | CORE-01 | Multi-browser load-time + bandwidth simulation belongs in Phase 2 Playwright (PIPE-07); Phase 1 ships the bundle and verifies build succeeds | Run `npm run build && npm run preview`, open in each browser locally, confirm load. Cross-browser perf gate is Phase 2's `gsd-verify-work` deliverable. |
+| Anti-FOMO doctrine review-readiness | UX-13 | Doctrine is enforced by human review per CONTEXT D-07; no lint rule | Read `.planning/anti-fomo-doctrine.md` end-to-end; confirm every banned pattern from PROJECT.md / REQUIREMENTS.md / CLAUDE.md / PITFALLS.md #9 is enumerated. |
+| Season 7 end-state principle clarity | STRY-09 (related; not the formal target — see note above) | Principle-level doc; enforced by review | Read `.planning/season-7-end-state.md`; confirm it answers (a) what *rest state* means, (b) what the finite Roothold ceiling is tied to, (c) the coda's tonal register. |
+| North-star reference set human-curation | AEST-09 | Curation gate is the human reviewer per CONTEXT D-03 | Manually review the 10–20 generations under `assets/north-stars/`; confirm provenance sidecars present and visually consistent. (Plan 05 Task 2 is `checkpoint:human-verify`.) |
+
+---
+
+## Validation Sign-Off
+
+- [x] All tasks have `` verify or Wave 0 dependencies (Plan 05 Task 2 is the sole `checkpoint:human-verify` per CONTEXT D-01 + D-03 — curation gate is human review by design)
+- [x] Sampling continuity: no 3 consecutive tasks without automated verify (every plan has at least one automated test except Plan 05 T2 which is the human-curation checkpoint)
+- [x] Wave 0 covers all MISSING references — see "Wave 0 Requirements" above
+- [x] No watch-mode flags — `npm test` uses `vitest run` (one-shot)
+- [x] Feedback latency < 30s — `npm run ci` is ~30s per RESEARCH § Validation Architecture
+- [x] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** approved