---
phase: 02-client-engine-documentation
plan: 06
type: execute
wave: 3
depends_on:
  - 02-05
files_modified:
  - docs/extracted-engine/MATRIX-rows.json
  - docs/extracted-engine/MATRIX.md
  - docs/extracted-engine/README.md
  - docs/adr/0001-client-engine.md
  - tools/asset-catalog/src/autogen.ts
  - tools/asset-catalog/scripts/lint-matrix.mjs
  - tools/asset-catalog/scripts/lint-adr.mjs
  - tools/asset-catalog/tests/lint-matrix.test.ts
  - tools/asset-catalog/tests/lint-adr.test.ts
autonomous: true
requirements:
  - CDOC-03
  - CDOC-04
tags:
  - matrix
  - adr
  - phaser
  - pixijs
  - engine-decision

must_haves:
  truths:
    - "docs/extracted-engine/MATRIX-rows.json exists as canonical data: each row has rowId + subsystem + feature + bnoUsage + weight + cite + scores per the locked TS interface (RESEARCH lines 829-841)"
    - "MATRIX-rows.json contains AT LEAST 18 rows covering every CDOC-01 subsystem (rendering, input, collision, animation, scene-room-model, save-load, audio, ui-and-menus, client-networking)"
    - "Each row has scores for THREE engines: phaser-3.90, phaser-4.1, pixi-8.18 (Phaser 4 caveat per RESEARCH §908-922)"
    - "Each score has a grade: native | plugin | manual | hard (PITFALLS D3 per RESEARCH lines 844-848)"
    - "docs/extracted-engine/MATRIX.md is rendered FROM MATRIX-rows.json via AUTOGEN blocks; hand-edits to row content are NOT permitted"
    - "Weighted totals at the bottom of MATRIX.md are computed from the JSON: Σ(row.weight × score(row, engine)) for each of the 3 engines"
    - "docs/adr/0001-client-engine.md exists with all four required sections (Status, Context, Decision, Consequences) per Michael Nygard format (D-14)"
    - "ADR Decision section explicitly addresses Phaser 3.90 vs Phaser 4.1 vs PixiJS 8.18 (Phaser 4 caveat satisfied)"
    - "ADR cites at least 3 specific MX-* row IDs from MATRIX-rows.json (Pitfall 6 mitigation enforced by lint-adr.mjs)"
    - "ADR is consistent with weighted-total winner from MATRIX.md (or explicitly justifies overriding the numeric winner per D-13 hard-knockout rule)"
    - "lint-matrix.mjs validates MATRIX-rows.json schema + verifies rendered totals match data (Pitfall 5 mitigation)"
    - "lint-adr.mjs validates ADR sections + every cited MX-* rowId resolves in MATRIX-rows.json (Pitfall 6 mitigation)"
  artifacts:
    - path: "docs/extracted-engine/MATRIX-rows.json"
      provides: "Canonical matrix data — sole source of truth for weights, scores, citations"
      contains: "MX-RENDER-01"
      min_lines: 200
    - path: "docs/extracted-engine/MATRIX.md"
      provides: "Rendered matrix with 3 engine columns + weighted totals; MOSTLY auto-generated via AUTOGEN blocks"
      contains: "Phaser 4.1"
      min_lines: 60
    - path: "docs/adr/0001-client-engine.md"
      provides: "Engine-locking ADR (first ADR in repo) per D-14 Michael Nygard format"
      contains: "## Decision"
      min_lines: 60
    - path: "tools/asset-catalog/scripts/lint-matrix.mjs"
      provides: "Schema + totals validator for MATRIX-rows.json + MATRIX.md (Pitfall 5)"
    - path: "tools/asset-catalog/scripts/lint-adr.mjs"
      provides: "ADR section + rowId citation validator (Pitfall 6)"
    - path: "tools/asset-catalog/tests/lint-matrix.test.ts"
      provides: "Unit tests for lint-matrix.mjs CLI exit codes"
      min_lines: 30
    - path: "tools/asset-catalog/tests/lint-adr.test.ts"
      provides: "Unit tests for lint-adr.mjs CLI exit codes"
      min_lines: 30
  key_links:
    - from: "docs/extracted-engine/MATRIX.md"
      to: "docs/extracted-engine/MATRIX-rows.json"
      via: "AUTOGEN blocks (matrix-weights, matrix-rows, matrix-totals) populated by autogen.ts generators"
      pattern: "AUTOGEN:matrix"
    - from: "docs/adr/0001-client-engine.md"
      to: "docs/extracted-engine/MATRIX-rows.json"
      via: "ADR cites specific MX-* rowIds for rationale (Pitfall 6 enforcement)"
      pattern: "MX-[A-Z]+-\\d+"
    - from: "docs/adr/0001-client-engine.md"
      to: "docs/extracted-engine/MATRIX.md"
      via: "ADR References section links MATRIX.md"
      pattern: "MATRIX\\.md"
    - from: "tools/asset-catalog/scripts/lint-adr.mjs"
      to: "docs/extracted-engine/MATRIX-rows.json"
      via: "regex extracts MX-* IDs from ADR; checks each resolves in JSON"
      pattern: "MX-"
---

<objective>
Close out CDOC-03 and CDOC-04 by:
1. Authoring `docs/extracted-engine/MATRIX-rows.json` — the canonical data file with ≥ 18 rows scoring each CDOC-01 feature against THREE engines (Phaser 3.90, Phaser 4.1, PixiJS 8.18) per the Phaser 4 caveat from RESEARCH §908-922.
2. Authoring `docs/extracted-engine/MATRIX.md` — a rendering of the JSON via three AUTOGEN blocks (matrix-weights, matrix-rows, matrix-totals); weights/scores live in JSON, prose live in the MD wrapper.
3. Implementing the three matrix AUTOGEN generators inside `src/autogen.ts` (extending Plan 05) so `pnpm catalog:client` populates MATRIX.md.
4. Authoring `docs/adr/0001-client-engine.md` — the engine-locking ADR (first ADR in the repo) per D-14 Michael Nygard format. Decision section MUST explicitly address Phaser 3 vs Phaser 4 vs PixiJS and cite specific MX-* rowIds.
5. Implementing `lint-matrix.mjs` and `lint-adr.mjs` (Plan 01 stubs) to enforce: MATRIX rendered totals match JSON computation (Pitfall 5); every MX-* citation in ADR resolves in MATRIX-rows.json (Pitfall 6).
6. Updating `docs/extracted-engine/README.md` to link to MATRIX.md + ADR.

The grade-to-score mapping per PITFALLS D3: native=4, plugin=3, manual=2, hard=1. Weighted score per engine = Σ(row.weight × score(row, engine)). D-13 hard-knockout: any row with weight ≥ 4 graded `hard` for Phaser 3 → ADR flips to PixiJS.

Per RESEARCH §"Hard-Knockout Watch List" + Pre-recorded position (line 922): Phaser 3.90 likely wins for MVP (CLI-08 ship pressure + STACK.md "still settling" caveat for v4); but the matrix MUST be filled honestly — DO NOT pre-commit the ADR before MATRIX is filled.

