From 39563f6934a7cff64f1ab2310aaf33571dda4bd0 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 23:09:08 -0400 Subject: [PATCH] =?UTF-8?q?docs(01):=20plan=20phase=201=20=E2=80=94=207=20?= =?UTF-8?q?plans=20across=203=20waves,=20verified=20after=201=20revision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1: Plan 01 (scaffold + test infra) Wave 2: Plans 02 (eslint firewall), 03 (save layer), 04 (content pipeline), 05 (asset provenance — autonomous:false human-curate checkpoint), 06 (doctrine docs) Wave 3: Plan 07 (CI workflow) All 16 Phase-1 REQ-IDs covered. Plan-checker found 4 blockers + 6 warnings on first pass; revision iteration 1 landed all 10 fixes; iteration 2 returned VERIFICATION PASSED. Two orchestrator judgment calls during revision: (1) implement CORE-04 localStorage fallback in Phase 1 (the literal requirement and ROADMAP success criterion #2 both call for it), (2) reclassify STRY-09 as vacuously satisfied in Phase 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/ROADMAP.md | 12 +- .planning/STATE.md | 14 +- .../01-01-scaffold-and-test-infra-PLAN.md | 303 +++++ .../01-02-eslint-firewall-PLAN.md | 278 +++++ .../01-03-save-layer-PLAN.md | 1033 +++++++++++++++++ .../01-04-content-pipeline-PLAN.md | 510 ++++++++ .../01-05-asset-provenance-PLAN.md | 411 +++++++ .../01-06-doctrine-docs-PLAN.md | 466 ++++++++ .../01-07-ci-workflow-PLAN.md | 216 ++++ .../01-VALIDATION.md | 136 +++ 10 files changed, 3370 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/01-foundations-and-doctrine/01-01-scaffold-and-test-infra-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-04-content-pipeline-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-05-asset-provenance-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-06-doctrine-docs-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-07-ci-workflow-PLAN.md create mode 100644 .planning/phases/01-foundations-and-doctrine/01-VALIDATION.md 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`. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md` documenting: +- Final dependency versions installed (may differ slightly from RESEARCH if `npm install` resolved a newer patch). +- The exact `package.json` `scripts` block as written (so Wave-2 executors can verify their assumptions). +- Any drift from the template (e.g., did the template ship Vite 6, 7, or 8? did it ship a legacy `.eslintrc.cjs` or already-flat `eslint.config.js`?) — this drift is critical context for Plan 02. +- Sentinel test command and current pass status. + + 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. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-02-SUMMARY.md` documenting: +- Final shape of `eslint.config.js` (was the template flat or legacy? what rules were preserved?). +- The exact ESLint version installed (template-default + `eslint-plugin-boundaries@6.0.2`). +- Confirmation that `npm run lint` is green and the firewall test is green. +- Note for Phase 2: when `src/sim/` starts containing real modules, the existing config will lint them; no further wiring needed. + + 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. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-03-SUMMARY.md` documenting: +- Final test count (`npx vitest run src/save/ 2>&1 | tail -5`). +- The exact `CURRENT_SCHEMA_VERSION` (must be 1) and what the v1 shape contains (so Phase 4's `migrate_v1_to_v2` author has the contract). +- **CORE-04 fallback note:** the localStorage fallback is shipped in Phase 1 per the orchestrator's revision-iteration-1 decision (REQUIREMENTS.md CORE-04 + ROADMAP success criterion #2 both require it). The fallback is a thin (~30-40 LoC) `LocalStorageDBAdapter` exposing the same minimal interface as the IDB DB; `openSaveDB()` wraps `openDB()` in try/catch and returns the adapter on rejection. A single Vitest test (`db.test.ts` "falls back to LocalStorageDBAdapter when IndexedDB is unavailable") stub-injects an IDB failure via `vi.doMock('idb')` and asserts the round-trip succeeds with `tlg.saves.main` written to localStorage. +- Confirmation that the public surface in `src/save/index.ts` is the only entry point Phase 2 should import from. + + 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. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md` documenting: +- The fragment ID regex committed (so Phase 2's writer has the contract). +- The path of the demo fragment (`content/seasons/00-demo/fragments.yaml`) and a note that Phase 2 will remove this directory and replace it with `01-soil/`. +- Confirmation that `compile:ink` is a no-op in Phase 1 (per CONTEXT D-08) and is replaced in Phase 2. +- Note for Phase 2: when authoring real fragments, follow `content/README.md` Section "Adding fragments" — the test in `src/content/loader.test.ts` proves any deviation from the schema fails the build. + 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. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-05-SUMMARY.md` documenting: +- The chosen path (A / B / C) and why. +- The exact count of north-star images committed (the validator output's `all assets` count). +- The `model_id` values present in the provenance set (so Phase 5 sees what tools were used in Phase 1). +- Confirmation that `npm run validate:assets` exits 0 and `npx vitest run scripts/validate-assets.test.ts` is green. +- Note for Phase 5: the schema is `provenance_schema_version: 1` (implicit / unset); Phase 5 may bump this when vendor consolidation lands. +- If Path C: explicit IOU recorded at `.planning/phases/01-foundations-and-doctrine/01-05-IOU.md` (NOT in STATE.md — STATE.md is orchestrator-owned). + + 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). + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-06-SUMMARY.md` documenting: +- Both docs' final paths and brief contents summary. +- Confirmation that the doc-lint test (`scripts/doctrine.test.ts`) passes. +- Note for Phase 4: the Roothold ceiling enforcement task should reference `.planning/season-7-end-state.md` § "What is the finite Roothold ceiling tied to?" for the principle. +- Note for Phase 7: the binary choice scene authoring should reference `.planning/season-7-end-state.md` § "What this document is NOT" for the boundary of what's authored when. +- Note for ongoing UX/monetization reviews: `.planning/anti-fomo-doctrine.md` is the canonical reference; consult before any UX change. + 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`. + + + +After completion, create `.planning/phases/01-foundations-and-doctrine/01-07-SUMMARY.md` documenting: +- The exact workflow shape (~25 lines). +- Node version chosen (22). +- Confirmation that local `npm run ci` exits 0 immediately before this plan's commit. +- Note for Phase 2: when economy tests + Playwright e2e (PIPE-07) land, they go through the same `npm run ci` script — the workflow file does NOT need to change. If Phase 2 wants to run Playwright on CI, add `npx playwright install --with-deps chromium` before the `npm run ci` step and update the `ci` script in package.json to include `&& npm run e2e`. +- Note for Phase 8: visual regression testing (PIPE-04) will likely require a separate workflow file (matrix runs against multiple OSes) since it's a different cost profile from this single-job lint/test workflow. + +Then update `.planning/phases/01-foundations-and-doctrine/01-VALIDATION.md` per-task table (the skeleton populated as plans were written): for each Plan 01–07 task, fill in one row mapping Task ID → Plan → Wave → Requirement → Test Type → Automated Command → File Exists → Status. Set `nyquist_compliant: true` in the frontmatter and approval to "approved" once all rows are filled. + 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