# Phase 7: Workflow Smoke + Convention Locks — Pattern Map

**Mapped:** 2026-05-20
**Files analyzed:** 14 (10 new, 4 modified)
**Analogs found:** 13 / 14 (one greenfield: `maps/_smoke/*.ldtk` — operator-authored, no in-repo analog)

This phase is **convention-locking + tooling wiring**, NOT runtime code. Every new file has a close existing analog in the repo. Planner should reference the excerpts below verbatim in `<read_first>` blocks; the lint-script template (Pattern A), ADR template (Pattern B), and `package.json` `&&` chain (Pattern C) are reused across nearly every plan.

## File Classification

| New / Modified File | Role | Data Flow | Closest Analog | Match Quality |
|--------------------|------|-----------|----------------|---------------|
| `docs/adr/0009-ldtk-gridsize-convention.md` (NEW) | ADR (doc) | static-text | `docs/adr/0008-canvas-base-resolution.md` | exact |
| `docs/adr/0010-ldtk-tileset-source-hash.md` (NEW) | ADR (doc) | static-text | `docs/adr/0004-room-hot-reload.md` + `0008-…md` | exact (0004 chosen for crypto/Ed25519 sibling posture) |
| `docs/adr/0011-bncentral-gridvania-chunking.md` (NEW) | ADR (doc) | static-text | `docs/adr/0008-canvas-base-resolution.md` | exact |
| `docs/adr/0012-ldtk-version-pin.md` (NEW) | ADR (doc) | static-text | `docs/adr/0008-canvas-base-resolution.md` | exact |
| `tools/ldtk-version.txt` (NEW) | config (single-line pin file) | static-read | none (greenfield) — closest precedent is `apps/server/keys/rebno-room-signing.ed25519.pub.pem` operator-trust posture | role-match |
| `tools/scripts/lint-no-req-placeholders.mjs` (NEW) | lint script (.mjs) | static-grep + allowlist filter | `tools/scripts/lint-no-clipboard-rce.mjs` (regex+exit) + `tooling/no-inline-origin.ts` (git grep + ExecError handling) | exact |
| `tools/scripts/lint-atlas-hash.mjs` (NEW, Phase-7 stub) | lint script (.mjs) | fs.walk + sha256 (deferred) | `tools/scripts/lint-no-clipboard-rce.mjs` (walk+match) + `tools/scripts/lint-room-layout.mjs` (`createHash('sha256')` + `existsSync` early-return) | exact |
| `tools/scripts/check-conversion-regression.mjs` (NEW, stub) | check script (.mjs) | static log+exit | `tools/scripts/lint-no-clipboard-rce.mjs` shape (header + exit pattern) | role-match (stub, no real logic) |
| `maps/_smoke/synth-8000x6400.ldtk` (NEW) | operator-authored content | operator-machine smoke | **none in repo** — first LDtk file ever; recipe in RESEARCH.md §"Smoke-Test LDtk Authoring Recipe" | no-analog (operator authors via LDtk GUI) |
| `package.json` (MODIFIED, additions) | manifest (json) | config | own line 27 `lint:adrs` chain + lines 22–26 per-ADR scripts | exact (mirror the line-22..27 pattern) |
| `docs/deploy/LOCAL-DEPLOY.md` (MODIFIED) | runbook (md) | doc-section insertion | own §"Pre-flight" section (lines 18–37) | exact (extend, not replace) |
| `traceable-reqs.toml` (MODIFIED) | reqs manifest | config | own lines 326–417 (v1.1 block already exists) | exact (verify + optionally tighten `required_stages`) |
| `.planning/phases/07-workflow-smoke-convention-locks/SMOKE-NOTES.md` (NEW) | operator notes (md) | doc | `.planning/phases/06.4-…/06.4-CONTEXT.md` operator-cycle pattern | role-match (greenfield; planner picks template) |
| `tooling/no-inline-origin.ts` (MODIFIED — TAG ONLY) | existing script | unchanged (just adds `[impl->REQ-HYG-01]` tag in header comment) | itself (line 3 already has `[impl->REQ-CLI-04] [impl->REQ-CLI-08]`) | exact |

---

## Shared Patterns (Cross-Cutting)

These patterns apply to MULTIPLE Phase 7 plans and MUST be referenced in `<read_first>` for every plan that touches them.

### Pattern A — Lint-script template (`tools/scripts/lint-*.mjs`)

**Source:** `tools/scripts/lint-no-clipboard-rce.mjs` (header + walk + exit) and `tools/scripts/lint-room-layout.mjs` (existsSync early-return for absent-dir success)

**Apply to:** `lint-no-req-placeholders.mjs`, `lint-atlas-hash.mjs`, `check-conversion-regression.mjs`

