docs(01): plan phase 1 — 7 plans across 3 waves, verified after 1 revision

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 23:09:08 -04:00
parent 9c9c6eddbc
commit 39563f6934
10 changed files with 3370 additions and 9 deletions
+10 -2
View File
@@ -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, 1020 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 | - |
+7 -7
View File
@@ -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 0206 (firewall, save layer, content pipeline, asset provenance, doctrine docs) in parallel; Plan 05 has a human-curate checkpoint (autonomous: false) for the 1020 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`
@@ -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\""
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Scaffold Phaser 4 official template + restructure src/ + install Phase-1 deps</name>
<files>
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
</files>
<read_first>
- .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)
</read_first>
<action>
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).
</action>
<verify>
<automated>npm run build &amp;&amp; test -d src/sim &amp;&amp; test -d src/render &amp;&amp; test -d src/ui &amp;&amp; test -d src/save &amp;&amp; test -d src/content &amp;&amp; test -d src/audio &amp;&amp; test -d src/store &amp;&amp; test -d content &amp;&amp; test -d assets &amp;&amp; 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')"</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Configure Vitest (happy-dom) + Playwright (install only) + sentinel test</name>
<files>
vitest.config.ts,
playwright.config.ts,
src/__sentinel__.test.ts
</files>
<read_first>
- .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)
</read_first>
<action>
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`.**
</action>
<verify>
<automated>npm test &amp;&amp; npx playwright --version</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
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.
</threat_model>
<verification>
- `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 0206 only need to add config files and source files, not edit `package.json`.
</verification>
<success_criteria>
- 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`.
</success_criteria>
<output>
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.
</output>
</output>
@@ -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`"
---
<objective>
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 `<verify>` test, not just by "lint exits 0 on clean code" (which proves nothing about the rule actually working).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Migrate to ESLint flat config + add boundaries plugin + define element types and the firewall rule</name>
<files>
eslint.config.js
</files>
<read_first>
- .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.
</read_first>
<action>
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`.**
</action>
<verify>
<automated>npm run lint &amp;&amp; grep -q "boundaries/element-types" eslint.config.js &amp;&amp; grep -E "disallow:\s*\[.*\b(render|ui)\b.*\]" eslint.config.js</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Add deliberate-violation fixture + Vitest test that asserts the boundary rule fires</name>
<files>
src/sim/__test_violation__/violator.ts,
src/sim/__test_violation__/lint-firewall.test.ts
</files>
<read_first>
- 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
</read_first>
<action>
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`.**
</action>
<verify>
<automated>npx vitest run src/sim/__test_violation__/lint-firewall.test.ts &amp;&amp; npm run lint</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
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.
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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.
</output>
</output>
File diff suppressed because it is too large Load Diff
@@ -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/<slug>/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"
---
<objective>
Stand up the Vite-native content pipeline: Zod schemas for `Fragment` (with the `season<N>.<id>` 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).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Zod schemas + loader + demo fragment + content README</name>
<files>
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
</files>
<read_first>
- .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)
</read_first>
<behavior>
- **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.
</behavior>
<action>
**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<N>.<id>` where <id> 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<typeof FragmentSchema>;
```
**Step 2 — `src/content/schemas/season.ts`:**
```typescript
import { z } from 'zod';
import { FragmentSchema } from './fragment';
/** Shape of one /content/seasons/<slug>/fragments.yaml file. */
export const SeasonContentSchema = z.object({
fragments: z.array(FragmentSchema),
});
export type SeasonContent = z.infer<typeof SeasonContentSchema>;
```
**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<string, string>;
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
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<string, string>,
mdGlob: Record<string, string> = {},
): 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 37 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<N>.<id>
```
where `<N>` is `0..7` and `<id>` 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/<slug>/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/<slug>/fragments/<slug>.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 27 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`.**
</action>
<verify>
<automated>npm run build &amp;&amp; npm run compile:ink</automated>
</verify>
<acceptance_criteria>
- 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<N>" 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.
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: PIPE-01 enforcement test — schema violation must throw</name>
<files>
src/content/loader.test.ts
</files>
<read_first>
- 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")
</read_first>
<behavior>
- 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.
</behavior>
<action>
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<N>.<slug> 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`.**
</action>
<verify>
<automated>npx vitest run src/content/loader.test.ts &amp;&amp; npm test</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
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.
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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.
</output>
@@ -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 `<filename>.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)"
- "1020 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 <filename>.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: "1020 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=<os.tmpdir()/...> + assert exit code"
pattern: "spawn\\(.*node.*validate-assets|execFile|os\\.tmpdir"
---
<objective>
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 `<filename>.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 1020 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 1020 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, ~1020 north-star images with provenance sidecars, and a Vitest test enforcing the gate.
This plan is `autonomous: false` because Task 2 — committing the 1020 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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Provenance validator script + Zod sidecar schema + Vitest enforcement test</name>
<files>
scripts/validate-assets.mjs,
scripts/validate-assets.test.ts,
assets/__samples__/refused/no-provenance.png,
assets/__samples__/refused/.gitkeep
</files>
<read_first>
- .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)
</read_first>
<behavior>
- **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 `<filename>.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=<that tmpdir>` 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.
</behavior>
<action>
**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 <filename>.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)`.**
</action>
<verify>
<automated>node scripts/validate-assets.mjs &amp;&amp; npx vitest run scripts/validate-assets.test.ts</automated>
</verify>
<acceptance_criteria>
- `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`.
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Curate and commit 1020 north-star reference images with provenance sidecars (CONTEXT D-01)</name>
<what-built>
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 1020 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 1020 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 `<filename>.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/<descriptive-slug>.png` + `assets/north-stars/<descriptive-slug>.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:<photographer>"`)
- `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 "1020 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)
</what-built>
<how-to-verify>
1. Choose a path (A, B, or C) and produce the images + sidecars.
2. Place all pairs under `assets/north-stars/`. Naming: `<slug>.png` + `<slug>.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 <N> 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 <N> north-star reference images with provenance sidecars (path <A|B|C>)`.
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:** <YYYY-MM-DD>
**Owner:** <your name or handle>
**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 <N> placeholder images (target: 1020).
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
- 1020 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").
</how-to-verify>
<resume-signal>Type "approved" or describe issues</resume-signal>
</task>
</tasks>
<threat_model>
| 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. |
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
- 30-line standalone Node script validates every non-sidecar asset has a sibling `<filename>.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.
- 1020 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.
</success_criteria>
<output>
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 <N> 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).
</output>
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Author `.planning/anti-fomo-doctrine.md` (consolidation per CONTEXT D-07 + RESEARCH outline)</name>
<files>
.planning/anti-fomo-doctrine.md
</files>
<read_first>
- .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)
</read_first>
<action>
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)`.
</action>
<verify>
<automated>test -f .planning/anti-fomo-doctrine.md &amp;&amp; grep -q "## Banned Mechanics" .planning/anti-fomo-doctrine.md &amp;&amp; grep -q "## Allowed Engagement" .planning/anti-fomo-doctrine.md &amp;&amp; grep -q "## Review Checklist" .planning/anti-fomo-doctrine.md &amp;&amp; grep -q "## Source Documents" .planning/anti-fomo-doctrine.md</automated>
</verify>
<acceptance_criteria>
- 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`.
</acceptance_criteria>
<done>
`.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.
</done>
</task>
<task type="auto">
<name>Task 2: Author `.planning/season-7-end-state.md` (principle-level per CONTEXT D-08 + RESEARCH outline) + Vitest doc-lint test</name>
<files>
.planning/season-7-end-state.md,
scripts/doctrine.test.ts
</files>
<read_first>
- .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
</read_first>
<action>
**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)`.**
</action>
<verify>
<automated>npx vitest run scripts/doctrine.test.ts &amp;&amp; test -f .planning/season-7-end-state.md &amp;&amp; ! test -f docs/season-7-end-state.md</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>
`.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.
</done>
</task>
</tasks>
<threat_model>
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.
</threat_model>
<verification>
- 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).
</verification>
<success_criteria>
- `.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).
</success_criteria>
<output>
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.
</output>
@@ -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"
---
<objective>
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 0206'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 0106 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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Author `.github/workflows/ci.yml` (~25 lines per RESEARCH Open Question #4)</name>
<files>
.github/workflows/ci.yml
</files>
<read_first>
- .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)
</read_first>
<action>
**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 <N> 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.
</action>
<verify>
<automated>test -f .github/workflows/ci.yml &amp;&amp; grep -q "npm run ci" .github/workflows/ci.yml &amp;&amp; grep -q "actions/setup-node@v4" .github/workflows/ci.yml &amp;&amp; grep -q "cache: 'npm'" .github/workflows/ci.yml &amp;&amp; npm run ci</automated>
</verify>
<acceptance_criteria>
- `.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`.
</acceptance_criteria>
<done>
`.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.
</done>
</task>
</tasks>
<threat_model>
| 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. |
</threat_model>
<verification>
- 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)
</verification>
<success_criteria>
- `.github/workflows/ci.yml` runs `npm ci && npm run ci` on push to main and PR.
- All Plan 0106 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`.
</success_criteria>
<output>
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 0107 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.
</output>
@@ -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 | 1020 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 `<content/>` 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 0206:
- [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 1020 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 `<automated>` 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