CLAUDE.md · git:20260917.dc5a726 · 2026-09-17 · sha256 b5f101a67ad6b065

CLAUDE.md git:20260917.dc5a726A

Immutable. This exact content never changes and is served at /api/v1/blob/b5f101a67ad6b065.

# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this repo is

A **Claude Code plugin**, not an application. Almost everything here is markdown that instructs
Claude at runtime — commands, agents and skills. There is no build step, no package manager, no
runtime for the plugin itself. The only executable code shipped is:

- `bin/*.sh` — env setup / ffmpeg wrappers, invoked by commands
- `skills/wp-polylang/scripts/*.php` — run inside a real WordPress via `wp eval-file`
- `starter-theme/**` — PHP copied into user projects (never executed here)

Editing a "feature" therefore usually means editing prose contracts, and the tests assert on
those contracts (grep for required tokens in the markdown), not on program behavior.

## Testing

Each check is a standalone bash script that prints `PASS` or exits non-zero. No runner, no framework.

```bash
bash tests/checks/wp-yolo-gate.sh                    # one check
for f in tests/checks/*.sh; do bash "$f"; done       # all checks
```

`tests/checks/wp-polylang-live.sh` is the only test that touches a real site; it exits 0 with
`SKIP` unless `PLL_TEST_SITE` points at a WordPress root with Polylang active
(`PLL_TEST_SRC`/`PLL_TEST_DST`/`PLL_TEST_THIRD` override the languages; the third language is
created and deleted by the suite and is guarded by a pre-existence probe).

There is no CI. Run the checks yourself before claiming a change works.

## Architecture

Four layers, strictly ordered:

```
commands/*.md   user-facing slash commands — orchestration, argument parsing, step-by-step flow
   ↓ dispatches
agents/*.md     specialized subagents (wp-template, wp-css, wp-acf, wp-audit-*, wp-normalize…)
   ↓ reads
skills/*/SKILL.md   knowledge libraries — methodology, never actions. user-invocable: false
   ↓ produces
starter-theme/ + templates/   PHP theme scaffolds and env/plugin-profile configs copied per project
```

Key consequences when changing things:

- A command must not reimplement a builder. `/wp-yolo` is the model: it normalizes the demo once,
  then drives the existing commands/agents in dependency order.