**Canonical header shape** (CITED: `lint-no-clipboard-rce.mjs:1-11`, `lint-room-layout.mjs:1-12`):

```javascript
#!/usr/bin/env node
// tools/scripts/lint-<name>.mjs
// [<impl>->REQ-XX-NN]
// Source: <where this lint requirement was decided>
//
// <what this script enforces>
//
// Usage: node tools/scripts/lint-<name>.mjs
// Exit:  0 clean, 1 violation.
```

**Canonical fs+exit shape** (CITED: `lint-no-clipboard-rce.mjs:13-69`):

```javascript
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

const ROOT = '<root-dir>';

function walk(dir, out = []) {
  for (const f of readdirSync(dir)) {
    const p = join(dir, f);
    const s = statSync(p);
    if (s.isDirectory()) walk(p, out);
    else if (p.endsWith('<ext>')) out.push(p);
  }
  return out;
}

// ... scan logic ...

if (violations > 0) {
  process.stderr.write(`lint-<name>: ${violations} violation(s)\n`);
  process.exit(1);
}
console.log(`lint-<name>: OK (${files.length} file(s) clean)`);
process.exit(0);
```

**Canonical "absent-dir = success" early-return** (CITED: `lint-room-layout.mjs:70-75`):

```javascript
if (!existsSync(ROOMS_DIR)) {
  console.log('lint-<name>: <dir> absent — nothing to validate (acceptable for fresh checkout)');
  process.exit(0);
}
```

→ **This is the exact pattern `lint-atlas-hash.mjs` uses for the Phase-7 stub** (no-op early-return when `maps/` is empty).

**Canonical `git grep` invocation with ExecError handling** (CITED: `tooling/no-inline-origin.ts:34-87`):

```typescript
import { execSync } from 'node:child_process';

type ExecError = Error & { status?: number; stderr?: Buffer | string };
function isExecError(e: unknown): e is ExecError {
  return typeof e === 'object' && e !== null && 'status' in e;
}

try {
  const out = execSync(`git grep -nE "${BAD_PATTERN}" -- ${SCOPE}`, {
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  if (out.trim().length > 0) { /* violation path */ process.exit(1); }
  process.exit(0);
} catch (e: unknown) {
  if (isExecError(e) && e.status === 1) {
    // git-grep exits 1 when there are NO matches — that is success.
    process.exit(0);
  }
  // Anything else (status 2+, ENOENT, etc.) is a real failure.
  process.exit(2);
}
```

→ **`lint-no-req-placeholders.mjs` MUST use this same try/catch shape** (git-grep status==1 == success, not failure). RESEARCH.md Pattern 3 skeleton already encodes this.

---

### Pattern B — ADR template (`docs/adr/NNNN-<kebab>.md` Michael Nygard format)

**Source:** `docs/adr/0008-canvas-base-resolution.md` (most-recent ADR, kebab-case title, `[doc->REQ-CLI-06]` tag on its own line)

**Apply to:** `0009-ldtk-gridsize-convention.md`, `0010-ldtk-tileset-source-hash.md`, `0011-bncentral-gridvania-chunking.md`, `0012-ldtk-version-pin.md`

**Required structure** (enforced by `tools/asset-catalog/scripts/lint-adr.mjs --no-matrix`; CITED: `lint-adr.mjs:67-72`):
1. H1 title `# ADR NNNN: <title>`
2. `**Date:** YYYY-MM-DD` / `**Phase:** N (<name>)`
3. `[doc->REQ-MAP-02]` tag on its own line (CITED: `docs/adr/0008-canvas-base-resolution.md:5`, `0007-…md:7`, `0004-…md:3`)
4. `## Status` (must include "Accepted" + re-evaluation gate)
5. `## Context`
6. `## Decision`
7. `## Consequences` (with `### Positive` / `### Negative` / `### Neutral` subsections per 0008 shape)
8. `## Alternatives considered` (bullet list with `Rejected: <reason>`)
9. `## References` (optional in `--no-matrix` mode but conventional — cite source-of-truth files)

**`--no-matrix` mode skips** (CITED: `lint-adr.mjs:86-153`): Phaser 4 caveat regex, `MX-*` citations, `MATRIX.md` mention. All Phase 7 ADRs use `--no-matrix` since they're not engine ADRs.

**Reference ADR 0008 header (lines 1-7) verbatim**:

```markdown
# ADR 0008: Client canvas base resolution

**Date:** 2026-05-10
**Phase:** 06 (Plan 06-04, retroactive lock)

[doc->REQ-CLI-06]

## Status
```

**Reference ADR 0004 header (lines 1-5) for crypto/integrity ADR (analog for ADR 0010)**:

