Files
TheLastGarden/.planning/phases/01-foundations-and-doctrine/01-04-content-pipeline-PLAN.md
T
josh 39563f6934 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>
2026-05-08 23:09:08 -04:00

25 KiB
Raw Blame History

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
01-01
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
true
PIPE-01
STRY-09
truths artifacts key_links
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)
path provides exports
src/content/schemas/fragment.ts FragmentSchema (Zod), Fragment type, ID regex `^season\d+.[a-z0-9._-]+$`
FragmentSchema
Fragment
path provides exports
src/content/schemas/season.ts SeasonContentSchema (Zod) — wraps fragments[]
SeasonContentSchema
SeasonContent
path provides
src/content/schemas/index.ts Re-exports for schemas/
path provides exports
src/content/loader.ts Vite-native glob loader using import.meta.glob for /content/seasons/*/fragments.yaml + /content/seasons/*/fragments/*.md; Zod-validated at module-eval time
fragments
loadFragmentsFromGlob
path provides
src/content/index.ts Public surface for Phase 2 consumers
path provides contains
content/seasons/00-demo/fragments.yaml One demo fragment proving the round-trip; removed in Phase 2 when real Season 1 content lands season0.demo
path provides
content/README.md Explains the /content/ convention: directory shape, frontmatter rules, ID convention, where things go in Phase 2
from to via pattern
src/content/loader.ts /content/seasons/*/fragments.yaml import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' }) import.meta.glob('/content/seasons/*/fragments.yaml'
from to via pattern
src/content/loader.ts src/content/schemas/season.ts SeasonContentSchema.safeParse — throws on schema violation, failing the build SeasonContentSchema.safeParse
Stand up the Vite-native content pipeline: Zod schemas for `Fragment` (with the `season.` stable-string-ID regex) and `SeasonContent` (wraps `fragments[]`); a `loader.ts` that uses `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })` to import every YAML file at build time, parses each via the `yaml` package, and validates via Zod at module-evaluation time so any schema violation fails `npm run build`. Ship one demo fragment under `/content/seasons/00-demo/fragments.yaml` to prove the round-trip end-to-end. Verify the `compile:ink` no-op script runs cleanly. Author a `content/README.md` documenting the convention so Phase 2 has a contract to write Season 1 fragments against.

Purpose: Phase 2 will pour Season 1 fragments into /content/seasons/01-soil/. Without this loader the build doesn't see them; without the schemas the writer can ship typos that compile and explode at runtime. CONTEXT D-11 and CLAUDE.md "Code Style" lock the /content/ repo-root location and stable-string-ID convention. RESEARCH § Pattern 4 + Pitfall 1 (literal-glob requirement) provide concrete code.

Output: A complete content pipeline: schemas, loader, demo fragment, README, all under src/content/ and /content/. One Vitest test that validates against a mocked schema-violating fragment to prove the build will fail (PIPE-01).

<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 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`.**
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>

- `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.

<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>
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.