# AGENTS.md

This file provides project guidance to AI coding assistants (Claude Code, GitHub Copilot, Cursor,
Codex, etc.) working with this repository. Read it before writing or changing any code.

**This file is the MAP and it stands alone** — every project fact lives here: shipped state, iron
laws, commands, architecture, gotchas, layout. `CLAUDE.md` beside it is the Claude Code **operating
brief** and asserts no fact of its own: a ranked checklist of the rules most often broken here, each
pointing back into this file, plus the procedure for proving a change is right. **A new project fact
goes HERE, once.** If the same rule ends up stated in both files, delete the `CLAUDE.md` copy and
leave a pointer — both are loaded into every Claude Code session, so a duplicated sentence is paid
for twice and drifts silently.

## Project Overview

ArchLang — A small declarative language that compiles to professional SVG floor plans — like Typst/LaTeX, but for architecture.

- **Primary language / stack:** TypeScript (Node 18+; the core also runs in the browser)
- **Default branch:** `main`
- **Repository:** https://github.com/ChanMeng666/archlang

## Project status & where things live (current)

**ArchLang is shipped and launched.** This is a published, deployed monorepo — not a
work-in-progress. **Probe the live artifacts; do not trust this table.** It has twice asserted
"PREPARED, NOT YET PUBLISHED" about a release that had already shipped — written during a release
prep and never corrected once the tag went out, which is the failure mode this file is most prone
to. The version is whatever `npm view @chanmeng666/archlang version`, `git ls-remote --tags origin`,
`gh release list`, the MCP registry (with `?version=latest`) and the VS Code gallery API say it is,
and `CHANGELOG.md` is the canonical release notes. What this table is *for* is the **durable
mechanics** beside each version — those are the part a future agent needs, and they are what
survived the 2026-09-07 trim.

