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>
25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01 | 04 | execute | 2 |
|
|
true |
|
|
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).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: Zod schemas + loader + demo fragment + content README src/content/schemas/fragment.ts, src/content/schemas/season.ts, src/content/schemas/index.ts, src/content/loader.ts, src/content/index.ts, content/seasons/00-demo/fragments.yaml, content/seasons/00-demo/.gitkeep, content/dialogue/.gitkeep, content/README.md - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 4: Vite-Native Content Pipeline" (verbatim code for FragmentSchema, SeasonContentSchema, loader.ts) and § "Common Pitfalls — Pitfall 1: import.meta.glob requires literal patterns" - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-11 — `/content/` at repo root; D-08 — Ink deferred to Phase 2) - CLAUDE.md "Code Style" — stable string fragment IDs (`season3.canopy.lura_07.vignette`, never numeric) - REQUIREMENTS.md MEMR-03 — fragment ID regex requirement (so the schema regex is correct for Phase 2) - **fragment.ts:** - The `id` field must match `^season\d+\.[a-z0-9._-]+$` (a Phase-1 demo can use `season0.demo`). - The `season` field must be an integer in [0, 7] (allowing 0 for the Phase-1 demo; Phase 2 will narrow to [1, 7] when Season 1 lands). - The `body` field must be a non-empty string. - **loader.ts:** - At module evaluation time, calls `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })`. - Parses each YAML string and validates against `SeasonContentSchema`. - Throws on any validation failure (the throw fails `npm run build` — that's PIPE-01). - Exports a flat `fragments: Fragment[]` containing all loaded fragments. - **content/seasons/00-demo/fragments.yaml:** one demo fragment with valid shape. **Step 1 — `src/content/schemas/fragment.ts`** (per RESEARCH Pattern 4 verbatim, with the regex from CLAUDE.md and the season range expanded to allow the Phase-1 demo): ```typescript import { z } from 'zod';/**
* Fragment ID convention (CLAUDE.md "Code Style"): stable string,
* `season<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 3–7 added in Phase 5+
├── dialogue/ # Phase 2+ Ink (.ink) files
│ └── (empty in Phase 1)
└── README.md (this file)
```
## Fragment ID convention (locked — see CLAUDE.md)
Fragment IDs are stable strings of the shape:
```
season<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 2–7 so the initial bundle contains only Season 1 (PIPE-02).
```
**Step 9 — Verify `npm run build` succeeds** (the loader runs at build time and the demo fragment passes the schema, so the build is clean).
**Step 10 — Verify `npm run compile:ink` succeeds with the no-op stub** (set up in Plan 01; this should print the placeholder message and exit 0).
**Step 11 — Commit `feat(01-04): Vite-native content pipeline + Zod schemas + demo fragment + /content/ README`.**
npm run build && npm run compile:ink
- All 4 schema/loader files exist: `test -f src/content/schemas/fragment.ts && test -f src/content/schemas/season.ts && test -f src/content/schemas/index.ts && test -f src/content/loader.ts && test -f src/content/index.ts`.
- `FragmentSchema` enforces the stable-string ID regex `^season\\d+\\.[a-z0-9._-]+$` — verify with `grep -F "^season\\d+\\." src/content/schemas/fragment.ts` (the regex appears in the file).
- `loader.ts` calls `import.meta.glob` with literal patterns (per Pitfall 1) — verify with `grep -E "import\\.meta\\.glob\\('/content/seasons/\\*/" src/content/loader.ts | wc -l` returns 2 (one for yaml, one for md).
- `loader.ts` throws on schema violation — verify with `grep -q "throw new Error" src/content/loader.ts`.
- `content/seasons/00-demo/fragments.yaml` exists with a valid `season0.demo` fragment — verify with `grep -q "season0.demo" content/seasons/00-demo/fragments.yaml`.
- `content/README.md` exists and documents the ID convention — verify with `grep -q "season" content/README.md && grep -q "Never use numeric IDs" content/README.md`.
- `content/dialogue/.gitkeep` exists (Plan 01 created it; Plan 04 confirms it survives) — verify with `test -f content/dialogue/.gitkeep`.
- `npm run build` exits 0 (the loader runs and the demo fragment passes).
- `npm run compile:ink` exits 0 (no-op stub from Plan 01) — verify exit code 0.
- `src/content/index.ts` exports the public surface — verify with `grep -cE "^export " src/content/index.ts` returns at least 4.
Zod schemas for Fragment + SeasonContent with the stable-string-ID regex; Vite-native loader using `import.meta.glob` with literal patterns; one demo fragment proving end-to-end round-trip; `content/README.md` documenting the convention for Phase 2 writers; `npm run build` and `npm run compile:ink` both green; commit landed.
Task 2: PIPE-01 enforcement test — schema violation must throw
src/content/loader.test.ts
- src/content/loader.ts (this plan's Task 1 — the `loadFragmentsFromGlob` helper)
- src/content/schemas/fragment.ts (the FragmentSchema regex — pick a violator that's clearly invalid)
- .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Validation Architecture (PIPE-01 row: "Demo content file with deliberate schema violation fails the build")
- Test 1: `loadFragmentsFromGlob({})` returns `[]` (empty glob, no fragments).
- Test 2: `loadFragmentsFromGlob` with a valid YAML mock returns the parsed fragments.
- Test 3 (the load-bearing PIPE-01 test): `loadFragmentsFromGlob` with a mocked YAML containing a numeric `id` (violates the stable-string-ID regex) THROWS, and the error message contains `[content] schema violation`.
- Test 4: `loadFragmentsFromGlob` with a mocked YAML containing a `season` value out of [0,7] range THROWS.
- Test 5: `loadFragmentsFromGlob` with a mocked Markdown frontmatter that omits required `id` THROWS.
Per RESEARCH § Validation Architecture, PIPE-01 is tested via "Vitest run with mocked import.meta.glob". The exported `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper from Task 1 makes this trivial — no need to mock Vite or run the build; we pass mocked glob outputs directly.
Use the Write tool to create `src/content/loader.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { loadFragmentsFromGlob } from './loader';
describe('PIPE-01: content schema validation', () => {
it('returns [] when both globs are empty', () => {
expect(loadFragmentsFromGlob({}, {})).toEqual([]);
});
it('parses valid YAML fragments', () => {
const yamlGlob = {
'/content/seasons/00-demo/fragments.yaml': `
fragments:
-
id: season0.demo.test season: 0 body: "demo body" `, }; const result = loadFragmentsFromGlob(yamlGlob); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ id: 'season0.demo.test', season: 0, body: 'demo body', }); });
it('THROWS on a numeric-id violation (stable-string-ID rule)', () => { const yamlGlob = { '/content/seasons/01-soil/fragments.yaml': ` fragments:
-
id: 42 season: 1 body: "this should fail because id must be a string matching the season. regex" `, }; expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/[content] schema violation/); });
it('THROWS when season is out of [0,7] range', () => { const yamlGlob = { '/content/seasons/99-bogus/fragments.yaml': ` fragments:
-
id: season99.bogus.test season: 99 body: "season 99 doesn't exist" `, }; expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/[content] schema violation/); });
it('THROWS when Markdown frontmatter omits required id', () => { const mdGlob = { '/content/seasons/01-soil/fragments/no-id.md': `--- season: 1
Body text without an id frontmatter key. `, }; expect(() => loadFragmentsFromGlob({}, mdGlob)).toThrow(/[content] schema violation/); }); }); ```
**Step 2 — Run `npx vitest run src/content/loader.test.ts`** and confirm all 5 tests pass (the 3 throw assertions plus the 2 happy-path).
**Step 3 — Run `npm test`** and confirm the full Phase-1 suite (sentinel + lint-firewall + save layer + content loader) is green.
**Step 4 — Commit `test(01-04): PIPE-01 enforcement — schema violations throw at content load`.**
npx vitest run src/content/loader.test.ts && npm test
- `src/content/loader.test.ts` exists and imports `loadFragmentsFromGlob` from `./loader` — verify with `grep -q "loadFragmentsFromGlob" src/content/loader.test.ts`.
- The test contains at least 3 `expect(() => ...).toThrow` assertions — verify with `grep -c "toThrow" src/content/loader.test.ts` returns at least 3.
- One throw assertion matches the numeric-id case — verify with `grep -E "id:\\s*42" src/content/loader.test.ts`.
- One throw assertion matches the out-of-range season case — verify with `grep -E "season:\\s*99" src/content/loader.test.ts`.
- The throw error messages contain `[content] schema violation` — verify with `grep -q "\\[content\\] schema violation" src/content/loader.test.ts`.
- `npx vitest run src/content/loader.test.ts` passes 5 tests — verify exit 0.
- `npm test` passes for the entire suite — verify exit 0.
Vitest test for PIPE-01 covering 5 cases (2 happy-path + 3 schema violations); the throws prove `npm run build` would fail on equivalent real-file violations; `npm test` passes the entire Phase-1 suite; commit landed.
<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>
<success_criteria>
- Zod schemas for Fragment (with stable-string-ID regex) and SeasonContent.
- Vite-native loader using
import.meta.globwith 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.mddocuments the convention for Phase 2 writers (STRY-09 prerequisite).- 5 Vitest assertions covering happy-path + 3 schema-violation cases.
compile:inkno-op stub from Plan 01 confirmed runnable. </success_criteria>