gaia-release · diff

git:20260925.3549fb0 to git:20261006.f5d0899

98 added, 46 removed. Audit B to B.

---
name: gaia-release
description: Use when testing, validating, or publishing Gaia releases -- "install local", "pre-release", "dry-run", "release", RC, stable, plugin dry-run, "instala local", "hagamos un release", "probemos el pre-release", "publiquemos la versión estable"
---
# Gaia Release
The norm for getting Gaia onto a machine and into the registry, organized as three layers of increasing confidence. The user expresses exactly one of three intentions -- **install local** (Layer 1, fast iteration), **pre-release** (Layer 2, the confidence gate), or **release** (Layer 3, the official publish) -- and each maps to a complete, automated sequence the orchestrator runs end-to-end. The user never recalls a sub-step and never runs a release script by hand: the script is a tool the flow invokes, not a command the human must remember. This is the lesson of the sagas that shipped broken -- a release failed because a version source was bumped one file at a time and a forgotten `pyproject.toml` drifted; another needed a force-push to reconcile a tag. Every one of those was a manual step a human was trusted to remember and didn't. The fix is to norm the sequence so the steps cannot be forgotten: they are the flow, not a checklist beside it.
- **This skill orchestrates the sequence; it does not define what a healthy install looks like.** Every layer closes by installing into a target workspace and then validating it -- and "how you validate" lives in `gaia-verify`, which owns the wire-up checklist and the per-surface checks. When a layer says "verify," it means "run `gaia-verify` for the matching mode." Keep the two apart: release is the *when and in what order*; verify is the *did it come out right*.
+ **This skill orchestrates the sequence; it does not define what a healthy install looks like.** Every layer closes by installing into a target install folder and then validating it -- and "how you validate" lives in `gaia-verify`, which owns the wire-up checklist and the per-surface checks. When a layer says "verify," it means "run `gaia-verify` for the matching mode." Keep the two apart: release is the *when and in what order*; verify is the *did it come out right*.
- ## The delivery model: one plugin, two distribution channels
+ ## The delivery model: one tree, three channels
- Gaia ships as a **single** plugin named `gaia` (`scripts/build-plugin.py` has `VALID_PLUGINS = ("gaia",)`). **The package root IS the plugin, and it is the same tree as the git repo root** -- there is no `dist/` bundle. That single tree reaches a workspace through **two distribution channels**, and a change can pass on one while breaking the other:
+ Gaia ships as a **single** plugin named `gaia` (`scripts/build-plugin.py` has `VALID_PLUGINS = ("gaia",)`). **The npm package root IS the plugin root, and both are the git repository root** -- there is no `dist/` bundle. That one tree reaches an install folder through three channels, and a change can pass on one while breaking another:
- - **plugin surface (git marketplace source)** -- Claude Code consumes the **git repository directly**: `.claude-plugin/marketplace.json` advertises the `gaia` plugin with `{"source": "github", "repo": "metraton/gaia"}`, so `/plugin marketplace add` + `/plugin install` make CC **clone the repo** into its plugin cache. **This is the channel that loads Gaia's agents, skills, and hooks in Claude Code.** The git source was adopted (commit `a43ef22`) precisely because the prior `source: npm` entry loaded **0 skills** on `/plugin install` -- a confirmed CC gap -- while the identical tree loads all 32 skills via a git/local source. CC loads hooks from the repo root's `hooks/hooks.json` (the standard plugin convention) -- it does **not** use `settings.local.json` for hooks, and the root `.claude-plugin/plugin.json` is **metadata only** (no inline `hooks` block). Hooks are declared in exactly ONE place because CC reads both a plugin.json inline block AND `hooks/hooks.json`; declaring them in both double-registered every hook and fired every event twice (fixed in `a1b1245`). Both `.claude-plugin/plugin.json` and `hooks/hooks.json` are **generated from the manifest** (`prepack` / `generate:plugin-root`) and tracked in git, so the cloned tree already carries them. Git sources support version pinning by ref/sha, and `release:prepare`'s `bumpMarketplace()` does exactly that: for github/git sources it sets `source.ref = v<version>` atomically alongside `plugin.version`, so `/plugin install` clones the fixed, reproducible release tag instead of tracking moving default-branch HEAD (npm/local sources carry no `ref` and are left untouched).
- - **CLI / npm surface (npm package `@jaguilar87/gaia`)** -- `npm|pnpm install @jaguilar87/gaia` (and `gaia dev`) install the npm package; this is what provides the `gaia` **CLI** and the workspace wiring. `gaia install` then wires the workspace: it symlinks `.claude/{agents,tools,hooks,config,skills}` (plus a `CHANGELOG.md` link) to the installed package and merges hook events into `settings.local.json`. The DB is bootstrapped **lazily on first `gaia` CLI use** (`_ensure_db_bootstrapped` in `bin/gaia`) -- there is **no npm `postinstall`** (removed so the install is non-invasive and works identically under npm and pnpm, which ignores lifecycle scripts by default). This surface reads hooks from the package root's generated `hooks/hooks.json` (via `merge_local_hooks` in `_install_helpers.py`), which is regenerated at pack time from the manifest.
+ ```
+ one source tree (git repo root = package root)
+ |
+ +----------------------------+----------------------------+
+ | | |
+ git ref (branch/tag) npm registry tarball npm registry tarball
+ | | |
+ Claude Code plugin package + gaia install package + gaia install
+ gaia@gaia-marketplace --channel npm --channel opencode
+ ```
- The two channels ship from the **same source tree** (git repo root and npm package root are one and the same). That is why Layer 2 exists: the plugin surface can only be proven by materializing the plugin root -- packing the tarball, extracting it, and mounting the extracted root in a live Claude Code -- nothing else exercises the root `plugin.json` / `hooks.json` / CC plugin loader.
+ - **Package (npm/pnpm + `gaia install`, for Claude Code).** The artifact is the `@jaguilar87/gaia` tarball on npm, published by `publish.yml`. `npm|pnpm install @jaguilar87/gaia` provides the `gaia` CLI (`node_modules/.bin/gaia`, invoked as `npx gaia` / `pnpm exec gaia`), and `gaia install` wires the folder it runs in, declaring no workspace (that is `gaia workspace declare <name> <path>`): it links `.claude/{agents,tools,hooks,config,skills,opencode}` (plus `CHANGELOG.md`) to the installed package and merges permissions and hook registrations into `.claude/settings.local.json` from the package's generated `hooks/hooks.json` (`merge_local_hooks` in `_install_helpers.py`). The DB is bootstrapped **lazily on first `gaia` CLI use** (`_ensure_db_bootstrapped` in `bin/gaia`) -- there is **no npm `postinstall`**, so the install behaves the same under npm and pnpm (pnpm ignores lifecycle scripts by default).
+ - **Claude Code plugin (`gaia@gaia-marketplace`).** The artifact is the git tree at a ref. `.claude-plugin/marketplace.json` names the marketplace `gaia-marketplace` and gives the `gaia` entry `"source": "."` with no `version`, so `/plugin marketplace add metraton/gaia#<branch-or-tag>` installs that ref's code at the version its `.claude-plugin/plugin.json` declares. Claude Code loads hooks from the root `hooks/hooks.json`, never from `settings.local.json`; the root `plugin.json` is **metadata only** (no inline `hooks` block -- declaring hooks in both places fired every event twice, fixed in `a1b1245`). Both files are **generated from the manifest** (`prepack` / `generate:plugin-root`) and tracked in git, so the fetched tree already carries them. The plugin's sessions merge the permission set and the `attribution` setting into `.claude/settings.local.json` (`setup_project_permissions` in `hooks/modules/core/plugin_setup.py`) and record those writes in `.claude/gaia-manifest.json`; it does not put `gaia` on the terminal's `PATH`.
+ - **OpenCode (on the package).** The artifact is the same npm tarball. `gaia install --channel opencode` writes `opencode.json` pointing at the packaged `opencode/plugin.ts` instead of touching `.claude/`.
+ | Channel | Artifact | How an rc reaches it | How a stable reaches it | Updating after a publish |
+ |---------|----------|----------------------|-------------------------|--------------------------|
+ | Package (Claude Code) | `@jaguilar87/gaia` tarball on npm | `gaia release publish x.y.z-rc.N` from the accumulating branch -> `publish.yml` publishes it under the `rc` dist-tag | `gaia release publish x.y.z` from `main` -> `publish.yml` publishes it under `latest` | `npm install @jaguilar87/gaia@latest` (or `@rc`, or `pnpm add ...`), then `npx gaia update` in the install folder, then restart Claude Code |
+ | Claude Code plugin | the git tree at a ref, via `gaia-marketplace` (`source: "."`) | the same publish pushes tag `vx.y.z-rc.N`; `/plugin marketplace add metraton/gaia#vx.y.z-rc.N` installs it | the same publish pushes tag `vx.y.z` to `main` | `claude plugin marketplace update gaia-marketplace`, then `claude plugin update gaia@gaia-marketplace`, then restart. Auto-update is off for third-party marketplaces, and the plugin only updates when the version in the fetched `plugin.json` differs from the installed one -- re-fetching a ref at the same version installs nothing new |
+ | OpenCode | the same npm tarball | the `rc` dist-tag, as for the package | the `latest` dist-tag, as for the package | install the new package version, then `npx gaia update`, which re-wires the recorded channels, then restart OpenCode |
+
+ Installing an rc on another machine is one command per channel: `pnpm add @jaguilar87/gaia@rc` (or `npm install @jaguilar87/gaia@rc`) followed by `npx gaia install --channel npm` -- `--channel opencode` for OpenCode -- and, for the plugin, `/plugin marketplace add metraton/gaia#vx.y.z-rc.N` followed by `/plugin install gaia@gaia-marketplace`.
+
+ All three channels ship from the **same source tree**, which is why Layer 2 exists: the plugin surface is only proven by materializing the plugin root -- packing the tarball, extracting it, and validating the extracted root -- and the OpenCode surface only by wiring that tarball into an install folder; nothing else exercises the root `plugin.json` / `hooks.json`, Claude Code's plugin loader, or `opencode/plugin.ts`'s relative paths.
+
## The three intentions
When the user says one of these, run the *whole* sequence. Do not stop after the first command and wait to be told the next one -- the sequence below IS the intention.
- ### Layer 1 -- "install local": put the working tree into a real workspace, fast
+ ### Layer 1 -- "install local": serve the working tree through one channel, fast
- The fast iteration loop. One command replaces the manual `npm pack` -> `npm`/`pnpm add <tarball>` -> `gaia install` sequence:
+ The fast iteration loop, and it needs neither a merge nor a publish. The PR's code lives in its worktree and is tried in a dev install folder (`<dev-install-folder>`). The folder that runs the published version (`<released-install-folder>`) is never a `gaia dev` target. One command replaces the manual `npm pack` -> `npm`/`pnpm add <tarball>` -> `gaia install` sequence, for the channel you name:
```
- gaia dev --workspace <TARGET>
+ python3 <pr-worktree>/bin/gaia dev --channel <npm|plugin|opencode> --workspace <dev-install-folder>
+ # or, from the installed CLI:
+ gaia dev --from-worktree <pr-worktree> --channel <channel> --workspace <dev-install-folder>
```
- `gaia dev` (`bin/cli/dev.py`) packs the CURRENT source tree (via the shared `_pack_helpers.pack_tarball` primitive), installs the freshly packed tarball into `<TARGET>`'s `node_modules` (npm or pnpm, auto-detected), then wires `.claude/` and bootstraps the DB by invoking the freshly-installed copy's own `gaia install --workspace <TARGET>`. This is the only mode: it reflects a real shippable version and exercises the exact install machinery a real consumer would. There is no source-linking mode -- the consumer workspace, its `.claude/`, and every global alias only ever depend on the packed tarball, never on this source checkout directly.
+ `gaia dev` (`bin/cli/dev.py`) packs the chosen source tree (via the shared `_pack_helpers.pack_tarball` primitive) and serves the packed tarball through the channel -- never the checkout itself, so there is no source-linking mode:
- `gaia dev` always wires through `gaia install`'s own logic (no duplicated wiring code); `gaia dev --help` documents the full flag set (`--workspace`, `--host`, `--keep-tarball`, `--pack-dest`, `--no-global-link`, `--quiet`, `--verbose`).
+ | `--channel` | What it changes in the install folder | How the change is picked up |
+ |-------------|----------------------------------|-----------------------------|
+ | `npm` | installs the tarball into `node_modules` (npm or pnpm, auto-detected) and runs the fresh copy's own `gaia install` | restart Claude Code |
+ | `plugin` | extracts the tarball into a stable per-install-folder directory that is a local marketplace `gaia-dev`, installs `gaia@gaia-dev` at local scope, and disables `gaia@gaia-marketplace` in that install folder | `/reload-plugins`, no restart |
+ | `opencode` | installs the tarball and wires OpenCode | restart OpenCode |
- **Drift-free convergence.** `gaia dev` never touches the global npm surface -- the consumer workspace's tarball install is the only thing it changes, and `--no-global-link` is a compatibility no-op kept for callers that still pass it. It prints a read-only **convergence report** of the 5 surfaces vs the origin (aligned / stale / absent; `bin/cli/_converge.py`). The DB half is guarded at bootstrap: an install NEVER runs code older than the DB (the reverse, finalize-breaking direction is refused, no clobber); it migrates forward when the code is newer. `gaia doctor` REPORTS this 5-surface + schema-direction skew but never fixes it. No `--from` flag: the command IS the origin.
+ The channel is required and there is no `all`: without `--channel` the command fails listing the three (`--host` is kept as an alias: `claude_code` = `npm`). `npm` and `plugin` refuse each other in one install folder, naming the channel found and the command that removes it; `opencode` joins either. `--ref <full-sha>` refuses to build unless the worktree's HEAD is that commit. Then `gaia doctor` in the install folder: its `Install provenance` check prints one line per channel with the source commit it was built from and how many commits that source has moved since. `gaia dev --help` documents the full flag set (`--workspace`, `--channel`, `--host`, `--from-worktree`, `--ref`, `--pack-dest`, `--quiet`, `--verbose`, and the compatibility no-ops `--keep-tarball` and `--no-global-link`).
+ **Drift-free convergence.** `gaia dev` never touches the global npm surface -- the consumer install folder's tarball install is the only thing it changes, and `--no-global-link` is a compatibility no-op kept for callers that still pass it. It prints a read-only **convergence report** of the 5 surfaces vs the origin (aligned / stale / absent; `bin/cli/_converge.py`). The DB half is guarded at bootstrap: an install NEVER runs code older than the DB (the reverse, finalize-breaking direction is refused, no clobber); it migrates forward when the code is newer. `gaia doctor` REPORTS this 5-surface + schema-direction skew but never fixes it. No `--from` flag: the command IS the origin.
+
Two properties of `gaia dev` shape the flow:
- - **It is T3.** `gaia dev` installs into a workspace, so it is classified state-mutating (anchored in `COMMAND_PATH_MUTATIVE_UPGRADES`, `mutative_verbs.py`) and **blocks for approval** before it runs -- expected, not a failure. The `gaia` launcher and `python3 <path>/bin/gaia dev` classify identically (the `bin/gaia` dispatcher re-dispatches through the classifier), so approval behaves the same from either entry point.
+ - **It is T3.** `gaia dev` installs into an install folder, so it is classified state-mutating (anchored in `COMMAND_PATH_MUTATIVE_UPGRADES`, `mutative_verbs.py`) and **blocks for approval** before it runs -- expected, not a failure. The `gaia` launcher and `python3 <path>/bin/gaia dev` classify identically (the `bin/gaia` dispatcher re-dispatches through the classifier), so approval behaves the same from either entry point.
- **It runs NO tests.** Install local is deliberately cheap -- edit, install, restart, poke. The L1 test subset runs in Layer 2 (`gaia release check`) and Layer 3 (`gaia release publish`) and in CI, never in this fast loop.
**Then, without being asked:**
1. Run `gaia-verify` in `live` mode against `<TARGET>`. If any check fails, jump to `reference.md` -> "Diagnostic guide".
- 2. **Restart Claude Code to pick up the change.** `gaia dev` prints an explicit restart notice on success (both modes) because the harness pins each hook's command at session start and does NOT hot-reload -- the open session keeps running the OLD hooks until it is restarted, so a freshly installed fix is inert until then. Heed the notice; do not tell the user the change is live without it. (Plugin-surface skill/agent changes differ -- see "Reloading a change".)
+ 2. **Pick up the change the channel's way.** On `npm` and `opencode`, restart the host: `gaia dev` prints an explicit restart notice because the harness pins each hook's command at session start and does NOT hot-reload -- the open session keeps running the OLD hooks until it is restarted. On `plugin`, run `/reload-plugins`. Do not tell the user the change is live before that step. See "Reloading a change".
+ 3. Run `gaia doctor` and read `Install provenance`: the channel you served must name the worktree's commit.
- Installing into a *different* workspace is the same intention with a different target -- pass `gaia dev --workspace <TARGET>`. Wiping install metadata first is NOT a `gaia dev` flag (its `register()` exposes no `--fresh`); `--fresh` belongs to the underlying `validate-sandbox.sh` form. See `reference.md` -> "Layer 1 runbook" for both, and for the raw `pnpm pack` / `npm run gaia:install-local` sequence `gaia dev` wraps (useful when diagnosing a failure inside the wrapped steps). Always pass `--workspace` explicitly when invoking from inside the gaia repo: the self-referencing `node_modules/@jaguilar87/gaia/` entry tricks auto-detect (guarded by `bin/validate-sandbox.sh::is_gaia_repo_root`, but explicit is safer).
+ Installing into a *different* install folder is the same intention with a different target -- pass `gaia dev --channel <channel> --workspace <TARGET>`. Wiping install metadata first is NOT a `gaia dev` flag (its `register()` exposes no `--fresh`); `--fresh` belongs to the underlying `validate-sandbox.sh` form. See `reference.md` -> "Layer 1 runbook" for both, and for the raw `pnpm pack` / `npm run gaia:install-local` sequence `gaia dev` wraps (useful when diagnosing a failure inside the wrapped steps). Always pass `--workspace` explicitly when invoking from inside the gaia repo: the self-referencing `node_modules/@jaguilar87/gaia/` entry tricks auto-detect (guarded by `bin/validate-sandbox.sh::is_gaia_repo_root`, but explicit is safer).
- ### Layer 2 -- "pre-release": prove a clean install works on BOTH surfaces, reproducing CI
+ ### Layer 2 -- "pre-release": prove a clean install works on every channel, reproducing CI
- This is the confidence gate before a version is cut. It is *entirely local* -- zero network, zero registry -- and it must run the same gates CI runs (see the pre-flight principle) **and** exercise the plugin surface, which nothing else validates. One command runs all five gates, always, and reports a complete PASS/FAIL/SKIP picture (never stopping at the first red light):
+ This is the confidence gate before a version is cut. It runs locally with no registry -- its only network use is gate 4's read-only GitHub API lookup -- and it must run the same gates CI runs (see the pre-flight principle) **and** exercise the plugin and OpenCode surfaces, which nothing else validates. One command runs all six gates, always, and reports a complete PASS/FAIL/SKIP picture (never stopping at the first red light):
```
- gaia release check # add --functional for the opt-in live plugin probe
+ gaia release check --gh ghx # --functional: opt-in live plugin probe; --local-suite: force npm test in gate 4
```
- **This command is T3 -- request consent before running it.** It is *local* and it is not *free*: gates 2 and 3 pack the tarball, which runs npm's `prepack`, which executes `scripts/build-plugin.py` and rewrites the plugin root manifests including `hooks/hooks.json`, a categorically protected path. So it is anchored MUTATIVE in `COMMAND_PATH_MUTATIVE_UPGRADES` (`hooks/modules/security/mutative_verbs.py`) and asks for approval on every run -- an accepted cost, not a misclassification to work around. Plan for it: request the approval together with the other release-sequence T3 commands rather than discovering the block mid-runbook. Do not read the reported tier to decide -- the command reports `tier=T1` (`tiers.py` matches `T1_PATTERNS` on the word `check` before consulting the detector) while being enforced as T3.
+ **This command is T3 -- request consent before running it.** It is *local* and it is not *free*: gates 2 and 3 pack the tarball, which runs npm's `prepack`, which executes `scripts/build-plugin.py` and rewrites the plugin root manifests including `hooks/hooks.json`, a categorically protected path. So it is anchored MUTATIVE in `COMMAND_PATH_MUTATIVE_UPGRADES` (`hooks/modules/security/mutative_verbs.py`) and asks for approval on every run -- an accepted cost, not a misclassification to work around. Plan for it: request the approval together with the other release-sequence T3 commands rather than discovering the block mid-runbook. The command reports `tier=T3` and is enforced as T3: `tiers.py` consults `detect_mutative_command` before `T1_PATTERNS`, so the word `check` does not downgrade it (the same account as `security-tiers`).
- `gaia release check` (`bin/cli/release.py`) runs, in order, these five gates -- gates 1-4 each a subprocess call to the existing script (never reimplemented), gate 5 an in-process read-only inspection via the shared `cli/_converge` inspector:
+ `gaia release check` (`bin/cli/release.py`) first installs the locked Node dependencies a fresh worktree lacks, with the same step publish runs (`bin/cli/release.py::step_node_deps`: `npm ci --ignore-scripts`, only when a declared dependency is missing from `node_modules`), so neither command needs a manual `npm ci`. A missing or out-of-sync lockfile fails the check there, alone, since every gate needs those dependencies. Then it runs, in order, these six gates -- gates 1-4 each a subprocess call to the existing script (never reimplemented), gates 5 and 6 in-process inspections:
- 1. `pre-publish:validate` -- the version-drift gate (`validate-manifests` in `ci.yml`, via `bin/pre-publish-validate.js --validate-only`). This is what catches a `package.json` / `pyproject.toml` / `plugin.json` / `marketplace.json` desync before it ships.
+ 1. `pre-publish:validate` -- the version-drift gate (`validate-manifests` in `ci.yml`, via `bin/pre-publish-validate.js --validate-only`). This is what catches a `package.json` / `pyproject.toml` / `plugin.json` / `CHANGELOG.md` desync, or a version reappearing in a `marketplace.json` entry, before it ships.
2. `gaia:verify-install:local` -- packs (via the shared `_pack_helpers.pack_tarball`, the same primitive `gaia dev` uses) and installs into a throwaway sandbox (`bin/validate-sandbox.sh --tarball <packed-tarball> --target sandbox`, as `release.py` invokes it). This proves the **npm surface** of exactly what a registry publish would ship. (This is `gaia-verify` mode `npm-sandbox`.)
- 3. **Plugin-surface dry-run** (`bin/plugin-dryrun.sh`) -- packs the tarball itself, extracts it to a throwaway temp dir (the package root IS the plugin), and runs a **headless, offline** gate: filesystem asserts (root `plugin.json` with NO inline `hooks` block, `hooks/hooks.json`, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) plus `claude plugin validate`. It touches no real workspace and spawns no session. `--functional` forwards to the script's own opt-in live `claude --plugin-dir <temp> -p '...'` probe (needs Claude auth/tokens -- never implicit). **SKIPs** (not fails) when the `claude` binary is not on PATH. This is the only place the plugin surface is proven before a tag exists. (This is `gaia-verify` mode `plugin`.) See the plugin-surface principle below for why a green npm sandbox does not cover it.
- 4. `npm test` -- the L1 suite (the tests CI runs that reproduce locally).
+ 3. **Plugin-surface dry-run** (`bin/plugin-dryrun.sh`) -- packs the tarball itself, extracts it to a throwaway temp dir (the package root IS the plugin), and runs a **headless, offline** gate: filesystem asserts (root `plugin.json` with NO inline `hooks` block, `hooks/hooks.json`, `bin/gaia`, `agents/`, `skills/`, and NO `dist/`) plus `claude plugin validate`. It touches no real install folder and spawns no session. `--functional` forwards to the script's own opt-in live functional probe, which starts the host on the extracted plugin (needs Claude auth/tokens -- never implicit). **SKIPs** (not fails) when the `claude` binary is not on PATH. This is the only place the plugin surface is proven before a tag exists. (This is `gaia-verify` mode `plugin`.) See the plugin-surface principle below for why a green npm sandbox does not cover it.
+ 4. **Tests: the CI verdict, or the local suite.** CI tests every tree once (see "CI/CD"), so this gate first asks `.github/scripts/ci_verdict.py` whether a green `CI verdict` already covers HEAD's tree. If one does, the gate PASSes citing that CI run and `npm test` does not run. It falls back to `npm test` -- the same L1 selection CI runs -- when there is no such verdict: HEAD not pushed, a working tree that differs from HEAD, CI red or still pending, or no network (the lookup is bounded and a failure is never a FAIL, only a reason to run locally). `--local-suite` skips the lookup and always runs `npm test`.
5. `convergence` -- the SAME drift-free convergence `gaia dev` runs after its reconcile, but with the **origin = the release artifact** (this repo's `package.json` version) rather than the local source -- the one difference from dev. It reuses `bin/cli/_converge.py` to inspect the destination's 5 install surfaces and applies the **schema-DIRECTION guard** (`scripts/bootstrap_database.py`): a live `~/.gaia/gaia.db` NEWER than the artifact's expected schema (reverse-direction drift) is a hard **FAIL**, because installing that artifact would be REFUSED by bootstrap (never ship code older than the DB, the finalize-breaking drift). Forward/stale surfaces are informational and do NOT fail the gate -- a release does not reconcile the developer's machine, so only the reverse-direction guard is a stop; an inspection error is a **SKIP**. This is what makes `gaia release` as drift-safe as `gaia dev`.
+ 6. `opencode:surface` -- the OpenCode surface of gate 2's tarball, without starting OpenCode (`gate_opencode_surface`): it extracts the tarball, wires it into a temp install folder as `gaia install --channel opencode` does, and FAILs naming what is missing -- `opencode/plugin.ts`, a file it resolves by relative path (`./bridge.py`, `../bin/gaia`), an agent `{file:...}` prompt in `opencode.json`, or a skill link. Starting OpenCode live stays outside the gate.
A `check` run that reports `FAIL` on gate 1 has failed a *subset* of CI, not passed a stand-in for it; a `FAIL`/`SKIP` on gate 3 means the plugin surface was never run (SKIP only when `claude` is genuinely absent) -- both gaps surface only after publish, when the fix costs another release. For the raw npm-script forms each gate wraps (useful when diagnosing which gate failed), see `reference.md` -> "Layer 2 runbook".
### Layer 3 -- "release [version]": end-to-end publish, fully automated
- The orchestrator runs every step below in order. The user supplies (or confirms) the version and approves the T3 operations; the orchestrator does the rest. **The user does not run `release:prepare` by hand -- `gaia release publish` invokes it as step 1 of one command.**
+ The orchestrator runs every step below in order. The user supplies (or confirms) the version and approves the T3 operations; the orchestrator does the rest. **The user does not run `release:prepare` by hand -- `gaia release publish` invokes it as step 2 of one command, after step 1 installs the Node dependencies.**
```
- gaia release publish [version] # add --dry-run to preview the sequence first
+ gaia release publish <version> --gh ghx
+ python3 <worktree>/bin/gaia release publish <version> --gh ghx
```
- `gaia release publish` (`bin/cli/release.py`) collapses steps (b)-(g) below into ONE command that runs steps 1-4 (local, no approval needed) then steps 5-6 (Tier 3, will block for approval), **stopping at the first failure** -- unlike `release check`'s always-run-all-gates design, these steps are causally dependent: tagging an untested tree or pushing before the tag exists is actively harmful. `[version]` accepts a bare semver (`5.1.0-rc.1`) or the bump keywords `patch`/`minor`/`major` (computed from the current `package.json` version -- `patch` is the default). It **never** runs npm's own registry-publish command itself: that stays inside `publish.yml`, behind `NODE_AUTH_TOKEN` in GitHub Secrets.
+ Add `--dry-run` to preview the sequence first; it names the push target and the gh account. The second form runs the release from a Gaia-managed worktree's own code: `release check`/`publish` resolve their repository from the `bin/gaia` they are run from.
- **Before step 1, a read-only (T0) preconditions gate (`preflight_publish`) runs and fails EARLY, loud, and actionably** rather than blowing up mid-sequence -- the failure mode behind the release saga (a tag created, then a permission error at `gh`; or a 30-minute `npm test` wait that only failed because pytest-xdist was missing). It checks three definite blockers and, if any fails, **runs no step**: (1) the **active `gh` account has push/admin on `metraton/gaia`** -- this is a real Layer 3 precondition, because step (g) is a Tier-3 `gh release create`; a `push:false` or an unauthenticated account fails naming the per-process fix below, while a transient/no-network/`gh`-missing case is treated as "could not verify" and does **not** block; (2) tag `v<version>` **does not already exist** (local or remote) -- if it does, the gate names the fix (`gh release create v<version>` to finish a half-completed release, or delete the tag to redo it); (3) **pytest-xdist is importable** (`npm test` runs pytest with `-n auto`) -- caught in ~1s instead of after the full npm-test wait.
+ `gaia release publish` (`bin/cli/release.py`) collapses steps (b)-(g) below into ONE command that runs steps 1-6 (local, no approval needed) then steps 7-8 (Tier 3, will block for approval), **stopping at the first failure** -- unlike `release check`'s always-run-all-gates design, these steps are causally dependent: tagging an untested tree or pushing before the tag exists is actively harmful. `[version]` accepts a bare semver (`5.1.0-rc.1`) or the bump keywords `patch`/`minor`/`major` (computed from the current `package.json` version -- `patch` is the default). It **never** runs npm's own registry-publish command itself: that stays inside `publish.yml`, which publishes through npm trusted publishing (OIDC) with no npm token.
- **The active `gh` account is not a parameter of the release -- it is resolved per process.** `gh` keeps ONE active account per host, so it is global state shared with every concurrent session and agent on the machine; `gh auth switch` mutates that state for all of them and is the demonstrated source of accounts drifting mid-release. Name the account on the invocation instead -- `GH_TOKEN="$(gh auth token --user <account>)" gaia release ...`, or the `ghx` wrapper if it is installed, which resolves the account from the repository's remote. `gh auth status` lists what is available and `gh auth login` adds an account that is missing; neither of those two mutates which account is active. `gh auth switch` and `gh auth logout` do, and both classify T3 for exactly that reason -- see the `gh` entry in `COMMAND_PATH_MUTATIVE_UPGRADES` (`hooks/modules/security/mutative_verbs.py`).
+ **Before step 1, a read-only (T0) preconditions gate (`preflight_publish`) runs and fails EARLY, loud, and actionably** rather than blowing up mid-sequence -- the failure mode behind the release saga (a tag created, then a permission error at `gh`; or a 30-minute `npm test` wait that only failed because pytest-xdist was missing). It checks five definite blockers and, if any fails, **runs no step**: (1) the **`--gh` program's account has push/admin on `metraton/gaia`** -- this is a real Layer 3 precondition, because step (g) is a Tier-3 `gh release create`; a missing program, an account the program cannot resolve or has no token for, an unauthenticated gh and a `push:false` all fail naming `--gh <program>`, while a network failure is "could not verify" and does **not** block; (2) tag `v<version>` **does not already exist** (local or remote) -- if it does, the gate names the fix (`gh release create v<version>` to finish a half-completed release, or delete the tag to redo it); (3) **pytest-xdist is importable** (`npm test` runs pytest with `-n auto`) -- caught in ~1s instead of after the full npm-test wait; (4) **the push target resolves and HEAD fast-forwards it** -- see "The push target" below; (5) **a stable version publishes only to `main`, an rc to any branch** -- see "Stable to main, rc to the branch" below.
- The `npm test` gate (step 2, shared with `gaia release check`) has a **configurable timeout**: the DEFAULT is **1800s** (raised from 1200s as the suite grew), overridable per-run via the **`GAIA_RELEASE_NPM_TEST_TIMEOUT`** env var (a positive integer of seconds). On expiry the gate reports an explicit `TIMEOUT after Ns` message that names the env-var lever and pytest-xdist, so a slow run is never mistaken for a test failure.
+ **The push target is an origin branch, not the local branch.** A Gaia-managed worktree always sits on a branch of its own, so step 7 never relies on the local branch's name or upstream: it runs `git push --atomic origin HEAD:refs/heads/<branch> refs/tags/v<version>`, landing the bump commit and the tag together or not at all, with no `+` and no `--force`, so git refuses anything but a fast-forward. `<branch>` is `--branch <name>` when given, else the current branch's `origin` upstream, else the one `origin` branch whose tip is HEAD -- the accumulating branch a worktree was cut from. Ambiguity (several branches at HEAD) or none fails asking for `--branch`, and the preconditions gate checks before step 1 that origin's tip is in HEAD's history; when it is not, merge it (never rebase) and re-run.
+ **Stable to main, rc to the branch.** A stable version (no `-rc.`/`-beta.`/`-alpha.`) goes to the `latest` dist-tag, which every package and OpenCode install takes by default, so it ships only what `main` holds. `_check_stable_from_main` reads the push target, not the local branch name, and for a stable version whose target is anything but `main` fails with: `stable version <v> can only be published to main, but this release would push to <branch>. Merge to main and publish from there, or publish a pre-release instead (`gaia release publish <v>-rc.N`).` Since step 7 only fast-forwards that target, a stable passes exactly when what it pushes lands on `main`. A pre-release publishes to any branch: run `gaia release publish x.y.z-rc.N --gh ghx` from a worktree cut at the accumulating branch's tip, and step 7 lands the bump commit and the tag there. There is no flag to bypass the check.
+
+ **The GitHub account is resolved per process, by the program the release runs gh through.** `gh` keeps ONE active account per host, so it is global state shared with every concurrent session and agent on the machine; `gh auth switch` mutates that state for all of them and is the demonstrated source of accounts drifting mid-release. Every gh call of `release check` and `release publish` -- the push-permission check, the CI-verdict lookup and `gh release create` -- runs through `--gh <program>` (default `gh`) with the repository as its working directory. Name a wrapper that picks the account for its own process, such as `ghx`, which maps the origin owner to an account and runs gh with `GH_TOKEN` set for that process only; the release command stays one program, so it is signed as one command, and nothing else the release runs (git, npm, node) sees the token. With the default `gh` the release runs as gh's active account, and `--dry-run` says so. `git push` takes no part in this: it authenticates with git's own per-remote credentials or SSH key. `gh auth status` lists what is available and `gh auth login` adds an account that is missing; neither of those two mutates which account is active. `gh auth switch` and `gh auth logout` do, and both classify T3 for exactly that reason -- see the `gh` entry in `COMMAND_PATH_MUTATIVE_UPGRADES` (`hooks/modules/security/mutative_verbs.py`).
+
+ **Nothing irreversible happens before the sandbox install has passed on the bumped tree.** Step 1 installs the locked Node dependencies a fresh worktree lacks (`npm ci --ignore-scripts`, only when a declared dependency is missing from `node_modules`), so `release:prepare` can import `chalk`; a missing or out-of-sync lockfile fails there, before any bump, naming the fix. Step 3 packs the bumped tree and installs it with `bin/validate-sandbox.sh --target sandbox` -- the gate `publish.yml` runs only after the tag and the GitHub release exist, and the one a reused CI verdict never exercises (`v5.5.0-rc.4` was tagged and then failed there). It refuses a tree that differs from HEAD outside the version sources, since the tag would not hold that difference. Neither step needs `gaia release check` first; Layer 2 remains where the plugin and OpenCode surfaces are proven.
+
+ Step 4 is the same tests gate as `gaia release check`'s gate 4. It runs after `release:prepare`, so HEAD is the parent of the version-only bump commit step 5 is about to make: a green `CI verdict` on HEAD covers the tree being released, and the step PASSes citing that CI run without the local suite. Only the version sources `release:prepare` rewrote may differ from HEAD; any other local change, a HEAD that is not pushed, CI red or pending, or no network makes it run `npm test` instead, and `gaia release publish --local-suite` forces `npm test`. CI then reuses the same verdict for the bump commit, since its diff touches version sources only.
+
+ When the tests step does fall back to the local suite, `npm test` has a **configurable timeout**: the DEFAULT is **1800s** (raised from 1200s as the suite grew), overridable per-run via the **`GAIA_RELEASE_NPM_TEST_TIMEOUT`** env var (a positive integer of seconds). The timeout applies only to that local fallback; a reused CI verdict never waits on it. On expiry the gate reports an explicit `TIMEOUT after Ns` message that names the env-var lever and pytest-xdist, so a slow run is never mistaken for a test failure.
+
| Step | Action | Notes |
|------|--------|-------|
| **(a)** | Determine the version | Default to the next **patch**. If the change is major/minor, **confirm with the user** (`NEEDS_INPUT`) before proceeding -- never silently pick major/minor. Pass the confirmed version as `gaia release publish`'s argument, or let it default to `patch`. |
- | **(b)** | `release:prepare <version>` -- `gaia release publish` step 1 | The atomic core: bumps the hand-owned version sources at once (`package.json`, `pyproject.toml`, `.claude-plugin/marketplace.json`, `CHANGELOG.md`), runs `generate:plugin-root` (regenerating the ROOT `.claude-plugin/plugin.json` (metadata only) + `hooks/hooks.json` from the manifest -- `plugin.json` version is inherited from `package.json`, not hand-bumped), then `pre-publish:validate`. Fails loud on any drift. No `dist/` bundle. This wraps `scripts/release-prepare.mjs` -- invoked by the flow, never run by hand. |
- | **(c)** | Pre-flight that reproduces CI -- step 2 | `pre-publish:validate` already ran inside (b). Now `npm test` runs so the local gate matches CI before the tag exists. Layer 2 should already be green. |
- | **(d)** | Commit -- step 3 | `git add` (the version-source paths only) + `git commit` -- local-safe, not T3. Idempotent: nothing-to-commit on a tree already at the target version is a PASS, not a failure. |
- | **(e)** | Tag, **force-free** -- step 4 | A *new* annotated tag (`v<version>`); never moves an existing one. If the remote diverged, reconcile with **merge, not rebase** (rebase forces a tag move, hard-denied locally). See `reference.md` -> "Reconciling a diverged remote". |
- | **(f)** | Push -- step 5, **Tier 3** | `git push --follow-tags` (pushes the commit and the new tag in one push). If diverged, the merge from (e) makes this force-free. The hook layer blocks this for approval -- expected. |
- | **(g)** | `gh release create v<version>` -- step 6, **Tier 3** | Triggers `publish.yml`, which packs (prepack regenerates root manifests), validates, sandbox-gates, and publishes to npm with the auto-detected tag (`-rc.` -> rc, else latest). It no longer builds/commits a `dist/` bundle or force-moves the tag. RC/beta/alpha versions are marked `--prerelease` automatically. |
- | **(h)** | Verify from the registry, then **reinstall every local workspace** | Watch the workflow to its outcome, then `gaia-verify` mode `registry` (`gaia:verify-install:rc` / `:latest`) confirms npm serves the new version. **A publish does NOT touch any workspace installed from a local `file:` tarball** -- their `package.json`/lockfile still point at the pre-release dev-pack tarball, so they keep running the OLD code until they are explicitly reinstalled. For EACH active local workspace, reinstall the new version (`gaia dev --workspace <TARGET>` re-packs and re-wires; or `pnpm add @jaguilar87/gaia@<tag>` + `gaia install` against the published tarball) and run `gaia-verify` live. To find WHICH local workspaces are running stale code, run `gaia doctor` in each -- its **Install provenance** check reports whether a `file:` install is fresh vs source and hints `gaia dev --workspace <ws>` to fix. The release is not done when the tag is pushed -- it is done when the published version is installed and validated in every target. |
+ | **(b)** | Node deps, then `release:prepare <version>` -- `gaia release publish` steps 1-2 | Step 1 runs `npm ci --ignore-scripts` when a declared dependency is missing from `node_modules`, and nothing otherwise. Step 2 is the atomic core: bumps the hand-owned version sources at once (`package.json`, `pyproject.toml`, `CHANGELOG.md`, where a stable folds `[Unreleased]` and its own pre-release sections into one section and a pre-release only adds its header -- no body is edited by hand; `.claude-plugin/marketplace.json` is never written -- its entry has `source: "."` and no version), runs `generate:plugin-root` (regenerating the ROOT `.claude-plugin/plugin.json` (metadata only) + `hooks/hooks.json` from the manifest -- `plugin.json` version is inherited from `package.json`, not hand-bumped), then `pre-publish:validate`. Fails loud on any drift. No `dist/` bundle. This wraps `scripts/release-prepare.mjs` -- invoked by the flow, never run by hand. |
+ | **(c)** | Sandbox install, then the pre-flight that reproduces CI -- steps 3-4 | `pre-publish:validate` already ran inside (b). Step 3 packs the bumped tree and runs `publish.yml`'s sandbox gate on it (`bin/validate-sandbox.sh --target sandbox`). Step 4 proves the tree with tests before the tag exists: a green `CI verdict` for HEAD (the bump commit's parent) PASSes citing the CI run; without one, `npm test` runs (`--local-suite` forces it). |
+ | **(d)** | Commit -- step 5 | `git add` (the version-source paths only) + `git commit` -- local-safe, not T3. Idempotent: nothing-to-commit on a tree already at the target version is a PASS, not a failure. |
+ | **(e)** | Tag, **force-free** -- step 6 | A *new* annotated tag (`v<version>`); never moves an existing one. If the remote diverged, reconcile with **merge, not rebase** (rebase forces a tag move, hard-denied locally). See `reference.md` -> "Reconciling a diverged remote". |
+ | **(f)** | Push -- step 7, **Tier 3** | `git push --atomic origin HEAD:refs/heads/<branch> refs/tags/v<version>` (the commit and the new tag in one push, to the push target above, never forced). If diverged, the merge from (e) makes this a fast-forward. The hook layer blocks this for approval -- expected. |
+ | **(g)** | `gh release create v<version>` -- step 8, **Tier 3** | Triggers `publish.yml`, which packs (prepack regenerates root manifests), validates, sandbox-gates, and publishes to npm through trusted publishing (OIDC, no npm token) with the auto-detected tag (`-rc.` -> rc, else latest). It no longer builds/commits a `dist/` bundle or force-moves the tag. RC/beta/alpha versions are marked `--prerelease` automatically. |
+ | **(h)** | Verify from the registry, then **update every install folder on every channel** | Watch the workflow to its outcome, then `gaia-verify` mode `registry` (`gaia:verify-install:rc` / `:latest`) confirms npm serves the new version. **A publish updates no install folder on its own**: an install folder installed from a local `file:` tarball keeps running the dev-pack code, and a plugin install keeps its version until updated. Per install folder and channel, apply the "Updating after a publish" column of the channel table (`pnpm add @jaguilar87/gaia@<dist-tag>` + `npx gaia update`; `claude plugin marketplace update gaia-marketplace` + `claude plugin update gaia@gaia-marketplace`), restart or `/reload-plugins` as that channel needs, and run `gaia-verify` live. To find WHICH local install folders are running stale code, run `gaia doctor` in each -- its **Install provenance** check reports whether a `file:` install is fresh vs source and hints `gaia dev --workspace <install-folder> --channel <channel>` to fix. The release is not done when the tag is pushed -- it is done when the published version is installed and validated in every target. |
For the raw command forms `gaia release publish` wraps, the schema-migration lockstep, and the diverged-remote reconciliation, see `reference.md`.
## Reloading a change
A fresh install or an edit is invisible until Claude Code picks it up, and *how* depends on what changed:
- - **After `gaia dev` (a re-install) -- restart Claude Code.** The harness snapshots each hook's command (and the `settings.local.json` hook registration) at **session start** and does not hot-reload it, so an open session keeps running the OLD hooks until it is restarted -- a freshly installed fix is inert until then. `gaia dev` prints this restart notice on success (both modes); heed it. This is why the fast loop is *edit source -> `gaia dev` -> restart -> test*, not *edit -> test*.
- - **plugin surface (cloned git repo in CC's plugin cache):** skills, agents, hooks, and MCP servers refresh with **`/reload-plugins`** in-session. A source edit only reaches this surface after the marketplace re-pulls the git source (or a fresh local dry-run mount); the plugin cache holds the cloned repo at the installed ref, not your working tree.
+ - **After `gaia dev --channel npm` or `--channel opencode` -- restart the host.** The harness snapshots each hook's command (and the `settings.local.json` hook registration) at **session start** and does not hot-reload it, so an open session keeps running the OLD hooks until it is restarted -- a freshly installed fix is inert until then. `gaia dev` prints this restart notice on success; heed it. This is why the fast loop is *edit source -> `gaia dev` -> restart -> test*, not *edit -> test*.
+ - **After `gaia dev --channel plugin` -- `/reload-plugins`.** The `gaia-dev` marketplace is a local directory loaded in place, so skills, agents, hooks, and MCP servers refresh in-session without a version bump.
+ - **Plugin from `gaia-marketplace`:** the plugin cache holds the tree at the installed version, not your working tree. `claude plugin update gaia@gaia-marketplace` installs a new tree only when the fetched `plugin.json` declares a different version; re-fetching the marketplace at the same version changes nothing. Use `gaia dev --channel plugin` to try unpublished code.
- **Any surface, slash-command change:** adding or renaming a **slash-command** needs a **full restart** -- `/reload-plugins` loads skills into context but does not rebuild the slash-command parser index.
+ ## Uninstalling, per channel
+
+ Each channel takes back only what it wrote; `~/.gaia/gaia.db` is never touched.
+
+ - **Package and OpenCode:** run `npx gaia uninstall` (add `--workspace <folder>` for an OpenCode-only folder, `--dry-run` to preview) **before** `npm uninstall @jaguilar87/gaia` / `pnpm remove @jaguilar87/gaia`. The order is forced: npm >= 7 does not run a package's `preuninstall` script and pnpm skips lifecycle scripts by default, so removing the package first leaves `.claude/` links, settings keys and `opencode.json` pointing at nothing, with no `gaia` left to revert them. `gaia uninstall` reverts `.claude/gaia-manifest.json` entry by entry, removes what the package manager and `gaia dev` left outside it (the package entry and `.bin` shim, Gaia's `package.json` and `package-lock.json` lines, the `gaia dev` tarballs) or lists it as left in place with the reason, and snapshots the DB to `~/.gaia/snapshots/` unless `--no-backup`.
+ - **Claude Code plugin:** `gaia uninstall` in the install folder first, then `claude plugin uninstall gaia@gaia-marketplace` (and optionally `claude plugin marketplace remove gaia-marketplace`). The plugin's sessions record their writes into the install folder -- the permissions and `attribution` merged into `.claude/settings.local.json`, the `.claude/hooks` link -- in `.claude/gaia-manifest.json` (`plugin_setup.recorded_in_manifest`), so `gaia uninstall` reverts them and keeps what the user added since.
+ - **One channel beside another:** without `--channel`, `gaia uninstall` takes back every channel the install folder records. Where the plugin and OpenCode run side by side, `gaia uninstall --channel opencode` removes OpenCode on its own (`opencode.json`, `.opencode/`, and the package copy once no remaining channel runs from it) and leaves the plugin recorded; `--channel plugin` or `--channel npm` takes back the Claude Code side. npm and the plugin share `.claude/`, so uninstalling either takes back every Claude Code entry in the manifest; a plugin still enabled re-records its own writes on its next session. A channel the manifest does not record fails, naming the recorded ones.
+
## CI/CD
| Workflow | File | Triggers |
|----------|------|----------|
- | CI | `.github/workflows/ci.yml` | Push / PR -- runs pytest (Python 3.12), Node tests, plugin build verification, and `validate-manifests`; deselects the exhaustive opencode alias matrix, which is too slow for every push |
+ | CI | `.github/workflows/ci.yml` | Push / PR -- a `Reuse decision` job first asks `.github/scripts/ci_verdict.py` whether this tree already passed; if not, pytest (Python 3.12) runs in 4 shards over the same selection as `npm test` (the exhaustive opencode alias matrix is deselected), and beside them the `upgrade` matrix (ubuntu and windows x every dump in `tests/fixtures/published_bases`) installs the Linux-packed tarball, upgrades that base with `gaia migrate`, runs the npm and plugin channels' registered hook commands as written and requires `gaia uninstall` to leave no manifest, and the `test-opencode` job runs the OpenCode plugin's TypeScript tests with `bun test ./tests/opencode` (bun pinned to the shards' version) -- both skipped with the shards when the tree is reused. ESLint on one Node (20), plugin build verification, `validate-manifests`, and the Windows compatibility job run alongside. The `CI verdict` job aggregates them all and is the one check `gaia release check`/`publish` read. The bun tests are the only non-Python test lane; `npm test` stays pytest, so `bun run test` and `bun --cwd <dir> test` run pytest, not bun's runner. |
| Nightly | `.github/workflows/nightly.yml` | Daily schedule and manual dispatch -- runs the exhaustive opencode alias matrix that CI deselects |
- | Publish | `.github/workflows/publish.yml` | GitHub Release event -- packs the npm tarball (`prepack` regenerates root manifests), validates, runs the sandbox gate, auto-detects npm tag from version (`-rc.` -> rc, `-beta.` -> beta, else -> latest), and publishes. It no longer builds/commits a `dist/` bundle or force-moves the tag (read-only checkout). |
+ | Publish | `.github/workflows/publish.yml` | GitHub Release event -- packs the npm tarball (`prepack` regenerates root manifests), validates, runs the sandbox gate, auto-detects npm tag from version (`-rc.` -> rc, `-beta.` -> beta, else -> latest), and publishes through npm trusted publishing (OIDC). It no longer builds/commits a `dist/` bundle or force-moves the tag (read-only checkout). |
- `NPM_TOKEN` lives in GitHub Secrets; local `npm publish` bypasses build verification and is not the supported path.
+ Publishing authenticates through npm trusted publishing (OIDC) from `publish.yml` -- no npm token in the publish step; local `npm publish` bypasses build verification and is not the supported path.
## Principles -- why the sequence is normed, not optional
- **The pre-flight reproduces what CI validates, not a subset of it.** When the local check skips a gate CI runs (`pre-publish:validate`), that gate's failures surface only *after* publishing, on the published tarball, where the only remedy is another release. That is exactly how a `pyproject.toml` drift shipped green-local and red-CI. Layer 2 step 1 and Layer 3 step (c) close the gap. See `reference.md` -> "The pre-flight reproduces what CI validates".
- **The plugin surface is only proven by packing the tarball and mounting the extracted root.** The npm sandbox exercises the symlink / `settings.local.json` path; it never touches the root `plugin.json` / `hooks.json` or CC's plugin loader. The tarball can be missing files, carry a broken `hooks.json`, or fail to expose `bin/gaia` on PATH -- and none of that shows until CC mounts it. Layer 2 step 3 (`gaia:plugin-dryrun` -- pack, extract, headless validate) is the only pre-tag check that runs the plugin surface. Skipping it means the plugin breaks silently in production.
- - **Bump every version source in one step, never one at a time.** `pre-publish:validate` requires `package.json`, `pyproject.toml`, `.claude-plugin/plugin.json` (generated), `.claude-plugin/marketplace.json`, and the `CHANGELOG.md` top header to agree. `release:prepare` writes the hand-owned sources from one target version and regenerates `plugin.json` (version inherited from `package.json`), so a hand-desync is impossible. See `scripts/release-prepare.mjs`.
+ - **Bump every version source in one step, never one at a time.** `pre-publish:validate` requires `package.json`, `pyproject.toml`, `.claude-plugin/plugin.json` (generated), and the `CHANGELOG.md` top header to agree, and rejects a version in any `.claude-plugin/marketplace.json` entry. `release:prepare` writes the hand-owned sources from one target version and regenerates `plugin.json` (version inherited from `package.json`), so a hand-desync is impossible. See `scripts/release-prepare.mjs`.
- **Tag force-free; reconcile with merge, never rebase.** `publish.yml` no longer commits artifacts back to `main` (read-only checkout), so the remote does not auto-advance on release. But if the remote ever diverges, rebasing rewrites hashes and forces a tag move (`git tag -f` / `--force`), hard-denied by local hooks (`git_destructive` in `blocked_commands.py`, exit 2, not approvable). Merge preserves hashes and tags; a new release gets a *new* tag, never a moved one. See `reference.md` -> "Reconciling a diverged remote".
- **A release ends at an installed, validated version -- not at the tag.** Pushing the tag only starts `publish.yml`. The intention is not satisfied until the workflow reaches its outcome, npm serves the new version, and it installs cleanly into the target (Layer 3 step (h)).
## Anti-Patterns
- **Stopping after the first command of an intention** -- "install local" is not just `gaia dev`'s pack step; "release" is not just `release:prepare`. Each intention is the *whole* sequence, and `gaia dev` / `gaia release check` / `gaia release publish` each already run their whole sequence in one invocation -- do not run one gate by hand and stop.
- **Asking the user to run `release:prepare` (or any Layer 3 step) by hand** -- it is a step `gaia release publish` invokes internally, not a command the human runs. Surfacing it as a manual step is the same failure mode (a step someone must remember) wearing a new script.
- **Publishing without a green `gaia release publish --dry-run` / `gaia release check`** -- the plugin surface (gate 3) breaks silently if skipped. Preview or run the full Layer 2 gate before any tag.
- **Pre-flight that is a subset of CI** -- skipping `pre-publish:validate` locally means the version drift surfaces after publish. `gaia release check` reproduces CI; do not approximate it by hand-picking gates.
- **Bumping version sources one at a time** -- desyncs a source by hand; `pre-publish:validate` rejects the tree and a forgotten file ships if the check is skipped. Always go through `gaia release publish` (which invokes `release:prepare`), never a hand-edit.
- **Rebase to reconcile a diverged remote** -- forces a tag move, hard-denied locally. Merge instead.
- - **Single-surface testing** -- a change can pass the npm sandbox and break the plugin mount, or vice versa. Layer 2 runs both surfaces for a reason.
+ - **Single-surface testing** -- a change can pass the npm sandbox and break the plugin mount or the OpenCode wiring, or the other way round. Layer 2 runs all three surfaces for a reason.
+ - **Publishing a stable from a feature branch** -- the preflight refuses it; merge to `main` first, or cut an rc (`x.y.z-rc.N`) from the branch.
+ - **Removing the package before `gaia uninstall`** -- npm >= 7 skips `preuninstall`, so nothing reverts the install folder and the `gaia` that could have done it is gone.
- **Stale root manifests** -- editing `build/gaia.manifest.json` or a hook entry without regenerating means the tarball ships a stale `hooks/hooks.json`. `prepack` regenerates at every `npm pack`, and `release:prepare` regenerates via `generate:plugin-root`; the dry-run packs fresh, so a stale manifest surfaces there.
- - **Skipping the restart after `gaia dev`** -- the harness pins hook commands at session start, so a reinstalled fix is inert in the open session until Claude Code is restarted. `gaia dev` prints the notice for exactly this reason; do not report the change as live without a restart. (Plugin-surface skill/agent changes take `/reload-plugins`; a slash-command change takes a full restart -- see "Reloading a change".)
+ - **Skipping the pickup step after `gaia dev`** -- on `npm` and `opencode` the harness pins hook commands at session start, so a reinstalled fix is inert until the host restarts; on `plugin` it is inert until `/reload-plugins`. Do not report the change as live before that step (a slash-command change takes a full restart on any channel -- see "Reloading a change").
- **Running tests in the install-local loop** -- Layer 1 (`gaia dev`) is deliberately cheap and runs NO tests; the L1 suite belongs to `gaia release check` / `gaia release publish` and CI. Adding a test gate to the fast loop defeats its purpose.
- - **Assuming a `postinstall` ran** -- there is none. The DB is bootstrapped lazily on first `gaia` CLI use; under pnpm a lifecycle hook would never fire anyway. If the DB is missing, run any `gaia` command (or `gaia install`), not "re-run postinstall".
+ - **Assuming a `postinstall` ran** -- there is none. The DB is bootstrapped lazily on first `gaia` CLI use; under pnpm a lifecycle hook would never fire anyway. If the DB is missing, run any `gaia` command (or `gaia update`), not "re-run postinstall".
- **Local npm publish** -- bypasses the pipeline's pack + validate + sandbox gate.
- - **Treating a green publish as a live local install** -- a publish updates the registry; it does NOT touch a workspace installed from a local `file:` tarball. Those workspaces keep running the pre-release dev-pack code until step (h) reinstalls them (`gaia dev --workspace <TARGET>`); `gaia doctor`'s Install provenance check surfaces which ones are stale. Confusing "published" with "running locally" is exactly how a fixed release keeps exhibiting the old bug in a local workspace.
+ - **Treating a green publish as a live local install** -- a publish updates the registry; it does NOT touch an install folder installed from a local `file:` tarball. Those install folders keep running the pre-release dev-pack code until step (h) reinstalls them (`gaia dev --channel <channel> --workspace <TARGET>`); `gaia doctor`'s Install provenance check surfaces which ones are stale. Confusing "published" with "running locally" is exactly how a fixed release keeps exhibiting the old bug in a local install folder.