| Thing | Current | Where |
|-------|---------|-------|
| **Core package** | `@chanmeng666/archlang@1.35.0` — live on npm (`latest`, with a SLSA provenance attestation), GitHub Release 2026-09-04T08:21:55Z, tag `v1.35.0` peeling to `b54fdcc` on origin; every release is probed from the PUBLISHED package after the tag, never assumed. Released **tokenlessly** by `.github/workflows/release.yml` (OIDC trusted publishing) on a `v*` tag push. **Standing hazard — the MCP-registry step races npm's own visibility lag.** v1.34.0 and v1.35.0 both FAILED on attempt 1 and needed a manual `gh run rerun --failed`; v1.35.0's log names the cause exactly: `npm publish` returned the shim with the notice *"Your package is being processed and may take a few minutes to become available"*, and the registry's package validation then 404'd on that exact version through all six bounded retries, the window closing roughly three minutes after the publish. **Both npm publishes succeed before the failure** — only the registry step and the GitHub Release are outstanding, and the re-run is idempotent. `docs/backlog.md` 4.7 is **RE-OPENED** on that measurement: the fix is a longer budget or a pre-flight wait on npm visibility, **never deleting the retry**. (Three consecutive clean runs before v1.34.0 had made it look settled — a clean run is evidence the retry works, not proof the race is gone.) | npmjs.com/package/@chanmeng666/archlang |
| **Agent interface** | the `arch` **CLI** (`--json`, exit codes, stdin — incl. `ast`/`complete`/`fix`/`suggest`, `compile --from-json`/`-f txt`, `validate --graph`, v1.14's `validate --intent`/`--feedback` + `score --brief`, and the v1.17 **self-describing + bounded-output** layer: manifest-rendered per-command `--help` with worked examples, `--version`, exit-3 did-you-mean on an unknown flag/verb, `describe --room/--select`, `lint\|validate --code/--severity`, `context --section`, `fix --dry-run/--backup` + unified diff) + `SKILL.md` + `spec.llm.md` + **`llms-full.txt` / `arch context`** + **`schemas/plan.schema.json`** + **`schemas/intent.schema.json`** + **`grammars/archlang.gbnf`**. Primary interface stays the CLI; an **optional MCP shim** (`packages/mcp`) is a discoverability channel, not a replacement | `src/cli.ts`, `SKILL.md`, `spec.llm.md`, `llms-full.txt`, `packages/mcp` |
| **MCP server** | `@chanmeng666/archlang-mcp@0.2.15` — live on npm (`latest`, with provenance) and on the MCP registry as `io.github.ChanMeng666/archlang-mcp` (`updatedAt` 2026-09-04, `isLatest` true), both probed after the release. **Probe the registry with `?version=latest`** — a bare `GET /v0/servers?search=…` returns the FIRST-published entry, which for this server is `0.1.1` from 2026-07-10 carrying its long-dead `archlang-docs.vercel.app` website URL; reading that as "the registry never got the updates" is wrong. A stdio shim over the library (tools compile/describe/lint/validate incl. `intent`/score/repair/fix/suggest/complete + spec/context/schema/intent-schema/grammar resources); the SDK dep is quarantined here so the core stays zero-dep. See ADR 0012. **The pack-time law:** its five context resources (`spec.llm.md`, `llms-full.txt`, `grammars/archlang.gbnf`, `schemas/plan.schema.json`, `schemas/intent.schema.json`) are **baked in at pack time**, so **only a version bump ships fresh ones** — which is why twelve of the last thirteen shim releases were version-bump-only (`git diff <prev-tag>..main -- packages/mcp` empty every time). The staleness is no longer silent: CI's `builds` job runs `packages/mcp/scripts/check-dist-resources.mjs` (byte-compares every baked `dist/` resource against its repo source) and `packages/mcp/test/lockstep.test.ts` pins the core dep range as a **string** equal to `^` + the root version, so **every core release turns this package RED on purpose** until someone re-pins the range, rebuilds the resources and bumps the shim in `packages/mcp/package.json` AND both of `server.json`'s version fields — don't relax it to a semver-satisfies check. **Read the SHAPE of a bump, not the roll-call.** *Nothing moved* (0.2.10 — no language surface changed; the bump existed only to re-pin the range). *The mild case*: only `llms-full.txt` moved — a new warning code the baked context would not mention. *The catalogue case*: `spec.llm.md` + `llms-full.txt` moved and the grammar did not — correct, because a fixture category is a catalogue entry the spec generator interpolates, not a grammar token. **The sharp case**: the GBNF and/or `plan.schema.json` moved — a constrained decoder pointed at the published shim then **cannot derive the new syntax at all**, and a host reading its baked schema **rejects payloads this core emits** (0.2.2, 0.2.9, 0.2.11 and 0.2.15 are the instances; 0.2.2 is also the only one whose cause was anything but staleness — it served a v1.19 grammar while its `^1.14.0` range happily resolved to a current core, so a host got a grammar and a compiler from different languages and nothing said so). **Measure what moved from the PUBLISHED tarball** — `npm pack` the old and the new version and SHA-256 each baked file against the repo tree — not by diffing the tag: the tag says what the repo changed, the tarball says what the last pack shipped | `packages/mcp/`, `server.json` |
| **VS Code extension** | **`0.24.0` — packaged 2026-09-04 against core 1.35.0; the Marketplace UPLOAD IS OUTSTANDING.** Last version confirmed LIVE by the gallery API (`extensionquery`, `filterType: 7` = `ChanMeng.archlang`): **`0.22.0`**, `lastUpdated` 2026-09-03, probed 2026-09-04 — so nothing before 0.24.0 is outstanding. Probe the gallery, not this table. **The six durable mechanics.** (1) **The Marketplace upload is a HUMAN WEB STEP** at marketplace.visualstudio.com/manage/publishers/ChanMeng — there is no CI publish and nothing in this repo performs it, so publishing the npm and MCP artifacts does nothing for the gallery on its own; `.vsix` files are gitignored, so the artifact is local to whoever ran `npm run package`, and **more than one version can sit un-uploaded** (0.23.0 was version-bumped and never packaged at all; 0.13.0 once carried three skipped tiers to the gallery in a single upload). The rule is therefore **always upload the HIGHEST**. (2) **The extension bundles the core at BUILD time**, so a stale bundle ships a stale language; `editors/vscode/test/stdio.test.ts` pins freshness via the `__CORE_VERSION__` stamp esbuild writes. (3) **"Rebundled" and "packaged" are different steps** — `npm run build` produces `dist/server.js`, only `npm run package` produces a `.vsix` — and `npm run package` esbuilds against the CORE's `dist/`, so **`npm run build` at the repo root must run first** or it silently packages a stale language. (4) **Build and package in the PRIMARY checkout only — guarded, not merely advised.** A `.claude/worktrees/*` checkout has no `node_modules`, so esbuild resolves the core by walking UP and bundles the SHARED repo's, and the `__CORE_VERSION__` stamp cannot catch it because both stamp the same version; `editors/vscode/resolve-core.mjs` compares the resolved core's real path against the repo root being built and REFUSES, naming both paths (`editors/vscode/test/wrong-core.test.ts` simulates that walk-up on disk at the SAME version). It fires for a **junctioned** worktree too, and that is right — npm links a workspace package by ABSOLUTE path to the main tree's root. (5) **The dep range is guarded separately from the bundle** (`editors/vscode/test/lockstep.test.ts`, a string equality against `^` + the root version): the range once sat two releases stale at `^1.24.0` while the stamp test stayed green throughout, because esbuild resolves the workspace symlink regardless of what the manifest declares. That guard deliberately does NOT also require the extension's own version to bump — its cadence is human uploads, and coupling them would train people to bump a digit for green. (6) **The gallery LAGS an upload by a varying, non-monotonic amount** (~10 min to ~40 min across past releases), so **a probe that disagrees with a just-performed upload means "wait and re-probe", never "the upload failed"**. **Verify from INSIDE the archive BEFORE any upload**, which is the order to keep: `Identity Version` + `Publisher` in `extension.vsixmanifest`; the packed manifest's `version` and its `devDependencies["@chanmeng666/archlang"]` range (it IS visible inside a `.vsix`, at `extension/package.json` — the old "`--no-dependencies` strips it" claim was wrong for at least four releases); `ARCHLANG_CORE_VERSION` in `dist/server.js`; an **unquoted** core-version scan of the whole bundle with every match read in context (a quoted regex misses the unchanging `"@scope/name:1.0.0"` example inside the import-diagnostic message and flatters the result); and a grep for the release's new keywords and error codes, so the new surface is proven present in the BUNDLE rather than merely in the repo | marketplace.visualstudio.com/items?itemName=ChanMeng.archlang |
| **Playground** | deployed, redesigned (**"The Compile Boundary"** one-light-world UI — see below · TypeScript app · pan/zoom · autocomplete · history · click-to-source · format · repair · error-explain · embeddable `embed.html` · circulation Paths toggle · **Copy-for-LLM** · inline diagnostic fixes) | https://playground.archlang.uk |
| **Docs site** | deployed, redesigned (**"The Compile Boundary"** one-light-world UI · compiler-as-hero · VitePress · live editable `<ArchLive>` examples · plain ```` ```arch ```` fences auto-live · serves `/llms.txt` + `/llms-full.txt` + **raw `/<page>.md`** + **`/plan.schema.json`** + **`/archlang.gbnf`**) | https://archlang.uk |
| **Git** | `main`, tags `v1.0.0` → `v1.35.0` — **`v1.35.0` is the newest**, pushed 2026-09-04, its annotated tag object `13a9b7f` peeling to `b54fdcc` on origin. A `v*` tag push triggers the tokenless OIDC release workflow. The tags are the release record: read them with `git ls-remote --tags origin`, never from this table | github.com/ChanMeng666/archlang |
| **Dataset** | HF `ChanMeng666/archlang-repair-trajectories` (**published, live 2026-07-13** — repair 1200 + authoring 400 rows) — two splits, fully synthetic, self-verifying, CC0-1.0, deterministic from seed `20260712`; generator `dataset/` (`npm run dataset:gen`), permanent CI leakage guard `test/dataset.test.ts` | `dataset/`, huggingface.co/datasets/ChanMeng666/archlang-repair-trajectories |
| **Tests** | **4338 passing, 0 skipped, 216 files** — measured 2026-09-04 in the PRIMARY checkout at the v1.35.0 release commit by `npm run build && npm run vscode:build:only && npx vitest run`. **Nothing gates this number: re-measure it, never carry it forward.** It drifts silently between measurements (2804 → 3092 → 3222 → 3410 → 3673 → 3853 → 3992 → 4055 → 4338). Three rules for reading it. **Build the VS Code bundle first**, or the count is different: without `editors/vscode/dist/server.js` both of `stdio.test.ts`'s describes emit a visible gated skip, and `wrong-core.test.ts`'s "THIS checkout passes" case fails outright. **A worktree's number is not the repo's** and must never be quoted as one — `editors/vscode/test/wrong-core.test.ts` PASSING is the primary-checkout tell (`resolve-core.mjs` REFUSES to bundle in a worktree, by design), so a reading shaped like "3849 passing / 1 failed / 2 skipped" is a worktree, not a defect. **The suite has one known load-sensitive case, filed rather than hidden:** `test/roof.test.ts`'s PDF export opens with a dynamic `import("pdfkit")` under vitest's 5000 ms default — 674 ms alone, 5821 ms under heavy parallel load, where it times out; `docs/backlog.md` 4.10 has the measurements and says explicitly **not** to raise the global `testTimeout`. That is the only known exception to a suite with no flake. **What the suite covers, and what to do when a guard goes red, is mapped in [`docs/testing.md`](docs/testing.md)** — read that rather than an inventory here. It spans the root vitest run (which also includes `playground/test`, `packages/*/test` and `editors/vscode/test`), the offline eval gates (`npm run eval:ci`, plus the separately-reported and judge-free `npm run eval:fidelity`), and Playwright E2E against the BUILT sites (50 playground cases in 6 files, 63 docs cases in 3 files of which 61 run and 2 are gated skips — counted with `npx playwright test --list` in each workspace, the only honest count, since `routes.spec.ts` generates its cases in a loop) | — |

**Latest release: v1.35.0 (2026-09-04)** — *"the third dimension, added twice — once as a number
nothing draws, once as a picture nothing measures"*, a MINOR. Two deliberately independent halves.
The **vertical datum** reuses `height` at three new sites (a plan setting, a `level` header clause, a
`wall` clause) and adds exactly two keywords, `sill` and `head`; it **draws nothing at all**, because
a floor plan is a horizontal cut, and `describe()`/Plan JSON emit its keys only when the source
authored a height anywhere. The **axonometric view** (`--view iso|axon`) is a **picture, not a
drawing**: no scale, no sheet, no dimensions, and `describe()`/`lint()` never learn it exists. The
laws both halves must keep — the fallback chain, accumulating elevation, the byte-identity pins, the
no-trigonometry rule, the total painter's order — are in `CLAUDE.md` § "Non-negotiable invariants"
and in the module map below; the full account is in `CHANGELOG.md` and `docs/axonometric.md`.
Shipped alongside **MCP shim 0.2.15** and **VS Code 0.24.0** (packaged, upload outstanding).

**Where the release narrative lives — and why it is not here.** `CHANGELOG.md` is the canonical
release notes, and per-tranche research verdicts live in `docs/research/`. This section used to
carry a per-release essay reaching back a dozen minors; by 2026-09-07 it had grown `AGENTS.md` to
160,300 characters, past the 150,000-character budget for a file loaded into **every** session, and
the status table alone was 92,000 of them. That prose is archived **verbatim** in the private growth
repository, at `archcanvas-growth/archive/archlang/docs-archive/agents-status-history-2026-09.md`
(v1.20.0 → v1.35.0), beside its predecessor `agents-status-history-2026-07.md` (v1.3.0 → v1.16.0).
**Its permanent conclusions are distilled into the table above and into "Standing decisions & iron
laws" just below — read *those*, not the archive, for what still binds you.** Older pre-launch docs
(the build plans and the frozen work log `WORK-LOG-v0.7-v1.15.md`) sit in that same directory;
nothing in this repository reads them. **Do not re-grow per-release prose here** — the convention is
stated at the foot of this file and has now been violated, and repaired, twice.

## Standing decisions & iron laws (never re-litigate)

Permanent decisions distilled from the archived narrative and `docs/research/`. Settled — do not
re-propose, re-open, or contradict them anywhere.

- **T3 — the diagnostic-loop live experiment is PERMANENTLY DECLINED** (owner, 2026-07-12). Never
  trigger `eval-l2.yml` live, never re-propose it, and **never claim a net model-loop gain OR its
  absence** anywhere (loop-vs-equal-budget-resampling stays permanently unanswered). So L3/L4/L5 stay
  unbuilt and the intent channel's adjacency/reachability assertions stay **advisory (`gate: false`)
  permanently**; the L2 harness (`eval/l2.ts`, `eval/l2-run.ts`) is kept only as reference.
- **T6 — area-syntax sugar is PARKED** behind the frozen reversal triggers in
  `docs/research/2026-07-g2-verdict.md` (Gate G2 CLOSED, residual 0/8). No `area` token enters the
  grammar and unit suffixes deliberately exclude `m2` unless one of that doc's triggers fires; only
  the intent channel's assertion form ships for area.
- **Dataset contamination iron law** (`test/dataset.test.ts` enforces it permanently; getting it
  wrong voids the eval forever). The 26-brief eval corpus/goldens are a **private holdout, never
  published**; `dataset/` imports only `../src/index.js`, never `eval/`; every row is double-
  deduplicated (text + `describe()`) against the holdout. The canary GUID in `dataset/canary.ts` is
  hardcoded once and **NEVER regenerated** (a new value silently splits the corpus, defeating leakage
  probing). `repair`-split sources stay fully literal (`repair()` declines scripting).
- **Judge comparability** — never compare eval rates across a `JUDGE_VERSION` / `SYNONYMS_VERSION`
  change (it measures the ruler, not the model; judge v1→v2 moved intent 9%→50% with zero model
  change). Regenerate `eval/judge-fixture.json` **only** for an approved bump, **never to green a red suite**.
- **Releases are tokenless OIDC trusted publishing only** (`v*` tag push → `.github/workflows/release.yml`).
  **Never add an npm token** anywhere (an auth failure means "redo the npmjs trusted-publisher
  registration", not "add a token"); **never automate npmjs account / 2FA / publisher management**
  (human-with-2FA only); `package.json`'s `repository.url` owner must be **`ChanMeng666` byte-for-byte**
  (else provenance E422s). Recipe: `docs/npm-oidc-publishing-playbook.md`.
- **A `packages/mcp` prose-only change (a tool description, a README) publishes ONLY with a version
  bump** — and the bump must land in BOTH `packages/mcp/package.json` AND `packages/mcp/server.json`
  (both of `server.json`'s `version` fields). The release workflow resolves each package's declared
  version and `npm view`-skips the publish when that exact version already exists on the registry, so
  an unbumped description edit silently never reaches npm or the MCP registry. (v1.16.0's 0.2.0 → 0.2.1
  bump existed only to ship a refreshed `suggest` tool description.)
- **The GitHub Release body is sliced from `CHANGELOG.md` by `scripts/changelog-section.mjs`**, which
  scans from `## [<version>]` to the next `## ` heading — so section ORDER doesn't affect extraction: a
  release section placed ABOVE `[Unreleased]` still extracts correctly (v1.16.0 shipped that way).
  `[Unreleased]` is back on top (keep-a-changelog convention) and is where in-flight work — including
  infrastructure that ships no version of its own — is recorded until a release claims it.
- **Brand assets are byte-sacred.** `brand/archlang-logo-master.svg` is the one source; every variant
  is a **fill-swap only** (never re-trace/simplify/re-fit path data). The "Compile Boundary" brand
  token block is **duplicated byte-identically** in `docs-site/.vitepress/theme/style.css` and
  `playground/src/styles/tokens.css` (no shared import — change one, change the other).
- **`eval/rubric.md` policies are frozen** (blind-drafted, then approved) and **`npm run eval:live` is
  paid and owner-only** — the offline `npm run eval:ci` golden gate is what runs in CI.
- **Custom domain `archlang.uk` (Cloudflare DNS + Cloudflare Workers), live since 2026-07-15, moved off
  Vercel onto Workers static assets in 2026-09 with NO URL change.** Docs → `archlang.uk` (apex),
  playground → `playground.archlang.uk`. Three things a future agent must not get wrong: (1) the
  **Cloudflare Worker names** and npm **workspace names** are still `archlang-docs` /
  `archlang-playground` — those are NOT the URLs and must never be renamed to match the domain (a grep
  for them legitimately hits `package.json`/`wrangler.jsonc`/`deploy.yml`); (2) **the proxy rule
  INVERTED** — the records are now **proxied (orange cloud)**, because a Workers custom domain
  necessarily is one; the old iron law ("DNS only, never proxied — proxying breaks Vercel's SSL") was
  right for a Vercel origin and is superseded, so if you meet it in an archived doc, it is history;
  (3) because the zone is now proxied, Cloudflare CAN rewrite bytes and challenge clients, so Bot Fight
  Mode, Email Obfuscation, Rocket Loader and Browser Integrity Check must stay OFF or `scripts/smoke.mjs`
  and the nightly byte-equality staleness probe break. Neither Worker has a `main` script, which is what
  keeps every request a free, unmetered static-asset request. Full recipe (DNS, TLS, the `www` redirect
  rule's exact API shape, `_headers`, and how to change a public URL in code without the escaped-dot grep
  trap): `docs/hosting-and-domains.md` and [ADR 0019](docs/adr/0019-cloudflare-workers-hosting.md).
- **A generator's TEMPLATE can go stale even when `check:drift` is green.** The gate compares
  generator *output* to the committed file — it proves reproducibility, not correctness. A generator
  that hardcodes a language fact reproduces the same *wrong* text forever: `gen-llm-spec.ts` shipped a
  v1.12 CLI + no `strip` for three releases, and `gen-grammars.ts` hardcoded a number regex without the
  unit suffixes. **Derive from the source of truth (`KEYWORDS`/`RULES`/`buildManifest()`), never
  retype it**, and give each generator a guard that fails when a source-of-truth entry has no
  rendering (as `gen-llm-spec.ts` now does for every `KEYWORDS.control` entry, not just `element`).
- **Every new language form ships with a byte-identity law, pinned by test:** a plan that does not
  use it renders, describes and lints exactly as before. `site`, the door kinds, `zone`, `paper`,
  `polygon`, `arc`, `roof`, `void`, the v1.35 `height`/`sill`/`head` datum and the v1.35 `--view`
  all have one. Prove it with a SHA-256 sweep over the shipped examples, not by eyeballing — and if
  a golden moves, that is a finding to explain before it is a diff to bless. Take the baseline with
  the **same digest body the test will run**, not a lookalike in a throwaway script:
  `test/roof-void-byte-identity.test.ts`'s first attempt used a scratch script whose payload
  separator differed by one character and produced four "failures" over artifacts that were in fact
  byte-identical. And the sweep's payload is the whole agent-facing surface — SVG, `describe()`
  **and** `lint()` — because a form that quietly appends an empty key to every summary leaves the
  drawing untouched and is still a behaviour change for every `arch describe --json` consumer.
  (The corpus sweep carries no parse- or resolve-stage diagnostic, because `lint()` is the soundness
  layer alone — sweep 125, not 95, whenever a change can reach the resolver. `docs/testing.md`.)
- **A derived POSITION must come from the shape, never from its bounding box or centroid — this is a
  defect CLASS, not a bug.** Six instances shipped and were fixed in v1.25.0, every one of them SILENT
  (`arch lint` reported none): the room label point, the circulation routing anchor (a 10.9 m walk
  reported as 5.6 m), `dims auto` witness lines floating metres off a sloped facade, `swing into
  <room>`, `furniture … against wall`, and `windows[].facing` on a courtyard plan. **The grep that
  finds a new one is `room\.size`/`r\.size\.w` WITHOUT a nearby `r.poly` branch** — a room-shape
  consumer reading the box when the ring is the honest datum. The fix is always local and closed
  form: probe one wall thickness off each face and ask which side has floor (`pointInRoomBox` /
  `pointInPolygon`, both poly-aware), or use `polygonLabelPoint`. Never reach for the wall JOINERY to
  answer a `describe()` question — `describe()` never builds a Scene, and doing so drags the whole
  poché pipeline into a read meant to be cheap. (It no longer drags an optional dep with it: since
  v1.30 the joinery is zero-dependency, so the cost is time, not an install.) Full inventory,
  including what the sweep CLEARED and why, in
  [`docs/research/2026-08-06-competitor-borrowing-roadmap.md`](docs/research/2026-08-06-competitor-borrowing-roadmap.md) §9.1.
- **`prepublishOnly` runs the WHOLE monorepo suite, so the release job must build what that suite
  asserts.** `npm publish` → `prepublishOnly` = `npm run build && npm run test`, and `npm run test`
  includes `editors/vscode/test/stdio.test.ts`, which hard-fails under CI when the core is built but
  `editors/vscode/dist/server.js` is not. `npm run build` does not build that bundle, so
  `release.yml` builds it explicitly before publishing. **Do not "fix" a future occurrence by
  narrowing the gate** — building the artifact keeps it verified, narrowing it means nothing about
  the shipped bundle was checked. This cost v1.25.0 its first publish attempt (the tag was moved to
  the fixed commit; nothing had reached npm, so re-tagging was clean). It had never fired because
  that test post-dates v1.24.0, and CI's `builds` job runs `vscode:build:only` before the suite.

**Monorepo layout (npm workspaces, one root lockfile):**

```
.                     @chanmeng666/archlang — the core (PUBLISHED package; src/, dist/)
├─ spec.llm.md        GENERATED one-page language spec for agents (`arch spec`, `gen:spec`)
├─ SKILL.md           agent Skill: the spec → compile → fix → describe → validate loop
├─ llms.txt           machine-readable project map (USE vs CONTRIBUTE)
├─ llms-full.txt      GENERATED full agent context (spec + skill + CLI + errors; `gen:llms`)
├─ schemas/           GENERATED plan.schema.json (`gen:plan-schema`) + intent.schema.json (`gen:intent-schema`), both drift-tested
├─ grammars/          GENERATED archlang.gbnf — GBNF constrained-decoding grammar (`gen:gbnf`)
├─ packages/mcp/      @chanmeng666/archlang-mcp — stdio MCP shim over the library (SDK dep quarantined
│                     here): src/server.ts, server.json (registry manifest), scripts/ (copy-resources +
│                     check-dist-resources staleness gate), test/ (tools · resources · lockstep · fuzz) — see ADR 0012
├─ editors/vscode     archlang-vscode → published as ChanMeng.archlang (esbuild-bundled extension);
│                     src/handlers.ts holds the DI'd LSP logic, test/ covers it + the built stdio bundle
├─ editors/*.json     generated TextMate grammar + language-configuration (shared by the extension)
├─ playground/        Vite + CodeMirror live editor (consumes built core via dist/); styles under
│                     src/styles/{tokens,chrome,editor,panels,embed}.css (tokens.css = the brand block);
│                     also ships embed.html — a chrome-less <iframe> viewer read from the #z= hash;
│                     test/ = pure-logic units, e2e/ = Playwright specs against the BUILT app
├─ docs-site/         VitePress docs (pages generated from docs/*.md, examples/*.arch); theme CSS as
│                     .vitepress/theme/{style,home,doc-pages}.css (style.css = the brand block);
│                     examples are live/editable <ArchLive> widgets; e2e/ = Playwright route + hydration specs
├─ docs/              language-reference.md · analysis.md · intent.md · error-codes.md (GEN) ·
│                     cli-reference.md (GEN from src/manifest.ts, `gen:cli`) · testing.md (the verification-system
│                     map: tiers, guards, what to do when one goes red) · adr/ (archive/ holds the frozen WORK-LOG)
├─ brand/             logo kit + brand book (README.md) — archlang-logo-master.svg is byte-sacred (iron law)
├─ examples/          30 plans, each the flagship of ONE thing (plus lib/ and the twenty committed
│                     README_SVGS renders — see `gen:example-svgs`). Showpiece: hillside-villa
│                     (the whole language on one sheet — a two-storey villa with an attached
│                     garage, A2 @ 1:50: `site`, a polygon nook, an L-shaped suite, an `arc` bay,
│                     all five door kinds, a two-level `stair` shaft, a `void`, a `roof overhang`,
│                     and a mirrored `place … mirror x` component; three deliberate lint warnings
│                     — W_BATH_VIA_BEDROOM ×1, W_ROOM_NOT_EQUATOR_FACING ×2 — left in and named in
│                     the source, so it is NOT strict-clean by design).
│                     OUTDOOR flagship: garden-house (the site plan — a two-storey family house
│                     on a 22 x 22 m lot, A2 @ 1:100: `site … boundary`, twelve `outdoor`
│                     surfaces across eight of the nine kinds, a `panel` fence round the pool
│                     and a `picket` one at the street, `uses garage` + `door garage`, and 15 of
│                     the 21 outdoor fixture families; ONE deliberate lint warning, named in
│                     the source — W_ROOM_NOT_EQUATOR_FACING x1. It used to carry a SECOND,
│                     W_BATH_VIA_BEDROOM, that was a FINDING rather than a layout mistake: the
│                     balcony door on L2 grounded its storey and suppressed the stair's arrival
│                     edge, so the BFS entered through the bedroom instead of the landing. Fixed
│                     — `levelIsGrounded` (`src/vertical.ts`) now discounts an exterior door
│                     whose outward probe lands inside an `outdoor balcony`; backlog 4.6 closed).
│                     Start here: one-room (the
│                     smallest plan that draws anything) · studio (the lint-clean, IMPORT-FREE
│                     flagship) · attached (nothing positioned by hand).
│                     Homes: laneway-house (the SIGNATURE plan — every opening on a wall run, every
│                     fixture on a room or wall, nothing hand-placed) · tiny-house (barn/bifold
│                     doors, now with eaves) · garden-loft ·
│                     two-bed (repaired — was `ok:false` with 6 warnings before the 2026-08
│                     gallery refresh) · bungalow (the DOOR-KIND flagship, and since v1.29 the `roof`
│                     one: `roof overhang 600`, now on a sheet — A3 landscape, schedule) · furnished-flat (the FURNITURE
│                     flagship: 30 statements covering 29 of the 36 catalogued glyph FAMILIES,
│                     across all five symbol domains, with no `label` on any of them and no
│                     `size` on most — the four v1.29 families and the `underlay` rug among
│                     them; since the 2026-08 gallery refresh also on a sheet — A3 portrait,
│                     schedule) · courtyard-house (the CONCAVE
│                     flagship: a U whose centroid is off its own floor and whose windows face out
│                     of a court, now with eaves) · townhouse (three levels, A3 portrait, now with eaves on
│                     L3) · terrace-row (one `component`
│                     placed four times) · two-storey (and since v1.29 the `void` flagship: a
│                     gallery over the ground floor, on L2 only, now with eaves on L2) · accessible (accTitle/accDescr, furnished).
│                     Public: museum (the LARGE-building flagship: paper A1 @ 1:200, furnished) · library
│                     (entrance hall furnished) ·
│                     transit-hall (kiosks and lobby furnished) · clinic (entrance furnished) ·
│                     hexagon-pavilion (oblique polygon rooms).
│                     Geometry: gallery-l (the POLYGON-room flagship, furnished, `theme presentation`)
│                     · aquarium (the CURVED-geometry
│                     flagship: a drum of two arcs round a `room circle`, A2 @ 1:200, furnished).
│                     Composition: parametric · relational (living room and kitchen furnished) · imports
│                     · museum-wing + museum-wings
│                     (the COMPONENT-v2 flagship: one wing authored in local coords, imported as a
│                     whole FILE and placed twice, once mirrored) · lib/.
│                     Style: themed (living room and bedroom furnished) · materials (the only user
│                     of `style <kind> { … }`, office furnished).
├─ eval/              NL→ArchLang authorability harness: corpus.json (26 briefs) · goldens/ · run.ts ·
│                     assertions.ts + synonyms.ts (re-export SHIMS over src/intent*.ts since T4) ·
│                     judge-fixture.json (byte-equivalence) · rubric.md (frozen) · faults/ + l1.ts (L1 gate) ·
│                     g1/ (Gate G1, PASSED) · l2.ts + l2-run.ts (T3 harness, live run never dispatched);
│                     offline gate `npm run eval:ci` in CI; guarded live `npm run eval:live` (see iron laws);
│                     PLUS the v1.25 intent-FIDELITY slice — corpus-fidelity.json · fidelity.ts ·
│                     fidelity-run.ts · fidelity-plans/ (faithful + laundered twins) ·
│                     fidelity-results.md, run by `npm run eval:fidelity`. Own corpus, own
│                     scorecard, JUDGE-FREE: it never touches corpus.json/judge-fixture.json and
│                     shares NO ruler with the 26-brief rate
├─ dataset/           repair + authoring dataset generator (`npm run dataset:gen`, tsx, no new dep):
│                     generate.ts · templates.ts · faults.ts · trajectory.ts · briefs.ts · rng.ts · diff.ts ·
│                     dedup.ts · canary.ts · CARD.md (HF README) · out/ (.gitignore'd jsonl); imports ONLY
│                     the pure core, never eval/; contamination iron law enforced by test/dataset.test.ts — CC0
├─ scripts/           check-test-wiring.mjs (fails if a tracked *.test.ts sits outside vitest.config.ts's
│                     include globs, or an include glob matches nothing — it PARSES the globs out of the
│                     config rather than duplicating them) + the
│                     single-source generators behind the `gen:*` npm scripts (gen-grammars, gen-error-codes,
│                     gen-llm-spec, …) + smoke.mjs (zero-dep post-deploy/nightly route check) + changelog-section.mjs
├─ bench/             ~1000-element timing harness (+ --json mode, CI regression comment)
└─ test/              vitest: snapshot + fast-check + unit + visual-regression + CLI/describe/lint/eval +
                      the cross-surface guards (lockstep, docs tripwires, escape fuzz) — map in docs/testing.md.
                      The root vitest run ALSO includes playground/test, packages/*/test and editors/vscode/test
```

Key agent-facing `src/` modules (all pure, exported from `src/index.ts`): `describe.ts` (semantic
summary; `.caption` = accessible one-liner, `.freedom` = authored-absolute vs resolver-derived
placement), `lint.ts` (soundness rules), `analyze.ts` (shared resolve pipeline + rectilinear geometry
behind both), `geometry.ts` (shared door-swing quarter-disc), **the fixture-symbol layer** —
`elements/glyph-lib.ts` (the shared drawing vocabulary every symbol is built from: `dot`/`ring`/
`arcSeg`/`insetRect`/`ellipsePoly`/`dashedPattern`/`easedRing`, each factory setting the named
`lineWeight` AND the raw `paint.width` from one ramp, because the SVG serializer follows the name
and the PDF serializer follows the number. **A helper moves here on its SECOND caller and it moves
VERBATIM** — v1.32 promoted `easedRing` out of `glyphs-living.ts` when the reception counter
became its second user, and the L-sofa's bytes did not change, which is the whole point of moving
it unedited. A helper with ONE caller stays in its domain module: `sofaBody`, which `drawSofa` and
`drawLoveseat` share, lives in `glyphs-living.ts` and belongs there), the six domain modules `elements/glyphs-bath`/`-kitchen`/`-bedroom`/`-living`/`-misc`/`-outdoor.ts`
that draw the art (`-outdoor` is the v1.31 site tranche — planting, garden furniture, parked things
and the small standing objects; nothing in it is `requiresWall`, because outdoors the wall is the
exception), and `elements/fixtures-glyphs.ts`, which now holds only the dispatch plus the
single-source `FIXTURE_FAMILIES` table that `FIXTURE_CATEGORIES` (129 words) and `CANONICAL_FIXTURES`
(83 families) are both DERIVED from — `hasFixtureGlyph` is what the legend filters on, so an
uncatalogued word still falls through to the labelled rectangle. Its semantics live next door in
`fixtures-catalog.ts`, where three flags are deliberately distinct and must stay so: **`requiresWall`
means SERVICES and only services** (the plumbed and vented goods — flagging room furniture raised 23
spurious floating-fixture warnings across nine shipped plans), **`directional`** carries the derived
quarter-turn for the eleven categories whose symbol has a back worth turning to a wall (`sofa`/
`chair`/`bench`/`desk` deliberately do NOT — seating is arranged, not installed), and **`underlay`**
marks a piece that lies flat and is stood on, read by exactly one shared predicate,
`solidFurniture()`, so the overlap rule, the clearance rule, the nav grid and the per-room flood fill
cannot disagree about what a rug is. `elements/roof.ts` (`offsetRingOutward` — the closed-form mitred
outward offset of a closed wall ring: line–line intersection with orientation from the shoelace sign,
exact at any angle and on either winding, refusing an `arc` edge rather than approximating it) and
`elements/void.ts` (a hole in this storey's floor plate: it obstructs the nav grid as a
`VerticalObstacle` while lifting its walkable halo on all four edges, does **not** reduce the
containing room's area — `describe().voids[]` gives the extent so a consumer can subtract — and finds
its room through the poly-aware containment test, never a bounding box). Both are drawing-only and
neither adds a `Theme` key: they reach existing paint through `STYLE_KEYS`. **The v1.35 VERTICAL
DATUM — `src/datum.ts`** (the six drafting defaults, `elevationOf`, the two range predicates and
`plansAuthorHeights`): `height` at three sites (a plan setting, a `level` header clause, a `wall`
clause) and `sill`/`head` on the three opening elements, all of which **draw nothing at all**
because a floor plan is a horizontal cut. The fallback chain is wall → level → plan →
`STOREY_HEIGHT`, resolved ONCE into `ResolveCtx.storeyHeight` and never re-derived per element;
**elevation ACCUMULATES the storeys below rather than multiplying a level number by one height**,
which is the whole reason `level … height` exists; and heights are deliberately NOT grid-snapped
(`grid` snaps plan coordinates so rooms line up with each other, and a height shares no axis with
them). Its refusals are refusals, not clamps: `E_HEIGHT_RANGE`, `E_SILL_ABOVE_HEAD` and
`E_OPENING_ABOVE_WALL` — the last measured against the HOST WALL, not the storey. **`describe()`
and Plan JSON emit the height keys ONLY when the source authored a height somewhere**, and the gate
is ONE boolean, `ResolvedPlan._heightsAuthored`, read by both so they cannot disagree — whole-PLAN
rather than per-storey, so a plan authoring nothing emits nothing
(`test/height-byte-identity.test.ts` pins that over all 30 examples and every storey).
**The v1.35 AXONOMETRIC VIEW — `src/view/{camera,extrude,paint,iso}.ts`**, `toIso` being a
SIBLING of `toScene` that produces the same `Scene` type, so every backend draws it unchanged.
Three laws, all pinned: **`describe()` never imports `src/view/`** (a grep guard, plus
`DescribeOptions` not admitting a `view` at all — an illustrative projection must never become a
measurement); **no `Math.cos`/`sin`/`tan`/`atan` anywhere under `src/view/`**, because those are
implementation-approximated in ECMAScript while `Math.sqrt` is exactly rounded, and CI spans two
operating systems and three Node versions; and the painter's order is **TOTAL**, its depth
quantised through the same `fmt2` the coordinates print at so two faces that serialise identically
cannot swap. It computes NO footprint of its own — `joinWallSet` (extracted from `lowerWallSet`)
hands it the drawing's own `EdgeLoop[]` before `emitLoops` narrows them, and an opening's glazing
band is `openingCut` asked for a wall 20% as thick. Two things a future editor must know: the SVG
backend's per-CAD-layer `<g>` grouping **RE-ORDERS nodes within a pass**, which is why a view emits
one flat group (the first render read as an open box); and the `V-3D-*` layers sit outside the
`A-`/`L-`/`C-` NCS discipline namespace and are declared in the DXF table only on a drawing that
uses them. **A view REFUSES rather than silently falling back to the plan** — `--view` with `-f
txt`, with `--ascii`, with `--level` or with `--overlay` all exit 3. Roof, furniture, ground, stairs
and every annotation are deliberately NOT drawn —
`roof` most pointedly, because ArchLang stores an eaves outline and no pitch. See
`docs/axonometric.md`. **The v1.31 GROUND layer
— `elements/outdoor.ts` and `elements/fence.ts`** (nine ground kinds and three fence styles, drawn on
`L-PLNT`/`L-SITE`/`A-FLOR-BALC`; the seven ground hatches live in `hatches.ts`'s one shared `META`
table beside the wall materials, each **scale-aware** off `c.gap * k * c.scale` and each painting no
background so the element's tint shows through — and a `hatch` node carries its `url(#…)` in the
PAINT, so a node that names a material and leaves `fill: "none"` draws nothing at all. Neither element
is a room or a wall: ground is absent from `rooms[]`, `totals.floor_area_m2`, the access graph and
Plan JSON, reporting itself in `describe().outdoor[]` + `totals.outdoor_area_m2`, and a fence hosts no
opening. Their two advisory rules live together in `lint/rules/outdoor.ts` (`W_OUTDOOR_OVERLAPS_ROOM`,
`W_BALCONY_NO_DOOR`), and the things that STAND on the ground are ordinary fixtures drawn by
`elements/glyphs-outdoor.ts` — the sixth domain module, 21 families, none of them `requiresWall`.
`OUTDOOR_LAYERS` is exported because `label-placement.ts` and the ASCII backend must both
skip it — the ASCII room pass identifies a room as "a polygon on the `floor` pass" and read a lawn as
one). `diagnostic-json.ts` (`diagnosticToJson` line/col/`fix` projection), `backends/error-svg.ts`
(`renderErrorSvg`), `intent.ts` + `intent-concepts.ts` (intent channel, shared with the eval via
shims), `vocabulary.ts` (`matchVocabulary` label matcher), `sheet.ts` (the sheet layer: the ISO 216 table, the
sheet-millimetre drafting constants, `sizesFromPaper` — the second `RenderSizes` constructor — and the
closed-form fit/auto-fit rule; a plan with no `paper` never reaches it), `axes.ts` (`numberAxes` /
`axisLetter` — the GB/T positioning-axis grid and its derived labels) and `sheet-tables.ts`
(`roomSchedule` / `legendEntries` + their Scene layout, so all four backends draw the margin tables
from one place), and `vertical.ts` (the shared `stair`/`elevator`/`escalator` semantics: which end a
run is entered from — a closed-form drafting convention, `dir`-dependent, used by BOTH the symbol and
the nav grid — what its footprint does to circulation, and `verticalConnections`/`verticalReach`, the
same-id-on-two-levels shaft graph that `describe().vertical`, `lint`'s per-storey reachability and
`checkGraph` all read), `site.ts` (the v1.25 orientation layer, and the ONE place a page direction becomes a compass one: `windowFacingPage` — which probes one wall thickness off each face of a window's own host segment and takes the side no room occupies, exact at any wall angle and the only rule that is right for a COURTYARD — plus `toCompass` and `deriveSite`. **`northQuarterTurns` is NOT here: it lives in `describe.ts` (`:548`), its historical home, and `site.ts`'s own header says so** — this line claimed `site.ts` for three releases. `describe()` AND the site lint rule both call `windowFacingPage`, because a second place to apply the north rotation is a second place to get it wrong), `label-placement.ts` (the post-pass inside `toScene` that moves a room's name and area text off furniture, swings, stair symbols and dimension text — it must run AFTER `lowerWalls` and after `dims auto`, because that is the only point at which a dimension number exists to avoid; it relocates only when >2% of the label box is buried, so a clear plan keeps its exact bytes), `text-metrics.ts` (`EM_PER_CHAR`/`textWidth` — the renderer has NO text metrics, so this closed-form estimate is the single source the label pass, the dimension stagger, `W_DIM_OVERLAP` and the error card all share; a test pins the literal to this one file so a fifth copy cannot appear), `lint/measure.ts` (the measured-deficit arithmetic behind the value/shortfall/remedy diagnostics), `elements/door-panels.ts` (the sliding/barn/bifold/pocket/garage panel geometry, emitted as the SAME Scene primitives every backend already serializes — no per-backend door code exists; the v1.31 `garage` arm is the module's first node with a NAMED `lineType`, and it settles the drawing's dash convention: **a dashed outline means a thing above the cut plane**, which is what `upper_cabinet`, `roof`, `void`, the outdoor `pergola`/`shed` and this projection now all say through the one `dashedPattern()` helper — closing half of backlog 5.5. Its projection side is DERIVED, not written: `door.ts`'s `roomSideOf` probes one wall thickness off each face and asks which side has floor, so `DOOR_KIND_CLAUSES` gives that kind no `swing` clause to contradict a fact with, and no clause at all besides), **the wall-joinery layer** — `wall-lowering.ts` (`lowerWallSet`, the ONE path every wall takes:
bands, cuts, `joinWalls`, then one `hatch` node per material group and ONE outline for the plan; it
lives outside `scene-build.ts` only because `elements/wall.ts` delegates to it and
`scene-build → registry → defs → wall` would otherwise be an import cycle), `geometry/band.ts`
(a wall as closed `EdgeLoop`s — two offset faces, end caps, an exact mitre at every interior vertex
bevelled past `MITER_LIMIT · h`, true arcs on a curve — plus `openingCut`, which is a rotated
rectangle on a straight host and an annular sector with RADIAL jambs on a curve; **every loop obeys
the orientation law, material on `+perp` of travel**, which is what lets the classification be
analytic), `geometry/intersect.ts` (the closed-form meets and ray crossings, no epsilon nudging) and
`geometry/joinery.ts` (`joinWalls` — split at every mutual crossing, classify each sub-edge by
probing off its own midpoint, keep it iff exactly ONE side has an owner, chain into canonical loops;
**thickest-wins** is what makes a thin partition on a thick shell's centreline vanish into it, and
**one owner per point** is what makes two materials tile without a doubled boundary). `emitLoops`
narrows to `region` while every edge is straight and `path` once one curves — see
[ADR 0018](docs/adr/0018-zero-dep-wall-joinery.md). `geometry/union.ts` and `geometry/clipper.ts` are
now TEST ORACLES only, and `geometry/backend.ts` is deprecated. And `frame.ts` (the `place` transform: a frame is a 2×2 signed-permutation
matrix + translation — exact, composable, no trig — and `transformElement` is the ONE place a
resolved element crosses from an instance's local frame into plan coordinates, including the handed
flips a reflection forces; see [ADR 0016](docs/adr/0016-component-instances-and-frames.md)). The CLI lives in `src/cli.ts` (dispatch) +
`src/cli/` (command modules); a single root `npm install` bootstraps every workspace.

### The sites' design system — "The Compile Boundary" (docs + playground)

Both public sites share one front-end system — **site chrome only**, no core/language change. Full
rationale in **[ADR 0014](docs/adr/0014-one-light-world.md)** (which supersedes ADR 0010 §1/§2/§6/§7 —
read 0010's carbon/mylar prose as history) and `brand/README.md`.

- **ONE LIGHT WORLD. There is no dark mode and no dark surface on either site.** Two worlds still split
  by a compile seam, but both are LIGHT and differ by **temperature + texture**, never by darkness: a
  cool **SOURCE world** (`--src-bg` #eceef2 / `--src-surface` #fbfbfc — code, mono type, syntax colour;
  plum survives *only* as the syntax accent + logo fills) vs. a warm **SHEET world** (drafting paper,
  blue-black ink, grid, title blocks). The seam is a solid plum rule (a glow reads as dirt on light).
  One shared attention accent, **REDLINE**, for CTAs and errors only. Body-size plum is `--plum-deep`;
  bare `--plum` (4.1:1) is graphics/≥24px only. A control's only border must be `--src-rule` (3.2:1),
  never the decorative `--src-border` (1.3:1).
- **One syntax palette, FOUR renderers.** The eight `--syn-*` tokens live in the shared block and
  feed the playground's CodeMirror (via `scripts/gen-grammars.ts`'s fallbacks), the docs fences (via
  the custom `archlangLight` Shiki theme in `docs-site/.vitepress/config.ts`), and — through
  `docs-site/.vitepress/theme/arch-highlight.js` — both the docs hero's typing pane and the
  `<ArchLive>` editor. Change a syntax colour in ALL FOUR places, then `npm run gen:grammars`.
  `arch-highlight.js` is **GENERATED** by `gen-grammars.ts` from the same `KEYWORDS`/`RULES`
  tables as the other two grammars, and deliberately adds no fifth copy of the palette: it emits
  `ahl-<name>` classes whose suffix IS a token name, coloured once by the `.ahl-*` rules at the
  foot of `style.css`. It is why ArchLang source is readable anywhere it appears — the ArchLive
  editor (a coloured `<pre>` under a transparent-text `<textarea>`, both sharing every metric that
  can move a glyph) is where nearly all of it lives, since every plain `arch` fence becomes one, and
  it used to render in ONE FLAT COLOUR while `CompileSeam.vue` carried a hand-typed keyword Set that
  had already drifted behind `src/grammar/tokens.ts`. `test/arch-highlight.test.ts` welds the
  generator's vocabulary to `KEYWORDS` and its class list to those CSS rules.
- **Fonts** (self-hosted `@fontsource`, zero CDN): **Archivo Variable** (display) + **Public Sans
  Variable** (body) + **IBM Plex Mono** (code).
- **Token-lockstep law.** The brand token block is **duplicated byte-identically** in
  `docs-site/.vitepress/theme/style.css` and `playground/src/styles/tokens.css` — change one, change
  the other (the brand iron law above).
- **Machine-readable routes.** `sync-docs.mjs` publishes at the docs-site root a raw markdown copy of
  every generated page at `/<route>.md` plus **`/plan.schema.json`** + **`/archlang.gbnf`** — the copies
  live in `public/`, excluded from page parsing so they serve verbatim.

## Commands

This is an **npm-workspaces monorepo**: the core (`@chanmeng666/archlang`) lives at
the repo root and is the published package; `editors/vscode`, `playground`,
`docs-site`, and `packages/*` (currently `packages/mcp`) are workspace members
sharing one root lockfile.

```bash
npm install          # bootstraps ALL workspaces (core has ZERO runtime deps)
npm run build        # build core library + CLI into dist/ (tsup)
npm run typecheck    # tsc --noEmit
npm run lint         # biome check . (format + lint; `npm run lint:fix` applies safe fixes)
npm test             # the whole vitest suite — test/, playground/test/, packages/*/test/, editors/vscode/test/
                     # (the include list is in vitest.config.ts; a test outside it silently never runs)
npm run cli -- compile examples/studio.arch -o studio.svg   # run the CLI from source via tsx
npm run bench        # compile a generated ~1000-element plan and report per-stage timings
npm run gen:grammars # regenerate the THREE grammars from src/grammar/tokens.ts — the TextMate
                     # grammar, the playground's CodeMirror mode, and the docs site's
                     # arch-highlight.js tokenizer (CI checks drift)
npm run gen:errors   # regenerate docs/error-codes.md from the catalog (CI checks drift)
npm run gen:cli      # regenerate docs/cli-reference.md from src/manifest.ts (CI checks drift)
npm run gen:spec     # regenerate spec.llm.md from tokens.ts + examples/ (CI checks drift)
npm run gen:llms     # regenerate llms-full.txt from spec + SKILL.md + manifest + error catalog (CI checks drift)
npm run gen:gbnf     # regenerate grammars/archlang.gbnf from src/grammar/tokens.ts (CI checks drift)
npm run gen:plan-schema  # regenerate schemas/plan.schema.json from PLAN_JSON_SCHEMA (CI checks drift)
npm run gen:intent-schema  # regenerate schemas/intent.schema.json from INTENT_JSON_SCHEMA (CI checks drift)
npm run gen:example-svgs   # re-render the twenty committed examples/*.svg the README embeds — plus the
                     # two axonometric renders docs/axonometric.md embeds (VIEW_SVGS) — from
                     # their .arch sources (README_SVGS in scripts/gen-example-svgs.ts; CI checks
                     # drift). Run it after ANY rendering-pipeline change — the three drawings that
                     # existed before this generator had rotted for months with nothing watching.
npm run gen:all      # run every gen:* generator in dependency order (gen:spec before gen:llms,
                     # gen:example-svgs last since it depends on nothing else)
npm run check        # typecheck + lint + check:test-wiring + test — the local pre-push gate
                     # NOTE: `typecheck` here does NOT compile test/ — only `typecheck:all` does,
                     # so a type error in a test file passes `check` and fails `typecheck:all`.
npm run check:test-wiring  # fail if a tracked *.test.ts sits outside vitest.config.ts's include
                     # globs (it would never run), or if an include glob matches nothing
npm run check:drift  # run every generator and fail if any generated artifact drifted (CI drift gate)
npm run lint:ci      # biome ci . — the non-writing lint entry CI uses
npm run typecheck:all    # full-repo typecheck: root tsconfig.dev.json (src+test+eval+dataset+scripts+bench)
                         # + playground + docs-site (vue-tsc) + packages/mcp + editors/vscode (CI: builds job)
                         # RUN `npm run build` FIRST — like build:workspaces below, this one reads dist/.
                         # playground/tsconfig.json maps the bare `archlang` specifier to
                         # ../dist/index.d.ts; with no dist/ that path misses and TS falls back to the
                         # repo-root node_modules/archlang symlink, which points at editors/vscode, NOT
                         # the core. A fresh worktree therefore fails with ~67 SPURIOUS errors (46
                         # TS2305 "Module 'archlang' has no exported member …" + 21 knock-on implicit-any
                         # TS7006) that read exactly like a broken public surface. Build and re-run
                         # before believing a single one of them.
npm run eval:ci          # the offline 26-brief authorability golden gate (no API key; runs in CI)
npm run eval:fidelity    # the intent-FIDELITY slice: infeasible briefs (declaring infeasibility is the
                         # correct answer) + a deterministic, JUDGE-FREE laundering detector. Separate
                         # corpus, separate scorecard — it shares no ruler with eval:ci and must never
                         # be compared against it
npm run test:coverage    # vitest run --coverage — report-only v8 coverage over src/ (CI: Node 22 leg)
npm run e2e:playground   # Playwright E2E against the built playground (build core + playground:build:only first)
npm run e2e:docs         # Playwright E2E against the built docs site (build core + docs:build:only first)
                         # Set E2E_BASE_URL=<origin> on either and the config drops its webServer and
                         # drives THAT origin — no build, no preview server. Nightly pairs it with
                         # `--grep @prod` (the READ-ONLY subset) against the live sites.

npm run playground:dev   # build core, then run the Vite playground dev server
npm run docs:build       # build core, then build the VitePress docs site
npm run mcp:build        # build core, then build the MCP shim (packages/mcp → dist/ + copied resources)
npm run build:workspaces # docs + playground + mcp + vscode, via the `*:only` variants — they SKIP the
                         # core rebuild, so `npm run build` must have run first (what CI's builds job does)
```

Full map of the verification system — the three tiers, every guard, and the red-run response for
each — is **[`docs/testing.md`](docs/testing.md)**.

`ci.yml` runs **five gating PR jobs in parallel** — the Node 18/20/22 test matrix (+ report-only
coverage on the 22 leg), a **builds** job (all four workspaces compile, `typecheck:all`, MCP
dist-resource freshness, VS Code bundle tests), a **Windows** leg (tests + drift at
runner-default line endings), and two **Playwright E2E** jobs (playground, docs) — plus an
informational `bench` PR comment that never gates; `codeql.yml` adds a sixth check on the same
events (and a weekly cron). `nightly.yml` adds six jobs: production smoke
(`scripts/smoke.mjs`), `npm audit` (report-only, into a pinned issue), gitleaks over the full
history, a full OS×Node matrix, **`e2e-prod`** — the `@prod`-tagged READ-ONLY Playwright subset
re-run against the live `playground.archlang.uk` / `archlang.uk` via `E2E_BASE_URL` (no build,
no preview server) — and the single `report` writer. Only tag a case `@prod` if it purely
navigates, reads and asserts: no downloads, no clipboard, no persisted-state or typing flows.
`e2e-prod`'s docs half is also a **deploy-staleness probe** — the raw `/<page>.md` cases compare
production's bytes against the checkout's, so a stale deploy fails the night.
**The whole verification system — every tier, every guard, and what to do when each one goes
red — is mapped in [`docs/testing.md`](docs/testing.md); read it before adding a test or
blessing a snapshot.**

Export to other formats from the CLI: `-f svg|dxf|txt|pdf|png` (`txt` is the
zero-dep ASCII plan; `pdf` needs optional `pdfkit`; `png` needs optional
`@resvg/resvg-js`).

**The CLI is agent-native.** Every command takes `--json` (structured result to stdout, messages to
stderr) with deterministic exit codes (`0` ok · `2` user-source error · `1` IO/internal · `3` bad
usage), and source can come from stdin (`-`). **It is also self-describing and hard to misuse:** the
manifest (`src/manifest.ts`) declares each command's *exact* flag set plus at least one worked
`examples[]` entry, and `src/cli/help.ts` renders `arch --help` / `arch <cmd> --help` / `arch help
<cmd>` **from that manifest** — so help can never list a flag the command doesn't take (a bidirectional
drift test pins the manifest against both the parser's `FLAG_KEYS` table and the dispatch table). An
undeclared flag or an unknown verb is a **usage error (exit 3)** with a `closest()` did-you-mean and a
`usage:` echo — never a silently-swallowed filename. `arch --version` prints the version.
Beyond `compile`/`watch`/`fmt`/`explain` there are
`arch spec` (print the whole language — `spec.llm.md`), `arch context` (print the full bundled agent
context — `llms-full.txt`: spec + skill + CLI reference + error catalog, for a cold-start agent;
`--section spec|workflow|cli|errors` prints just one slice instead of the whole ~60KB bundle),
`arch describe` (semantic JSON: rooms,
areas, adjacency, door connections — backed by `describe()` in `src/describe.ts`), `arch lint`
(architectural soundness `W_*` warnings — `src/lint.ts`), `arch validate` (parse+resolve+lint, no
render; `--strict`/`--fail-on-warning` makes warnings fail too — the pipeline ship-gate; `--graph
<g.json>` also checks interior-door adjacency against an intended graph via `checkGraph`; `--intent
<intent.json>` gates on a brief's intent contract via `validateIntent` — exit 2 on a gating
violation, `--feedback` appends deterministic correction prompts), `arch score` (`--brief
<intent.json>` — the continuous intent-satisfaction meter, `satisfied/total` + subscores, exit 0 on
any successful measurement; measures, never gates), `arch ast`
(parse-only span-bearing AST JSON — `astToJson`), `arch complete --at <offset>` (LSP `completion()`
items in scope), `arch fix` (apply the machine-applicable `diagnostics[].fixes` via a bounded
fixpoint — `applyFixes`, `--unsafe`/`--dry-run`/`--force`; ADR 0011 — it prints a **unified diff** of
what it would write on stderr, `--dry-run` included, and `--backup` saves the original bytes to
`<file>.bak` before rewriting in place), `arch suggest` (advisory
door/window topology statements as data — `suggestTopology`, ADR 0005), `arch new` (scaffold),
`arch repair` (the explicit source-to-source **geometric** corrector — pushes furniture out of walls
and emits new `.arch` + a change log; `src/repair.ts`, see ADR 0006 — distinct from `fix`), `arch
preview` (render a PNG an agent can look at, or `--ascii` for a zero-dep text plan; opt-in `--install`
fetches the optional `@resvg/resvg-js`), `arch batch` (render many files concurrently → `{ ok,
results[] }`), `arch md` (render the ` ```arch ` blocks in a Markdown file → image links; pure
`src/markdown.ts`), and `arch manifest`/`capabilities` (the whole CLI API as structured data —
`src/manifest.ts`). Output-shaping flags: `-f txt` (zero-dep ASCII plan via `renderAscii`),
`compile --from-json` (read Plan JSON — `planFromJson` — instead of `.arch`); opt-in
`--error-svg` (on `compile`/`preview`/`md`) renders a failing plan as a self-describing error-card
SVG instead of no bytes, and `--accessible` (on `compile`) emits SVG `<title>`/`<desc>` +
`role="img"`; the error-svg/accessible paths leave the default output byte-identical.
**Bounded output** (so a big plan can't blow an agent's context): `describe --select <keys>` emits only
the named top-level keys and `describe --room <ids>` keeps only those rooms plus the elements touching
them (whole-plan facts — `bbox`, `totals`, `caption`, `adjacent` — stay whole-plan, so a narrowed read
never lies about the building), while `lint`/`validate --code <CODE,…>` / `--severity <error|warning>`
narrow which diagnostics are *shown*. **The `--code`/`--severity` filters are DISPLAY-only and must stay
that way — `ok` and the exit code are always computed from the unfiltered diagnostic set**, so reading
less can never turn a failing plan green (a filtered result is marked `filtered: true` +
`total_diagnostics`/`selected_rooms`). The narrowing lives in the CLI layer (`src/cli/commands-analyze.ts`),
never in the library: `describe()`/`lint()` stay pure whole-plan fact producers.
`describe`/`lint` share the pure analysis layer in `src/analyze.ts` (+ `src/analyze/occupancy.ts`, the
circulation flood-fill); all are exported from `src/index.ts`. The CLI is the **primary** agent
interface; an optional stdio **MCP shim** (`packages/mcp`, `@chanmeng666/archlang-mcp`) wraps the same
library functions for MCP-native hosts as a discoverability channel (see ADR 0012 and the README's
agent section) — the core stays zero-dependency, the SDK lives only in that package.

## Architecture & Conventions

ArchLang is a compiler pipeline. Source text → backend-neutral **Scene IR** →
backends, in stages:

```
source (.arch)
  └─ src/lexer.ts       hand-written lexer  → Token[]   (byte spans)
  └─ src/parser.ts      recursive descent   → PlanNode  (src/ast.ts); recovers, never throws
  └─ src/import.ts      link `import`s through the World seam (the one I/O phase)
  └─ src/ir.ts          resolve(): expand scripting, grid-snap, auto-id, host openings,
                        relational placement (src/layout.ts) → ResolvedPlan
  └─ src/scene-build.ts toScene(): elements → primitives, hatches, page sizing → Scene (src/scene.ts)
       └─ src/wall-lowering.ts  lowerWallSet(): the WHOLE wall set in one joinery pass
            └─ geometry/band.ts      each wall as exact mitred EdgeLoops + openingCut
            └─ geometry/intersect.ts closed-form meets + half-open ray crossings
            └─ geometry/joinery.ts   joinWalls(): split · classify · keep · chain → one outline
  └─ src/backends/      pure serializers of the Scene:
       svg.ts (default, zero-dep) · png.ts (optional @resvg/resvg-js)
  └─ src/export/        dxf.ts (zero-dep) · pdf.ts (optional pdfkit)
  └─ src/index.ts       compile() — orchestrates the above; memoizes by source + extension id
```

- **`src/index.ts` is the only public surface.** It exports `compile(source, opts) =>
  { svg, errors, warnings, diagnostics, ast?, scene? }` plus the backends, the
  extension registry, the World seam, and the types. The `CompileResult` is
  **append-only** — add fields, never remove/rename.
- **`compile()` is pure, synchronous, and isomorphic** — no I/O, no `Date.now()`, no
  `Math.random()`. This guarantees determinism and lets it run in the browser. Do **not**
  introduce non-determinism or Node-only APIs into the `src/` core. The CLI (`src/cli.ts` +
  `src/cli/`) is the one place Node APIs and real time are allowed; everything else gets its
  environment injected through the **`World`** seam (`src/world.ts`).
- **Optional power is lazily `import()`ed.** The remaining heavy/native deps (pdfkit, resvg) are
  `optionalDependencies`, loaded only at point of use, so the default SVG path pulls nothing.
  **Clipper2 is no longer one of them:** since v1.30 every wall is joined by one closed-form
  zero-dependency pass, so no `GeometryBackend` is consulted while rendering and `clipper2-wasm`
  is a **devDependency** — the angled oracle for `test/joinery-oracle.test.ts`. The seam's exports
  are kept and documented deprecated; removing them is a MAJOR. See
  [ADR 0018](docs/adr/0018-zero-dep-wall-joinery.md), which amends
  [ADR 0002](docs/adr/0002-optional-dep-geometry.md).
- **Errors are returned, never thrown** for user-source problems. Push a `Diagnostic` (with a
  byte `span` and an `E_*`/`W_*` `code` documented in `src/error-catalog.ts`); the parser
  recovers and reports all problems in one pass.
- **Adding an element = one module** in `src/elements/` exporting an `ElementDef`, registered
  in `src/elements/defs.ts`. Parse/resolve/render dispatch through the registry, not a switch.
- **Output formats are deliberately NOT a public registry seam** (unlike elements/themes/
  hatches/geometry-backend): formats drag optional native deps and CLI flags with them, which
  a registry can't abstract cleanly. Adding one = a row in `EXPORT_FORMATS`
  (`src/manifest.ts`) + a serializer line in `src/cli/serialize.ts` `serialize()`.
- **Coordinates are millimetres**; origin top-left, +x right, +y down (matches SVG).
- **Rendering constants** (colours, line weights, fonts) live in the theme (`src/theme.ts`)
  and the size formulas in the backends — tune there, not inline.
- **Zero runtime dependencies in the core is a feature.** Don't add a hard runtime dep;
  prefer arithmetic or an optional lazy dep.

## Gotchas & Anti-patterns

- **(Typecheck) A file's options come from the PROGRAM compiling it, not from the tsconfig nearest
  it — and `exclude` cannot hold a file out of a program it was IMPORTED into.** `tsconfig.dev.json`
  checks `test/` and excludes `playground`/`docs-site`/`packages`/`editors`, but a root test that
  imports `../playground/src/share.js` pulls that module into the ROOT program, where
  `noUncheckedIndexedAccess` is ON; the playground tsconfig's deliberate relaxation does not travel,
  because `exclude` only filters a config's own `include` globs. So any workspace module a root test
  imports is compiled once per leg of `typecheck:all`, under two option sets, and must satisfy the
  stricter one — `test/lsp-diagnostics.test.ts` → `editors/vscode/src/diagnostics.ts` is a second
  live instance that happens to pass. Symptom: `TS2345 … | undefined` on workspace source that
  `tsc -p <workspace>` reports as clean — check WHICH leg emitted it before blaming the workspace
  config (`tsc -p tsconfig.dev.json --listFiles` names every file the program actually pulls in),
  and fix it IN the shared module (guard or a provably-safe assertion), never by relaxing the root
  option or adding an exclude.
- **Don't edit `dist/` or generated files.** `dist/` is a build output. The generated artifacts —
  editor grammars (`editors/archlang.tmLanguage.json`, `playground/src/arch-language.js`,
  `docs-site/.vitepress/theme/arch-highlight.js`),
  `docs/error-codes.md`, `docs/cli-reference.md`, `spec.llm.md`, `llms-full.txt`,
  `grammars/archlang.gbnf`, the two `schemas/*.schema.json`, **the twenty committed
  `examples/*.svg`** the README embeds and **the two axonometric renders `docs/axonometric.md`
  embeds** (`README_SVGS` and `VIEW_SVGS`, both in `scripts/gen-example-svgs.ts`) — each come from a
  single source (`src/grammar/tokens.ts` / `src/error-catalog.ts` / `src/manifest.ts` / `examples/` /
  `SKILL.md` + manifest / `PLAN_JSON_SCHEMA` / `INTENT_JSON_SCHEMA` / the matching `.arch`) via the matching `npm run gen:*`
  (order: `gen:spec` before `gen:llms`, which consumes it). **CI fails on drift** — edit the source
  and regenerate, never hand-edit. **The `examples/*.svg` are the newest members of that list and
  the reason it is worth restating:** they were hand-committed for years and nothing compared them
  to the compiler, so `studio`/`two-bed`/`attached` silently showed the README a building rendered
  before the opening-void fix, the fixture-orientation fix, the miter cap and the label-placement
  pass. `npm run gen:example-svgs` renders them; `test/example-svgs-drift.test.ts` gates them and
  also pins the curated list against the README's own `<img>` tags, in both directions. The docs site
  copies the root artifacts + a raw markdown copy of each page into `public/` via
  `docs-site/sync-docs.mjs` — edit the repo-root source, not the copies. **Editor syntax colors also
  route through the generator:** `arch-language.js` emits each `HighlightStyle` tag as
  `var(--syn-<name>, <fallback>)` (palette in `playground/src/styles/editor.css`) — recolor via the
  `scripts/gen-grammars.ts` template or `--syn-*` values + `npm run gen:grammars`, never by hand.
- **Determinism is tested.** The suite asserts `compile(s) === compile(s)` byte-for-byte, geometry
  engine both present and absent — which since v1.30 holds for the trivial reason that nothing reads
  the engine; `test/union.test.ts` and `test/miter-limit.test.ts` now assert that registering one
  changes no byte, on angled and rectilinear plans alike. Anything varying output across runs (object key order, floats, time)
  fails — route number formatting through `fmt()`. The one opt-in output change is
  `compile(src, { annotate: true })` (adds `data-span`); it is deterministic and leaves the **default**
  output byte-identical, so never emit annotation unconditionally (ADR 0007) — a test enforces equality.
- **The parse-stage memo's AST is shared — never mutate it downstream.** `parser.ts` memoizes
  `parse()` by content key (parser.ts ~line 59–63) on the contract that the cached `PlanNode` is never
  mutated. Anything consuming `parse()` or any memoized structure treats it as immutable — clone before
  you mutate (an in-place `repair()` edit once made output history-dependent; fixed in `51a47ee`).
- **Relational placement is deterministic, not an optimizer.** `src/layout.ts` resolves
  `right-of`/`below`/… by pure arithmetic in topological order; the absolute `at (x,y)` path must stay
  byte-identical (it is the default and has its own golden snapshots). See ADR 0004.
- **The PNG backend is Node-only and async** (resvg is a native binding); it rasterizes the SVG with a
  **bundled font** so text is deterministic. Keep `node:*` imports lazy so the module stays browser-safe.
- **Keep every Node-only lazy `import()` bundler-safe.** The lazy `import()`s of `@resvg/resvg-js`,
  `pdfkit`, `clipper2-wasm` — **and** the PNG font-lookup `import("node:fs")` / `import("node:url")` —
  carry `/* webpackIgnore: true */ /* @vite-ignore */` so a downstream webpack/Next.js consumer doesn't
  try to resolve a native `.node` binary or a `node:*` builtin for the browser and fail its build. The
  comments are needed even though these paths never run in a browser — preserve them on any new
  Node-only or optional-dep import (the 1.0.0→1.0.1 + 1.12.1 fixes).
- **`npm run dev`** (repo root) runs `tsup --watch` (a rebuild watcher), not a web server — the
  playground/docs sites are separate Vite apps (`npm run playground:dev` / `docs:dev`).
- **(Releasing) npm provenance exact-matches `repository.url`'s casing.** The OIDC release
  (`.github/workflows/release.yml`; a `v*` tag push runs it) fails with `E422` if `repository.url`'s
  owner segment isn't `ChanMeng666` byte-for-byte (the v1.14.0 release needed a same-day casing fix +
  re-tag). See the release/npmjs iron law above and `docs/npm-oidc-publishing-playbook.md`.
- **(MCP registry) The `io.github.<Owner>/*` namespace is case-sensitive and identity-checked.**
  registry.modelcontextprotocol.io exact-matches the published npm package's **`mcpName`** against
  `server.json`'s `name`, so the owner segment must match your GitHub login byte-for-byte
  (`io.github.ChanMeng666/…`, not `chanmeng666`); it also **caps the server `description` at 100
  chars**. A mismatch or over-long description is rejected at publish (the 0.1.0 → 0.1.1 patch fixed exactly this).
- **(Eval harness) Reasoning models spend thinking tokens out of `max_completion_tokens`.** A too-low
  cap starves the model into truncated (invalid) output and a bogus low baseline; `eval/run.ts` uses
  16384. If a model scores implausibly low, suspect a token cap before the language.
- **(Eval harness) Never compare rates across a judge change** (an iron law above; the mechanics:
  `JUDGE_VERSION` / `SYNONYMS_VERSION` are pinned by tests and stamped into every result +
  `live-baseline.json`'s `judge` field, and `renderDelta` prints a non-comparability warning when they
  differ). See `eval/README.md`.
- **(Dataset) Holdout never published, canary never regenerated, `repair`-split sources stay literal**
  — the contamination iron law above, enforced by `test/dataset.test.ts`. Operationally: `dataset/out/`
  is git-ignored (HF-only); on re-upload the HF card's `task_categories` must come from HF's official
  list (`text-generation`, not `text2text-generation` — the upload warns), namespace uses the canonical
  `ChanMeng666` casing. See `dataset/README.md` and [ADR 0013](docs/adr/0013-repair-trajectory-dataset.md).
- **(`place`) NEVER pre-transform a resolver's INPUT coordinates.** A `place`d instance resolves in
  its OWN frame — against its own walls and rooms, with its own `placeRelational` pass — and
  `frame.ts`'s `transformElement` then carries the resolved element into plan coordinates. That is
  not an implementation accident: every derived-geometry rule is stated in world terms (`anchor
  top-left` names a corner of the page, `against wall … side left` a face, `hinge left` a wall
  handedness, `right-of` the page's +x, a room's `at` its TOP-LEFT — which a turn moves), so
  feeding rotated coordinates in would silently change what each one means and force every element
  resolver to learn about frames. Add a handed rule ⇒ add its flip to `transformElement` (`det < 0`),
  not a frame parameter to the element. **The general question, when any fact crosses a frame, is
  "can this be re-expressed in plan coordinates?"** — and the two answers are both legitimate, so do
  not assume a reflection always flips. A *placement clause* (`anchor top-right`) names a corner in
  the instance's own local vocabulary, which plan space has no word for and the reflection renames:
  it is **dropped** (backlog G.4). A *symbol's handedness* does not exist before the crossing — the
  frame creates it — and is re-expressible exactly, as one reflection about the footprint's own
  centre line: it is **flipped** (backlog 5.4). Flip what can be re-expressed; drop what cannot.
  Both land in the same arm of `transformGeometry`, on different fields, with no ordering between
  them. See [ADR 0016](docs/adr/0016-component-instances-and-frames.md).
- **(`place`) A bare `wing()` call is still the LEGACY MACRO and must stay byte-identical** — caller's
  coordinates, caller's GLOBAL auto-id counters, no namespace, no zone. A plan with no `place` has
  exactly one resolution group and takes the historical single pass; the SVG snapshots and visual
  goldens are the gate. Never "unify" the two forms.
- **A diagnostic's `span` is not always in the file you compiled.** A component's body statements
  carry spans into the module they were WRITTEN in, so `Diagnostic.file` names that module when it is
  not the compiled source — and `applyFixes` **skips** any `FixSuggestion` carrying a `file`. Before
  v1.22 it did not, and an imported component's off-wall-door fix rewrote the middle of the
  importer's `wall` statement. Any new consumer of `diagnostics[].fixes` must honour `file`.
- **Door `hinge left/right` is relative to the wall's traversal direction**, not the screen — so the
  hinge side can flip with the order of a wall's points. The swing quarter-disc is computed once in
  `geometry.ts` (`doorSwing`) and shared by `door.render()` and the `W_SWING_OBSTRUCTED` lint rule —
  keep them on that one helper.
- **Fixtures draw by category, not a new element kind — and a drawn symbol IGNORES its `label`.**
  `furniture.render()` dispatches the category to `elements/fixtures-glyphs.ts`, which since v1.32
  covers **129 category words across 83 families** (all six domain modules); anything else falls back
  to the labelled rectangle, and that fallback is the escape hatch, not a defect. The label rule is
  long-standing for `wc`/`basin` and now true of twenty shipped examples whose fixture words vanish
  from the drawing while staying in the source and in `describe()` — so **do not add a `label` to make
  a catalogued piece read; add a category, or accept the rectangle.** Adding a family means one row in
  the single-source `FIXTURE_FAMILIES` table plus a `CATALOG` entry, never a new element or a `switch`
  arm, and the three catalog flags mean different things (`requiresWall` = services only,
  `directional` = has a back worth turning to a wall, `underlay` = lies flat and is stood on — read
  ONLY through `solidFurniture()`, never re-tested inline). The lint rules
  key off two closed vocabularies: room-label classification through `src/vocabulary.ts`
  (`USE_VOCABULARY` + token-bounded `matchVocabulary`; an alias-only classification raises advisory
  `W_ALIAS_MATCH` with a fix) and fixture category through `src/fixtures-catalog.ts`. Corpus
  classification is pinned by `test/vocabulary-equivalence.test.ts` — fix the vocabulary, never regenerate the pin.
- **`examples/studio.arch` is import-free on purpose** (`test/world.test.ts` asserts the flagship
  compiles from a single file with no World) — use inline `furniture <fixture>` there, not imports.
- **(Sites) A bare `|` inside inline code in a Markdown TABLE cell breaks the docs build.** GFM
  splits table cells on `|` *before* inline-code parsing, so `` `anchor|centered` `` severs the
  backtick pair and any `<token>` inside leaks out as raw HTML — VitePress/Vue then fails the whole
  build with "Element is missing end tag" (took the docs deploy down for four pushes on 2026-07-12).
  Write `\|` inside table cells, and treat **`npm run docs:build` as verification for any `docs/*.md`
  edit** — the core test suite doesn't compile the site.
- **(Sites) `docs/adr/*.md` is copied FLAT into `docs-site/adr/`, so a relative link from an ADR to
  anything outside `docs/adr/` is a dead link that FAILS the build.** `../archive/…`, `../research/…`
  and `../testing.md` all resolve at the repo but not on the site, because the copy loses the parent
  directory. Sibling ADR links (`0012-mcp-shim-discoverability.md`) are fine. Cite an out-of-tree path
  as inline code instead of linking it. Cost a build during the 2026-08-12 doc sweep; `docs:build` is
  the only gate that sees it.
- **(Sites) A plain ```` ```arch ```` fence in a published page COMPILES in the reader's browser.**
  The fence rule in `docs-site/.vitepress/config.ts` rewrites every one into a live `<ArchLive>`
  widget, so an illustrative FRAGMENT (a lone `room …` line) or a deliberate error demo renders a red
  error card on the public page. `npm run docs:build` cannot catch it — the compile happens at
  runtime. Mark those fences ```` ```arch static ````: same ArchLang highlighting, no live compile.
  Two real instances: `/relational`'s opening fragment, and `/errors`, where 104 error-catalog
  examples each showed a generic `Expected "plan" but found …` instead of the code they document
  (fixed at the source — `scripts/gen-error-codes.ts` emits `arch static`). `test/docs-fences.test.ts`
  is the gate, and it pins the `static` convention against config.ts's own source.
- **(Sites) There is no dark mode — if you are writing a `.dark` rule, you are on the wrong plan.**
  (History, in case you are tempted: a partial `:global(.dark) …` selector inside a Vue `<style scoped>`
  block miscompiles to a bare `.dark { … }` rule and once inverted the whole site.) The docs site sets
  `appearance: false`, and `color-scheme: only light` in the shared `:root` is what keeps Chromium's
  Auto Dark Mode off — do not "restore" a `light dark` declaration.
- **(Sites) VitePress `.vp-doc a:hover` (specificity 0,2,1) outranks a two-class rule (0,2,0) on
  hover.** Any `.vp-doc <class> a` control whose color must survive hover has to re-assert `color` in
  its own `:hover` rule. Verify interactive states (hover/focus/active), not just static render.
- **(Sites) Nothing flips per mode any more, so a fixed hex in the site CSS is a FOSSIL** — convert it
  to a token. (The old rule was "a mode-flipping token is unsafe on ground that doesn't flip", which is
  why the CTAs and the terminal once carried literal `#b3261e` / `#f0705f`. ADR 0014 retired all of
  them.) The one legitimate literal left is the CodeMirror lint squiggle's data-URI hex — a `var()`
  cannot cross into an SVG — so keep it in step with `--redline` / `--warn-ink` by hand.
- **(Sites) The public hosts are `archlang.uk` / `playground.archlang.uk`, served by Cloudflare
  Workers — the old `*.vercel.app` URLs are gone from source AND gone from the internet** (they used
  to 301 onward; they died with the Vercel projects in the 2026-09 hosting migration, which changed
  no URL of ours). If you ever change a public host again: grep the host **prefix without dots**
  (`archlang-playground`, not
  `archlang-playground.vercel.app`) — some references are regexes with escaped dots (e.g.
  `test/readme-permalink.test.ts`) that a literal-dot grep misses. Edit sources and regenerate (schema
  `$id`s in `src/plan-json.ts`/`src/intent.ts` → `gen:*-schema`; agent-context URLs in `SKILL.md` →
  `gen:llms`); never hand-edit `schemas/*.json` or `llms-full.txt`. The README `#z=` permalinks are
  base-independent, so only the host prefix swaps. Full playbook: `docs/hosting-and-domains.md`.
- **(Parallel worktrees) A clean auto-merge is NOT evidence of correctness when one branch MOVED a
  function another MODIFIED.** v1.25.0's closest call: one agent fixed `windowFacing` in
  `describe.ts` while a second, branched earlier, *extracted that function into a new
  `src/site.ts`* — carrying the pre-fix body. Git conflicted only on the doc comment; taking
  "theirs" would have **silently reverted the fix with a fully green suite**, because the moving
  branch had no courtyard fixture to fail. The typechecker then caught the second half — the new
  lint rule still called the old 4-arg signature, which would have let `arch lint` and `arch
  describe` disagree about a window's facing (invisible on a rectangular plan, wrong on a
  courtyard). **When merging parallel agent branches: diff the MOVED body against the newer
  version, and run both branches' fixtures TOGETHER** — that pairing is the only real proof, and
  neither branch can produce it alone.
- **(Docs vs npm) Pushing to `main` deploys the docs site; only a `v*` tag moves npm.** So a
  language feature merged and not released puts `archlang.uk` in the position of documenting syntax
  `npx @chanmeng666/archlang` cannot parse. This actually happened between the v1.25.0 merges and
  the release (the live reference served `site { … }` and `door pocket … slide left` while npm was
  still 1.24.0). If you land a language surface and are not releasing it the same day, say so
  explicitly — the mismatch is invisible from inside the repo, where everything is consistent.

## Reading Order

**To USE ArchLang (author/edit floor plans as an agent):** read `spec.llm.md` (the whole language
in one page — or run `arch spec`), then follow `SKILL.md`'s loop: `spec` → write `.arch` →
`arch compile --json` → fix from each `diagnostics[].fix` → `arch describe --json` to confirm
intent. Zero install: `npx @chanmeng666/archlang …`.

**To CONTRIBUTE (work on this repo), read in this order:**
1. `README.md` — what the project is and how to run it
2. This `AGENTS.md` — how to work in it
3. `CONTRIBUTING.md` — contribution workflow and quality gates
4. `docs/testing.md` — the verification system: what runs locally, on a PR and nightly, what each
   guard enforces, and what to do when one goes red (read before adding a test or updating a pin)

## Conventions for Changes

- Follow [Conventional Commits](https://www.conventionalcommits.org/).
- Run the project's lint/test commands before proposing changes.
- Keep this file up to date when you change build steps, structure, or conventions.
- Ongoing release narrative goes in `CHANGELOG.md` only — do not re-grow per-release prose here. It
  has been re-grown and re-archived twice now; the archives live outside this repo, at
  `archcanvas-growth/archive/archlang/docs-archive/agents-status-history-2026-07.md` (v1.3.0 →
  v1.16.0) and `…/agents-status-history-2026-09.md` (v1.20.0 → v1.35.0). **This file is loaded into
  every session, so treat 150,000 characters as a hard budget** — measure it (`wc -c AGENTS.md`)
  whenever you add a paragraph to the status table, and archive rather than accumulate.
- Release and work history is recorded in `CHANGELOG.md` **only** — do not create or append
  per-session work logs under `docs/` (the old top-level `WORK-LOG.md` is frozen in that same
  archive, as `WORK-LOG-v0.7-v1.15.md`).