Purpose: Phase 2 closeout. CDOC-03 + CDOC-04 are the engine-locking deliverables. Phase 6 (CLI-01..09 client rebuild) commits to the chosen engine based on this ADR; AST-01 (Phase 6/7) targets the chosen engine's atlas/audio/font formats.

Output: 3 deliverable docs (MATRIX-rows.json + MATRIX.md + ADR) + 2 fully-implemented linters + 2 unit-test files for the linters + autogen extensions + README link refresh. After this plan, Phase 2 is COMPLETE.
</objective>

<execution_context>
@$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
@$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-client-engine-documentation/02-CONTEXT.md
@.planning/phases/02-client-engine-documentation/02-RESEARCH.md
@.planning/phases/02-client-engine-documentation/02-PATTERNS.md
@.planning/phases/02-client-engine-documentation/02-04-SUMMARY.md
@.planning/phases/02-client-engine-documentation/02-05-SUMMARY.md
@./CLAUDE.md
@.planning/research/STACK.md
@docs/extracted-engine/README.md
@docs/extracted-engine/SUBSYSTEM-MAP.json
@docs/extracted-engine/rendering.md
@docs/extracted-engine/input.md
@docs/extracted-engine/collision.md
@docs/extracted-engine/animation.md
@docs/extracted-engine/scene-room-model.md
@docs/extracted-engine/audio.md
@docs/extracted-engine/ui-and-menus.md
@docs/extracted-engine/client-networking.md
@tools/asset-catalog/src/autogen.ts
@tools/asset-catalog/scripts/lint-matrix.mjs
@tools/asset-catalog/scripts/lint-adr.mjs

<interfaces>
<!-- MatrixRow schema — locked from RESEARCH lines 829-841 -->

```typescript
export interface MatrixRow {
  rowId: string;          // stable, e.g. "MX-RENDER-01"
  subsystem: string;      // matches a CDOC-01 subsystem MD basename (rendering, input, ...)
  feature: string;        // human-readable feature name
  bnoUsage: 'heavy' | 'light' | 'none';   // drives weight selection
  weight: number;         // 1-5; total budget target ~60-80
  cite: string;           // path:section in subsystem MD (e.g. "rendering.md#draw-text-with-custom-fonts")
  scores: {
    'phaser-3.90': { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
    'phaser-4.1':  { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
    'pixi-8.18':   { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
  };
}
```

Grade-to-score mapping (PITFALLS D3 per RESEARCH §844-848):
- native = 4
- plugin = 3
- manual = 2
- hard = 1

Weighted score per engine = Σ(row.weight × scoreFor(row.scores[engine].grade)) for all rows.

Engine columns (RESEARCH §"State of the Art" + Phaser 4 caveat):
- phaser-3.90 (STACK.md preferred MVP engine; "Tsugumi" release)
- phaser-4.1 (npm latest as of 2026-05-02; "Salusa" release; STACK.md "still settling" caveat)
- pixi-8.18 (STACK.md fallback; +@pixi/tilemap@5.0.2 + howler@2.2.4 if chosen)

Suggested initial row list — RESEARCH §"Suggested Row List" lines 864-887 — 21 candidate rows pre-drafted with sketched weights. Planner finalizes after subsystem MDs are drafted.

