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

511 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>