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