ADR Format (RESEARCH §"ADR Format + Citation Discipline" lines 924-963 — full template available):
```markdown
# ADR 0001: Client engine choice

**Status:** Accepted
**Date:** 2026-MM-DD
**Phase:** 02 close

## Context
[2-4 paragraphs from CDOC-01 + CDOC-02 findings]

## Decision
We will use **<engine name>** for Phase 6 MVP and Phase 7 parity.
Rationale, by MATRIX row:
- <engine> scores **<N>** weighted; <other> scores **<M>**; <third> scores **<P>**.
- Decisive rows: MX-XXX-NN (...), MX-YYY-NN (...), MX-ZZZ-NN (...).
- Phaser 4.1 considered (npm `latest` as of 2026-05-02). [Rejected/Accepted because ...].
  Re-evaluation gate: Phase 6 start (or Phase 7 retro).

## Consequences
- Phase 6 (CLI-01, CLI-04, CLI-06) commits to <engine> patterns.
- AST-01 (Phase 6/7) targets <engine> atlas JSON format.
- AST-02 (Phase 7 MIDI→OGG) targets <engine> audio loader.
- AST-03 (Phase 7 fonts) targets <engine> BitmapText/Text format.
- If <other-engine> ecosystem matures by Phase 7 retro, migration cost is one apps/client refactor.

## References
- docs/extracted-engine/MATRIX.md (rows MX-XXX-NN, MX-YYY-NN, MX-ZZZ-NN)
- .planning/research/STACK.md §"Phaser 3 → Phaser 4"
- [Phaser 3 → 4 Migration Guide](https://phaser.io/news/2026/04/migrating-from-phaser-3-to-phaser-4-what-you-need-to-know)
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Author MATRIX-rows.json + MATRIX.md + extend autogen.ts with matrix generators + lint-matrix.mjs</name>
  <files>
    docs/extracted-engine/MATRIX-rows.json,
    docs/extracted-engine/MATRIX.md,
    tools/asset-catalog/src/autogen.ts,
    tools/asset-catalog/scripts/lint-matrix.mjs,
    tools/asset-catalog/tests/lint-matrix.test.ts,
    docs/extracted-engine/README.md
  </files>
  <read_first>
    docs/extracted-engine/SUBSYSTEM-MAP.json (Plan 05 — subsystem keys),
    docs/extracted-engine/rendering.md (for cite paths),
    docs/extracted-engine/input.md,
    docs/extracted-engine/collision.md,
    docs/extracted-engine/animation.md,
    docs/extracted-engine/scene-room-model.md,
    docs/extracted-engine/audio.md (Plan 05 — audio finding determines MX-AUDIO-01 weight),
    docs/extracted-engine/ui-and-menus.md,
    docs/extracted-engine/client-networking.md,
    .planning/research/STACK.md (Phaser 3 vs 4 vs Pixi positioning),
    .planning/phases/02-client-engine-documentation/02-RESEARCH.md (§"MATRIX Methodology" lines 822-887, §"Hard-Knockout Watch List" lines 891-906, §"Phaser 4 Caveat" lines 908-922, §Pitfall 5 lines 445-453, §Pitfall 6 lines 455-463),
    .planning/phases/02-client-engine-documentation/02-PATTERNS.md (§"MATRIX.md" lines 833-882, §"lint-matrix.mjs" lines 546-558),
    tools/asset-catalog/src/autogen.ts (Plan 05 — AUTOGEN_GENERATORS to extend),
    tools/asset-catalog/scripts/lint-matrix.mjs (Plan 01 stub — replace TODO body)
  </read_first>
  <action>
    **Step 1: Author docs/extracted-engine/MATRIX-rows.json.**

    Use RESEARCH §"Suggested Row List" (lines 864-887) as the starting point. Adjust row weights based on Plan 05 findings (especially MX-AUDIO-01 weight per Plan 05 audio finding).

    Each row MUST score against THREE engines. For each engine cell, fill `grade` based on knowledge of the engine's first-class capabilities and `note` with a 1-line justification. WHEN UNCERTAIN about a specific engine's API support, conservatively grade `manual` (2) — do NOT inflate scores. Use `phaser.io/api-documentation` and `pixijs.io` as primary sources if a row needs verification.

    Minimum 18 rows; recommend ~21 covering all 9 subsystems with autogen blocks. Concrete starter row (use this exactly for MX-RENDER-01):

    ```json
    {
      "rowId": "MX-RENDER-01",
      "subsystem": "rendering",
      "feature": "Sprite rendering with origin point + bounding box + collision mask (GM `sprite_index`/`mask_index`/`originX`/`originY`/`bbox*`)",
      "bnoUsage": "heavy",
      "weight": 5,
      "cite": "rendering.md#sprite-rendering",
      "scores": {
        "phaser-3.90": { "grade": "native", "note": "Phaser.GameObjects.Sprite supports setOrigin / setBoundingBox; Body matches mask via Arcade or Matter physics." },
        "phaser-4.1":  { "grade": "native", "note": "Same as v3; rendering pipeline rewritten but Sprite/Origin API compatible per migration guide." },
        "pixi-8.18":   { "grade": "manual", "note": "Pixi.Sprite has anchor (~origin); collision mask requires manual integration (no built-in physics)." }
      }
    }
    ```

    Cover at minimum the 21 rows from RESEARCH §"Suggested Row List" (MX-RENDER-01..06, MX-INPUT-01..03, MX-COLLIDE-01..03, MX-ANIM-01..02, MX-SCENE-01..03, MX-AUDIO-01, MX-SAVE-01, MX-UI-01, MX-NET-01). Add MX-PATH-01 if Plan 05 surfaces non-zero path resources (per RESEARCH Open Question #3).

    **Apply Plan 05 audio finding to MX-AUDIO-01 weight:** if Plan 05's grep found ZERO `sound_play|playmidi` invocations, set MX-AUDIO-01 weight to 1 (RESEARCH line 1004). If non-zero, weight 3.

    **Output JSON deterministically:** 2-space indent, sorted keys at every depth, LF only, single trailing LF. Use `tools/asset-catalog/src/emit.ts` `writeJsonDeterministic` for consistency (or hand-write conforming to the same contract).

    **Step 2: Author docs/extracted-engine/MATRIX.md** per PATTERNS.md §"MATRIX.md" lines 838-878:

    ```markdown
    ---
    mvp: no
    subsystem: matrix
    ---

    # Client Engine Feature Matrix — Phaser 3.90 vs Phaser 4.1 vs PixiJS 8.18

    > **Source of truth:** `MATRIX-rows.json`. This MD is rendered from it by `tools/asset-catalog regen-autogen`. Do NOT hand-edit the rows below — edit the JSON and re-run `pnpm catalog:client`.

    ## Methodology
    - Grade per cell: `native` (4) / `plugin` (3) / `manual` (2) / `hard` (1) per PITFALLS D3.
    - Weighted score per engine = Σ(row.weight × score(row, engine)).
    - Hard-knockout per D-13: any row with weight ≥ 4 graded `hard` for Phaser 3 → ADR flips to PixiJS.

    ## Engine Versions
    - Phaser 3.90.0 ("Tsugumi") — npm pinned via `phaser@3.90.0`. STACK.md preferred MVP engine.
    - Phaser 4.1.0 ("Salusa") — npm `latest` as of 2026-05-02. STACK.md "still settling" caveat.
    - PixiJS 8.18.1 — STACK.md fallback. Adds @pixi/tilemap@5.0.2 + howler@2.2.4 if chosen.

    ## Weight Distribution

    <!-- AUTOGEN:matrix-weights:start -->
    <!-- AUTOGEN:matrix-weights:end -->

    ## Rows

    <!-- AUTOGEN:matrix-rows:start -->
    <!-- AUTOGEN:matrix-rows:end -->

    ## Weighted Totals

    <!-- AUTOGEN:matrix-totals:start -->
    <!-- AUTOGEN:matrix-totals:end -->

    ## Hard-Knockout Watch

    Per D-13: any row with weight ≥ 4 graded `hard` for Phaser 3 flips the ADR to PixiJS. Audit:

    - If any phaser-3.90 cell has grade `hard` AND row.weight ≥ 4: ADR Decision section MUST justify either (a) flipping to PixiJS or (b) accepting the workaround cost with explicit reasoning.

    See [adr/0001-client-engine.md](../adr/0001-client-engine.md) for the locked decision.
    ```

    **Step 3: Extend tools/asset-catalog/src/autogen.ts with matrix generators.**

    Add three new entries to `AUTOGEN_GENERATORS`:

    ```typescript
    // Add at top of autogen.ts:
    interface MatrixRow {
      rowId: string;
      subsystem: string;
      feature: string;
      bnoUsage: 'heavy' | 'light' | 'none';
      weight: number;
      cite: string;
      scores: Record<string, { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string }>;
    }

    function loadMatrixRows(docsDir: string): MatrixRow[] {
      const path = join(docsDir, 'MATRIX-rows.json');
      if (!existsSync(path)) return [];
      return JSON.parse(readFileSync(path, 'utf-8')) as MatrixRow[];
    }

    function gradeToScore(grade: string): number {
      switch (grade) { case 'native': return 4; case 'plugin': return 3; case 'manual': return 2; case 'hard': return 1; default: return 0; }
    }

    // NOTE: `docsDir` is already a field on AutogenContext (declared in Plan 05 from the start).
    // This plan only CONSUMES `ctx.docsDir` — it does NOT redeclare or modify the AutogenContext interface.
    // (Cross-plan contract: Plan 05 owns AutogenContext shape; Plan 06 reads it.)

    // Add to AUTOGEN_GENERATORS:
    'matrix-weights': (_subsystem, ctx) => {
      const rows = loadMatrixRows(ctx.docsDir);
      const bySubsystem: Record<string, number> = {};
      for (const r of rows) bySubsystem[r.subsystem] = (bySubsystem[r.subsystem] ?? 0) + r.weight;
      const lines = ['| Subsystem | Sum of weights |', '|-----------|----------------|'];
      for (const sub of Object.keys(bySubsystem).sort()) {
        lines.push(`| ${sub} | ${bySubsystem[sub]} |`);
      }
      lines.push(`| **Total** | **${rows.reduce((s, r) => s + r.weight, 0)}** |`);
      return lines.join('\n');
    },

    'matrix-rows': (_subsystem, ctx) => {
      const rows = loadMatrixRows(ctx.docsDir);
      const lines = [
        '| Row ID | Subsystem | Feature | Weight | Phaser 3.90 | Phaser 4.1 | PixiJS 8.18 | Cite |',
        '|--------|-----------|---------|--------|-------------|------------|-------------|------|',
      ];
      for (const r of [...rows].sort((a, b) => a.rowId.localeCompare(b.rowId))) {
        const p3 = r.scores['phaser-3.90'] ?? { grade: 'manual', note: '' };
        const p4 = r.scores['phaser-4.1'] ?? { grade: 'manual', note: '' };
        const px = r.scores['pixi-8.18'] ?? { grade: 'manual', note: '' };
        const featureCell = r.feature.replace(/\|/g, '\\|');
        lines.push(`| ${r.rowId} | ${r.subsystem} | ${featureCell} | ${r.weight} | ${p3.grade} (${gradeToScore(p3.grade)}) | ${p4.grade} (${gradeToScore(p4.grade)}) | ${px.grade} (${gradeToScore(px.grade)}) | [${r.cite}](${r.cite}) |`);
      }
      return lines.join('\n');
    },

    'matrix-totals': (_subsystem, ctx) => {
      const rows = loadMatrixRows(ctx.docsDir);
      const engines = ['phaser-3.90', 'phaser-4.1', 'pixi-8.18'];
      const totals = engines.map(eng => ({
        engine: eng,
        total: rows.reduce((s, r) => s + r.weight * gradeToScore(r.scores[eng]?.grade ?? 'manual'), 0),
      }));
      const lines = ['| Engine | Weighted total |', '|--------|----------------|'];
      for (const t of totals) {
        const winner = t.total === Math.max(...totals.map(x => x.total));
        lines.push(`| ${t.engine}${winner ? ' ✓' : ''} | ${t.total} |`);
      }
      return lines.join('\n');
    },
    ```

    No edits to `tools/asset-catalog/src/emit.ts` required: Plan 05 already constructs `AutogenContext` with `docsDir` populated. Plan 06 consumes `ctx.docsDir` inside the new matrix generators only.

    **Step 4: Implement tools/asset-catalog/scripts/lint-matrix.mjs.**

    Replace the Plan 01 TODO with a real validator:

    ```javascript
    #!/usr/bin/env node
    // tools/asset-catalog/scripts/lint-matrix.mjs
    // Validates MATRIX-rows.json schema + computed totals.
    // Exit codes: 0 success, 1 schema/totals failure, 2 usage error

    import { existsSync, readFileSync } from 'node:fs';
    import { join } from 'node:path';

    function printUsage() {
      process.stderr.write('Usage: lint-matrix.mjs <docs-dir>\n');
    }

    const arg = process.argv[2];
    if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); }
    if (!arg) { printUsage(); process.exit(2); }

    const rowsPath = join(arg, 'MATRIX-rows.json');
    if (!existsSync(rowsPath)) {
      process.stderr.write(`MATRIX-rows.json not found at ${rowsPath}\n`);
      process.exit(1);
    }

    let rows;
    try {
      rows = JSON.parse(readFileSync(rowsPath, 'utf-8'));
    } catch (e) {
      process.stderr.write(`MATRIX-rows.json: invalid JSON: ${e.message}\n`);
      process.exit(1);
    }

    if (!Array.isArray(rows)) {
      process.stderr.write(`MATRIX-rows.json: top-level must be an array\n`);
      process.exit(1);
    }

    const requiredEngines = ['phaser-3.90', 'phaser-4.1', 'pixi-8.18'];
    const validGrades = new Set(['native', 'plugin', 'manual', 'hard']);
    const seenIds = new Set();
    let errors = 0;

    for (let i = 0; i < rows.length; i++) {
      const r = rows[i];
      const where = `row ${i} (${r.rowId ?? '?'})`;
      if (typeof r.rowId !== 'string' || !/^MX-[A-Z]+-\d+$/.test(r.rowId)) {
        process.stderr.write(`${where}: invalid rowId (must match /^MX-[A-Z]+-\\d+$/)\n`); errors++;
      }
      if (seenIds.has(r.rowId)) {
        process.stderr.write(`${where}: duplicate rowId\n`); errors++;
      }
      seenIds.add(r.rowId);
      if (typeof r.subsystem !== 'string') { process.stderr.write(`${where}: missing subsystem\n`); errors++; }
      if (typeof r.feature !== 'string') { process.stderr.write(`${where}: missing feature\n`); errors++; }
      if (!['heavy', 'light', 'none'].includes(r.bnoUsage)) { process.stderr.write(`${where}: invalid bnoUsage\n`); errors++; }
      if (typeof r.weight !== 'number' || r.weight < 1 || r.weight > 5) { process.stderr.write(`${where}: weight must be 1..5\n`); errors++; }
      if (typeof r.cite !== 'string') { process.stderr.write(`${where}: missing cite\n`); errors++; }
      if (!r.scores || typeof r.scores !== 'object') { process.stderr.write(`${where}: missing scores\n`); errors++; continue; }
      for (const eng of requiredEngines) {
        const s = r.scores[eng];
        if (!s || !validGrades.has(s.grade)) {
          process.stderr.write(`${where}: scores.${eng}.grade must be one of ${[...validGrades].join('/')}; got ${s?.grade}\n`); errors++;
        }
        if (typeof s?.note !== 'string') { process.stderr.write(`${where}: scores.${eng}.note must be string\n`); errors++; }
      }
    }

    if (rows.length < 18) {
      process.stderr.write(`MATRIX-rows.json: expected ≥ 18 rows, found ${rows.length}\n`);
      errors++;
    }

    // Compute totals from JSON; verify they match what's in MATRIX.md (if present)
    const matrixMdPath = join(arg, 'MATRIX.md');
    if (existsSync(matrixMdPath)) {
      const mdContent = readFileSync(matrixMdPath, 'utf-8');
      // Look for the matrix-totals AUTOGEN block; extract the rendered totals
      const m = mdContent.match(/<!-- AUTOGEN:matrix-totals:start -->\s*([\s\S]*?)<!-- AUTOGEN:matrix-totals:end -->/);
      if (m) {
        const block = m[1];
        function gradeToScore(g) { return { native: 4, plugin: 3, manual: 2, hard: 1 }[g] ?? 0; }
        for (const eng of requiredEngines) {
          const expected = rows.reduce((s, r) => s + r.weight * gradeToScore(r.scores[eng]?.grade ?? 'manual'), 0);
          // Find row in block: "| <eng> | <number> |"
          const rowRe = new RegExp(`\\|\\s*${eng.replace(/\./g, '\\.')}[^|]*\\|\\s*(\\d+)\\s*\\|`);
          const rowM = block.match(rowRe);
          if (!rowM) {
            process.stderr.write(`MATRIX.md: missing total row for ${eng}\n`); errors++;
          } else if (Number(rowM[1]) !== expected) {
            process.stderr.write(`MATRIX.md: ${eng} total ${rowM[1]} does not match JSON-computed ${expected} (Pitfall 5 — re-run pnpm catalog:client)\n`); errors++;
          }
        }
      }
    }

    if (errors > 0) {
      process.stderr.write(`\nlint-matrix: ${errors} error(s)\n`);
      process.exit(1);
    }
    process.stdout.write(`OK: MATRIX-rows.json (${rows.length} rows) and MATRIX.md totals validated\n`);
    process.exit(0);
    ```

    **Step 5: Add tools/asset-catalog/tests/lint-matrix.test.ts:**

    ```typescript
    import { describe, it, expect } from 'vitest';
    import { execFileSync } from 'node:child_process';
    import { existsSync, mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import { join } from 'node:path';

    function runLint(arg) {
      try {
        const stdout = execFileSync('node', ['tools/asset-catalog/scripts/lint-matrix.mjs', arg], { encoding: 'utf-8' });
        return { code: 0, stdout, stderr: '' };
      } catch (e) {
        return { code: e.status, stdout: e.stdout?.toString() ?? '', stderr: e.stderr?.toString() ?? '' };
      }
    }

    describe('lint-matrix.mjs', () => {
      it('no arg → exit 2', () => {
        try { execFileSync('node', ['tools/asset-catalog/scripts/lint-matrix.mjs'], { encoding: 'utf-8' }); throw new Error('expected exit 2'); }
        catch (e) { expect(e.status).toBe(2); }
      });
      it('--help → exit 0', () => {
        const r = execFileSync('node', ['tools/asset-catalog/scripts/lint-matrix.mjs', '--help'], { encoding: 'utf-8' });
        expect(r).toBeTruthy();
      });
      it('missing MATRIX-rows.json → exit 1', () => {
        const tmp = mkdtempSync(join(tmpdir(), 'lint-mx-'));
        try {
          const r = runLint(tmp);
          expect(r.code).toBe(1);
        } finally { rmSync(tmp, { recursive: true }); }
      });
      it('valid MATRIX-rows.json (committed) → exit 0', () => {
        if (!existsSync('docs/extracted-engine/MATRIX-rows.json')) { return; }
        const r = runLint('docs/extracted-engine');
        expect(r.code).toBe(0);
      });
    });
    ```

    **Step 6: Update docs/extracted-engine/README.md to link MATRIX.md** — add (or confirm existing) row in the jump table:
    ```markdown
    | "Which Phaser/Pixi feature gaps drove the engine choice?" | [MATRIX](MATRIX.md) → [adr/0001-client-engine](../adr/0001-client-engine.md) |
    ```

    **Step 7: Run catalog + linter.**

    ```bash
    pnpm catalog:client                                    # populates MATRIX.md AUTOGEN blocks
    pnpm catalog:client                                    # idempotency check
    git status docs/extracted-engine/MATRIX.md             # MUST show no diff after second run
    node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine    # MUST exit 0
    cd tools/asset-catalog && pnpm test -- tests/lint-matrix.test.ts          # MUST exit 0
    ```
  </action>
  <verify>
    <automated>bash -c "node -e \"const r=require('./docs/extracted-engine/MATRIX-rows.json'); if(!Array.isArray(r))process.exit(1); if(r.length<18)process.exit(2); for(const x of r){if(!/^MX-[A-Z]+-\d+$/.test(x.rowId))process.exit(3); for(const e of ['phaser-3.90','phaser-4.1','pixi-8.18'])if(!x.scores[e])process.exit(4)} console.log('OK',r.length,'rows')\" && pnpm catalog:client && pnpm catalog:client && [ -z \"$(git status --porcelain docs/extracted-engine/MATRIX.md)\" ] && node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine && cd tools/asset-catalog && pnpm test -- tests/lint-matrix.test.ts"</automated>
  </verify>
  <acceptance_criteria>
    - File `docs/extracted-engine/MATRIX-rows.json` exists; parses as JSON; is an array of ≥ 18 entries
    - Every row has rowId matching `/^MX-[A-Z]+-\d+$/`; every row has `scores['phaser-3.90']`, `scores['phaser-4.1']`, `scores['pixi-8.18']` with valid grades (native/plugin/manual/hard)
    - File `docs/extracted-engine/MATRIX.md` exists; contains "Phaser 3.90", "Phaser 4.1", "PixiJS 8.18" literal strings; contains all 3 AUTOGEN block pairs (matrix-weights, matrix-rows, matrix-totals)
    - `pnpm catalog:client` populates MATRIX.md autogen blocks; second run produces zero diff (idempotency)
    - `node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine` exits 0
    - `node tools/asset-catalog/scripts/lint-matrix.mjs` (no arg) exits 2
    - `node tools/asset-catalog/scripts/lint-matrix.mjs /nonexistent` exits 1
    - File `tools/asset-catalog/tests/lint-matrix.test.ts` exists with ≥ 4 passing tests
    - File `tools/asset-catalog/src/autogen.ts` exports matrix-weights / matrix-rows / matrix-totals generators (`grep -c "matrix-rows" tools/asset-catalog/src/autogen.ts` ≥ 1)
    - `docs/extracted-engine/README.md` mentions MATRIX.md in jump table
  </acceptance_criteria>
  <done>MATRIX-rows.json is the canonical data source; MATRIX.md is rendered from it via AUTOGEN blocks; lint-matrix.mjs enforces schema + Pitfall 5 totals coherence. The matrix is filled honestly with three engine columns satisfying the Phaser 4 caveat. Task 2 now writes the ADR that cites these specific MX-* rowIds.</done>
</task>

<task type="auto">
  <name>Task 2: Author docs/adr/0001-client-engine.md + lint-adr.mjs + lint-adr.test.ts</name>
  <files>
    docs/adr/0001-client-engine.md,
    tools/asset-catalog/scripts/lint-adr.mjs,
    tools/asset-catalog/tests/lint-adr.test.ts
  </files>
  <read_first>
    docs/extracted-engine/MATRIX-rows.json (Task 1 output — read computed totals; identify decisive rows; Pitfall 6 cite source),
    docs/extracted-engine/MATRIX.md (Task 1 — confirm rendered totals match),
    .planning/research/STACK.md (Phaser 3 vs 4 positioning + "Decision gate: re-evaluate at Stage 6 start" wording),
    .planning/phases/02-client-engine-documentation/02-RESEARCH.md (§"ADR Format + Citation Discipline" lines 924-963 — full ADR template; §"Phaser 4 Caveat" lines 908-922; §"Hard-Knockout Watch List" lines 891-906),
    .planning/phases/02-client-engine-documentation/02-PATTERNS.md (§"docs/adr/0001-client-engine.md" lines 947-957),
    tools/asset-catalog/scripts/lint-adr.mjs (Plan 01 stub — replace TODO body)
  </read_first>
  <action>
    **Step 1: Compute the matrix-driven decision.**

    Read docs/extracted-engine/MATRIX.md and identify the engine with the highest weighted total (rendered by Task 1's autogen). Note the computed totals for all 3 engines.

    Then check D-13 hard-knockout: scan MATRIX-rows.json for any row where `weight ≥ 4` AND `scores['phaser-3.90'].grade === 'hard'`. If found AND no equivalent hard for the other engines, the ADR Decision MUST flip to PixiJS (or justify why the workaround is acceptable).

    Per RESEARCH §922 pre-recorded position: Phaser 3.90 is the safer Phase 6 choice for MVP (CLI-08 ship pressure + STACK.md "still settling" caveat for v4). v4 becomes attractive at Phase 7 retro. Document this thinking explicitly.

    **Step 2: Author docs/adr/0001-client-engine.md** per RESEARCH §"ADR Format + Citation Discipline" lines 928-961. The full template is in RESEARCH — adapt it with the actual computed numbers from Step 1.

    **Critical content requirements:**
    - All four required sections in order: `## Status`, `## Context`, `## Decision`, `## Consequences`. Plus `## References` at end.
    - Status: `Accepted`. Date: `2026-MM-DD` (today; use `2026-05-02` if author is unsure of execution date). Phase: `02 close`.
    - Context: 2-4 paragraphs summarizing CDOC-01 + CDOC-02 findings (854 sprites / 12 backgrounds / 198 scripts / 320 objects / 16 rooms; no embedded audio per audio.md; UI-heavy menus per ui-and-menus.md). Cites docs/extracted-engine/README.md + docs/extracted-engine/MATRIX.md.
    - Decision: explicit engine choice with weighted-total numbers from Task 1. Rationale-by-MATRIX-row block citing AT LEAST 3 specific MX-* rowIds (Pitfall 6). Phaser 4 explicitly addressed.
    - Consequences: Phase 6 commitments (CLI-01/04/06 atlas/font targets); AST-01/02/03 format implications; Phase 7 retro re-evaluation gate.
    - References: explicit MATRIX.md citation with cited row IDs in parens; STACK.md cross-link; Phaser 3→4 migration guide URL per RESEARCH line 920.

    Minimum 60 lines. Example concrete shape (use the actual matrix winner; this is illustrative — substitute with the computed values from Step 1):

    ```markdown
    # ADR 0001: Client engine choice

    **Status:** Accepted
    **Date:** 2026-05-02
    **Phase:** 02 close

    ## Context

    Phase 1 extracted 854 sprites, 12 backgrounds, 198 scripts, 320 objects, and 16 rooms from
    `BN Online Client 5-8.gmd` into `extracted/client-5-8/` (8307 files, 83 MB). Phase 2 documented
    every engine subsystem under `docs/extracted-engine/` and produced a feature-vs-engine matrix
    scoring three candidate 2D engines: Phaser 3.90.0 ("Tsugumi"), Phaser 4.1.0 ("Salusa"), and
    PixiJS 8.18.1 (with `@pixi/tilemap@5.0.2` + `howler@2.2.4` if chosen).

    Key engine surfaces from CDOC-01:
    - Sprite rendering with origin/bounding-box/mask (heavy — every visible object)
    - Per-key keyboard event handling (heavy — movement)
    - Per-object Collision events with target filter (heavy — walls/walkability)
    - Tile-background drawing (rooms use tiles)
    - Custom-font text rendering (UI-heavy; action 523/525 resolved per Plan 02-02)
    - Per-instance integer depth ordering
    - Room model with view config + room transitions (16 rooms; CLI-08 needs 1)
    - **No embedded audio** in the client `.gmd` (Phase 2 plan 02-05 audio finding)
    - DOM-overlay vs canvas UI compatibility (chat HUD)

    See `docs/extracted-engine/README.md` for the full subsystem index and
    `docs/extracted-engine/MATRIX.md` for the per-row engine scoring.

    ## Decision

    We will use **Phaser 3.90.0** for the Phase 6 MVP and Phase 7 parity client.

    Weighted totals (per `docs/extracted-engine/MATRIX-rows.json`, rendered in MATRIX.md):
    - Phaser 3.90: **<N>** weighted points
    - Phaser 4.1: **<M>** weighted points
    - PixiJS 8.18: **<P>** weighted points

    Decisive MATRIX rows:
    - **MX-RENDER-01** — Sprite rendering with origin/bbox/mask: Phaser 3 native (4); PixiJS manual (2). Heavy weight.
    - **MX-COLLIDE-01** — Per-object Collision events: Phaser 3 native via Arcade physics; PixiJS would require manual integration.
    - **MX-SCENE-01** — Room/scene model: Phaser 3 native via Scene API; PixiJS lacks first-class scene model.
    - **MX-AUDIO-01** — Audio playback: low weight (1) per Phase 2 audio finding (no embedded audio); not a differentiator.
    - **MX-UI-01** — DOM-overlay UI for chat HUD: both engines support; Phaser 3 has slightly cleaner integration.

    **Phaser 4.1 considered** (npm `latest` as of 2026-05-02 per `npm view phaser dist-tags`). Rejected
    for MVP because: (a) v4 is "still settling" per STACK.md decision-gate language; (b) ecosystem
    (plugins, tutorials, AI scaffolding) is less mature than v3; (c) CLI-08 ship pressure favours the
    safer choice. v4 has the same scores as v3 on most rows (rendering pipeline rewritten but Sprite
    API compatible per migration guide), so the migration cost at Phase 7 retro is low if v4 ecosystem
    matures.

    Re-evaluation gate: Phase 6 start AND Phase 7 retro. If either gate surfaces a v4-specific
    advantage that v3 cannot match, the migration is feasible per the official Phaser 3 → 4
    migration guide ("biggest release ever, migration mostly automatic but still recent").

    ## Consequences

    - **Phase 6 (CLI-01, CLI-04, CLI-06)** commits to Phaser 3.90 patterns: `pixelArt: true` config,
      `roundPixels: true` camera, Phaser.GameObjects.Sprite for movement+animation, Phaser.Tilemaps
      for room rendering.
    - **AST-01 (Phase 6)** targets Phaser atlas JSON format. `pnpm add phaser@3.90.0` in `apps/client`.
    - **AST-02 (Phase 7 MIDI→OGG)** targets Phaser audio loader. (Note: low weight per audio finding.)
    - **AST-03 (Phase 7 fonts)** targets Phaser BitmapText format (sprite-strip atlases for `OCRA` and
      `Fixedsys` fonts referenced in action 523/525).
    - If Phaser 4 ecosystem matures by Phase 7 retro, migration cost is one `apps/client` refactor
      (per migration guide, "mostly automatic"). Re-evaluation timing: post-CLI-08, pre-Phase-7-PR-01.
    - PixiJS deferred. If Phase 6 surfaces a Phaser 3 hard-knockout missed by this matrix, fall back
      to PixiJS 8.18.1 + `@pixi/tilemap@5.0.2` + `howler@2.2.4` per D-13 hard-knockout rule.

    ## References

    - `docs/extracted-engine/MATRIX.md` (rows MX-RENDER-01, MX-COLLIDE-01, MX-SCENE-01, MX-AUDIO-01, MX-UI-01)
    - `docs/extracted-engine/MATRIX-rows.json` (canonical scoring data)
    - `docs/extracted-engine/README.md` (subsystem index)
    - `.planning/research/STACK.md` §"Phaser 3 → Phaser 4"
    - [Phaser 3 → 4 Migration Guide](https://phaser.io/news/2026/04/migrating-from-phaser-3-to-phaser-4-what-you-need-to-know)
    - CLAUDE.md (hard rule #6: extract → document → rewrite — this ADR closes the "document" stage and unlocks "rewrite" Phase 6)
    ```

    **CRITICAL: substitute actual computed numbers** from Task 1's MATRIX.md totals. If the matrix winner is NOT Phaser 3.90 (e.g. PixiJS pulls ahead because audio is weighted low and PixiJS scores higher elsewhere), update the Decision section accordingly — the ADR follows the matrix, not the pre-recorded position. The pre-recorded position is a starting hypothesis, not a foregone conclusion.

    **CRITICAL: cite at least 3 unique MX-* row IDs in the Decision section.** lint-adr.mjs enforces this in Step 4.

    **Step 3: Create the docs/adr/ directory** if it doesn't exist:
    ```bash
    mkdir -p docs/adr
    ```

    **Step 4: Implement tools/asset-catalog/scripts/lint-adr.mjs** — replace Plan 01 TODO:

    ```javascript
    #!/usr/bin/env node
    // tools/asset-catalog/scripts/lint-adr.mjs
    // Validates ADR sections + every MX-* citation resolves in MATRIX-rows.json.
    // Exit codes: 0 success, 1 validation failure, 2 usage error

    import { existsSync, readFileSync } from 'node:fs';
    import { join } from 'node:path';

    function printUsage() {
      process.stderr.write('Usage: lint-adr.mjs <adr-path> [<matrix-rows-json-path>]\n');
      process.stderr.write('  adr-path: path to ADR .md file (e.g. docs/adr/0001-client-engine.md)\n');
      process.stderr.write('  matrix-rows-json-path: path to MATRIX-rows.json (default docs/extracted-engine/MATRIX-rows.json)\n');
    }

    const adrArg = process.argv[2];
    const matrixArg = process.argv[3] ?? 'docs/extracted-engine/MATRIX-rows.json';
    if (adrArg === '--help' || adrArg === '-h') { printUsage(); process.exit(0); }
    if (!adrArg) { printUsage(); process.exit(2); }
    if (!existsSync(adrArg)) { process.stderr.write(`ADR not found: ${adrArg}\n`); process.exit(1); }

    const adr = readFileSync(adrArg, 'utf-8');
    let errors = 0;

    // Required sections (Michael Nygard format per D-14)
    for (const section of ['## Status', '## Context', '## Decision', '## Consequences']) {
      if (!adr.includes(section)) {
        process.stderr.write(`ADR missing required section: ${section}\n`);
        errors++;
      }
    }

    // Phaser 4 caveat: ADR must mention Phaser 4 explicitly
    if (!/[Pp]haser\s*4/.test(adr)) {
      process.stderr.write(`ADR missing Phaser 4 discussion (Phaser 4 caveat per RESEARCH §908-922)\n`);
      errors++;
    }

    // Pitfall 6: every MX-* citation must resolve in MATRIX-rows.json
    const mxIds = [...adr.matchAll(/\bMX-[A-Z]+-\d+\b/g)].map(m => m[0]);
    const uniqueIds = [...new Set(mxIds)];
    if (uniqueIds.length < 3) {
      process.stderr.write(`ADR cites ${uniqueIds.length} unique MX-* row IDs; need ≥ 3 (Pitfall 6 + RESEARCH validation row CDOC-04)\n`);
      errors++;
    }

    if (existsSync(matrixArg)) {
      let rows;
      try { rows = JSON.parse(readFileSync(matrixArg, 'utf-8')); }
      catch (e) { process.stderr.write(`MATRIX-rows.json invalid JSON: ${e.message}\n`); process.exit(1); }
      const validIds = new Set(rows.map(r => r.rowId));
      for (const id of uniqueIds) {
        if (!validIds.has(id)) {
          process.stderr.write(`ADR cites ${id} but it does not resolve in MATRIX-rows.json (Pitfall 6 mitigation)\n`);
          errors++;
        }
      }
    } else {
      process.stderr.write(`MATRIX-rows.json not found at ${matrixArg}; skipping rowId citation check\n`);
    }

    // References section should mention MATRIX.md
    if (!/MATRIX\.md/.test(adr)) {
      process.stderr.write(`ADR References section should mention MATRIX.md\n`);
      errors++;
    }

    if (errors > 0) {
      process.stderr.write(`\nlint-adr: ${errors} error(s)\n`);
      process.exit(1);
    }
    process.stdout.write(`OK: ADR ${adrArg} validated (${uniqueIds.length} MX-* citations resolved)\n`);
    process.exit(0);
    ```

    **Step 5: Add tools/asset-catalog/tests/lint-adr.test.ts:**

    ```typescript
    import { describe, it, expect } from 'vitest';
    import { execFileSync } from 'node:child_process';
    import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import { join } from 'node:path';

    function runLint(...args) {
      try {
        const stdout = execFileSync('node', ['tools/asset-catalog/scripts/lint-adr.mjs', ...args], { encoding: 'utf-8' });
        return { code: 0, stdout, stderr: '' };
      } catch (e) {
        return { code: e.status, stdout: e.stdout?.toString() ?? '', stderr: e.stderr?.toString() ?? '' };
      }
    }

    describe('lint-adr.mjs', () => {
      it('no arg → exit 2', () => {
        try { execFileSync('node', ['tools/asset-catalog/scripts/lint-adr.mjs'], { encoding: 'utf-8' }); throw new Error('expected exit 2'); }
        catch (e) { expect(e.status).toBe(2); }
      });
      it('--help → exit 0', () => {
        const out = execFileSync('node', ['tools/asset-catalog/scripts/lint-adr.mjs', '--help'], { encoding: 'utf-8' });
        expect(out).toBeTruthy();
      });
      it('missing ADR file → exit 1', () => {
        const r = runLint('/nonexistent.md');
        expect(r.code).toBe(1);
      });
      it('ADR missing Decision section → exit 1', () => {
        const tmp = mkdtempSync(join(tmpdir(), 'lint-adr-'));
        try {
          const p = join(tmp, 'bad.md');
          writeFileSync(p, '# ADR\n## Context\nfoo\n## Consequences\nbar\n');
          const r = runLint(p);
          expect(r.code).toBe(1);
          expect(r.stderr).toMatch(/Decision/);
        } finally { rmSync(tmp, { recursive: true }); }
      });
      it('ADR missing Phaser 4 mention → exit 1', () => {
        const tmp = mkdtempSync(join(tmpdir(), 'lint-adr-'));
        try {
          const p = join(tmp, 'no-v4.md');
          writeFileSync(p, '## Status\n## Context\n## Decision\nUse Phaser 3.\n## Consequences\nMX-RENDER-01\nMX-INPUT-01\nMX-COLLIDE-01\nMATRIX.md\n');
          const r = runLint(p);
          expect(r.code).toBe(1);
          expect(r.stderr).toMatch(/[Pp]haser\s*4/);
        } finally { rmSync(tmp, { recursive: true }); }
      });
      it('committed ADR validates → exit 0', () => {
        if (!existsSync('docs/adr/0001-client-engine.md')) return;
        const r = runLint('docs/adr/0001-client-engine.md', 'docs/extracted-engine/MATRIX-rows.json');
        expect(r.code).toBe(0);
      });
    });
    ```

    **Step 6: Run all final checks.**

    ```bash
    node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md docs/extracted-engine/MATRIX-rows.json
    # MUST exit 0

    cd tools/asset-catalog && pnpm test          # full suite green
    cd tools/asset-catalog && pnpm test:full     # includes integration

    # Final round-trip stability (Wave 2 + Wave 3 deliverables)
    pnpm catalog:client && pnpm catalog:client && git diff --exit-code docs/extracted-engine/

    # Repo-level catalog regen + lint chain (recommended for CI gate)
    pnpm catalog:client && \
      node tools/asset-catalog/scripts/lint-docs.mjs docs/extracted-engine && \
      node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine && \
      node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md docs/extracted-engine/MATRIX-rows.json
    ```
  </action>
  <verify>
    <automated>bash -c "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md docs/extracted-engine/MATRIX-rows.json && cd tools/asset-catalog && pnpm test -- tests/lint-adr.test.ts && cd ../.. && pnpm catalog:client && pnpm catalog:client && [ -z \"$(git status --porcelain docs/extracted-engine/ docs/adr/)\" ] && node tools/asset-catalog/scripts/lint-docs.mjs docs/extracted-engine && node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine"</automated>
  </verify>
  <acceptance_criteria>
    - File `docs/adr/0001-client-engine.md` exists; ≥ 60 lines
    - ADR contains all 4 required sections: `## Status`, `## Context`, `## Decision`, `## Consequences` (each present at least once)
    - ADR mentions "Phaser 4" (case-insensitive) at least once (Phaser 4 caveat satisfied)
    - ADR cites at least 3 unique `MX-[A-Z]+-\d+` rowIds
    - Every MX-* citation in the ADR resolves to a row in `docs/extracted-engine/MATRIX-rows.json` (verified by lint-adr.mjs)
    - ADR contains substring "MATRIX.md" in References section
    - File `tools/asset-catalog/scripts/lint-adr.mjs` no longer contains the Plan 01 TODO marker
    - File `tools/asset-catalog/tests/lint-adr.test.ts` exists with ≥ 5 passing tests
    - Command `node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md docs/extracted-engine/MATRIX-rows.json` exits 0
    - Command `node tools/asset-catalog/scripts/lint-adr.mjs` (no args) exits 2
    - Command `node tools/asset-catalog/scripts/lint-adr.mjs /nonexistent.md` exits 1
    - Full chain `pnpm catalog:client && node tools/asset-catalog/scripts/lint-docs.mjs docs/extracted-engine && node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine && node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md docs/extracted-engine/MATRIX-rows.json` exits 0
    - `cd tools/asset-catalog && pnpm test` exits 0; full unit + integration suite passes
    - `git status` shows zero unintended drift after the second `pnpm catalog:client` run (idempotency at full Wave 1+2+3 scale)
  </acceptance_criteria>
  <done>CDOC-04 closeout: docs/adr/0001-client-engine.md is the locked engine choice for Phase 6 + Phase 7. Cites specific MATRIX rows; addresses Phaser 4 explicitly per the caveat; constraints the future asset pipeline target formats. lint-adr.mjs enforces Pitfall 6 citation discipline. Phase 2 is COMPLETE — all 4 CDOC-* requirements satisfied; Phase 6 client rebuild can begin against the chosen engine pin.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| MATRIX-rows.json (canonical data) → MATRIX.md (rendered) | Local data flow; tool-managed via AUTOGEN blocks |
| MATRIX-rows.json → ADR Decision rationale | Pitfall 6: ADR cites by rowId; lint-adr enforces every citation resolves |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06-01 | Tampering | MATRIX.md hand-edited; numbers diverge from MATRIX-rows.json (Pitfall 5) | mitigate | lint-matrix.mjs verifies rendered totals match JSON-computed totals; fails CI if drift; `pnpm catalog:client` re-renders from JSON. |
| T-02-06-02 | Tampering | ADR cites stale MX-* IDs that no longer exist in MATRIX-rows.json (Pitfall 6) | mitigate | lint-adr.mjs regex-extracts every MX-* citation and confirms each resolves in JSON; fails CI on any unresolved ID. |
| T-02-06-03 | Tampering | Engine choice in ADR contradicts matrix winner (e.g. matrix says PixiJS leads but ADR picks Phaser without justification) | mitigate | ADR Decision section MUST state weighted totals AND justify deviation per D-13 hard-knockout rule. lint-adr does not enforce semantic consistency; manual review during /gsd-verify-work catches this. Documented as a manual-only verification per VALIDATION.md. |

</threat_model>

<verification>
- `docs/extracted-engine/MATRIX-rows.json` exists with ≥ 18 valid rows, three engine columns each
- `docs/extracted-engine/MATRIX.md` rendered totals match JSON-computed totals (lint-matrix passes)
- `docs/adr/0001-client-engine.md` exists with all 4 sections + Phaser 4 mention + ≥ 3 MX-* citations all resolving in MATRIX-rows.json
- `pnpm catalog:client` produces idempotent output (zero git diff after second run)
- All three linters pass (lint-docs + lint-matrix + lint-adr)
- Full asset-catalog test suite passes (`cd tools/asset-catalog && pnpm test:full` exits 0)
- Phase 2 completes: CDOC-01 (Plan 05) + CDOC-02 (Plan 04) + CDOC-03 (this plan Task 1) + CDOC-04 (this plan Task 2) all satisfied
</verification>

<success_criteria>
- All `must_haves.artifacts` present at the specified paths and meet min_lines counts
- All `must_haves.key_links` greppable
- CDOC-03 + CDOC-04 closed; first ADR in the project is committed
- Phase 6 unblocked: knows the engine to commit to (apps/client `pnpm add phaser@<version>`)
- Asset pipeline phases (AST-01..04) know their target format (Phaser atlas / audio loader / BitmapText)
- Phaser 4 caveat satisfied: explicitly addressed in both MATRIX (3-column scoring) and ADR (Decision section justification)
- D-13 hard-knockout rule applied: if the matrix surfaced a Phaser 3 hard-knockout, the ADR Decision flips accordingly OR justifies the workaround
</success_criteria>

<output>
After completion, create `.planning/phases/02-client-engine-documentation/02-06-SUMMARY.md` with:
- MATRIX-rows.json: row count, weight total, distribution by subsystem
- MATRIX.md weighted totals for all 3 engines
- ADR Decision: which engine was chosen + key driving rows
- Phaser 4 disposition: chosen for v1 / deferred to Phase 7 retro / rejected outright
- Hard-knockout audit: did D-13 fire? If yes, where?
- All 4 lint chains green at end-of-phase
- **Phase 2 closure summary**: CDOC-01 ✓ Plan 05, CDOC-02 ✓ Plan 04, CDOC-03 ✓ Plan 06 Task 1, CDOC-04 ✓ Plan 06 Task 2 — Phase 2 COMPLETE.
- Carry note for Phase 6: exact engine pin (e.g. `phaser@3.90.0`) + first plan to commit it (CLI-01)
- Carry note for Phase 7 retro: re-evaluate Phaser 4 against MATRIX after CLI-08 ships
</output>