```markdown
# ADR 0004: Room Hot-Reload — fs.watch + Ed25519 Signed Manifests + Atomic Writes

[doc->REQ-SRV-13]

**Date:** 2026-05-07
**Phase:** 04 (during execution of Wave 4 / Plan 04-12b)
```

(NOTE: ADR 0004 places the `[<doc>->]` tag BEFORE date; ADR 0008 places it AFTER. Both pass `lint-adr.mjs`. **Phase 7 ADRs should mirror 0008's "Date + Phase + blank + tag + blank + ## Status" ordering** for consistency with the most-recent style.)

---

### Pattern C — `package.json` script convention + `&&` chain (cross-shell)

**Source:** `package.json` lines 22–27 (per-ADR `lint:adr:NNNN` + `lint:adrs` chain) and line 8 (`extract:all` 2-step chain)

**Apply to:** `package.json` additions for `preflight`, `lint:no-req-placeholders`, `lint:atlas-hash`, `lint:adr:0009..0012`, and the extended `lint:adrs` chain.

**Verified pattern — per-ADR script line** (CITED: `package.json:22-26`):

```json
"lint:adr:0002": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0002-persistence-layer.md --no-matrix",
"lint:adr:0003": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0003-canonical-snapshot.md --no-matrix",
"lint:adr:0004": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0004-room-hot-reload.md --no-matrix",
"lint:adr:0005": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0005-deploy-topology.md --no-matrix",
"lint:adr:0006": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0006-observability-stack.md --no-matrix",
```

→ **Phase 7 mirrors this for `0009`, `0010`, `0011`, `0012`** with literal kebab filenames.

**Verified pattern — `&&` chain** (CITED: `package.json:27` chains 6 commands; `package.json:8` chains 2):

```json
"lint:adrs": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md && pnpm lint:adr:0002 && pnpm lint:adr:0003 && pnpm lint:adr:0004 && pnpm lint:adr:0005 && pnpm lint:adr:0006",
"extract:all": "pnpm run extract:client && pnpm run extract:server",
```

→ **Phase 7 extends `lint:adrs` with `&& pnpm lint:adr:0009 && pnpm lint:adr:0010 && pnpm lint:adr:0011 && pnpm lint:adr:0012`** and creates `preflight` as a 7-step `&&` chain (literal value in RESEARCH.md Pattern 2).

**Verified pattern — `tsx tooling/<script>.ts` invocation alias** (CITED: `package.json:81`):

```json
"gate:no-inline-origin": "pnpm tsx tooling/no-inline-origin.ts",
```

→ **`preflight` step 3 reuses `pnpm gate:no-inline-origin`** (don't inline the `tsx` invocation; reference the existing alias).

**Verified pattern — inline `node -e` for trivial 3-line operations** (CITED: `package.json:34-35`):

```json
"db:emit-check": "pnpm -C packages/db exec drizzle-kit generate && node -e \"require('fs').copyFileSync('packages/db/migrations/0001_baseline.sql','docs/extracted-server/0001_baseline.sql')\" && git diff --exit-code packages/db/migrations/0001_baseline.sql docs/extracted-server/0001_baseline.sql",
"lint:schema-sync": "node -e \"const a=require('fs').readFileSync('packages/db/migrations/0001_baseline.sql');const b=require('fs').readFileSync('docs/extracted-server/0001_baseline.sql');if(Buffer.compare(a,b)!==0){console.error('docs/extracted-server/0001_baseline.sql out of sync with packages/db/migrations/0001_baseline.sql');process.exit(1)}console.log('OK')\"",
```

→ **`preflight` step 1 (LDtk version pin check) uses this inline-node pattern** — RESEARCH.md Pattern 2 supplies the literal. **WARNING per RESEARCH PITFALL 3:** Avoid raw single quotes inside `node -e` on cmd.exe Windows; use JSON-escaped double quotes only. If quoting becomes painful, promote to `tools/scripts/check-ldtk-version.mjs` (~10 lines).

---

### Pattern D — Tagging contract (`[<doc>->]` / `[<impl>->]`)

**Source:** `CLAUDE.md` §"Requirements Traceability" + existing examples in `tooling/no-inline-origin.ts:3` and `docs/adr/0008-canvas-base-resolution.md:5`

**Apply to:** Every new file in Phase 7.

| New File | Required Tag(s) | Placement |
|----------|-----------------|-----------|
| `docs/adr/0009..0012-*.md` | `[doc->REQ-MAP-02]` | own line, between header block and `## Status` (CITED: ADR 0008:5) |
| `tools/scripts/lint-no-req-placeholders.mjs` | `// [impl->REQ-HYG-02]` | header comment (CITED: `lint-no-clipboard-rce.mjs:2-3` analog placement) |
| `tools/scripts/lint-atlas-hash.mjs` | `// [impl->REQ-MAP-02]` (placeholder; Phase 8 swaps to `[impl->REQ-MAP-01]`) | header comment |
| `tools/scripts/check-conversion-regression.mjs` | `// [impl->REQ-MAP-02]` (placeholder; Phase 9 swaps to `[impl->REQ-MAP-09]`) | header comment |
| `docs/deploy/LOCAL-DEPLOY.md` new `## Preflight gate (MANDATORY)` section | `[doc->REQ-MAP-15]` | inside the new section (existing file already has `[doc->REQ-DEP-04] [doc->REQ-DEP-08] [doc->REQ-CLI-08]` on line 3) |
| `tooling/no-inline-origin.ts` | ADD `[impl->REQ-HYG-01]` to existing tag line | line 3 — extend existing `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]` to `// [impl->REQ-CLI-04] [impl->REQ-CLI-08] [impl->REQ-HYG-01]` |
| `tools/ldtk-version.txt` | none — single-line value file, no comments | n/a |
| `maps/_smoke/synth-8000x6400.ldtk` | none — LDtk binary-ish JSON; no comment surface | n/a |
| `.planning/phases/.../SMOKE-NOTES.md` | optional `[doc->REQ-MAP-02]` reference to ADR 0011 lock | inside the notes preamble |
| `traceable-reqs.toml` | n/a — the manifest IS the tagging surface | n/a |

→ **Validation:** `pnpm trace:check` (preflight step 4) catches missing tags after Phase 7 lands.

---

## Pattern Assignments (Per-File)

### `docs/adr/0009-ldtk-gridsize-convention.md` (ADR, doc)

**Analog:** `docs/adr/0008-canvas-base-resolution.md` (lines 1-117 — most-recent ADR; covers a sibling "lock canvas-grid invariant" decision)

**Apply Pattern B verbatim.** Specifics for this ADR:
- Status: "Accepted — re-evaluation gate: any Phase 8 operator-machine evidence that 4 px entity-snap visibly drifts vs legacy GML. Absent that, locked through v1.2."
- Context cites `CLAUDE.md` "Extracted Constants" (44×40 floor) + extracted source-of-truth `extracted/client-5-8/sprites/0023-Tile1/meta.json`
- Decision: `gridSize = 4` unified (D-01); 44×40 → 11×10 cells (D-02); entity 4 px snap (D-03)
- Alternatives: 4/1 split (research default); 1/1 (pixel-accurate)
- References: link to ADR 0008 (canvas base-res neighbor), CLAUDE.md §Extracted Constants

**lint command (add to `package.json`):**
```json
"lint:adr:0009": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0009-ldtk-gridsize-convention.md --no-matrix",
```

---

### `docs/adr/0010-ldtk-tileset-source-hash.md` (ADR, doc)

**Analog:** `docs/adr/0004-room-hot-reload.md` (lines 1-60 — sibling crypto/integrity ADR; ADR 0010 inherits Ed25519-fail-loud posture)

**Apply Pattern B + crypto-posture extracts from ADR 0004:**
- ADR 0004 lines 1-15 demonstrate `Supersedes: nothing. Superseded by: nothing.` + re-eval gate phrasing
- ADR 0004 lines 23-43 show sha256-payload framing (`Buffer.concat([room_id, rev, sha256(layout_bytes)])`) — ADR 0010 mirrors this discipline for atlas-PNG hashing
- Decision references already-locked CONTEXT.md D-05 (sha256), D-06 sidecar JSON (Option B, resolved 2026-05-20 per latest commit), D-07 HARD throw posture
- Carrier: **Sidecar JSON** `maps/<world>.tileset-hashes.json` (D-06 locked). Schema: `{ "<TilesetDef.uid>": "<sha256-hex>" }`
- Cites RESEARCH.md §Pitfall 1 acknowledging LDtk 1.5.3 schema absence of `customFields` on TilesetDef

**lint command:**
```json
"lint:adr:0010": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0010-ldtk-tileset-source-hash.md --no-matrix",
```

---

### `docs/adr/0011-bncentral-gridvania-chunking.md` (ADR, doc)

**Analog:** `docs/adr/0008-canvas-base-resolution.md` (Pattern B template)

**Specifics:**
- Decision: 3×3 GridVania (D-09); chunk dims cols 2668/2664/2668, rows 2132/2136/2132 (D-10); each %4==0
- **MANDATORY arithmetic check inline** (per RESEARCH PITFALL 5):
  ```
  cols: 2668 + 2664 + 2668 = 8000 ✓ (matches BNCentral pxWid)
  rows: 2132 + 2136 + 2132 = 6400 ✓ (matches BNCentral pxHei)
  all dims % 4 == 0 ✓ (honors gridSize=4)
  ```
- Cites `extracted/client-5-8/rooms/0058-BNCentral/meta.json` (8000×6400 source of truth)
- References LDtk editor-lag issues deepnight/ldtk #1029, #1073 (no in-repo path; external URL)
- Concrete BNCentral conversion explicitly **out of v1.1 scope** (Status section notes this)

**lint command:**
```json
"lint:adr:0011": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0011-bncentral-gridvania-chunking.md --no-matrix",
```

---

### `docs/adr/0012-ldtk-version-pin.md` (ADR, doc)

**Analog:** `docs/adr/0008-canvas-base-resolution.md` (Pattern B template)

**Specifics:**
- Decision: pin file `tools/ldtk-version.txt`, single-line content `1.5.3` (D-12); operator-trust posture (no programmatic introspection)
- Alternatives considered: programmatic LDtk version introspection (rejected: no stable CLI surface on Windows); embed in `package.json` (rejected: harder to bump independently of npm deps)
- Re-eval gate: LDtk ships a stable version-query CLI on Windows OR operator UAT reveals schema drift between versions
- References: RESEARCH.md `[A2]` assumption ("Operator's installed LDtk is 1.5.3")

**lint command:**
```json
"lint:adr:0012": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0012-ldtk-version-pin.md --no-matrix",
```

---

### `tools/ldtk-version.txt` (NEW config file)

**Analog:** None in-repo (greenfield). Closest precedent: `apps/server/keys/rebno-room-signing.ed25519.pub.pem` — operator-managed file, plaintext, no programmatic generation. **No code-pattern needed; ship literal one-line content:**

```
1.5.3
```

(No trailing comments — `pnpm preflight` step 1 reads the file with `readFileSync('tools/ldtk-version.txt','utf8').trim()` and asserts non-empty.)

---

### `tools/scripts/lint-no-req-placeholders.mjs` (NEW lint script)

**Analog:** `tools/scripts/lint-no-clipboard-rce.mjs` (regex+exit shape) + `tooling/no-inline-origin.ts` (git grep + ExecError handling)

**Apply Pattern A + Pattern D tag.**

**Concrete excerpts to combine:**

1. **Header (from `lint-no-clipboard-rce.mjs:1-11`):**
```javascript
#!/usr/bin/env node
// tools/scripts/lint-no-req-placeholders.mjs
// [impl->REQ-HYG-02]
// Source: 07-CONTEXT.md D-19/D-20 — drift-lock against REQ-…-XX placeholder
// regression in docs/, .planning/, packages/, apps/, tools/.
//
// Greps traceable-reqs.toml [scan].roots for placeholder REQ-IDs; fails if
// any hit is outside the allowlist (which excludes files that DOCUMENT
// the regex itself — meta-references, not debt).
//
// Usage: node tools/scripts/lint-no-req-placeholders.mjs
// Exit:  0 clean, 1 violation, 2 internal error.
```

2. **Body (use RESEARCH.md Pattern 3 skeleton verbatim, lines 369-415).** Critical: includes the **allowlist array** as a mandatory first-commit element (PITFALL 2):
```javascript
const ALLOWLIST = [
  '.planning/REQUIREMENTS.md',
  '.planning/STATE.md',
  '.planning/PROJECT.md',
  '.planning/ROADMAP.md',
  '.planning/research/v1.1/',
  '.planning/phases/07-workflow-smoke-convention-locks/',
  '.planning/milestones/',
  'tools/scripts/lint-no-req-placeholders.mjs',
  'traceable-reqs.toml',
];
```

3. **git-grep ExecError handling (from `tooling/no-inline-origin.ts:70-87`):** git-grep status==1 == success (no matches found); status==2+ == real error.

**package.json entry:**
```json
"lint:no-req-placeholders": "node tools/scripts/lint-no-req-placeholders.mjs",
```

---

### `tools/scripts/lint-atlas-hash.mjs` (NEW stub script)

**Analog:** `tools/scripts/lint-no-clipboard-rce.mjs:36-44` (recursive walk) + `tools/scripts/lint-room-layout.mjs:16,70-75` (`createHash('sha256')` import + `existsSync` early-return for absent-dir success)

**Apply Pattern A + Pattern D tag.**

**Concrete skeleton (from RESEARCH.md Pattern 4, lines 425-464):**

```javascript
#!/usr/bin/env node
// tools/scripts/lint-atlas-hash.mjs
// [impl->REQ-MAP-02]
// TODO Phase 8: swap to [impl->REQ-MAP-01]; activate real hash compare against
//   apps/client/public/atlas-mvp.png.
//
// Stub: assert that no .ldtk file declares a tilesetSourceHash that mismatches
// the live sha256(apps/client/public/atlas-mvp.png). When maps/ is empty,
// early-return cleanly. Activates in Phase 8 once TestBed_001.ldtk lands.

import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';

const MAPS_DIR = 'maps';
if (!existsSync(MAPS_DIR)) {
  console.log('lint-atlas-hash: OK (maps/ does not exist yet — Phase 7 baseline)');
  process.exit(0);
}

function findLdtk(dir, out = []) {
  for (const f of readdirSync(dir)) {
    const p = join(dir, f);
    if (statSync(p).isDirectory()) findLdtk(p, out);
    else if (p.endsWith('.ldtk')) out.push(p);
  }
  return out;
}

const ldtkFiles = findLdtk(MAPS_DIR).filter((p) => !p.includes('_smoke')); // skip throwaway smoke files
if (ldtkFiles.length === 0) {
  console.log('lint-atlas-hash: OK (no production .ldtk files yet — Phase 8 will activate)');
  process.exit(0);
}

console.log('lint-atlas-hash: Phase 8 logic not yet wired —', ldtkFiles.length, 'file(s) found, deferred');
process.exit(0);
```

**CRITICAL — PITFALL 4 mitigation:** the `console.log` MUST print a "skipped/deferred" reminder on EVERY preflight run (annoying-enough-to-notice). Don't silence.

**package.json entry:**
```json
"lint:atlas-hash": "node tools/scripts/lint-atlas-hash.mjs",
```

---

### `tools/scripts/check-conversion-regression.mjs` (NEW stub script)

**Analog:** `tools/scripts/lint-no-clipboard-rce.mjs` shape (header + exit), but body is a 3-line stub.

**Apply Pattern A header + Pattern D tag.**

**Concrete skeleton (from RESEARCH.md Pattern 5):**

```javascript
#!/usr/bin/env node
// tools/scripts/check-conversion-regression.mjs
// [impl->REQ-MAP-02]
// TODO Phase 9: swap to [impl->REQ-MAP-09]; re-run tools/room-converter
//   ldtk-import on a fixture .ldtk and assert byte-stable output
//   (deterministic round-trip).
//
// Stub: Phase 9 swaps the body; chain slot pre-wired in preflight step 7.

console.log('check-conversion-regression: skipped (pending Phase 9 ldtk-import subcommand)');
process.exit(0);
```

**package.json entry:** invoked directly inside the `preflight` chain (no dedicated alias needed):
```
... && node tools/scripts/check-conversion-regression.mjs
```

---

### `maps/_smoke/synth-8000x6400.ldtk` (NEW, operator-authored)

**Analog:** **NONE** — first LDtk file in the repo. Operator authors via LDtk 1.5.3 GUI.

**Recipe:** RESEARCH.md §"Smoke-Test LDtk Authoring Recipe (D-22, D-24)" lines 667-700 — 9-step click-recipe with explicit per-level `worldX/worldY/pxWid/pxHei` numbers.

**Arithmetic check (must match ADR 0011 inline check):**
- cols: `0 + 2668 + 2664 = 5332` (NE corner x); `5332 + 2668 = 8000` (right edge) ✓
- rows: `0 + 2132 + 2136 = 4268`; `4268 + 2132 = 6400` (bottom edge) ✓

→ **Planner action:** Phase 7's plan for this artifact is operator-authoring task. Plan writes the recipe-checklist into `SMOKE-NOTES.md` for the operator to follow; no code-pattern to copy.

---

### `package.json` (MODIFIED — script additions)

**Analog:** own lines 22-27 (per-ADR + chain) and lines 81 (`gate:no-inline-origin` alias) and lines 34-35 (inline `node -e` pattern).

**Apply Pattern C verbatim.**

**Additions (order matters — insert near line 27 `lint:adrs` block):**

```json
"lint:adr:0009": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0009-ldtk-gridsize-convention.md --no-matrix",
"lint:adr:0010": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0010-ldtk-tileset-source-hash.md --no-matrix",
"lint:adr:0011": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0011-bncentral-gridvania-chunking.md --no-matrix",
"lint:adr:0012": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0012-ldtk-version-pin.md --no-matrix",
```

**Extend the existing `lint:adrs` line 27** with `&& pnpm lint:adr:0009 && pnpm lint:adr:0010 && pnpm lint:adr:0011 && pnpm lint:adr:0012`.

**Add new entries (near line 50 `lint:room-layout` block):**

```json
"lint:no-req-placeholders": "node tools/scripts/lint-no-req-placeholders.mjs",
"lint:atlas-hash": "node tools/scripts/lint-atlas-hash.mjs",
```

**Add `preflight` 7-step chain (RESEARCH.md Pattern 2 literal, line 323):**

```json
"preflight": "node -e \"const v=require('fs').readFileSync('tools/ldtk-version.txt','utf8').trim();if(!v)throw new Error('ldtk-version.txt empty');console.log('LDtk pin:',v)\" && pnpm lint:room-layout && pnpm gate:no-inline-origin && pnpm trace:check && pnpm lint:no-req-placeholders && pnpm lint:atlas-hash && node tools/scripts/check-conversion-regression.mjs"
```

→ **PITFALL 3:** Test on Windows cmd.exe + PowerShell + Git Bash before closing the plan. If quoting breaks, promote step 1 to `tools/scripts/check-ldtk-version.mjs`.

---

### `docs/deploy/LOCAL-DEPLOY.md` (MODIFIED — add Preflight gate section)

**Analog:** own §"Pre-flight" section (lines 18-37) — existing prose section above the "Staging deploy" section.

**Insertion strategy:** Insert NEW H2 `## Preflight gate (MANDATORY)` **between** the existing `## Pre-flight` section (lines 18-37) and the `## Staging deploy` section (line 38).

**Existing section anchor (line 18-37 excerpt):**
```markdown
## Pre-flight

Confirmed once per machine, then trusted until something changes:

- **`flyctl` installed and authenticated.** ...
- **Docker installed and running OR Fly remote builders available.** ...
- **Node 22 + pnpm 10.** ...
- **`pnpm trace:check` reviewed.** ...
```

**New section template:**
```markdown
## Preflight gate (MANDATORY)

[doc->REQ-MAP-15]

`pnpm preflight` MUST exit 0 before any `flyctl deploy` invocation below.
The chain enforces the v1.1 convention locks and substitutes for the
decommissioned GitHub Actions CI:

1. LDtk version pin assertion (`tools/ldtk-version.txt` non-empty)
2. `pnpm lint:room-layout` — room-layout schema-union drift guard
3. `pnpm gate:no-inline-origin` — D-63 inline origin math gate (HYG-01)
4. `pnpm trace:check` — `traceable-reqs.toml` coverage
5. `pnpm lint:no-req-placeholders` — REQ-…-XX placeholder drift-lock (HYG-02)
6. `pnpm lint:atlas-hash` — tileset PNG hash drift (active from Phase 8)
7. `node tools/scripts/check-conversion-regression.mjs` — converter round-trip (active from Phase 9)

First failure aborts the chain. Operator iterates one check at a time.
```

→ Line 3's existing `[doc->REQ-DEP-04] [doc->REQ-DEP-08] [doc->REQ-CLI-08]` is left intact; the new section's `[doc->REQ-MAP-15]` is additive.

---

### `traceable-reqs.toml` (MODIFIED — verify + optionally tighten)

**Analog:** own lines 326-417 (v1.1 block already present per RESEARCH.md verification).

**Apply pattern (CITED: lines 328-336 sample entry shape):**

```toml
[[requirements]]
id = "REQ-MAP-02"
title = "Convention ADRs locked (gridSize, BNCentral chunk grid, tilesetSourceHash, LDtk version pin)"
required_stages = ["doc"]
```

**Phase 7 verification action** (per RESEARCH §"traceable-reqs entry shape" lines 656-665):

| REQ | Current `required_stages` | Phase 7 action |
|-----|---------------------------|---------------|
| `REQ-MAP-02` | `["doc"]` | none — Phase 7 doc-only for the 4 ADRs |
| `REQ-MAP-15` | `["doc", "impl"]` | none — already correct |
| `REQ-HYG-01` | `["doc", "impl"]` | none — already correct |
| `REQ-HYG-02` | `["doc"]` | **TIGHTEN to `["doc", "impl"]`** — Phase 7 ships `lint-no-req-placeholders.mjs` impl |

→ **Single-line edit** at line 416 in `traceable-reqs.toml`: change `required_stages = ["doc"]` to `required_stages = ["doc", "impl"]` for `REQ-HYG-02`.

---

### `.planning/phases/07-workflow-smoke-convention-locks/SMOKE-NOTES.md` (NEW)

**Analog:** No direct analog. Closest precedent: operator-cycle UAT-notes pattern from `.planning/phases/06.4-*/` cycle records.

**Required content per CONTEXT.md D-24 + RESEARCH PITFALL 6:**
- Operator screenshot of LDtk editor 3×3 world layout
- Per-chunk qualitative lag descriptor (e.g. "center: instant", "NW: <1s noticeable but tolerable")
- Multi-state coverage: each chunk opened + saved + filled
- Boolean PASS/FAIL line at the bottom + escalation path if FAIL (re-open D-09/D-10 per D-25)

**Template skeleton (planner discretion per CONTEXT.md Claude's Discretion §4):**

```markdown
# Phase 7 Smoke Test — `maps/_smoke/synth-8000x6400.ldtk`

**Operator:** <name>
**Date:** YYYY-MM-DD
**LDtk version:** 1.5.3 (must match `tools/ldtk-version.txt`)
**Source ADR:** 0011 (3×3 GridVania chunking)

## Authoring recipe followed
- [ ] All 9 levels created with explicit `worldX/worldY` per ADR 0011 D-10
- [ ] gridSize = 4 (project default) per ADR 0009 D-01
- [ ] Each level filled with one BORDERED tile species

## Per-chunk subjective lag

| Chunk | worldX | worldY | Open | Paint-fill | Save | Notes |
|-------|--------|--------|------|-----------|------|-------|
| NW    | 0      | 0      | …    | …         | …    | …     |
| N     | 2668   | 0      | …    | …         | …    | …     |
| NE    | 5332   | 0      | …    | …         | …    | …     |
| W     | 0      | 2132   | …    | …         | …    | …     |
| CENTER| 2668   | 2132   | …    | …         | …    | …     |
| E     | 5332   | 2132   | …    | …         | …    | …     |
| SW    | 0      | 4268   | …    | …         | …    | …     |
| S     | 2668   | 4268   | …    | …         | …    | …     |
| SE    | 5332   | 4268   | …    | …         | …    | …     |

## Screenshot
![ldtk-3x3-smoke](./smoke-screenshot.png)

## Verdict
**PASS / FAIL** — <verdict>.

## Escalation (if FAIL)
Per CONTEXT.md D-25: re-open ADR 0011 chunk-grid decision (D-09/D-10).
```

---

### `tooling/no-inline-origin.ts` (MODIFIED — tag-only edit)

**Analog:** itself.

**Edit:** Line 3 currently reads:
```typescript
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
```

**Becomes:**
```typescript
// [impl->REQ-CLI-04] [impl->REQ-CLI-08] [impl->REQ-HYG-01]
```

**That's the entire diff.** Per CONTEXT.md D-18 the script's behavior, scope, and regex are **unchanged**. Phase 7 only attaches the HYG-01 traceability tag.

---

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `maps/_smoke/synth-8000x6400.ldtk` | operator-authored content | operator-machine smoke | First LDtk file in repo. Operator authors via LDtk 1.5.3 GUI following recipe in RESEARCH.md §"Smoke-Test LDtk Authoring Recipe". Planner produces the recipe-checklist task; no code pattern applies. |

---

## Cross-Phase Notes for Planner

- **5 parallelizable plans + 1 serial closer** is the recommended decomposition (RESEARCH.md §"Primary recommendation"):
  1. ADR 0009 (gridSize) — parallel
  2. ADR 0010 (tilesetSourceHash) — parallel (Q1 already resolved 2026-05-20 → Option B sidecar JSON)
  3. ADR 0011 (BNCentral chunking) — parallel
  4. ADR 0012 (LDtk version pin) + `tools/ldtk-version.txt` — parallel
  5. Preflight wiring (3 scripts + `package.json` + LOCAL-DEPLOY.md + traceable-reqs.toml tighten) — parallel
  6. **Serial closer:** operator smoke test → SMOKE-NOTES.md → `maps/_smoke/synth-8000x6400.ldtk` (depends on ADRs 0009 + 0011 being authored)

- **All 4 ADRs use Pattern B verbatim**; deltas are only in §Decision content.
- **All 3 new `.mjs` scripts use Pattern A + Pattern D**; deltas are body logic (lint-no-req-placeholders has real allowlist filter; lint-atlas-hash and check-conversion-regression are deliberate stubs per PITFALL 4).
- **`package.json` and LOCAL-DEPLOY.md edits are additive** — never replace existing lines; insert at documented anchor points.
- **`tooling/no-inline-origin.ts` is read-only EXCEPT for the single tag line** — do not refactor, do not extend regex (`__pivot` discard is Phase 8 work per D-18).

---

## Metadata

**Analog search scope:**
- `docs/adr/` (8 ADRs, all read or sampled)
- `tools/scripts/` (12 `.mjs` lint files, 2 samples read in full — `lint-no-clipboard-rce.mjs`, `lint-room-layout.mjs`)
- `tools/asset-catalog/scripts/` (`lint-adr.mjs` read in full)
- `tooling/no-inline-origin.ts` (read in full)
- `package.json` (read in full)
- `docs/deploy/LOCAL-DEPLOY.md` (first 140 lines read)
- `traceable-reqs.toml` (header + v1.1 block read)

**Files scanned:** 8 ADRs + 12 lint scripts + 4 manifest/runbook files = 24

**Pattern extraction date:** 2026-05-20