- Every agent's first mandatory step is to read the **project's** `.claude/CLAUDE.md` (generated by
  `/wp-init` in the user's WordPress project — a different file from this one) to pick up the
  function prefix, languages and theme slug. `prefix_` in agent/skill docs is a placeholder.
- Plugin-relative paths in commands are always `${CLAUDE_PLUGIN_ROOT}/…`, never relative.
- The `.wp-create.json` manifest in a user project is the shared source of truth for the WP-CLI
  wrapper, environment type and language config — commands read it instead of re-asking.

### Two independent i18n systems — do not mix them

| | `wp-bilingual` skill | `wp-polylang` skill |
|---|---|---|
| Model | one page, ACF field `_<lang>` suffixes (`hero_title_es`) | one post per language, joined by translation groups |
| Helpers | `prefix_get_field()`, `prefix_t()`, `prefix_e()` resolve the suffix transparently | `pll_*` API via `wp eval-file` scripts |
| Used by | `/wp-init` when `i18n strategy: suffix`, `/wp-section`, `/wp-seed` | `/wp-init` when `i18n strategy: polylang` (**the default**), and `/wp-polylang` to retrofit an existing site |

**Which one a project uses is a recorded decision, not a guess.** `/wp-init`
asks (Step 0.7) and writes the answer as `i18n strategy` into the project's
`.claude/CLAUDE.md`. Every downstream command and agent branches on that line —
`/wp-seed`, `/wp-header`, `wp-acf`, `/wp-yolo` — so read it before assuming the
suffix model. When the line is absent, the project predates the choice and is
`suffix`.

**New scaffolds default to `polylang`, and the reason is SEO.** The suffix model
serves both languages from one URL off `?lang=`/cookie/`Accept-Language`, so a
crawler only ever sees the primary language; `?lang=es` canonicalizes back to the
primary URL, there is no hreflang pair because there is only one post, and per-post
meta leaves Rank Math nowhere to store a translated title or description. `suffix`
is therefore offered as what it is — a language toggle for a site whose second
language does not need to be found — not as a way to build a bilingual site that
has to rank. Existing projects are untouched; the absent-line fallback stays
`suffix`.

The seam is a single file: `inc/i18n.php`. Templates call `prefix_get_field()`
and friends and never `get_field()` directly, so swapping that file switches the
whole theme's model with no template changes. The Polylang variants live in
`starter-theme/_i18n-variants/<template>.php` and are copied over `inc/i18n.php`
by `/wp-init`. They are kept OUT of the theme directories on purpose: a starter
carrying two definitions of the same function fatals the moment anything globs
`inc/*.php`, and `tests/checks/tailwind-starter.sh` refuses that state. The two
starters have different contracts (nine helpers vs three), so each variant
mirrors its own, and `tests/checks/wp-polylang.sh` asserts the pairing per
starter.

One deliberate crossover: under Polylang, ACF **options-page** fields keep their
`_<lang>` suffixes. Options are global — one set of values per site, not per
language — so Polylang's one-post-per-language model does not reach them and the
free plugin does not translate them. `wp-acf` therefore drops `_<lang>`
duplicates everywhere except the settings group.

The Polylang path is real code with real failure modes: translations join through the
`post_translations` taxonomy, so anything that writes translations must go through
`pll_save_post_translations` / `pll_save_term_translations`. `pll-lib.php` bridges `wp eval-file`'s
local `$args` into `$GLOBALS` — the scripts do not work without that require. PHP 7.4 floor:
no `match`, no union types.

### Craft vs plain is a recorded decision

`/wp-demo` (and `/wp-yolo`) choose craft or plain mode from the project's docs and write the
answer as `demo mode` into `.wp-create.json`. Every downstream command branches on that line
(`/wp-init`, `/wp-section`, `/wp-finalize`), so read it instead of re-deriving it. Craft builds
read `skills/wp-demo-craft/` and ship GSAP plus `motion.js` in the theme bundle; plain builds
ship neither and use the `wp-aos-animator` skill instead. When the line is absent, the project
predates the choice and is `plain`.

Craft mode has a hard prerequisite: a browser. `/wp-demo` probes with
`bin/demo-verify.mjs --probe` and stops on exit 2; it never builds blind and never
falls back to plain. A craft build writes two files: `demo/BRIEF.md` (the story) and
`demo/DESIGN.md` (tokens, in the open Stitch DESIGN.md format). `/wp-init` reads
`demo/DESIGN.md` before scraping `:root`, and copies it into the theme. Sections are
built from `skills/wp-demo-craft/compositions/` (previews rendered against the
neutral `references/design-md/_preview.md`), and verified by `npx impeccable detect`
plus the seven-line rubric in `references/verify.md`, at most three rounds.

**The two token vocabularies are joined by aliases, not by a rename.** The
compositions' CSS speaks `--color-canvas` / `--color-ink` / `--font-display`; the
starter's speaks `--color-primary` / `--color-dark` / `--font-primary`; only
`--color-accent` is in both. `/wp-init` Step D4 writes the craft set into the
Tailwind `@theme` block from `demo/DESIGN.md` and then defines the starter's six
colour and two font tokens as `var()` aliases onto it, mapped **by role, not by
lightness** — on a dark craft palette `--color-light` correctly resolves to a
near-black canvas, because what the starter's CSS depends on is the contrast pair,
not the name. The table is appended to the theme's copied `DESIGN.md` under
`## Token aliases` so later agents read the mapping instead of guessing it.

## Authoring conventions

**Command** (`commands/<name>.md`) — frontmatter with `description`, `allowed-tools`,
`argument-hint`. Add `Agent` to `allowed-tools` only if it dispatches subagents.

**Agent** (`agents/<name>.md`) — frontmatter `name`, `description`, `tools`, `model` (`opus`|`sonnet`|`haiku` — cost tier, see `tests/checks/model-routing.sh`) (order:
`Read, Write, Edit, Grep, Glob, Bash`). Must open with the "First Action (MANDATORY)" block.

**Skill** (`skills/<name>/SKILL.md`) — frontmatter `name`, `description`, `user-invocable: false`.
Skills inform; they never act.

**Starter theme edits** — use the placeholder tokens (`__starter__`, `__STARTER__`,
`__STARTER_NAME__`), replaced by `/wp-init`.

**New behavior needs a check.** The house style is a grep-for-required-tokens script under
`tests/checks/` that fails if the contract wording disappears from the command/skill/agent.

## Generated-theme conventions (what the agents must emit)

- Fields: `<section>_<element>` / repeaters `<section>_<plural>` / keys `field_<section>_<element>`,
  `group_<section>`; bilingual duplicates append `_<lang>`.
- Templates: always `prefix_get_field()` (never raw `get_field()`), `?:` fallbacks, everything
  escaped (`esc_html`, `esc_url`, `esc_attr`).
- CSS: custom properties for all tokens, BEM, mobile-first `min-width` queries, section delimiters
  `/* ====== Section: Hero ====== */`. In transcription mode (`--transcribe`, `/wp-yolo`) agents copy
  the demo's exact declared values instead of re-authoring them, and scope every selector under a
  unique BEM block so parallel section builds cannot collide.
- Prefer WP-CLI over generating PHP (`wp-cli-patterns` skill).

## Release chores

Version lives in `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` (twice) and the
README badge — bump all of them together. Add an `[Unreleased]` entry to `CHANGELOG.md`; update
`README.md` when adding a command.

**Cutting a release never renames `## [Unreleased]`.** Insert the new
`## [x.y.z] - YYYY-MM-DD` heading *below* it and move the entries down, leaving an
empty `## [Unreleased]` at the top. Renaming it deletes the anchor every open branch
is editing, so a release turns every open PR into a `CHANGELOG.md` conflict at once.
`.gitattributes` marks the file `merge=union` so two branches appending under the same
heading merge instead of conflicting; the cost is that union never reports a conflict,
so a genuine same-line edit keeps both sides silently — check the top of the file after
a release.

## Known ceilings

These are deliberate, documented limits — not bugs to "fix" on sight:

- **Media is not translated.** The Polylang importer copies an image or file id to the counterpart
  as-is rather than swapping it for that attachment's own translation. Mapping media is a separate
  decision; `pll-import.php` and the live suite both state the ceiling.
- **ACF reference re-pointing is one level deep.** `link`, `page_link`, `post_object` and
  `relationship` are re-pointed only as top-level fields, matching `pllx_acf_walk()`'s own ceiling.
  The same field nested inside a repeater or flexible-content row keeps pointing at the source.
- **Parent structure mirrors the source.** Both parent fixup passes rewrite the counterpart's parent
  on every run, so a post or term an editor deliberately re-parented in the target language is put
  back. Only the ACF reference pass records ownership; parents do not.
- **The site tagline is written in the primary language only.** `/wp-init` Step 9 sets
  `blogname` and `blogdescription` from the confirmed tagline; under `i18n strategy: polylang`
  the secondary language is left to `/wp-polylang`, which exports both as registered strings.
  Writing them is still the prerequisite — Polylang omits an empty option from its string table.
- **Fonts are carried once, at scaffold time.** `/wp-init` Step 4.5 self-hosts the demo's
  families; `/wp-section`, `/wp-header` and `wp-css` do not detect fonts, on purpose — fonts
  are a site-wide asset and per-section detection would repeat the download N times and race
  under `/wp-yolo`'s concurrent section builds. The cost is that a demo swapped in *after*
  `/wp-init` has run brings no new fonts with it: re-run the step, or carry the woff2 by hand.
- **`/wp-init`'s Polylang branching is verified by grep, not by an automated end-to-end run.**
  The path has been run against a real project and works — that is why it is now the default —
  but its scripts are covered automatically only by `tests/checks/wp-polylang-live.sh` (which
  needs `PLL_TEST_SITE`), and the command's own prose branching only by the grep checks in
  `tests/checks/wp-polylang.sh` and `wp-init-templates.sh`.
- **Compositions are ports, not copies: thirteen of them, across ten roles.** Effects
  from Magic UI and Aceternity are rewritten in CSS and GSAP inside the `data-motion`
  contract; the React libraries are never dependencies. A feeling curve that needs an
  eleventh role builds it by hand under the same contract with a reason in
  `demo/BRIEF.md`.
- **The evaluator reads headless sheets.** Real-device feel is still unproven, and
  Landing Gallery screenshots are inspiration only.
- **`designlang` extracts what a site declares.** A site built on inline styles or
  canvas yields thin tokens; the catalogue fallback fills the gap and is cited.
- **A craft demo emits no bilingual `<!-- i18n: … -->` hint comments.** The craft build
  path takes only the header, footer and responsive requirements from the plain path,
  so nothing in the markup marks a translatable string. The bilingual pipeline reads
  the recorded `i18n strategy` instead — which it should anyway; markers in a demo were
  never the source of truth.
- **A composition's breakpoints and its fluid ramps both size to its own container now,
  except where the screen is the point.** Every composition's size-based breakpoints are
  `@container` queries against the block's own `container-type: inline-size`, so a section
  dropped into a narrow column lays out for the column. The `vw` in `clamp()` gaps and type
  scales was the one exception — 40 occurrences across 12 of the 13 compositions still keyed
  off the viewport — and is converted: 37 now read `cqi` and track the container. Three
  stay `vw`, each with a comment. Two are the display headline of a full-bleed hero
  (`hero-bleed`, `hero-type`), sized against the viewport on purpose because
  a hero in a narrow column is not a scenario those compositions serve. `hero-split`
  used to be counted a third: its title sits in a `1.1fr 0.9fr` split column, so the
  borrowed "full-bleed" justification was false for it, and since its nearest container
  is the section root the conversion is identical at full bleed (86.4px at 1440,
  measured) and correct in a narrow column (38.4px at 420px) — so it converted. The third is
  `feature-zigzag`'s root `gap`, which *cannot* be `cqi`: that rule is the element
  declaring `container-type`, and an element never matches a container query against the
  container it establishes itself, so `cqi` there would resolve against the viewport while
  reading as if it tracked the block. `compositions/README.md` states the rule; `tests/checks/wp-craft-compositions.sh`
  asserts every remaining `vw` carries its justification on that line or the line above it.
- **The two `reveal` paths differ above the fold.** The CSS path's range is
  `entry 0% entry 40%`, so an element already fully in the viewport at load is past
  its entry range and `animation-fill-mode: both` lands it on the end state with no
  animation; the JS path animates it in. A hero fades in on a browser without
  scroll-driven animation and is already there on one that has it. Deliberate — the
  CSS behaviour is the better one for the "First paint complete" rubric line — and
  recorded in `references/devices.md` rather than reconciled.
- **Composition previews need the network to regenerate.** They render against the
  neutral `references/design-md/_preview.md` and their fill copy lives in
  `compositions/fills.json`, which points at remote placeholder images. The committed
  `preview-1440.png` / `preview-390.png` are the artifact; treat them as such rather
  than assuming a rebuild is always available.
- **`@property --container-max` needs Chrome/Edge 85+, Safari 16.4+, Firefox 128+.**
  A malformed value (`wide`, an empty string) used to make `calc()` invalid at
  computed-value time and unset `padding-inline` to `0` at every viewport, the same
  failure the `var(--container-max, 1280px)` fallback only ever guarded against for
  an *absent* token. `commands/wp-demo.md` and `bin/composition-preview.mjs` now emit
  `@property --container-max { syntax: "<length>"; inherits: true; initial-value:
  1280px; }` alongside `:root`, so an invalid value falls back to `initial-value`
  instead. Where `@property` is unsupported, the `1280px` `var()` fallback remains
  the only guard, and it still covers only the absent case.
- **`unobserved` is a confession, not a measurement.** It is now a per-section
  judgment — `probe()` counts devices inside the section's own subtree, so a section
  carrying only pointer devices (`tilt`, `magnet`, `spotlight`) reports it instead of
  falling to a blocking `dead-scroll` — but a section that genuinely does not move and
  a section the harness cannot read are still not distinguished by the harness, only
  by which finding it emits and what the operator does next. `no-engine` stays
  document-wide on purpose: scoping it too would fire on every ordinary static section.
- **The harness changes were measured on a composition corpus, not on a client
  build.** The v1.15.0 baseline demo named in the plan no longer exists on disk — that
  project is now a WordPress install and its demo was consumed into the theme — so the
  before/after walk was run on a five-page corpus assembled from the thirteen in-repo
  compositions instead, with the release commit's `demo-verify.mjs` and with HEAD's.
  That proves the harness changed behaviour as intended; it does not prove what a
  messy real build now scores, and a composition corpus is cleaner than one.
- **Two verification paths are still not covered.** Item F's last preview-token bypass
  — a reassignment at `composition-preview.mjs`'s render call site, which happens after
  `--tokens` has exited and which no assertion on that output can see — needs the render
  itself to close. And `motion.js`'s GSAP `reveal` branch is never walked, because
  nothing in the suite runs a browser without `animation-timeline`.
- **`motion.js` sets `overflowX` on the rail, not on the frame — and it is not inert.**
  Under reduced motion the stylesheet resets the rail to `width: auto`, and a scroll
  container is sized by its box rather than by its content, so the assignment makes the
  *rail* the scroller and the frame stops overflowing: measured at 1280, rail 2042/1280,
  frame 1280/1280. The frame's own `overflow-x: auto` is then only the no-JS fallback.
  Which box scrolls therefore depends on whether the engine ran, which is why the
  keyboard affordance in the pan device picks the scroller by measurement, not by name.
- **Verification serves over HTTP; the delivered demo is a `file://` artifact.**
  A defect that only appears when the file is double-clicked can pass a green walk.
  `external-module` findings name the one case known to matter.
- **The same-client rule reads the plan, which is prose.** It constrains a build
  that argues honestly and does not stop one that does not.
- **The image generator's HTTP call and response parsing are not covered.** No
  check can drive a real adapter request without a live key and a real bill,
  and a mock would only ever assert the mock. What the suite does cover is
  everything around the call — slot discovery, crop and size resolution, the
  cache, the ambiguity refusal, and the missing-key stop.
- **The key-leak guard catches the leaks a careless author writes, not every
  leak possible.** `say()`/`warn()` route every write through `scrub()`, and
  the pin greps for `console.log`, `.error`, `.warn`, `.info`, `.debug`,
  `.trace`, `process.stdout.write` and `process.stderr.write` appearing
  outside those two functions. An aliased stream (`const w = process.stderr; w.write(...)`),
  computed member access (`process['stderr'].write(...)`), `fs.writeSync(2,
  ...)` and `process._rawDebug` all pass that grep untouched — a grep cannot
  follow aliasing, and pinning a seventh spelling would mostly teach the next
  reader that the sixth one was the ceiling. The threat model is an honest
  mistake by a future author, not an attacker with commit access, who could
  delete the pin anyway.
- **A thrown error that embeds the key is not a leak, and that is by design.**
  `scrub()` runs on every value `say()`/`warn()` print, and again on the
  caught error in `cmdRun`'s own catch block, so `throw new Error('... ' +
  key)` is redacted before it reaches a stream. Worth stating because it
  reads like a gap next to the entry above and is not one — the grep pins
  correctly leave it unflagged.
- **Image cost figures are estimates and will drift.** Google publishes no
  per-image price in its own documentation — the figures in `bin/image-gen.mjs`
  come from third-party trackers — and OpenAI bills tokens rather than images,
  so its per-image number is an approximation by construction. The plan
  output prints the total as `~$0.10`, followed by its own line, "Costs are
  estimates, not a bill." — never a number presented as a bill.
- **Aspect fidelity is provider-dependent.** Google fills all three of the
  library's crops — 4:5, 3:2, 4:3 — exactly. OpenAI offers only three fixed
  sizes, so every one of those crops lands inexact and is cropped by
  `object-fit: cover` after the pixels have already been paid for. This is
  why `google/...` is the recommended default, ahead of price.
- **A generated plate is detectably AI-generated.** Every Google plate
  carries an invisible SynthID watermark. `demo/BRIEF.md` records this for
  the client, not only the build log, because whoever ships these to
  production is entitled to know.
- **The audit's checks are contract greps, so no test can fail because an audit
  *missed a defect*.** `tests/checks/*.sh` assert that the wording defining a check is
  present in the command, agent or skill that owns it — the house style, and the reason
  documentation and behaviour stay in step. What it cannot do is run an audit against a
  WordPress site and notice that a real defect went unreported: that would need a fixture
  site with seeded defects and an expected-findings file, which is a test harness rather
  than a check. So a coverage gap — a defect class nothing looks for — is invisible to the
  suite by construction, and is found the way the site-name and schema `@id` gaps were
  found: by auditing a real site by hand and asking which contract should have caught it.
  Deliberate; recorded here rather than papered over with a check that would only assert
  its own mock.
- **The manifest reconciliation measures, but only what WP-CLI can reach.** `/wp-audit`
  Step 2.5 re-measures plugins, PHP version, web server and Tier 3 availability on every
  run instead of trusting the manifest. Without Tier 2 it can measure none of them, and
  reports the whole block `UNMEASURED` — which is honest, and still means a Tier 1 run on
  a stale project cannot tell you the manifest is wrong.
- **The category coverage matrix answers "has this ever run", not "is it still true".**
  Step 2.5d diffs `categories_run` against the categories this plugin version offers, so a
  category that shipped after a project was built is surfaced the next time that project is
  audited. It cannot notice that a category which *did* run has since gained checks the
  project has never seen — code-level versioning per category would be needed for that, and
  the honest signal there is the audit-record age, which Step 2.5e already reports.
- **A dev host is now distinguishable from a missing report, and both still block.**
  `bin/geo-scan.sh` exits `3` for a host it cannot reach publicly and `2` for a host with no
  report yet, so the audit can tell a configuration problem from an absence. Neither is a
  pass, and `--host` is the fix for the first. What neither exit code proves is that the
  public URL someone passes actually serves the same site as the one WP-CLI is talking to.

- **Prompt quality is judgment, not machinery.** Nothing stops a weak prompt
  from producing a plate that fills its slot while reading as generic.
  `demo/BRIEF.md` is the only defence there is.
- **The network half of client research is covered by no check.** No assertion
  can drive a real search or fetch without live network, and a mock would only
  assert the mock. `tests/checks/wp-research.sh` covers everything around the
  call: the branch order, the `"research": "none"` contract in both directions,
  the degradation lines, and the six citation rules.
- **The Firecrawl and DataForSEO rungs are never exercised by the suite.** Both
  need a connected MCP server. Only the `WebSearch`/`WebFetch` baseline is
  reachable from a test, so the two upgrade paths are verified by reading, not
  by running.
- **`unconfirmed` is a confession, not a measurement.** A business the agent
  could not confirm and a business with no web presence are not distinguished by
  the artifact — only by the candidates it lists. What the flag does guarantee is
  narrower and real: no `research.site` is recorded, so `designlang` is never
  pointed at a guess.
- **Research quality is judgment, not machinery.** A weak competitor set yields a
  weak differentiation line, and nothing catches it. The six citation rules force
  the artifact to be read; they cannot force it to be good. `demo/RESEARCH.md` is
  the only defence there is.
- **Competitor design signal is read from markup, not from rendering.** The agent
  reads HTML and CSS, never a screenshot, so a site whose identity lives in
  motion or in canvas yields a thin row — the same ceiling `designlang` already
  carries.
- **`research.at`, `research.tier`, `research.location` and
  `research.competitors` are recorded, not wired up.** `agents/wp-research.md`
  writes all four into `.wp-create.json`, for the operator and for future use,
  but no command reads them today — only `research.site`, `confidence` and the
  scalar `"research": "none"` are read. Reuse keys off `demo/RESEARCH.md`'s
  existence, not off `research.at`'s date.
- **An MCP server's instructions reach every session, not just the command that wants
  them.** Measured: `inspo-mcp@0.1.16` returns 1,478 characters of instructions on
  `initialize`; `wp-design-library` returns none. Registering a reference server puts
  its guidance in front of every task in that project, which is why inspo is opt-in
  rather than shipped. Inspo's own text currently defers to the project, but the
  precedence line in `commands/wp-demo.md` does not rely on that surviving their next
  release.
- **The inspo exclusions are prose, and prose binds an agent that reads it.** The
  greps in `tests/checks/wp-library.sh` pin the wording; nothing can assert that a
  build actually declined to read a colour table. Same ceiling the transcription and
  fidelity mandates already carry.
- **Inspo's corpus is a runtime dependency on a third party's storage.** Nothing is
  vendored. If it goes away, builds degrade through the `References: inspo
  unavailable` path, which is why that line exists in both modes.
- **Opt-in means most builds will not have it.** Registration is a deliberate act, so the
  plain path's reference gap is closed only for operators who take that act. That is the
  cost of not shipping a third-party network dependency in the default path.
- **The validator binds the commands that call it.** `bin/wp-config.mjs` is the single
  definition of a valid `.wp-create.json`, but a command whose gate line is deleted simply
  stops validating, and only `tests/checks/wp-config-gate.sh` notices. Same ceiling every
  prose contract here carries.
- **The gate substitutes its own path.** `${PROJECT_PATH}` is prose, not an environment
  variable the way `${CLAUDE_PLUGIN_ROOT}` beside it in the same `bash -c` is — a Claude
  that treats both alike runs `validate ''`, gets the usage line and exit `1`, and the
  table reads that as "stop". The block now says where the path comes from, and
  `tests/checks/wp-config-gate.sh` diffs all thirteen sites against one canonical copy so
  the sentence cannot be lost from one of them; nothing can check that the directory a
  Claude substituted is the right one, and the tool still answers a missing argument and an
  invalid manifest with the same exit code.
- **A tested compatibility range is a claim someone has to keep honest.** An absent range
  is reported as untested, which makes the gap visible; nothing keeps a declared range true
  as plugins release.
- **Writing the generated block is what retires the legacy prose lines.** `render-context`
  comments them out in the same pass that writes the block, so any caller that takes
  ownership also retires the old record and a project states each decision exactly once
  instead of contradicting itself with `validate` exiting 0. It happens only there, and
  only downstream of `render-context`'s refusal to render an incomplete manifest — a block
  with a blank where a decision belongs is not worth the record it would retire, which is
  how migration used to leave a project permanently invalid. `contextDrift` still compares
  only inside the markers, so a decision line an operator writes outside them afterwards is
  invisible, and `/wp-init` Step 7 still writes those lines into a fresh project that has no
  generated block at all.
- **Generated credentials are only as private as the local file.** Splitting them into
  `.wp-create.local.json` keeps them out of the committed manifest; it encrypts nothing, and
  a password already committed needs rotating rather than migrating.