---
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 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 tree, three channels

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:

```
                         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
```

- **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": serve the working tree through one channel, fast

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:

```
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 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:

| `--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 |

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 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. **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* 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 every channel, reproducing CI

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 --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. 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`) 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` / `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 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 2 of one command, after step 1 installs the Node dependencies.**

```
gaia release publish <version> --gh ghx
python3 <worktree>/bin/gaia release publish <version> --gh ghx
```

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.

`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.

**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 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)** | 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 --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 -- 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 through npm trusted publishing (OIDC). It no longer builds/commits a `dist/` bundle or force-moves the tag (read-only checkout). |

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), 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 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 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 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 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.
