diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
new file mode 100644
index 00000000..02924c96
--- /dev/null
+++ b/.github/workflows/build-release.yml
@@ -0,0 +1,44 @@
+name: Build release artifacts
+
+on:
+  workflow_call:
+    inputs:
+      version:
+        required: true
+        type: string
+
+permissions:
+  contents: read
+  id-token: write
+  attestations: write
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        include:
+          - os: macos-14
+            target: darwin-arm64
+          - os: ubuntu-22.04
+            target: linux-x86_64
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4.1"
+          bundler-cache: true
+      - name: Install pinned Tebako
+        run: gem install tebako --version 0.12.0
+      - name: Build and smoke test
+        run: scripts/build-release --version "${{ inputs.version }}" --target "${{ matrix.target }}"
+      - uses: anchore/sbom-action@v0
+        with:
+          path: build/hive-${{ matrix.target }}
+          output-file: build/hive-${{ matrix.target }}.spdx.json
+      - uses: actions/upload-artifact@v4
+        with:
+          name: binary-${{ matrix.target }}
+          path: |
+            build/hive-${{ matrix.target }}
+            build/hive-${{ matrix.target }}.spdx.json
diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
new file mode 100644
index 00000000..b9c384d4
--- /dev/null
+++ b/.github/workflows/install-smoke.yml
@@ -0,0 +1,62 @@
+name: Install smoke
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: Released semver without v
+        required: true
+        type: string
+
+permissions:
+  contents: read
+
+jobs:
+  shell:
+    strategy:
+      matrix:
+        os: [ubuntu-22.04, macos-14]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v6
+      - name: Install without system Ruby dependency
+        env:
+          HIVE_RELEASE_REPOSITORY: ${{ github.repository }}
+        run: |
+          env -u GEM_HOME -u GEM_PATH scripts/install.sh --version "${{ inputs.version }}" --yes
+          "${HOME}/.local/bin/hv" --version
+          test "$("${HOME}/.local/bin/hv" --version)" = "${{ inputs.version }}"
+          "${HOME}/.local/bin/hv" update
+          "${HOME}/.local/bin/hv" uninstall
+
+  homebrew:
+    runs-on: macos-14
+    steps:
+      - name: Verify published tap lifecycle
+        run: |
+          brew install "${GITHUB_REPOSITORY}/hive"
+          test "$(hv --version)" = "${{ inputs.version }}"
+          hv update
+          hv uninstall
+
+  arch:
+    runs-on: ubuntu-22.04
+    container: archlinux:latest
+    steps:
+      - run: pacman -Syu --noconfirm base-devel git curl
+      - name: Verify published AUR package lifecycle without Ruby
+        env:
+          AUR_REPOSITORY: ${{ vars.AUR_REPOSITORY }}
+        run: |
+          test -n "${AUR_REPOSITORY}"
+          git clone "https://aur.archlinux.org/${AUR_REPOSITORY}.git" aur
+          useradd -m builder
+          chown -R builder:builder aur
+          runuser -u builder -- bash -lc "cd '${PWD}/aur' && makepkg --noconfirm"
+          pacman -U --noconfirm aur/hive-bin-*.pkg.tar.*
+          test "$(hv --version)" = "${{ inputs.version }}"
+          mkdir -p fake-bin
+          printf '#!/bin/sh\nif [ "$1" = "-S" ]; then exit 0; fi\nexec pacman "$@"\n' > fake-bin/yay
+          chmod 0755 fake-bin/yay
+          PATH="${PWD}/fake-bin:${PATH}" hv update
+          PATH="${PWD}/fake-bin:${PATH}" hv uninstall
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..513679dc
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,361 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - "v[0-9]+.[0-9]+.[0-9]+"
+
+permissions:
+  contents: write
+  id-token: write
+  attestations: write
+
+jobs:
+  qualify:
+    runs-on: ubuntu-22.04
+    outputs:
+      version: ${{ steps.version.outputs.value }}
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4"
+          bundler-cache: true
+      - name: Require immutable matching semver
+        id: version
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          version="$(ruby -Ilib -rhive/version -e 'print Hive::VERSION')"
+          test "v${version}" = "${GITHUB_REF_NAME}"
+          ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1
+          echo "value=${version}" >> "${GITHUB_OUTPUT}"
+      - name: Require every v0.1 publication channel
+        env:
+          HOMEBREW_TAP_REPOSITORY: ${{ vars.HOMEBREW_TAP_REPOSITORY }}
+          AUR_REPOSITORY: ${{ vars.AUR_REPOSITORY }}
+          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+          AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
+          HIVE_SKILLS_TOKEN: ${{ secrets.HIVE_SKILLS_TOKEN }}
+        run: |
+          test -n "${HOMEBREW_TAP_REPOSITORY}"
+          test -n "${AUR_REPOSITORY}"
+          test -n "${HOMEBREW_TAP_TOKEN}"
+          test -n "${AUR_SSH_KEY}"
+          test -n "${HIVE_SKILLS_TOKEN}"
+      - run: bundle exec rake test
+      - run: bundle exec rake distribution
+      - run: bundle exec rubocop --parallel
+      - name: Install current advertised clients
+        run: npm install --global @anthropic-ai/claude-code @openai/codex @mariozechner/pi-coding-agent
+      - name: Validate Claude marketplace and install
+        run: |
+          export CLAUDE_CONFIG_DIR="${RUNNER_TEMP}/claude-config"
+          claude plugin validate --strict hive-skills
+          claude plugin marketplace add ./hive-skills --scope user
+          claude plugin install hive-skills@hive-skills --scope user
+          claude plugin list | grep -F hive-skills@hive-skills
+      - name: Validate Codex marketplace and install
+        run: |
+          export CODEX_HOME="${RUNNER_TEMP}/codex-home"
+          mkdir -p "${CODEX_HOME}"
+          codex plugin marketplace add ./hive-skills
+          codex plugin add hive-skills@hive-skills
+      - name: Validate Pi package install
+        run: |
+          export PI_CODING_AGENT_DIR="${RUNNER_TEMP}/pi-agent"
+          pi install ./hive-skills --no-approve
+          pi list | grep -F hive-skills
+
+  build:
+    needs: qualify
+    uses: ./.github/workflows/build-release.yml
+    with:
+      version: ${{ needs.qualify.outputs.version }}
+    secrets: inherit
+
+  candidate:
+    needs: [qualify, build]
+    runs-on: ubuntu-22.04
+    env:
+      HIVE_RELEASE_REPOSITORY: ${{ github.repository }}
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v5
+        with:
+          pattern: binary-*
+          path: build
+          merge-multiple: true
+      - name: Package, render, and verify the candidate
+        run: |
+          scripts/package-release --version "${{ needs.qualify.outputs.version }}"
+          scripts/verify-release --manifest dist/release-manifest.json
+          scripts/render-packages --manifest dist/release-manifest.json --output-dir generated
+      - uses: actions/attest-build-provenance@v2
+        with:
+          subject-path: dist/hive-*.tar.gz
+      - uses: actions/upload-artifact@v4
+        with:
+          name: qualified-release-candidate
+          path: |
+            dist/
+            generated/
+            build/*.spdx.json
+
+  tier1-shell:
+    needs: [qualify, candidate]
+    strategy:
+      matrix:
+        os: [ubuntu-22.04, macos-14]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4"
+          bundler-cache: true
+      - uses: actions/download-artifact@v5
+        with:
+          name: qualified-release-candidate
+          path: candidate
+      - name: AE3 shell install, collision, update, and preservation gate
+        run: |
+          version="${{ needs.qualify.outputs.version }}"
+          mkdir -p "release-root/v${version}" "release-root/latest" "${RUNNER_TEMP}/foreign-bin"
+          cp candidate/dist/* "release-root/v${version}/"
+          cp candidate/dist/* "release-root/latest/"
+          printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/hive"
+          printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/systemctl"
+          chmod 0755 "${RUNNER_TEMP}/foreign-bin/hive"
+          chmod 0755 "${RUNNER_TEMP}/foreign-bin/systemctl"
+          export HOME="${RUNNER_TEMP}/home"
+          export XDG_CONFIG_HOME="${RUNNER_TEMP}/config"
+          export XDG_DATA_HOME="${RUNNER_TEMP}/data"
+          export XDG_STATE_HOME="${RUNNER_TEMP}/state"
+          export XDG_CACHE_HOME="${RUNNER_TEMP}/cache"
+          export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime"
+          export HIVE_BIN_DIR="${RUNNER_TEMP}/bin"
+          export HIVE_INSTALL_BASE_URL="file://${PWD}/release-root"
+          export PATH="${RUNNER_TEMP}/foreign-bin:${HIVE_BIN_DIR}:${PATH}"
+          "release-root/v${version}/install.sh" --version "${version}" --yes
+          test "$("${HIVE_BIN_DIR}/hv" --version)" = "${version}"
+          test "$("${RUNNER_TEMP}/foreign-bin/hive"; printf preserved)" = "preserved"
+          project="${RUNNER_TEMP}/project"
+          git init -b main "${project}"
+          git -C "${project}" config user.name release-test
+          git -C "${project}" config user.email release-test@example.com
+          printf 'release gate\n' > "${project}/README.md"
+          git -C "${project}" add README.md
+          git -C "${project}" commit -m initial
+          "${HIVE_BIN_DIR}/hv" init "${project}"
+          test -e "${project}/.hive/settings.yml"
+          if [ "$(uname -s)" = Darwin ]; then
+            service_definition="${HOME}/Library/LaunchAgents/org.hive.daemon.plist"
+          else
+            service_definition="${XDG_CONFIG_HOME}/systemd/user/hive.service"
+          fi
+          test -e "${service_definition}"
+          grep -F "${HIVE_BIN_DIR}/hv" "${service_definition}"
+          mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output"
+          printf preserved > "${project}/.agents/skills/hive/SKILL.md"
+          printf preserved > "${project}/completed-output/result"
+          "${HIVE_BIN_DIR}/hv" update
+          "${HIVE_BIN_DIR}/hv" uninstall
+          test ! -e "${HIVE_BIN_DIR}/hv"
+          test ! -e "${service_definition}"
+          test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved
+          test "$(cat "${project}/completed-output/result")" = preserved
+      - run: bundle exec ruby -Itest -Ilib test/acceptance/ubuntu_shell_test.rb
+      - run: bundle exec ruby -Itest -Ilib test/acceptance/uninstall_preserves_work_test.rb
+
+  tier1-homebrew:
+    needs: [qualify, candidate]
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v5
+        with:
+          name: qualified-release-candidate
+          path: candidate
+      - name: AE1 Homebrew lifecycle and collision gate
+        run: |
+          version="${{ needs.qualify.outputs.version }}"
+          formula="${PWD}/candidate/generated/homebrew/Formula/hive.rb"
+          archive="${PWD}/candidate/dist/hive-${version}-darwin-arm64.tar.gz"
+          export ARCHIVE="${archive}"
+          ruby -pi -e 'gsub(%r{url ".*"}, "url \\"file://#{ENV.fetch("ARCHIVE")}\\"")' "${formula}"
+          mkdir -p "${RUNNER_TEMP}/foreign-bin"
+          printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/foreign-bin/hive"
+          chmod 0755 "${RUNNER_TEMP}/foreign-bin/hive"
+          export XDG_CONFIG_HOME="${RUNNER_TEMP}/config"
+          export XDG_DATA_HOME="${RUNNER_TEMP}/data"
+          export XDG_STATE_HOME="${RUNNER_TEMP}/state"
+          export XDG_CACHE_HOME="${RUNNER_TEMP}/cache"
+          export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime"
+          export PATH="${RUNNER_TEMP}/foreign-bin:${PATH}"
+          brew tap-new "${{ github.repository }}"
+          cp "${formula}" "$(brew --repository "${{ github.repository }}")/Formula/hive.rb"
+          brew install "${{ github.repository }}/hive"
+          test "$(hv --version)" = "${version}"
+          project="${RUNNER_TEMP}/project"
+          git init -b main "${project}"
+          git -C "${project}" config user.name release-test
+          git -C "${project}" config user.email release-test@example.com
+          printf 'release gate\n' > "${project}/README.md"
+          git -C "${project}" add README.md
+          git -C "${project}" commit -m initial
+          hv init "${project}"
+          service_definition="${HOME}/Library/LaunchAgents/org.hive.daemon.plist"
+          test -e "${service_definition}"
+          grep -F "$(brew --prefix "${{ github.repository }}/hive")/bin/hv" "${service_definition}"
+          mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output"
+          printf preserved > "${project}/.agents/skills/hive/SKILL.md"
+          printf preserved > "${project}/completed-output/result"
+          hv update
+          hv uninstall
+          test ! -e "$(brew --prefix)/bin/hv"
+          test ! -e "${service_definition}"
+          test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved
+          test "$(cat "${project}/completed-output/result")" = preserved
+
+  tier1-aur:
+    needs: [qualify, candidate]
+    runs-on: ubuntu-22.04
+    container: archlinux:latest
+    steps:
+      - run: pacman -Syu --noconfirm base-devel git curl
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v5
+        with:
+          name: qualified-release-candidate
+          path: candidate
+      - name: AE2 AUR build, update delegation, and removal gate
+        run: |
+          version="${{ needs.qualify.outputs.version }}"
+          cp -R candidate/generated/aur "${RUNNER_TEMP}/aur"
+          archive="${PWD}/candidate/dist/hive-${version}-linux-x86_64.tar.gz"
+          sed -i "s|https://github.com/.*/hive-${version}-linux-x86_64.tar.gz|file://${archive}|" \
+            "${RUNNER_TEMP}/aur/PKGBUILD"
+          useradd -m builder
+          chown -R builder:builder "${RUNNER_TEMP}/aur"
+          runuser -u builder -- bash -lc "cd '${RUNNER_TEMP}/aur' && makepkg --noconfirm"
+          pacman -U --noconfirm "${RUNNER_TEMP}/aur/"hive-bin-*.pkg.tar.*
+          test "$(hv --version)" = "${version}"
+          mkdir -p "${RUNNER_TEMP}/fake-bin"
+          printf '#!/bin/sh\nexit 0\n' > "${RUNNER_TEMP}/fake-bin/systemctl"
+          printf '#!/bin/sh\nif [ "$1" = "-S" ]; then printf delegated > "%s"; exit 0; fi\nexec pacman "$@"\n' \
+            "${RUNNER_TEMP}/yay-called" > "${RUNNER_TEMP}/fake-bin/yay"
+          chmod 0755 "${RUNNER_TEMP}/fake-bin/systemctl" "${RUNNER_TEMP}/fake-bin/yay"
+          export XDG_CONFIG_HOME="${RUNNER_TEMP}/config"
+          export XDG_DATA_HOME="${RUNNER_TEMP}/data"
+          export XDG_STATE_HOME="${RUNNER_TEMP}/state"
+          export XDG_CACHE_HOME="${RUNNER_TEMP}/cache"
+          export XDG_RUNTIME_DIR="${RUNNER_TEMP}/runtime"
+          export PATH="${RUNNER_TEMP}/fake-bin:${PATH}"
+          project="${RUNNER_TEMP}/project"
+          git init -b main "${project}"
+          git -C "${project}" config user.name release-test
+          git -C "${project}" config user.email release-test@example.com
+          printf 'release gate\n' > "${project}/README.md"
+          git -C "${project}" add README.md
+          git -C "${project}" commit -m initial
+          hv init "${project}"
+          service_definition="${XDG_CONFIG_HOME}/systemd/user/hive.service"
+          test -e "${service_definition}"
+          grep -F /usr/bin/hv "${service_definition}"
+          mkdir -p "${project}/.agents/skills/hive" "${project}/completed-output"
+          printf preserved > "${project}/.agents/skills/hive/SKILL.md"
+          printf preserved > "${project}/completed-output/result"
+          hv update
+          test "$(cat "${RUNNER_TEMP}/yay-called")" = delegated
+          hv uninstall
+          test ! -e /usr/bin/hv
+          test ! -e "${service_definition}"
+          test "$(cat "${project}/.agents/skills/hive/SKILL.md")" = preserved
+          test "$(cat "${project}/completed-output/result")" = preserved
+
+  publish-skills:
+    needs: [qualify, tier1-shell, tier1-homebrew, tier1-aur]
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          path: hive
+      - uses: actions/checkout@v6
+        with:
+          repository: ivankuznetsov/hive-skills
+          token: ${{ secrets.HIVE_SKILLS_TOKEN }}
+          path: skills
+      - name: Publish the separately versioned compatible skills tree
+        run: |
+          test "$(ruby -Ihive/lib -rhive/version -e 'print Hive::VERSION')" = \
+            "$(ruby -rjson -e 'print JSON.parse(File.read("hive/hive-skills/package.json")).fetch("version")')"
+          rsync -a --delete --exclude .git hive/hive-skills/ skills/
+          git -C skills config user.name github-actions
+          git -C skills config user.email github-actions@github.com
+          git -C skills add -A
+          git -C skills diff --cached --quiet || git -C skills commit -m "release: hive-skills ${GITHUB_REF_NAME}"
+          ! git -C skills ls-remote --exit-code --tags origin "refs/tags/${GITHUB_REF_NAME}" >/dev/null 2>&1
+          git -C skills tag "${GITHUB_REF_NAME}"
+          git -C skills push origin HEAD "${GITHUB_REF_NAME}"
+
+  release:
+    needs: [qualify, candidate, tier1-shell, tier1-homebrew, tier1-aur, publish-skills]
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v5
+        with:
+          name: qualified-release-candidate
+          path: candidate
+      - name: Publish immutable qualified release
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          gh release create "${GITHUB_REF_NAME}" candidate/dist/* candidate/build/*.spdx.json \
+            --verify-tag --title "Hive ${GITHUB_REF_NAME}" --generate-notes
+      - uses: actions/upload-artifact@v4
+        with:
+          name: canonical-release-metadata
+          path: candidate/dist/
+
+  publish-downstream:
+    needs: release
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          path: hive
+      - uses: actions/download-artifact@v5
+        with:
+          name: canonical-release-metadata
+          path: hive/dist
+      - uses: actions/checkout@v6
+        with:
+          repository: ${{ vars.HOMEBREW_TAP_REPOSITORY }}
+          token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+          path: tap
+      - name: Check out AUR package with pinned host identity
+        env:
+          AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
+        run: |
+          install -Dm600 /dev/stdin ~/.ssh/id_ed25519 <<<"${AUR_SSH_KEY}"
+          mkdir -p ~/.ssh
+          ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "${RUNNER_TEMP}/aur-known-host"
+          test "$(ssh-keygen -E sha256 -lf "${RUNNER_TEMP}/aur-known-host" | awk '{print $2}')" = \
+            "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"
+          install -m600 "${RUNNER_TEMP}/aur-known-host" ~/.ssh/known_hosts
+          git clone "ssh://aur@aur.archlinux.org/${{ vars.AUR_REPOSITORY }}.git" aur
+      - name: Verify public bytes, render, and publish both native channels
+        run: |
+          cd hive
+          HOMEBREW_TAP_DIR=../tap AUR_REPO_DIR=../aur \
+            scripts/publish-packages --manifest dist/release-manifest.json
+          for repo in ../tap ../aur; do
+            git -C "${repo}" config user.name github-actions
+            git -C "${repo}" config user.email github-actions@github.com
+            git -C "${repo}" add -A
+            git -C "${repo}" diff --cached --quiet || \
+              git -C "${repo}" commit -m "hive ${GITHUB_REF_NAME}"
+            git -C "${repo}" push
+          done
diff --git a/README.md b/README.md
index 1fd7a0ff..d066446c 100644
--- a/README.md
+++ b/README.md
@@ -48,30 +48,11 @@ Hive's other primary surface is a coding agent — Claude Code, Codex, Gemini, P
 
 ### Install Hive via an agent
 
-Paste this into Claude Code, Codex, or another agent CLI when you want it to install Hive for you. The block has explicit stop-conditions so the agent halts before clobbering existing state.
-
-```text
-Install Hive from the canonical GitHub source into ~/Dev/hive and put the hive binary on PATH.
-
-Before changing anything:
-- If ~/Dev/hive already exists, stop and ask whether to reuse it, pull it, or choose another directory.
-- If `claude` is missing or `claude --version` is older than 2.1.118, stop and report the missing prerequisite.
-- If `codex` is missing or `codex --version` is older than 0.125.0, stop and report that the default execute agent will not work until Codex is installed.
-- If `gh auth status` fails, stop and ask the user to authenticate GitHub CLI.
-- If ~/.local/bin is not on PATH, stop and ask which PATH directory should receive the symlink, then substitute that directory for ~/.local/bin in the link command below.
-- If <bin-dir>/hive already exists (file, symlink, or another checkout's binary), stop and ask whether to overwrite it or pick a different bin directory before running the link command below.
-
-Run these commands in order (replace <bin-dir> with the chosen PATH directory; default is ~/.local/bin):
-
-git clone https://github.com/ivankuznetsov/hive ~/Dev/hive
-cd ~/Dev/hive
-bundle install
-mkdir -p <bin-dir>
-ln -sf ~/Dev/hive/bin/hive <bin-dir>/hive
-hive --version
-
-Report the installed version and the path returned by `command -v hive`.
-```
+Use the unchanged, consent-gated prompt in
+[docs/install-with-agent.md](docs/install-with-agent.md). It detects macOS,
+Ubuntu, or Arch; selects Homebrew, AUR, or the verified shell channel; preserves
+a foreign `hive` command by using `hv`; confirms the project root before init;
+and treats the separate agent-skill install as another approval boundary.
 
 ### Operate Hive day-to-day via an agent
 
@@ -86,17 +67,16 @@ The `--json` envelope is stable across versions (schemas live under [schemas/](s
 
 ## Install
 
-Requires Ruby 3.4, git ≥ 2.40, an authenticated `claude` ≥ 2.1.118, `codex` ≥ 0.125.0 for the default execute agent, and an authenticated `gh`. See [docs/getting-started.md#prerequisites](docs/getting-started.md#prerequisites) for the full prerequisite list and the first-run walkthrough.
+Release installs are self-contained and require no host Ruby:
 
-```bash
-git clone https://github.com/ivankuznetsov/hive ~/Dev/hive
-cd ~/Dev/hive
-bundle install
-mkdir -p ~/.local/bin
-ln -sf ~/Dev/hive/bin/hive ~/.local/bin/hive
-```
+- Homebrew tap on macOS arm64
+- AUR package `hive-bin` on Arch x86_64
+- checksum-verifying shell installer on macOS arm64 and Linux x86_64
 
-If `~/.local/bin` is not on `PATH`, put the symlink in a directory that is. The `-sf` form overwrites an existing `hive` at the target path; run `command -v hive` first if you already have one installed and don't want to clobber it. Verify the install with `hive --version` and `hive doctor`.
+Every channel installs `hv`; it exposes `hive` only when that name is
+unclaimed. See [docs/install.md](docs/install.md) for commands, XDG paths,
+dependency diagnostics, service consent, and the separate `hive-skills`
+package. Source-checkout development still uses Ruby 3.4 and Bundler.
 
 ## Power-User / Scripting CLI
 
diff --git a/Rakefile b/Rakefile
index 9f5959cd..764def85 100644
--- a/Rakefile
+++ b/Rakefile
@@ -40,6 +40,14 @@ namespace :e2e do
   end
 end
 
+Rake::TestTask.new(:distribution) do |t|
+  t.libs << "test"
+  t.libs << "lib"
+  t.test_files = FileList["test/{release,install,acceptance}/**/*_test.rb"]
+  t.warning = false
+  t.description = "Run release, installer, package, prompt, and safety contracts"
+end
+
 desc "Run real-subprocess CLI/TUI e2e scenarios"
 task :e2e do
   ruby "bin/hive-e2e", "run"
diff --git a/assets/project/README.md b/assets/project/README.md
new file mode 100644
index 00000000..c27e4d33
--- /dev/null
+++ b/assets/project/README.md
@@ -0,0 +1,5 @@
+# Hive project configuration
+
+Files in this directory are generated conservatively by `hive init`. Hive
+never overwrites an edited file and tracks only generated files in
+`ownership.yml`.
diff --git a/assets/project/settings.yml b/assets/project/settings.yml
new file mode 100644
index 00000000..7e4ba08a
--- /dev/null
+++ b/assets/project/settings.yml
@@ -0,0 +1,2 @@
+version: 1
+pipeline_state: ../.hive-state
diff --git a/assets/services/hive.service b/assets/services/hive.service
new file mode 100644
index 00000000..35a5249e
--- /dev/null
+++ b/assets/services/hive.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Hive pipeline daemon
+
+[Service]
+Type=simple
+ExecStart=@EXECUTABLE@ daemon start
+Restart=on-failure
+Environment=HIVE_STATE_DIR=@STATE_DIR@
+
+[Install]
+WantedBy=default.target
diff --git a/assets/services/launchd.plist b/assets/services/launchd.plist
new file mode 100644
index 00000000..476ba3f5
--- /dev/null
+++ b/assets/services/launchd.plist
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
+  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>org.hive.daemon</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>@EXECUTABLE@</string>
+    <string>daemon</string>
+    <string>start</string>
+  </array>
+  <key>RunAtLoad</key><false/>
+  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
+  <key>StandardOutPath</key><string>@STATE_DIR@/daemon.log</string>
+  <key>StandardErrorPath</key><string>@STATE_DIR@/daemon.log</string>
+</dict>
+</plist>
diff --git a/assets/shared/release.yml b/assets/shared/release.yml
new file mode 100644
index 00000000..6e907490
--- /dev/null
+++ b/assets/shared/release.yml
@@ -0,0 +1,2 @@
+version: 1
+project_assets: project
diff --git a/bin/hive b/bin/hive
index bfd53884..297cfe8c 100755
--- a/bin/hive
+++ b/bin/hive
@@ -1,6 +1,7 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
+resource_root = ENV.fetch("HIVE_RESOURCE_ROOT") { File.expand_path("..", __dir__) }
+$LOAD_PATH.unshift(File.join(resource_root, "lib"))
 
 require "hive"
 require "hive/cli"
diff --git a/docs/install-with-agent.md b/docs/install-with-agent.md
new file mode 100644
index 00000000..037927e2
--- /dev/null
+++ b/docs/install-with-agent.md
@@ -0,0 +1,49 @@
+# Install Hive with an agent
+
+Paste the following prompt unchanged into Claude Code, Codex, or Pi:
+
+> Inspect the host without changing it: report `uname -s`, `uname -m`, the
+> Linux distribution (when applicable), whether `brew` exists on macOS,
+> whether `paru` or `yay` exists on Arch, and whether `hive` or `hv` already
+> resolves on PATH. Stop with an explanation unless this is macOS arm64,
+> Ubuntu 22.04+ x86_64, or Arch x86_64.
+>
+> Install requested core release `0.1.0` from repository
+> `ivankuznetsov/hive`. Select exactly one channel: run
+> `brew install ivankuznetsov/hive/hive` on macOS when Homebrew is present;
+> install AUR package `hive-bin` through `paru` or `yay` on Arch when one is
+> present; otherwise download
+> `https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh`,
+> show it to the user, and run it with `--version 0.1.0` as the
+> checksum-verifying Hive shell installer. Do not install Homebrew, an AUR
+> helper, Ruby, Git, Bash, jq, gh, Claude Code, Codex, Pi, or any other
+> third-party dependency. Show the exact commands and selected channel, then
+> ask for approval before installing.
+>
+> After installation, resolve both `hive` and `hv`. Preserve any pre-existing
+> foreign `hive`; use `hv` for every remaining step when there is a conflict.
+> Run the selected command with `--version` and require the requested release
+> version before continuing.
+>
+> Ask whether to initialize this project. If approved, show and confirm the
+> absolute Git project root, then run `<selected-command> init <root>`. Do not
+> initialize `/`, the home directory, a parent workspace, or an unconfirmed
+> directory. Explain the dependency diagnostics and daemon-start consent
+> result.
+>
+> Finally, ask separately whether to install Hive skill release `0.1.0` from
+> `ivankuznetsov/hive-skills`. If approved, use the current client's native
+> mechanism: `claude plugin marketplace add
+> ivankuznetsov/hive-skills@v0.1.0` then `claude plugin install
+> hive-skills@hive-skills`; `codex plugin marketplace add
+> ivankuznetsov/hive-skills --ref v0.1.0` then `codex plugin add
+> hive-skills@hive-skills`; or `pi install
+> git:github.com/ivankuznetsov/hive-skills@v0.1.0`. Show the selected commands
+> first and require the skill version to match core `0.1.0`. If the current
+> client has no verified native mechanism, skip this step and explain why.
+> Never copy files directly into private agent directories. Report the core
+> version, selected command, channel, init result, service state, and skill
+> result.
+
+The prompt deliberately separates core installation, project initialization,
+and skill installation into three consent boundaries.
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 00000000..670fe3ce
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,29 @@
+# Install Hive
+
+Hive v0.1 supports:
+
+- macOS arm64 through the configured Homebrew tap
+- Arch x86_64 through AUR package `hive-bin`
+- macOS arm64 and Linux x86_64 through the verified shell installer
+
+All channels install the same self-contained release payload and require no
+host Ruby. `hv` is always installed. `hive` is linked only when that name is
+not already owned by another command.
+
+The shell channel is:
+
+```sh
+curl -fsSLO https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/install.sh
+less install.sh
+bash install.sh --version 0.1.0
+```
+
+The installer verifies the release manifest and archive SHA-256 before
+replacement. It uses `${HIVE_BIN_DIR:-$HOME/.local/bin}` and writes its receipt
+under `${XDG_DATA_HOME:-$HOME/.local/share}/hive`.
+
+After installation, run `hive init /absolute/project` (or `hv init ...` after a
+name conflict). Init creates versioned XDG data, a conservative `.hive/`
+scaffold, the existing `.hive-state` pipeline worktree, and a stopped user
+service definition. Starting the service requires the init consent prompt.
+Agent skills are distributed separately through `hive-skills`.
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 00000000..a5a25440
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,26 @@
+# Release Hive
+
+Tags are strict immutable semver (`vX.Y.Z`) and the embedded
+`Hive::VERSION` must match. The release workflow:
+
+1. Requires both native channel repositories and their credentials, then runs
+   Ruby/distribution/lint plus native installs with the current Claude, Codex,
+   and Pi clients.
+2. Builds Tebako executables on macOS arm64 and Ubuntu 22.04 x86_64, then runs
+   packaged `--version` smoke checks without relying on host Ruby.
+3. Packages both targets and authenticated shell helpers, writes
+   `release-manifest.json`, `SHA256SUMS`, and SPDX SBOMs, then attests the
+   actual release archives.
+4. Runs shell lifecycle gates on macOS/Linux, a Homebrew lifecycle on macOS,
+   and an AUR build/update/uninstall lifecycle on Arch.
+5. Publishes the matching `ivankuznetsov/hive-skills` tag, then refuses to
+   mutate an existing core release and publishes the immutable GitHub assets.
+6. Downloads every public asset and verifies its size and SHA-256 before
+   rendering and pushing both the Homebrew tap and AUR metadata.
+
+Configure `HOMEBREW_TAP_REPOSITORY`, `AUR_REPOSITORY`,
+`HOMEBREW_TAP_TOKEN`, `AUR_SSH_KEY`, and `HIVE_SKILLS_TOKEN` before tagging.
+The AUR checkout accepts only its pinned ED25519 host fingerprint. Rehearse
+with a disposable prerelease tag.
+Tier-1 failures block publication; tier-2 jobs, when added, must remain clearly
+labelled best-effort.
diff --git a/docs/uninstall.md b/docs/uninstall.md
new file mode 100644
index 00000000..cb99d943
--- /dev/null
+++ b/docs/uninstall.md
@@ -0,0 +1,19 @@
+# Uninstall Hive
+
+Run `hive uninstall` (or `hv uninstall`) to validate the installation receipt,
+stop and unregister the user service, and delegate core removal to Homebrew,
+`paru`/`yay`, or the shell channel.
+
+By default, all user work is preserved:
+
+- XDG config, data, cache, and state
+- `.hive-state` tasks, logs, completed outputs, and worktrees
+- every project `.hive/` file
+- all agent skills and marketplace packages
+
+To remove generated project scaffold files, pass an absolute project path with
+`--project-cleanup`. `--purge` makes that same narrow cleanup
+non-interactive. Both modes remove only regular, unmodified files whose digest
+matches `.hive/ownership.yml`. Modified files, unknown files, symlinks,
+path-escaping entries, state, completed work, and agent skills remain
+preserved. Hive never recursively deletes `.hive/` or `.hive-state/`.
diff --git a/hive-skills/.agents/plugins/marketplace.json b/hive-skills/.agents/plugins/marketplace.json
new file mode 100644
index 00000000..7d18a57b
--- /dev/null
+++ b/hive-skills/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+  "name": "hive-skills",
+  "interface": {
+    "displayName": "Hive Skills"
+  },
+  "plugins": [
+    {
+      "name": "hive-skills",
+      "source": {
+        "source": "local",
+        "path": "./plugins/hive-skills"
+      },
+      "policy": {
+        "installation": "AVAILABLE",
+        "authentication": "ON_INSTALL"
+      },
+      "category": "Productivity"
+    }
+  ]
+}
diff --git a/hive-skills/.claude-plugin/marketplace.json b/hive-skills/.claude-plugin/marketplace.json
new file mode 100644
index 00000000..518ee3a9
--- /dev/null
+++ b/hive-skills/.claude-plugin/marketplace.json
@@ -0,0 +1,18 @@
+{
+  "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
+  "name": "hive-skills",
+  "version": "0.1.0",
+  "description": "Agent workflows for Hive delivery pipelines.",
+  "owner": {
+    "name": "Hive maintainers"
+  },
+  "plugins": [
+    {
+      "name": "hive-skills",
+      "source": "./plugins/hive-skills",
+      "description": "Safe Hive pipeline operations.",
+      "version": "0.1.0",
+      "category": "productivity"
+    }
+  ]
+}
diff --git a/hive-skills/marketplaces/adapters.json b/hive-skills/marketplaces/adapters.json
new file mode 100644
index 00000000..18595ba1
--- /dev/null
+++ b/hive-skills/marketplaces/adapters.json
@@ -0,0 +1,17 @@
+{
+  "schema_version": 1,
+  "version": "0.1.0",
+  "adapters": {
+    "claude": [
+      "claude plugin marketplace add ivankuznetsov/hive-skills@v0.1.0",
+      "claude plugin install hive-skills@hive-skills"
+    ],
+    "codex": [
+      "codex plugin marketplace add ivankuznetsov/hive-skills --ref v0.1.0",
+      "codex plugin add hive-skills@hive-skills"
+    ],
+    "pi": [
+      "pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0"
+    ]
+  }
+}
diff --git a/hive-skills/marketplaces/claude/contract.json b/hive-skills/marketplaces/claude/contract.json
new file mode 100644
index 00000000..7ae8d153
--- /dev/null
+++ b/hive-skills/marketplaces/claude/contract.json
@@ -0,0 +1,4 @@
+{
+  "marketplace": ".claude-plugin/marketplace.json",
+  "plugin": "plugins/hive-skills/.claude-plugin/plugin.json"
+}
diff --git a/hive-skills/marketplaces/codex/contract.json b/hive-skills/marketplaces/codex/contract.json
new file mode 100644
index 00000000..2bf23fc8
--- /dev/null
+++ b/hive-skills/marketplaces/codex/contract.json
@@ -0,0 +1,4 @@
+{
+  "marketplace": ".agents/plugins/marketplace.json",
+  "plugin": "plugins/hive-skills/.codex-plugin/plugin.json"
+}
diff --git a/hive-skills/marketplaces/pi/contract.json b/hive-skills/marketplaces/pi/contract.json
new file mode 100644
index 00000000..1e77b79c
--- /dev/null
+++ b/hive-skills/marketplaces/pi/contract.json
@@ -0,0 +1,4 @@
+{
+  "manifest": "package.json",
+  "skills": "skills"
+}
diff --git a/hive-skills/package.json b/hive-skills/package.json
new file mode 100644
index 00000000..228e370b
--- /dev/null
+++ b/hive-skills/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "hive-skills",
+  "version": "0.1.0",
+  "private": true,
+  "keywords": ["pi-package"],
+  "pi": {
+    "skills": ["./skills"]
+  }
+}
diff --git a/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json b/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json
new file mode 100644
index 00000000..2162c429
--- /dev/null
+++ b/hive-skills/plugins/hive-skills/.claude-plugin/plugin.json
@@ -0,0 +1,10 @@
+{
+  "name": "hive-skills",
+  "version": "0.1.0",
+  "description": "Safe agent workflows for operating Hive delivery pipelines.",
+  "author": {
+    "name": "Hive maintainers"
+  },
+  "license": "MIT",
+  "skills": "./skills/"
+}
diff --git a/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json b/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json
new file mode 100644
index 00000000..55d4db3d
--- /dev/null
+++ b/hive-skills/plugins/hive-skills/.codex-plugin/plugin.json
@@ -0,0 +1,20 @@
+{
+  "name": "hive-skills",
+  "version": "0.1.0",
+  "description": "Safe agent workflows for operating Hive delivery pipelines.",
+  "author": {
+    "name": "Hive maintainers"
+  },
+  "license": "MIT",
+  "keywords": ["hive", "delivery", "workflow"],
+  "skills": "./skills/",
+  "interface": {
+    "displayName": "Hive Skills",
+    "shortDescription": "Operate Hive pipelines from Codex.",
+    "longDescription": "Inspect, diagnose, and advance Hive delivery tasks while preserving approval and state boundaries.",
+    "developerName": "Hive maintainers",
+    "category": "Productivity",
+    "capabilities": ["Interactive"],
+    "defaultPrompt": ["Inspect this project's Hive status and recommend the next safe action."]
+  }
+}
diff --git a/hive-skills/plugins/hive-skills/skills/hive/SKILL.md b/hive-skills/plugins/hive-skills/skills/hive/SKILL.md
new file mode 100644
index 00000000..f1fbd01f
--- /dev/null
+++ b/hive-skills/plugins/hive-skills/skills/hive/SKILL.md
@@ -0,0 +1,21 @@
+---
+name: hive
+description: Operate an initialized Hive software-delivery pipeline. Use when an agent needs to inspect Hive status, create work, advance a task, diagnose a blocked stage, or manage the Hive daemon; do not use to install Hive or copy agent skills.
+---
+
+# Hive
+
+1. Resolve `hive`, falling back to `hv` when `hive` is absent or foreign.
+2. Run `<command> status --json` before choosing an action.
+3. Prefer the workflow verbs reported by status (`brainstorm`, `plan`,
+   `develop`, `open-pr`, `review`, `finalize`, `archive`) over moving stage
+   directories directly.
+4. Show the exact target and state-changing command before running it. Respect
+   waiting markers and explicit user approval gates.
+5. Use `<command> status --diagnose <slug>` for red rows before changing state.
+   Clear only the named recovery marker recommended by the diagnostic.
+6. Never edit `plan.md`, `worktree.yml`, generated reviews, or pipeline markers
+   to manufacture completion. Never delete `.hive-state`, completed outputs, or
+   unknown `.hive/` content.
+7. For daemon work, check `<command> daemon status` first. Do not enable,
+   start, stop, update, or uninstall without explicit user approval.
diff --git a/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml b/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml
new file mode 100644
index 00000000..83589f42
--- /dev/null
+++ b/hive-skills/plugins/hive-skills/skills/hive/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+  display_name: "Hive"
+  short_description: "Operate Hive delivery pipelines safely"
+  default_prompt: "Use $hive to inspect this project and advance the appropriate Hive workflow."
diff --git a/hive-skills/skills/hive/SKILL.md b/hive-skills/skills/hive/SKILL.md
new file mode 100644
index 00000000..f1fbd01f
--- /dev/null
+++ b/hive-skills/skills/hive/SKILL.md
@@ -0,0 +1,21 @@
+---
+name: hive
+description: Operate an initialized Hive software-delivery pipeline. Use when an agent needs to inspect Hive status, create work, advance a task, diagnose a blocked stage, or manage the Hive daemon; do not use to install Hive or copy agent skills.
+---
+
+# Hive
+
+1. Resolve `hive`, falling back to `hv` when `hive` is absent or foreign.
+2. Run `<command> status --json` before choosing an action.
+3. Prefer the workflow verbs reported by status (`brainstorm`, `plan`,
+   `develop`, `open-pr`, `review`, `finalize`, `archive`) over moving stage
+   directories directly.
+4. Show the exact target and state-changing command before running it. Respect
+   waiting markers and explicit user approval gates.
+5. Use `<command> status --diagnose <slug>` for red rows before changing state.
+   Clear only the named recovery marker recommended by the diagnostic.
+6. Never edit `plan.md`, `worktree.yml`, generated reviews, or pipeline markers
+   to manufacture completion. Never delete `.hive-state`, completed outputs, or
+   unknown `.hive/` content.
+7. For daemon work, check `<command> daemon status` first. Do not enable,
+   start, stop, update, or uninstall without explicit user approval.
diff --git a/hive-skills/skills/hive/agents/openai.yaml b/hive-skills/skills/hive/agents/openai.yaml
new file mode 100644
index 00000000..83589f42
--- /dev/null
+++ b/hive-skills/skills/hive/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+  display_name: "Hive"
+  short_description: "Operate Hive delivery pipelines safely"
+  default_prompt: "Use $hive to inspect this project and advance the appropriate Hive workflow."
diff --git a/lib/hive.rb b/lib/hive.rb
index 8db3300a..428254f6 100644
--- a/lib/hive.rb
+++ b/lib/hive.rb
@@ -1,5 +1,6 @@
+require "hive/version"
+
 module Hive
-  VERSION = "0.1.0".freeze
   MIN_CLAUDE_VERSION = "2.1.118".freeze
 
   module Schemas
diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb
new file mode 100644
index 00000000..2277abf9
--- /dev/null
+++ b/lib/hive/assets.rb
@@ -0,0 +1,163 @@
+require "digest"
+require "fileutils"
+require "yaml"
+require "hive/paths"
+
+module Hive
+  module Assets
+    MANIFEST = ".hive/ownership.yml".freeze
+    module_function
+
+    def seed_shared!(source: nil, project_source: nil,
+                     destination: Hive::Paths.releases_dir, version: Hive::VERSION)
+      bundled_source = source.nil?
+      source ||= Hive::Paths.resource("assets", "shared")
+      project_source ||= Hive::Paths.resource("assets", "project") if bundled_source
+      target = File.join(destination, version)
+      if complete_release?(target)
+        switch_current!(destination, version)
+        return target
+      end
+
+      FileUtils.mkdir_p(destination)
+      temporary = File.join(destination, ".#{version}.tmp-#{Process.pid}")
+      FileUtils.rm_rf(temporary) if File.exist?(temporary)
+      FileUtils.mkdir_p(temporary)
+      copy_tree(source, temporary)
+      copy_tree(project_source, File.join(temporary, "project")) if project_source
+      File.write(File.join(temporary, ".complete"), "#{version}\n")
+      File.rename(temporary, target)
+      switch_current!(destination, version)
+      target
+    ensure
+      FileUtils.rm_rf(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
+    end
+
+    def scaffold_project!(project_root, source: File.join(Hive::Paths.release_dir, "project"))
+      root = safe_project_root(project_root)
+      destination = File.join(root, ".hive")
+      manifest_path = File.join(root, MANIFEST)
+      ensure_safe_project_destination!(root, destination)
+      previous = load_manifest(manifest_path)
+      result = { created: [], conflicts: [] }
+      managed = previous.fetch("files", {})
+
+      source_files(source).each do |relative|
+        target = File.join(destination, relative)
+        project_relative = File.join(".hive", relative)
+        source_digest = digest(File.join(source, relative))
+        if File.exist?(target) || File.symlink?(target)
+          if safe_regular_file?(target) && managed[project_relative] == digest(target) && digest(target) == source_digest
+            next
+          end
+          result[:conflicts] << project_relative
+          next
+        end
+
+        ensure_safe_parent!(root, File.dirname(target))
+        FileUtils.mkdir_p(File.dirname(target))
+        FileUtils.cp(File.join(source, relative), target)
+        managed[project_relative] = digest(target)
+        result[:created] << project_relative
+      end
+
+      write_manifest(manifest_path, managed)
+      result
+    end
+
+    def safe_project_root(project_root)
+      root = File.realpath(File.expand_path(project_root))
+      unsafe = [ File::SEPARATOR, File.realpath(Dir.home) ]
+      raise Hive::ConfigError, "refusing unsafe project root: #{root}" if unsafe.include?(root)
+      raise Hive::ConfigError, "project root is not writable: #{root}" unless File.writable?(root)
+
+      root
+    rescue Errno::ENOENT
+      raise Hive::ConfigError, "project root does not exist: #{project_root}"
+    end
+
+    def source_files(source)
+      return [] unless File.directory?(source)
+
+      Dir.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH)
+         .select { |path| File.file?(path) }
+         .map { |path| path.delete_prefix("#{source}/") }
+         .sort
+    end
+
+    def copy_tree(source, destination)
+      source_files(source).each do |relative|
+        target = File.join(destination, relative)
+        FileUtils.mkdir_p(File.dirname(target))
+        FileUtils.cp(File.join(source, relative), target)
+      end
+    end
+
+    def complete_release?(target)
+      File.directory?(target) && File.file?(File.join(target, ".complete"))
+    end
+
+    def switch_current!(destination, version)
+      pointer = File.join(destination, "current")
+      if File.exist?(pointer) && !File.symlink?(pointer)
+        raise Hive::ConfigError, "refusing to replace non-symlink release pointer: #{pointer}"
+      end
+      return if File.symlink?(pointer) && File.readlink(pointer) == version
+
+      temporary = File.join(destination, ".current-#{Process.pid}")
+      File.symlink(version, temporary)
+      File.rename(temporary, pointer)
+    ensure
+      FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+    end
+
+    def load_manifest(path)
+      return { "version" => 1, "files" => {} } unless File.file?(path)
+
+      data = YAML.safe_load_file(path)
+      data.is_a?(Hash) && data["files"].is_a?(Hash) ? data : { "version" => 1, "files" => {} }
+    rescue Psych::Exception
+      { "version" => 1, "files" => {} }
+    end
+
+    def write_manifest(path, files)
+      raise Hive::ConfigError, "refusing symlinked ownership manifest: #{path}" if File.symlink?(path)
+
+      FileUtils.mkdir_p(File.dirname(path))
+      temporary = "#{path}.tmp-#{Process.pid}"
+      File.write(temporary, { "version" => 1, "files" => files.sort.to_h }.to_yaml)
+      File.rename(temporary, path)
+    ensure
+      FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+    end
+
+    def safe_regular_file?(path)
+      File.file?(path) && !File.symlink?(path)
+    end
+
+    def digest(path)
+      Digest::SHA256.file(path).hexdigest
+    end
+
+    def ensure_safe_project_destination!(root, destination)
+      if File.symlink?(destination)
+        raise Hive::ConfigError, "refusing symlinked project scaffold directory: #{destination}"
+      end
+      return unless File.exist?(destination) && !File.directory?(destination)
+
+      raise Hive::ConfigError, "project scaffold path is not a directory: #{destination}"
+    end
+
+    def ensure_safe_parent!(root, parent)
+      hive_root = File.join(root, ".hive")
+      current = parent
+      while current.start_with?("#{hive_root}#{File::SEPARATOR}") || current == hive_root
+        raise Hive::ConfigError, "refusing symlinked project scaffold parent: #{current}" if File.symlink?(current)
+
+        break if current == hive_root
+
+        current = File.dirname(current)
+      end
+    end
+  end
+end
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index 9bc60cb8..c697e21f 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -63,9 +63,18 @@ module Hive
       See `wiki/commands/init.md` for the full prompt flow and ADR-023.
     DESC
     option :force, type: :boolean, default: false, desc: "skip clean-tree check"
+    option :provider, type: :string, desc: "first-run provider choice"
+    option :model, type: :string, desc: "first-run model choice"
+    option :credential_env, type: :string, desc: "environment variable containing provider credentials"
     def init(project_path = Dir.pwd)
       require "hive/commands/init"
-      Hive::Commands::Init.new(project_path, force: options[:force]).call
+      Hive::Commands::Init.new(
+        project_path,
+        force: options[:force],
+        provider: options[:provider],
+        model: options[:model],
+        credential_env: options[:credential_env]
+      ).call
     end
 
     desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)"
@@ -92,6 +101,25 @@ module Hive
       Hive::Commands::Forget.new(name, json: options[:json]).call
     end
 
+    desc "update", "Update Hive through its owning installation channel"
+    def update
+      require "hive/commands/update"
+      Hive::Commands::Update.new.call
+    end
+
+    desc "uninstall [PROJECT_PATH]", "Unregister Hive and remove the core through its owning channel"
+    option :project_cleanup, type: :boolean, default: false,
+                             desc: "remove only unmodified generated .hive files"
+    option :purge, type: :boolean, default: false,
+                   desc: "non-interactively remove known generated .hive files"
+    def uninstall(project_path = nil)
+      require "hive/commands/uninstall"
+      Hive::Commands::Uninstall.new(
+        project_root: project_path,
+        cleanup: options[:project_cleanup] || options[:purge]
+      ).call
+    end
+
     desc "prune", "Drop registry entries whose project path no longer exists"
     long_desc <<~DESC
       Walks ~/Dev/hive/config.yml and removes every `registered_projects`
diff --git a/lib/hive/command_name.rb b/lib/hive/command_name.rb
new file mode 100644
index 00000000..2455152d
--- /dev/null
+++ b/lib/hive/command_name.rb
@@ -0,0 +1,35 @@
+module Hive
+  module CommandName
+    module_function
+
+    def select(path: ENV.fetch("PATH", ""), installed_executable: $PROGRAM_NAME)
+      resolved = resolve("hive", path)
+      return "hive" unless resolved
+      return "hive" if same_file?(resolved, installed_executable)
+
+      "hv"
+    end
+
+    def resolve(command, path = ENV.fetch("PATH", ""))
+      path.split(File::PATH_SEPARATOR).each do |directory|
+        candidate = File.expand_path(command, directory.empty? ? "." : directory)
+        return candidate if File.file?(candidate) && File.executable?(candidate)
+      end
+      nil
+    end
+
+    def stable_executable(path: ENV.fetch("PATH", ""), current: $PROGRAM_NAME)
+      command = select(path: path, installed_executable: current)
+      resolve(command, path) || resolve(File.basename(current), path) || File.expand_path(current)
+    end
+
+    def same_file?(left, right)
+      return false unless right && File.exist?(right)
+
+      File.identical?(left, right)
+    rescue Errno::ENOENT
+      false
+    end
+    private_class_method :same_file?
+  end
+end
diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb
index f88bca6f..bd576a00 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -44,14 +44,17 @@ module Hive
       end
 
       def initialize(subcommand, target = nil, detach: false, dry_run: false,
-                     all: false, json: false, hive_home: Hive::Config.hive_home)
+                     all: false, json: false, hive_home: nil)
         @subcommand = subcommand
         @target = target
         @detach = detach
         @dry_run = dry_run
         @all = all
         @json = json
-        @hive_home = hive_home
+        legacy_runtime_root = hive_home || ENV["HIVE_HOME"]
+        legacy_runtime_root = nil if legacy_runtime_root&.empty?
+        @hive_home = legacy_runtime_root || Hive::Config.hive_home
+        @legacy_runtime_root = legacy_runtime_root
       end
 
       def call
@@ -72,11 +75,19 @@ module Hive
       end
 
       def pid_file
-        @pid_file ||= File.join(@hive_home, ".daemon.pid")
+        @pid_file ||= if @legacy_runtime_root
+                        File.join(@legacy_runtime_root, ".daemon.pid")
+        else
+          File.join(Hive::Paths.runtime_dir, "daemon.pid")
+        end
       end
 
       def log_file
-        @log_file ||= File.join(@hive_home, "logs", "daemon.log")
+        @log_file ||= if @legacy_runtime_root
+                        File.join(@legacy_runtime_root, "logs", "daemon.log")
+        else
+          File.join(Hive::Paths.state_dir, "logs", "daemon.log")
+        end
       end
 
       private
@@ -84,6 +95,7 @@ module Hive
       def start_daemon
         warn_unsupported_json_flag if @json
         FileUtils.mkdir_p(@hive_home)
+        FileUtils.mkdir_p(File.dirname(pid_file))
         FileUtils.mkdir_p(File.dirname(log_file))
 
         # Single-instance check: if a live daemon already owns the PID
diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb
index b86e8d61..2c59c699 100644
--- a/lib/hive/commands/init.rb
+++ b/lib/hive/commands/init.rb
@@ -5,13 +5,19 @@ require "hive/config"
 require "hive/git_ops"
 require "hive/commands/init/prompts"
 require "hive/commands/doctor"
+require "hive/assets"
+require "hive/dependency_check"
+require "hive/service"
 
 module Hive
   module Commands
     class Init
-      def initialize(project_path, force: false, prompts: nil)
+      def initialize(project_path, force: false, prompts: nil, provider: nil, model: nil, credential_env: nil)
         @project_path = File.expand_path(project_path)
         @force = force
+        @provider = provider
+        @model = model
+        @credential_env = credential_env
         # Optional Prompts instance for testability. Tests inject a
         # pre-fed StringIO-backed instance to drive the interactive flow
         # without touching $stdin. Production keeps this nil so the
@@ -22,14 +28,15 @@ module Hive
       end
 
       def call
+        print_dependency_diagnostics
         validate_git_repo!
-        validate_clean_tree! unless @force
 
         ops = Hive::GitOps.new(@project_path)
         if ops.hive_state_branch_exists?
-          raise Hive::AlreadyInitialized,
-                "already initialized; hive/state branch present at #{@project_path}"
+          repair_existing_install!(ops)
+          return
         end
+        validate_clean_tree! unless @force
 
         # Prompt placement is load-bearing (per ADR-023): runs AFTER the
         # already-initialized guard above, BEFORE any disk writes below.
@@ -37,13 +44,73 @@ module Hive
         # no orphan branch, no worktree, no master .gitignore update —
         # so a re-run of `hive init` proceeds normally.
         answers = collect_prompt_answers
+        provider_values = provider_values(answers)
+        Hive::Config.provider_config_data(**provider_values)
+        validate_project_destination!
+        Hive::Config.write_provider_config!(**provider_values)
 
         ops.hive_state_init
         write_per_project_config(ops, answers: answers)
+        seed_install_assets!
         ops.add_hive_state_to_master_gitignore!
 
         entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path)
+        register_user_service!(start: answers.fetch("daemon_enabled", false))
+
+        print_summary(entry: entry, ops: ops)
+        run_init_preflight!
+      end
+
+      def seed_install_assets!
+        legacy_home = ENV["HIVE_HOME"]
+        destination = legacy_home && !legacy_home.empty? ? File.join(legacy_home, "data", "releases") : Hive::Paths.releases_dir
+        release = Hive::Assets.seed_shared!(destination: destination)
+        result = Hive::Assets.scaffold_project!(@project_path, source: File.join(release, "project"))
+        result[:conflicts].each { |path| write_warn("hive: preserving existing or modified #{path}") }
+      end
+
+      def provider_values(answers)
+        supplied = [ @provider, @model, @credential_env ]
+        if supplied.any? && !supplied.all?
+          raise Hive::ConfigError, "provider, model, and credential-env must be supplied together"
+        end
+
+        {
+          provider: @provider || answers.fetch("provider"),
+          model: @model || answers.fetch("model"),
+          credential_env: @credential_env || answers.fetch("credential_env")
+        }
+      end
 
+      def validate_project_destination!
+        root = Hive::Assets.safe_project_root(@project_path)
+        Hive::Assets.ensure_safe_project_destination!(root, File.join(root, ".hive"))
+      end
+
+      def print_dependency_diagnostics
+        Hive::DependencyCheck.call.each do |row|
+          next unless row[:status] == :missing
+
+          write_warn("hive: dependency #{row[:name]} is missing. #{row[:hint]}")
+        end
+      end
+
+      def register_user_service!(start:)
+        return if ENV["HIVE_HOME"] && !ENV["HIVE_HOME"].empty?
+
+        service = Hive::Service.current
+        service.register
+        service.start if start
+      end
+
+      def repair_existing_install!(ops)
+        ops.hive_state_init
+        validate_project_destination!
+        seed_install_assets!
+        config = Hive::Config.load(@project_path)
+        ops.add_hive_state_to_master_gitignore!
+        entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path)
+        register_user_service!(start: config.dig("daemon", "enabled") == true)
         print_summary(entry: entry, ops: ops)
         run_init_preflight!
       end
diff --git a/lib/hive/commands/init/prompts.rb b/lib/hive/commands/init/prompts.rb
index 088635f9..65d364ba 100644
--- a/lib/hive/commands/init/prompts.rb
+++ b/lib/hive/commands/init/prompts.rb
@@ -26,6 +26,9 @@ module Hive
         DEFAULT_PLANNING_AGENT = "claude".freeze
         DEFAULT_DEVELOPMENT_AGENT = "codex".freeze
         DEFAULT_TRIAGE_BIAS = "courageous".freeze
+        DEFAULT_PROVIDER = "openai".freeze
+        DEFAULT_MODEL = "gpt-5".freeze
+        DEFAULT_CREDENTIAL_ENV = "OPENAI_API_KEY".freeze
         TRIAGE_BIASES = %w[courageous safetyist].freeze
 
         # Reviewer entries shipped in templates/project_config.yml.erb. The
@@ -109,6 +112,9 @@ module Hive
           triage_bias = prompt_triage_bias
           budgets, timeouts = prompt_limits
           daemon_enabled = prompt_daemon_enabled
+          provider = prompt_value("Provider", DEFAULT_PROVIDER)
+          model = prompt_value("Model", DEFAULT_MODEL)
+          credential_env = prompt_value("Credential environment variable", DEFAULT_CREDENTIAL_ENV)
 
           answers = {
             "planning_agent" => planning,
@@ -117,7 +123,10 @@ module Hive
             "triage_bias" => triage_bias,
             "budgets" => budgets,
             "timeouts" => timeouts,
-            "daemon_enabled" => daemon_enabled
+            "daemon_enabled" => daemon_enabled,
+            "provider" => provider,
+            "model" => model,
+            "credential_env" => credential_env
           }
 
           summarize(answers)
@@ -143,7 +152,10 @@ module Hive
             "triage_bias" => DEFAULT_TRIAGE_BIAS,
             "budgets" => default_budgets,
             "timeouts" => default_timeouts,
-            "daemon_enabled" => true
+            "daemon_enabled" => false,
+            "provider" => DEFAULT_PROVIDER,
+            "model" => DEFAULT_MODEL,
+            "credential_env" => DEFAULT_CREDENTIAL_ENV
           }
           # Goes to @summary_io (stdout by default) so a non-TTY caller's
           # `summary=$(hive init)` capture has a parseable single line.
@@ -151,7 +163,8 @@ module Hive
             "hive: using defaults — planning=#{DEFAULT_PLANNING_AGENT}, " \
             "dev=#{DEFAULT_DEVELOPMENT_AGENT}, " \
             "reviewers=all#{DEFAULT_REVIEWER_NAMES.size}, " \
-            "triage=#{DEFAULT_TRIAGE_BIAS}, limits=defaults, daemon=enabled"
+            "triage=#{DEFAULT_TRIAGE_BIAS}, limits=defaults, daemon=disabled, " \
+            "provider=#{DEFAULT_PROVIDER}, model=#{DEFAULT_MODEL}"
           )
           answers
         end
@@ -352,6 +365,16 @@ module Hive
           end
         end
 
+        def prompt_value(label, default)
+          loop do
+            @output.print "#{label} [#{default}]: "
+            @output.flush
+            answer = read_line
+            return default if answer.empty?
+            return answer unless answer.match?(/[\r\n]/)
+          end
+        end
+
         def summarize(answers)
           @output.puts ""
           @output.puts "Summary:"
@@ -361,6 +384,9 @@ module Hive
           @output.puts "  triage_bias       = #{answers['triage_bias']}"
           @output.puts "  limits            = #{summarize_limits(answers)}"
           @output.puts "  daemon            = #{answers['daemon_enabled'] ? 'enabled' : 'disabled'}"
+          @output.puts "  provider          = #{answers['provider']}"
+          @output.puts "  model             = #{answers['model']}"
+          @output.puts "  credential_source = env:#{answers['credential_env']}"
         end
 
         def summarize_limits(answers)
diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb
new file mode 100644
index 00000000..5abe1fdd
--- /dev/null
+++ b/lib/hive/commands/uninstall.rb
@@ -0,0 +1,101 @@
+require "digest"
+require "yaml"
+require "hive/install/channel"
+require "hive/install/receipt"
+require "hive/service"
+
+module Hive
+  module Commands
+    class Uninstall
+      def initialize(project_root: nil, cleanup: false, receipt_path: Hive::Paths.receipt_file, output: $stdout,
+                     receipt_loader: Hive::Install::Receipt.method(:load),
+                     channel_factory: Hive::Install::Channel.method(:new), service: nil)
+        @project_root = project_root && File.expand_path(project_root)
+        @cleanup = cleanup
+        @receipt_path = receipt_path
+        @output = output
+        @receipt_loader = receipt_loader
+        @channel_factory = channel_factory
+        @service = service
+      end
+
+      def call
+        receipt = @receipt_loader.call(@receipt_path)
+        channel = @channel_factory.call(receipt)
+        channel.verify_ownership!
+        plan = @cleanup && @project_root ? self.class.plan_project_cleanup(@project_root) : empty_cleanup_plan
+        print_preview(receipt, plan)
+        (@service || Hive::Service.current).unregister
+        channel.uninstall
+        self.class.apply_cleanup_plan(plan) if @cleanup && @project_root
+        true
+      end
+
+      def self.cleanup_project(project_root)
+        apply_cleanup_plan(plan_project_cleanup(project_root))
+      end
+
+      def self.plan_project_cleanup(project_root)
+        root = File.realpath(project_root)
+        manifest_path = File.join(root, ".hive", "ownership.yml")
+        return { candidates: [], preserved: [] } unless File.file?(manifest_path)
+
+        manifest = YAML.safe_load_file(manifest_path)
+        files = manifest.is_a?(Hash) && manifest["files"].is_a?(Hash) ? manifest["files"] : {}
+        files.each_with_object({ candidates: [], preserved: [] }) do |(relative, expected), result|
+          target = safe_owned_target(root, relative)
+          if target && File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == expected
+            result[:candidates] << [ relative, target, expected ]
+          else
+            result[:preserved] << relative
+          end
+        end
+      rescue Psych::Exception, Errno::ENOENT
+        { candidates: [], preserved: [ ".hive/ownership.yml" ] }
+      end
+
+      def self.apply_cleanup_plan(plan)
+        plan.fetch(:candidates).each_with_object({ removed: [], preserved: plan.fetch(:preserved).dup }) do |entry, result|
+          relative, target, expected = entry
+          if File.file?(target) && !File.symlink?(target) && Digest::SHA256.file(target).hexdigest == expected
+            File.delete(target)
+            result[:removed] << relative
+          else
+            result[:preserved] << relative
+          end
+        end
+      end
+
+      def self.safe_owned_target(root, relative)
+        return unless relative.start_with?(".hive/")
+        return if File.symlink?(File.join(root, ".hive"))
+
+        target = File.expand_path(relative, root)
+        prefix = "#{File.join(root, '.hive')}#{File::SEPARATOR}"
+        return unless target.start_with?(prefix)
+
+        parent = File.dirname(target)
+        while parent.start_with?(prefix)
+          return if File.symlink?(parent)
+          parent = File.dirname(parent)
+        end
+        target
+      end
+
+      private
+
+      def empty_cleanup_plan
+        { candidates: [], preserved: [] }
+      end
+
+      def print_preview(receipt, plan)
+        @output.puts "hive: removing core through #{receipt['channel']} (#{receipt['package']})"
+        @output.puts "hive: will unregister the user service before core removal"
+        removals = plan[:candidates].map(&:first)
+        @output.puts "hive: will remove generated project files: #{removals.join(', ')}" unless removals.empty?
+        @output.puts "hive: will preserve project entries: #{plan[:preserved].join(', ')}" unless plan[:preserved].empty?
+        @output.puts "hive: XDG config/state, completed outputs, unknown project files, and agent skills are preserved"
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb
new file mode 100644
index 00000000..a0cce500
--- /dev/null
+++ b/lib/hive/commands/update.rb
@@ -0,0 +1,22 @@
+require "hive/install/channel"
+require "hive/install/receipt"
+
+module Hive
+  module Commands
+    class Update
+      def initialize(receipt_path: Hive::Paths.receipt_file, output: $stdout)
+        @receipt_path = receipt_path
+        @output = output
+      end
+
+      def call
+        receipt = Hive::Install::Receipt.load(@receipt_path)
+        channel = Hive::Install::Channel.new(receipt)
+        channel.verify_ownership!
+        channel.update
+        @output.puts "hive: update delegated to #{receipt['channel']} (#{receipt['package']})"
+        true
+      end
+    end
+  end
+end
diff --git a/lib/hive/config.rb b/lib/hive/config.rb
index a67097d8..f586be04 100644
--- a/lib/hive/config.rb
+++ b/lib/hive/config.rb
@@ -1,6 +1,7 @@
 require "yaml"
 require "fileutils"
 require "hive/agent_profiles"
+require "hive/paths"
 
 module Hive
   module Config
@@ -170,11 +171,11 @@ module Hive
         "codex_budget_usd" => 1,
         "codex_timeout_sec" => 120,
         "shutdown_grace_sec" => 60,
-        "pid_file" => "~/Dev/hive/.bot.pid",
-        "log_file" => "~/Dev/hive/logs/bot.log",
+        "pid_file" => nil,
+        "log_file" => nil,
         "log_max_bytes" => 10_485_760,
         "log_max_files" => 5,
-        "last_seen_state_file" => "~/Dev/hive/.bot.last_seen_update_id"
+        "last_seen_state_file" => nil
       },
       # Auto-rebase pre-step for `hive run` (plan
       # docs/plans/2026-05-14-001-feat-hive-auto-rebase-stale-worktree-plan.md).
@@ -209,13 +210,50 @@ module Hive
     module_function
 
     def hive_home
-      ENV["HIVE_HOME"] || File.expand_path("~/Dev/hive")
+      legacy = ENV["HIVE_HOME"]
+      legacy && !legacy.empty? ? File.expand_path(legacy) : Hive::Paths.config_dir
     end
 
     def global_config_path
       File.join(hive_home, "config.yml")
     end
 
+    def write_provider_config!(path: Hive::Paths.config_file, provider:, model:, credential_env:)
+      provider_data = provider_config_data(provider: provider, model: model, credential_env: credential_env)
+
+      FileUtils.mkdir_p(File.dirname(path))
+      temporary = "#{path}.tmp-#{Process.pid}"
+      data = File.file?(path) ? YAML.safe_load_file(path) : {}
+      raise ConfigError, "provider config at #{path} must be a mapping" unless data.nil? || data.is_a?(Hash)
+
+      data = (data || {}).merge(provider_data)
+      File.write(temporary, data.to_yaml, mode: "w", perm: 0o600)
+      File.chmod(0o600, temporary)
+      File.rename(temporary, path)
+      path
+    rescue Psych::Exception => e
+      raise ConfigError, "provider config at #{path} is not valid YAML: #{e.message}"
+    rescue SystemCallError => e
+      raise ConfigError, "provider config at #{path} could not be written: #{e.message}"
+    ensure
+      FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+    end
+
+    def provider_config_data(provider:, model:, credential_env:)
+      if provider.to_s.strip.empty? || model.to_s.strip.empty?
+        raise ConfigError, "provider and model choices must be non-empty"
+      end
+      unless credential_env.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/)
+        raise ConfigError, "credential source must be an environment variable name, not a secret value"
+      end
+
+      {
+        "provider" => provider.to_s,
+        "model" => model.to_s,
+        "credential_source" => "env:#{credential_env}"
+      }
+    end
+
     def hive_state_dir(project_root, hive_state_name = ".hive-state")
       File.join(project_root, hive_state_name)
     end
@@ -327,7 +365,7 @@ module Hive
     end
 
     # Load and validate the global `daemon` block from
-    # `~/Dev/hive/config.yml`. Returns the merged Hash (operator
+    # the XDG global config. Returns the merged Hash (operator
     # overrides on top of `Config::DEFAULTS["daemon"]`). Used by
     # `hive daemon start` / `reload` so operator knobs in the global
     # config (max_concurrent_runs, poll_interval_sec, log_*, etc.)
@@ -355,7 +393,7 @@ module Hive
       merged
     end
 
-    # Load and validate the global `bot` block from ~/Dev/hive/config.yml.
+    # Load and validate the global `bot` block from the XDG global config.
     # `require_runtime: true` is used by `hive bot start` so the opt-in
     # credentials fail loudly there without making read-only commands like
     # `hive status` require a Telegram token.
@@ -386,9 +424,16 @@ module Hive
 
     def global_bot_defaults
       defaults = deep_dup(DEFAULTS["bot"])
-      defaults["pid_file"] = File.join(hive_home, ".bot.pid")
-      defaults["log_file"] = File.join(hive_home, "logs", "bot.log")
-      defaults["last_seen_state_file"] = File.join(hive_home, ".bot.last_seen_update_id")
+      legacy = ENV["HIVE_HOME"]
+      if legacy && !legacy.empty?
+        defaults["pid_file"] = File.join(hive_home, ".bot.pid")
+        defaults["log_file"] = File.join(hive_home, "logs", "bot.log")
+        defaults["last_seen_state_file"] = File.join(hive_home, ".bot.last_seen_update_id")
+      else
+        defaults["pid_file"] = File.join(Hive::Paths.runtime_dir, "bot.pid")
+        defaults["log_file"] = File.join(Hive::Paths.state_dir, "logs", "bot.log")
+        defaults["last_seen_state_file"] = File.join(Hive::Paths.state_dir, "bot.last_seen_update_id")
+      end
       defaults
     end
 
diff --git a/lib/hive/dependency_check.rb b/lib/hive/dependency_check.rb
new file mode 100644
index 00000000..0b063c6f
--- /dev/null
+++ b/lib/hive/dependency_check.rb
@@ -0,0 +1,27 @@
+module Hive
+  module DependencyCheck
+    TOOLS = {
+      "git" => "Install Git with your operating system's package manager.",
+      "bash" => "Install Bash with your operating system's package manager.",
+      "claude" => "Install the Claude Code CLI from its official distribution.",
+      "gh" => "Install GitHub CLI from https://cli.github.com/.",
+      "jq" => "Install jq with your operating system's package manager."
+    }.freeze
+    module_function
+
+    def call(path: ENV.fetch("PATH", ""))
+      TOOLS.map do |name, hint|
+        executable = resolve(name, path)
+        { name: name, status: executable ? :available : :missing, path: executable, hint: executable ? nil : hint }
+      end
+    end
+
+    def resolve(name, path)
+      path.split(File::PATH_SEPARATOR).each do |directory|
+        candidate = File.join(directory, name)
+        return candidate if File.file?(candidate) && File.executable?(candidate)
+      end
+      nil
+    end
+  end
+end
diff --git a/lib/hive/install/channel.rb b/lib/hive/install/channel.rb
new file mode 100644
index 00000000..c8e753ca
--- /dev/null
+++ b/lib/hive/install/channel.rb
@@ -0,0 +1,121 @@
+require "open3"
+require "digest"
+require "hive/command_name"
+require "hive/install/receipt"
+
+module Hive
+  module Install
+    class Channel
+      def initialize(receipt, runner: Open3.method(:capture3), resolver: Hive::CommandName.method(:resolve))
+        @receipt = Hive::Install::Receipt.validate!(receipt)
+        @runner = runner
+        @resolver = resolver
+      end
+
+      def verify_ownership!
+        case @receipt["channel"]
+        when "homebrew"
+          verify_homebrew_ownership!
+        when "aur"
+          verify_aur_ownership!
+        when "shell"
+          verify_shell_ownership!
+        end
+        true
+      end
+
+      def update
+        case @receipt["channel"]
+        when "homebrew"
+          execute("brew", "upgrade", @receipt["package"])
+        when "aur"
+          helper = aur_helper!
+          execute(helper, "-S", "--needed", @receipt["package"])
+        when "shell"
+          installer = @receipt["installer"]
+          raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell installer path is absent") unless installer
+
+          execute(installer, "--update")
+        end
+      end
+
+      def uninstall
+        case @receipt["channel"]
+        when "homebrew"
+          execute("brew", "uninstall", @receipt["package"])
+        when "aur"
+          execute(aur_helper!, "-Rns", "--noconfirm", @receipt["package"])
+        when "shell"
+          uninstaller = @receipt["uninstaller"]
+          raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell uninstaller path is absent") unless uninstaller
+
+          execute(uninstaller, "--yes")
+        end
+      end
+
+      private
+
+      def verify_homebrew_ownership!
+        prefix = execute_capture("brew", "--prefix", @receipt["package"]).strip
+        expected = File.join(prefix, "bin", "hv")
+        return if same_file_or_path?(@receipt["executable"], expected)
+
+        raise Hive::ConfigError,
+              Hive::Install::Receipt.recovery("Homebrew receipt executable does not belong to #{@receipt['package']}")
+      end
+
+      def verify_aur_ownership!
+        owner = execute_capture("pacman", "-Qqo", @receipt["executable"]).strip
+        return if owner == @receipt["package"]
+
+        raise Hive::ConfigError,
+              Hive::Install::Receipt.recovery("AUR receipt executable is owned by #{owner.inspect}")
+      end
+
+      def verify_shell_ownership!
+        {
+          @receipt["executable"] => @receipt["executable_sha256"],
+          @receipt["installer"] => @receipt["installer_sha256"],
+          @receipt["uninstaller"] => @receipt["uninstaller_sha256"]
+        }.each do |path, expected|
+          unless File.file?(path) && !File.symlink?(path) && Digest::SHA256.file(path).hexdigest == expected
+            raise Hive::ConfigError, Hive::Install::Receipt.recovery("shell-managed file failed ownership verification: #{path}")
+          end
+        end
+      end
+
+      def same_file_or_path?(left, right)
+        return false unless File.file?(left) && File.file?(right)
+
+        File.identical?(left, right)
+      rescue Errno::ENOENT
+        false
+      end
+
+      def aur_helper!
+        helper = %w[paru yay].filter_map { |name| @resolver.call(name) }.first
+        raise Hive::ConfigError, "no supported AUR helper found (install paru or yay)" unless helper
+
+        helper
+      end
+
+      def execute_capture(*command)
+        out, err, status = @runner.call(*command)
+        return out if status.success?
+
+        raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}"
+      rescue Errno::ENOENT
+        raise Hive::ConfigError, "owning package tool #{command.first} is unavailable"
+      end
+
+      def execute(*command)
+        _out, err, status = @runner.call(*command)
+        return true if status.success?
+
+        raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}"
+      rescue Errno::ENOENT
+        raise Hive::ConfigError, "owning package tool #{command.first} is unavailable"
+      end
+    end
+  end
+end
diff --git a/lib/hive/install/receipt.rb b/lib/hive/install/receipt.rb
new file mode 100644
index 00000000..0a716d7d
--- /dev/null
+++ b/lib/hive/install/receipt.rb
@@ -0,0 +1,106 @@
+require "fileutils"
+require "yaml"
+require "hive/paths"
+
+module Hive
+  module Install
+    module Receipt
+      CHANNELS = %w[homebrew aur shell].freeze
+      REQUIRED = %w[schema_version channel package command_name executable release_source managed_files].freeze
+      SHELL_REQUIRED = %w[
+        executable_sha256 installer installer_sha256 uninstaller uninstaller_sha256
+      ].freeze
+      module_function
+
+      def load(path = Hive::Paths.receipt_file)
+        raise Hive::ConfigError, recovery("install receipt is missing at #{path}") unless File.file?(path)
+
+        data = YAML.safe_load_file(path)
+        validate!(data)
+      rescue Psych::Exception => e
+        raise Hive::ConfigError, recovery("install receipt is malformed: #{e.message}")
+      end
+
+      def write!(path: Hive::Paths.receipt_file, data:)
+        receipt = { "schema_version" => 1 }.merge(data)
+        validate!(receipt)
+        FileUtils.mkdir_p(File.dirname(path))
+        temporary = "#{path}.tmp-#{Process.pid}"
+        File.write(temporary, receipt.to_yaml, mode: "w", perm: 0o600)
+        File.chmod(0o600, temporary)
+        File.rename(temporary, path)
+        receipt
+      ensure
+        FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+      end
+
+      def validate!(data)
+        raise Hive::ConfigError, recovery("install receipt must be a mapping") unless data.is_a?(Hash)
+        unless data["schema_version"] == 1
+          raise Hive::ConfigError, recovery("unsupported install receipt schema")
+        end
+        unless CHANNELS.include?(data["channel"])
+          raise Hive::ConfigError, recovery("invalid install receipt channel #{data['channel'].inspect}")
+        end
+        missing = REQUIRED.reject { |key| present_field?(data[key]) }
+        raise Hive::ConfigError, recovery("install receipt is missing #{missing.join(', ')}") unless missing.empty?
+        unless %w[hive hv].include?(data["command_name"])
+          raise Hive::ConfigError, recovery("invalid install receipt command name")
+        end
+        unless File.basename(data["executable"]) == "hv"
+          raise Hive::ConfigError, recovery("install receipt executable must be the stable hv command")
+        end
+
+        validate_managed_files!(data)
+        validate_shell!(data) if data["channel"] == "shell"
+
+        data
+      end
+
+      def present_field?(value)
+        (value.is_a?(String) && !value.empty?) || (value.is_a?(Array) && !value.empty?) || value == 1
+      end
+      private_class_method :present_field?
+
+      def validate_managed_files!(data)
+        paths = data["managed_files"]
+        unless paths.is_a?(Array) && paths.all? { |path| absolute_path?(path) } && paths.uniq == paths
+          raise Hive::ConfigError, recovery("install receipt managed_files must be unique absolute paths")
+        end
+        return if absolute_path?(data["executable"]) && paths.include?(data["executable"])
+
+        raise Hive::ConfigError, recovery("install receipt does not bind its executable to managed_files")
+      end
+      private_class_method :validate_managed_files!
+
+      def validate_shell!(data)
+        missing = SHELL_REQUIRED.reject { |key| data[key].is_a?(String) && !data[key].empty? }
+        raise Hive::ConfigError, recovery("shell receipt is missing #{missing.join(', ')}") unless missing.empty?
+
+        %w[executable_sha256 installer_sha256 uninstaller_sha256].each do |key|
+          next if data[key].match?(/\A[a-f0-9]{64}\z/)
+
+          raise Hive::ConfigError, recovery("shell receipt has invalid #{key}")
+        end
+        { "installer" => "install.sh", "uninstaller" => "uninstall-shell.sh" }.each do |key, basename|
+          path = data[key]
+          unless absolute_path?(path) && File.basename(path) == basename && data["managed_files"].include?(path)
+            raise Hive::ConfigError, recovery("shell receipt has invalid #{key} ownership")
+          end
+        end
+      end
+      private_class_method :validate_shell!
+
+      def absolute_path?(path)
+        path.is_a?(String) && !path.empty? && File.absolute_path(path) == path
+      rescue ArgumentError
+        false
+      end
+      private_class_method :absolute_path?
+
+      def recovery(message)
+        "#{message}; reinstall Hive through the original channel to repair ownership"
+      end
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
new file mode 100644
index 00000000..a668f7c3
--- /dev/null
+++ b/lib/hive/paths.rb
@@ -0,0 +1,72 @@
+module Hive
+  module Paths
+    module_function
+
+    def config_dir
+      File.join(xdg_root("XDG_CONFIG_HOME", ".config"), "hive")
+    end
+
+    def data_dir
+      File.join(xdg_root("XDG_DATA_HOME", ".local/share"), "hive")
+    end
+
+    def state_dir
+      service_state = ENV["HIVE_STATE_DIR"]
+      return File.expand_path(service_state) if service_state && !service_state.empty?
+
+      File.join(xdg_root("XDG_STATE_HOME", ".local/state"), "hive")
+    end
+
+    def cache_dir
+      File.join(xdg_root("XDG_CACHE_HOME", ".cache"), "hive")
+    end
+
+    def runtime_dir
+      root = ENV["XDG_RUNTIME_DIR"]
+      root && !root.empty? ? File.join(root, "hive") : File.join(state_dir, "run")
+    end
+
+    def bin_dir
+      ENV.fetch("HIVE_BIN_DIR") { File.join(home, ".local", "bin") }
+    end
+
+    def config_file
+      File.join(config_dir, "config.yml")
+    end
+
+    def receipt_file
+      ENV.fetch("HIVE_INSTALL_RECEIPT") { File.join(data_dir, "install-receipt.yml") }
+    end
+
+    def releases_dir
+      File.join(data_dir, "releases")
+    end
+
+    def release_dir(version = Hive::VERSION)
+      File.join(releases_dir, version)
+    end
+
+    def resource_root
+      override = ENV["HIVE_RESOURCE_ROOT"]
+      return File.expand_path(override) if override && !override.empty?
+
+      File.expand_path("../..", __dir__)
+    end
+
+    def resource(*parts)
+      File.join(resource_root, *parts)
+    end
+
+    def home
+      File.expand_path(ENV.fetch("HOME"))
+    end
+
+    def xdg_root(name, fallback)
+      configured = ENV[name]
+      return File.expand_path(configured) if configured && !configured.empty?
+
+      File.join(home, fallback)
+    end
+    private_class_method :xdg_root
+  end
+end
diff --git a/lib/hive/service.rb b/lib/hive/service.rb
new file mode 100644
index 00000000..8fce3b7c
--- /dev/null
+++ b/lib/hive/service.rb
@@ -0,0 +1,24 @@
+require "rbconfig"
+require "hive/paths"
+require "hive/command_name"
+require "hive/services/launchd"
+require "hive/services/systemd_user"
+
+module Hive
+  module Service
+    module_function
+
+    def current(executable: nil, platform: nil)
+      executable ||= Hive::CommandName.stable_executable
+      platform ||= ENV["HIVE_SERVICE_PLATFORM"] || RbConfig::CONFIG["host_os"]
+      case platform
+      when /darwin/
+        Hive::Services::Launchd.new(executable: executable)
+      when /linux/
+        Hive::Services::SystemdUser.new(executable: executable)
+      else
+        raise Hive::ConfigError, "unsupported user service manager for #{platform}"
+      end
+    end
+  end
+end
diff --git a/lib/hive/services/launchd.rb b/lib/hive/services/launchd.rb
new file mode 100644
index 00000000..bbf8320e
--- /dev/null
+++ b/lib/hive/services/launchd.rb
@@ -0,0 +1,79 @@
+require "cgi"
+require "fileutils"
+require "open3"
+require "hive/paths"
+
+module Hive
+  module Services
+    class Launchd
+      LABEL = "org.hive.daemon".freeze
+
+      def initialize(executable:, home: Dir.home, runner: Open3.method(:capture3), uid: Process.uid)
+        @executable = File.expand_path(executable)
+        @home = home
+        @runner = runner
+        @uid = uid
+      end
+
+      def definition_path
+        File.join(@home, "Library", "LaunchAgents", "#{LABEL}.plist")
+      end
+
+      def register
+        FileUtils.mkdir_p(File.dirname(definition_path))
+        content = File.read(Hive::Paths.resource("assets", "services", "launchd.plist"))
+                      .gsub("@EXECUTABLE@", CGI.escapeHTML(@executable))
+                      .gsub("@STATE_DIR@", CGI.escapeHTML(Hive::Paths.state_dir))
+        write_atomic(definition_path, content)
+        :registered_stopped
+      end
+
+      def start
+        execute("launchctl", "bootstrap", domain, definition_path, allow_failure: true)
+        execute("launchctl", "kickstart", "#{domain}/#{LABEL}")
+        :running
+      end
+
+      def stop
+        execute("launchctl", "bootout", "#{domain}/#{LABEL}", allow_failure: true)
+        :registered_stopped
+      end
+
+      def status
+        _out, _err, result = @runner.call("launchctl", "print", "#{domain}/#{LABEL}")
+        result.success? ? :running : :registered_stopped
+      end
+
+      def unregister
+        return :unregistered unless File.exist?(definition_path) || File.symlink?(definition_path)
+
+        stop
+        FileUtils.rm_f(definition_path)
+        :unregistered
+      end
+
+      private
+
+      def domain
+        "gui/#{@uid}"
+      end
+
+      def write_atomic(path, content)
+        temporary = "#{path}.tmp-#{Process.pid}"
+        File.write(temporary, content)
+        File.rename(temporary, path)
+      ensure
+        FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+      end
+
+      def execute(*command, allow_failure: false)
+        _out, err, status = @runner.call(*command)
+        return if status.success? || allow_failure
+
+        raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}"
+      rescue Errno::ENOENT
+        raise Hive::ConfigError, "launchd is unavailable"
+      end
+    end
+  end
+end
diff --git a/lib/hive/services/systemd_user.rb b/lib/hive/services/systemd_user.rb
new file mode 100644
index 00000000..ed28daa0
--- /dev/null
+++ b/lib/hive/services/systemd_user.rb
@@ -0,0 +1,97 @@
+require "fileutils"
+require "open3"
+require "shellwords"
+require "hive/paths"
+
+module Hive
+  module Services
+    class SystemdUser
+      UNIT = "hive.service".freeze
+
+      def initialize(executable:, config_home: ENV["XDG_CONFIG_HOME"], runner: Open3.method(:capture3))
+        @executable = File.expand_path(executable)
+        @config_home = config_home && !config_home.empty? ? config_home : File.join(Dir.home, ".config")
+        @runner = runner
+      end
+
+      def definition_path
+        File.join(@config_home, "systemd", "user", UNIT)
+      end
+
+      def register
+        previous = File.binread(definition_path) if File.file?(definition_path) && !File.symlink?(definition_path)
+        write_definition
+        execute("systemctl", "--user", "daemon-reload")
+        :registered_stopped
+      rescue Hive::Error
+        restore_definition(previous)
+        execute("systemctl", "--user", "daemon-reload", allow_failure: true)
+        raise
+      end
+
+      def start
+        execute("systemctl", "--user", "enable", "--now", UNIT)
+        :running
+      end
+
+      def stop
+        execute("systemctl", "--user", "disable", "--now", UNIT, allow_failure: true)
+        :registered_stopped
+      end
+
+      def status
+        _out, _err, result = @runner.call("systemctl", "--user", "is-active", "--quiet", UNIT)
+        result.success? ? :running : :registered_stopped
+      end
+
+      def unregister
+        return :unregistered unless File.exist?(definition_path) || File.symlink?(definition_path)
+
+        stop
+        previous = File.binread(definition_path) if File.file?(definition_path) && !File.symlink?(definition_path)
+        FileUtils.rm_f(definition_path)
+        execute("systemctl", "--user", "daemon-reload")
+        :unregistered
+      rescue Hive::Error
+        restore_definition(previous) if defined?(previous)
+        execute("systemctl", "--user", "daemon-reload", allow_failure: true)
+        raise
+      end
+
+      private
+
+      def write_definition
+        FileUtils.mkdir_p(File.dirname(definition_path))
+        content = File.read(Hive::Paths.resource("assets", "services", "hive.service"))
+                      .gsub("@EXECUTABLE@", Shellwords.escape(@executable))
+                      .gsub("@STATE_DIR@", Shellwords.escape(Hive::Paths.state_dir))
+        write_atomic(definition_path, content)
+      end
+
+      def write_atomic(path, content)
+        temporary = "#{path}.tmp-#{Process.pid}"
+        File.write(temporary, content)
+        File.rename(temporary, path)
+      ensure
+        FileUtils.rm_f(temporary) if defined?(temporary) && temporary
+      end
+
+      def restore_definition(previous)
+        if previous
+          write_atomic(definition_path, previous)
+        else
+          FileUtils.rm_f(definition_path)
+        end
+      end
+
+      def execute(*command, allow_failure: false)
+        _out, err, status = @runner.call(*command)
+        return if status.success? || allow_failure
+
+        raise Hive::Error, "#{command.join(' ')} failed: #{err.strip}"
+      rescue Errno::ENOENT
+        raise Hive::ConfigError, "systemd user service manager is unavailable"
+      end
+    end
+  end
+end
diff --git a/lib/hive/version.rb b/lib/hive/version.rb
new file mode 100644
index 00000000..a89e72fa
--- /dev/null
+++ b/lib/hive/version.rb
@@ -0,0 +1,3 @@
+module Hive
+  VERSION = "0.1.0".freeze unless const_defined?(:VERSION)
+end
diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO
new file mode 100644
index 00000000..48837e93
--- /dev/null
+++ b/packaging/aur/.SRCINFO
@@ -0,0 +1,14 @@
+pkgbase = hive-bin
+	pkgdesc = Agent-driven software delivery pipeline (prebuilt)
+	pkgver = 0.1.0
+	pkgrel = 1
+	url = https://github.com/example/hive
+	arch = x86_64
+	license = MIT
+	depends = glibc
+	provides = hive
+	install = hive-bin.install
+	source_x86_64 = hive-0.1.0-linux-x86_64.tar.gz::https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz
+	sha256sums_x86_64 = 0000000000000000000000000000000000000000000000000000000000000000
+
+pkgname = hive-bin
diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD
new file mode 100644
index 00000000..36462710
--- /dev/null
+++ b/packaging/aur/PKGBUILD
@@ -0,0 +1,33 @@
+# Maintainer: Hive release automation
+# Generated by scripts/render-packages; checksum is replaced at publication.
+pkgname=hive-bin
+pkgver=0.1.0
+pkgrel=1
+pkgdesc='Agent-driven software delivery pipeline (prebuilt)'
+arch=('x86_64')
+url='https://github.com/example/hive'
+license=('MIT')
+depends=('glibc')
+provides=('hive')
+install=hive-bin.install
+source_x86_64=('hive-'${pkgver}'-linux-x86_64.tar.gz::https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz')
+sha256sums_x86_64=('0000000000000000000000000000000000000000000000000000000000000000')
+
+package() {
+  install -Dm755 "$srcdir/hive-$pkgver/bin/hv" "$pkgdir/usr/lib/hive/hv"
+  install -Dm644 "$srcdir/hive-$pkgver/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
+  install -Dm644 /dev/stdin "$pkgdir/usr/share/hive/install-receipt.yml" <<EOF
+schema_version: 1
+channel: aur
+package: hive-bin
+command_name: hv
+executable: /usr/bin/hv
+release_source: https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz
+managed_files:
+  - /usr/bin/hv
+EOF
+  install -Dm755 /dev/stdin "$pkgdir/usr/bin/hv" <<'EOF'
+#!/bin/sh
+HIVE_INSTALL_RECEIPT=/usr/share/hive/install-receipt.yml exec /usr/lib/hive/hv "$@"
+EOF
+}
diff --git a/packaging/aur/hive-bin.install b/packaging/aur/hive-bin.install
new file mode 100644
index 00000000..a5ae52e4
--- /dev/null
+++ b/packaging/aur/hive-bin.install
@@ -0,0 +1,32 @@
+post_install() {
+  if [ -e /usr/bin/hive ] || [ -L /usr/bin/hive ]; then
+    sed -i 's/^command_name: hive$/command_name: hv/' /usr/share/hive/install-receipt.yml
+    echo "Hive installed as hv; preserving existing hive command."
+  elif command -v hive >/dev/null 2>&1; then
+    echo "Hive installed as hv; preserving existing hive command."
+  else
+    ln -s /usr/bin/hv /usr/bin/hive
+    sed -i 's/^command_name: hv$/command_name: hive/' /usr/share/hive/install-receipt.yml
+  fi
+  for tool in git bash claude gh jq; do
+    command -v "$tool" >/dev/null 2>&1 ||
+      echo "Optional Hive dependency missing: $tool. Install it separately."
+  done
+  echo "Run hive init (or hv init) to register the user daemon; package installation does not start it."
+}
+
+pre_upgrade() {
+  if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then
+    rm -f /usr/bin/hive
+  fi
+}
+
+post_upgrade() {
+  post_install
+}
+
+pre_remove() {
+  if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then
+    rm -f /usr/bin/hive
+  fi
+}
diff --git a/packaging/homebrew/Formula/hive.rb b/packaging/homebrew/Formula/hive.rb
new file mode 100644
index 00000000..0887ac18
--- /dev/null
+++ b/packaging/homebrew/Formula/hive.rb
@@ -0,0 +1,63 @@
+# Generated by scripts/render-packages from release-manifest.json.
+# The checked-in v0.1.0 fixture is replaced during release publication.
+class Hive < Formula
+  desc "Agent-driven software delivery pipeline"
+  homepage "https://github.com/example/hive"
+  version "0.1.0"
+  url "https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-darwin-arm64.tar.gz"
+  sha256 "0000000000000000000000000000000000000000000000000000000000000000"
+
+  def install
+    libexec.install Dir["*"]
+    path_hive = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
+                   .map { |directory| Pathname(directory)/"hive" }
+                   .find(&:executable?)
+    prefix_hive = HOMEBREW_PREFIX/"bin/hive"
+    prefix_hv = HOMEBREW_PREFIX/"bin/hv"
+    candidates = [ path_hive, (prefix_hive if prefix_hive.exist? || prefix_hive.symlink?) ].compact
+    owned_prefix_hive = prefix_hive.symlink? && prefix_hv.exist? &&
+                        prefix_hive.realpath == prefix_hv.realpath
+    foreign_hive = candidates.any? do |candidate|
+      !owned_prefix_hive || !File.identical?(candidate, prefix_hive)
+    rescue Errno::ENOENT
+      true
+    end
+    selected_command = foreign_hive ? "hv" : "hive"
+
+    receipt = share/"hive/install-receipt.yml"
+    receipt.dirname.mkpath
+    receipt.write <<~YAML
+      schema_version: 1
+      channel: homebrew
+      package: example/hive/hive
+      command_name: #{selected_command}
+      executable: #{opt_bin}/hv
+      release_source: https://github.com/example/hive/releases/download/v0.1.0/hive-0.1.0-darwin-arm64.tar.gz
+      managed_files:
+        - #{opt_bin}/hv
+    YAML
+    (bin/"hv").write <<~SH
+      #!/bin/sh
+      HIVE_INSTALL_RECEIPT="#{receipt}" exec "#{libexec}/bin/hv" "$@"
+    SH
+    chmod 0755, bin/"hv"
+    bin.install_symlink "hv" => "hive" if selected_command == "hive"
+  end
+
+  def post_install
+    %w[git bash claude gh jq].each do |tool|
+      opoo "Optional Hive dependency missing: #{tool}. Install it separately." unless which(tool)
+    end
+  end
+
+  service do
+    run [ opt_bin/"hv", "daemon", "start" ]
+    keep_alive false
+    log_path var/"log/hive.log"
+    error_log_path var/"log/hive.log"
+  end
+
+  test do
+    assert_equal version.to_s, shell_output("#{bin}/hv --version").strip
+  end
+end
diff --git a/packaging/receipt-template.yml b/packaging/receipt-template.yml
new file mode 100644
index 00000000..fc93ea27
--- /dev/null
+++ b/packaging/receipt-template.yml
@@ -0,0 +1,8 @@
+schema_version: 1
+channel: CHANNEL
+package: PACKAGE
+command_name: COMMAND_NAME
+executable: EXECUTABLE
+release_source: RELEASE_SOURCE
+managed_files:
+  - EXECUTABLE
diff --git a/packaging/release-manifest.schema.json b/packaging/release-manifest.schema.json
new file mode 100644
index 00000000..b4d5d6ef
--- /dev/null
+++ b/packaging/release-manifest.schema.json
@@ -0,0 +1,48 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://hive.example/schemas/release-manifest.v1.json",
+  "type": "object",
+  "required": ["schema_version", "version", "tag", "repository", "ruby_version", "installer_compatibility", "targets", "shell_assets"],
+  "properties": {
+    "schema_version": { "const": 1 },
+    "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
+    "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" },
+    "repository": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
+    "ruby_version": { "type": "string" },
+    "installer_compatibility": { "const": 1 },
+    "targets": {
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "required": ["target", "asset", "url", "sha256", "size", "minimum_os"],
+        "properties": {
+          "target": { "enum": ["darwin-arm64", "linux-x86_64"] },
+          "asset": { "type": "string" },
+          "url": { "type": "string", "format": "uri" },
+          "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+          "size": { "type": "integer", "minimum": 1 },
+          "minimum_os": { "type": "string" }
+        },
+        "additionalProperties": false
+      }
+    },
+    "shell_assets": {
+      "type": "array",
+      "minItems": 2,
+      "maxItems": 2,
+      "items": {
+        "type": "object",
+        "required": ["asset", "url", "sha256", "size"],
+        "properties": {
+          "asset": { "enum": ["install.sh", "uninstall-shell.sh"] },
+          "url": { "type": "string", "format": "uri" },
+          "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+          "size": { "type": "integer", "minimum": 1 }
+        },
+        "additionalProperties": false
+      }
+    }
+  },
+  "additionalProperties": false
+}
diff --git a/packaging/tebako/README.md b/packaging/tebako/README.md
new file mode 100644
index 00000000..33b61e9e
--- /dev/null
+++ b/packaging/tebako/README.md
@@ -0,0 +1,12 @@
+# Tebako builder adapter
+
+Hive's primary builder uses Tebako's single-executable `press` contract:
+
+```sh
+tebako press --root=<checkout> --entry-point=bin/hive \
+  --output=<build>/hive-<target> --Ruby=3.4.1
+```
+
+`scripts/build-release` is the replaceable boundary. A replacement builder may
+be selected with `HIVE_PACKAGER`, but it must create the same executable path
+and pass the packaged-binary and release-manifest contracts.
diff --git a/packaging/tebako/versions.env b/packaging/tebako/versions.env
new file mode 100644
index 00000000..e8d67a8d
--- /dev/null
+++ b/packaging/tebako/versions.env
@@ -0,0 +1,2 @@
+TEBAKO_VERSION=0.12.0
+RUBY_VERSION=3.4.1
diff --git a/scripts/build-release b/scripts/build-release
new file mode 100755
index 00000000..399b9372
--- /dev/null
+++ b/scripts/build-release
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version=""
+target=""
+output_dir="${root}/build"
+
+while (($#)); do
+  case "$1" in
+    --version) version="${2:-}"; shift 2 ;;
+    --target) target="${2:-}"; shift 2 ;;
+    --output-dir) output_dir="${2:-}"; shift 2 ;;
+    *) echo "build-release: unknown argument: $1" >&2; exit 64 ;;
+  esac
+done
+
+if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "build-release: --version must be strict semver (without v)" >&2
+  exit 64
+fi
+
+if [[ -z "${target}" ]]; then
+  case "$(uname -s)-$(uname -m)" in
+    Darwin-arm64) target="darwin-arm64" ;;
+    Linux-x86_64) target="linux-x86_64" ;;
+    *) echo "build-release: unsupported build host" >&2; exit 64 ;;
+  esac
+fi
+
+case "${target}" in
+  darwin-arm64|linux-x86_64) ;;
+  *) echo "build-release: unsupported target ${target}" >&2; exit 64 ;;
+esac
+
+source "${root}/packaging/tebako/versions.env"
+packager="${HIVE_PACKAGER:-tebako}"
+command -v "${packager}" >/dev/null 2>&1 || {
+  echo "build-release: ${packager} is unavailable" >&2
+  exit 69
+}
+
+mkdir -p "${output_dir}"
+output="${output_dir}/hive-${target}"
+"${packager}" press \
+  "--root=${root}" \
+  "--entry-point=bin/hive" \
+  "--output=${output}" \
+  "--Ruby=${RUBY_VERSION}"
+chmod 0755 "${output}"
+"${output}" --version | grep -Fx "${version}" >/dev/null
+echo "${output}"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 00000000..db5a8b63
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,374 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repository="${HIVE_RELEASE_REPOSITORY:-ivankuznetsov/hive}"
+requested_version=""
+update=false
+uninstall=false
+assume_yes=false
+destination="${HIVE_BIN_DIR:-${HOME}/.local/bin}"
+base_url="${HIVE_INSTALL_BASE_URL:-https://github.com/${repository}/releases/download}"
+
+usage() {
+  echo "usage: install.sh [--version X.Y.Z] [--update] [--uninstall] [--yes] [--dest-dir PATH]"
+}
+
+while (($#)); do
+  case "$1" in
+    --version) requested_version="${2:-}"; shift 2 ;;
+    --update) update=true; shift ;;
+    --uninstall) uninstall=true; shift ;;
+    --yes) assume_yes=true; shift ;;
+    --dest-dir) destination="${2:-}"; shift 2 ;;
+    -h|--help) usage; exit 0 ;;
+    *) echo "hive installer: unknown argument $1" >&2; usage >&2; exit 64 ;;
+  esac
+done
+
+data_home="${XDG_DATA_HOME:-${HOME}/.local/share}/hive"
+receipt="${data_home}/install-receipt.yml"
+uninstaller="${data_home}/uninstall-shell.sh"
+installed_installer="${data_home}/install.sh"
+
+temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-install.XXXXXX")"
+trap 'rm -rf "${temporary}"' EXIT
+
+sha256_file() {
+  if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum "$1" | awk '{print $1}'
+  elif command -v shasum >/dev/null 2>&1; then
+    shasum -a 256 "$1" | awk '{print $1}'
+  else
+    echo "hive installer: sha256sum or shasum is required" >&2
+    exit 69
+  fi
+}
+
+receipt_value() {
+  [[ -f "${receipt}" ]] || return 0
+  sed -n "s/^$1: //p" "${receipt}" | head -n 1
+}
+
+receipt_manages() {
+  [[ -f "${receipt}" ]] && grep -Fqx "  - $1" "${receipt}"
+}
+
+verify_installed_helper() {
+  path_key="$1"
+  digest_key="$2"
+  expected_name="$3"
+  path="$(receipt_value "${path_key}")"
+  expected="$(receipt_value "${digest_key}")"
+  if [[ "${path}" != "${data_home}/${expected_name}" || ! "${expected}" =~ ^[a-f0-9]{64}$ ||
+        ! -f "${path}" || -L "${path}" || "$(sha256_file "${path}")" != "${expected}" ]]; then
+    echo "hive installer: ${expected_name} failed receipt ownership verification; reinstall Hive" >&2
+    exit 78
+  fi
+  if ! receipt_manages "${path}"; then
+    echo "hive installer: ${expected_name} failed receipt ownership verification; reinstall Hive" >&2
+    exit 78
+  fi
+}
+
+if ${uninstall}; then
+  [[ "$(receipt_value schema_version)" == "1" && "$(receipt_value channel)" == "shell" ]] || {
+    echo "hive installer: valid shell uninstall receipt is missing" >&2
+    exit 78
+  }
+  verify_installed_helper uninstaller uninstaller_sha256 uninstall-shell.sh
+  if ${assume_yes}; then
+    exec "${uninstaller}" --yes
+  else
+    exec "${uninstaller}"
+  fi
+fi
+
+case "$(uname -s)-$(uname -m)" in
+  Darwin-arm64) target="darwin-arm64" ;;
+  Linux-x86_64|Linux-amd64) target="linux-x86_64" ;;
+  *)
+    echo "hive installer: unsupported OS/architecture $(uname -s)/$(uname -m)" >&2
+    exit 64
+    ;;
+esac
+
+download() {
+  source_url="$1"
+  output="$2"
+  case "${source_url}" in
+    file://*) cp "${source_url#file://}" "${output}" ;;
+    *)
+      if command -v curl >/dev/null 2>&1; then
+        curl --fail --location --silent --show-error \
+          --connect-timeout 10 --max-time 120 \
+          --retry 3 --retry-delay 1 --retry-all-errors \
+          "${source_url}" --output "${output}"
+      elif command -v wget >/dev/null 2>&1; then
+        wget -q --timeout=30 --tries=3 "${source_url}" -O "${output}"
+      else
+        echo "hive installer: curl or wget is required" >&2
+        exit 69
+      fi
+      ;;
+  esac
+}
+
+if [[ -n "${requested_version}" ]]; then
+  [[ "${requested_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
+    echo "hive installer: version must be strict semver" >&2
+    exit 64
+  }
+  release_root="${base_url}/v${requested_version}"
+else
+  if [[ -n "${HIVE_INSTALL_BASE_URL:-}" ]]; then
+    release_root="${base_url}/latest"
+  else
+    release_root="https://github.com/${repository}/releases/latest/download"
+  fi
+fi
+
+manifest="${temporary}/release-manifest.json"
+download "${release_root}/release-manifest.json" "${manifest}"
+version="${requested_version}"
+if [[ -z "${version}" ]]; then
+  version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "${manifest}" | head -n 1)"
+fi
+[[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
+  echo "hive installer: manifest contains an invalid version" >&2
+  exit 65
+}
+
+asset="hive-${version}-${target}.tar.gz"
+record="$(awk -v wanted="${target}" '
+  /"target":/ { selected = index($0, "\"" wanted "\"") > 0 }
+  selected { print }
+  selected && /"minimum_os":/ { exit }
+' "${manifest}")"
+manifest_asset="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"asset": "\([^"]*\)",*$/\1/p')"
+expected_sha="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"sha256": "\([a-f0-9]*\)",*$/\1/p')"
+expected_size="$(printf '%s\n' "${record}" | sed -n 's/^[[:space:]]*"size": \([0-9]*\),*$/\1/p')"
+if [[ "${manifest_asset}" != "${asset}" || ! "${expected_sha}" =~ ^[a-f0-9]{64}$ ||
+      ! "${expected_size}" =~ ^[1-9][0-9]*$ ]]; then
+  echo "hive installer: target is absent or invalid in the checksum manifest" >&2
+  exit 65
+fi
+
+archive="${temporary}/${asset}"
+download "${release_root}/${asset}" "${archive}"
+actual_sha="$(sha256_file "${archive}")"
+actual_size="$(wc -c < "${archive}" | tr -d ' ')"
+if [[ "${actual_sha}" != "${expected_sha}" || "${actual_size}" != "${expected_size}" ]]; then
+  echo "hive installer: SHA-256 verification failed" >&2
+  exit 65
+fi
+
+tar -xzf "${archive}" -C "${temporary}"
+candidate="${temporary}/hive-${version}/bin/hv"
+[[ -x "${candidate}" ]] || { echo "hive installer: archive has no executable hv" >&2; exit 65; }
+[[ "$("${candidate}" --version)" == "${version}" ]] || {
+  echo "hive installer: packaged executable version check failed" >&2
+  exit 65
+}
+
+manifest_shell_record() {
+  wanted="$1"
+  awk -v wanted="${wanted}" '
+    $0 ~ "\"asset\": \"" wanted "\"" { selected = 1 }
+    selected { print }
+    selected && /^[[:space:]]*}/ { exit }
+  ' "${manifest}"
+}
+
+for helper in install.sh uninstall-shell.sh; do
+  helper_record="$(manifest_shell_record "${helper}")"
+  helper_sha="$(printf '%s\n' "${helper_record}" | sed -n 's/^[[:space:]]*"sha256": "\([a-f0-9]*\)",*$/\1/p')"
+  helper_size="$(printf '%s\n' "${helper_record}" | sed -n 's/^[[:space:]]*"size": \([0-9]*\),*$/\1/p')"
+  if [[ ! "${helper_sha}" =~ ^[a-f0-9]{64}$ || ! "${helper_size}" =~ ^[1-9][0-9]*$ ]]; then
+    echo "hive installer: ${helper} is absent or invalid in the checksum manifest" >&2
+    exit 65
+  fi
+  download "${release_root}/${helper}" "${temporary}/${helper}"
+  if [[ "$(sha256_file "${temporary}/${helper}")" != "${helper_sha}" ||
+        "$(wc -c < "${temporary}/${helper}" | tr -d ' ')" != "${helper_size}" ]]; then
+    echo "hive installer: SHA-256 verification failed for ${helper}" >&2
+    exit 65
+  fi
+  if [[ "${helper}" == "install.sh" ]]; then
+    installer_sha="${helper_sha}"
+  else
+    uninstaller_sha="${helper_sha}"
+  fi
+done
+
+mkdir -p "${destination}" "${data_home}"
+
+owns_hv=false
+owns_alias=false
+owns_installer=false
+owns_uninstaller=false
+owns_receipt=false
+if [[ -f "${receipt}" && "$(receipt_value schema_version)" == "1" &&
+      "$(receipt_value channel)" == "shell" &&
+      "$(receipt_value executable)" == "${destination}/hv" ]]; then
+  old_hv_sha="$(receipt_value executable_sha256)"
+  if receipt_manages "${destination}/hv" && [[ "${old_hv_sha}" =~ ^[a-f0-9]{64}$ &&
+       -f "${destination}/hv" && ! -L "${destination}/hv" &&
+       "$(sha256_file "${destination}/hv")" == "${old_hv_sha}" ]]; then
+    owns_hv=true
+  fi
+  if receipt_manages "${destination}/hive" &&
+     [[ -L "${destination}/hive" && "$(readlink "${destination}/hive")" == "hv" ]]; then
+    owns_alias=true
+  fi
+  old_installer_sha="$(receipt_value installer_sha256)"
+  if receipt_manages "${installed_installer}" && [[ "$(receipt_value installer)" == "${installed_installer}" &&
+       "${old_installer_sha}" =~ ^[a-f0-9]{64}$ && -f "${installed_installer}" &&
+       ! -L "${installed_installer}" && "$(sha256_file "${installed_installer}")" == "${old_installer_sha}" ]]; then
+    owns_installer=true
+  fi
+  old_uninstaller_sha="$(receipt_value uninstaller_sha256)"
+  if receipt_manages "${uninstaller}" && [[ "$(receipt_value uninstaller)" == "${uninstaller}" &&
+       "${old_uninstaller_sha}" =~ ^[a-f0-9]{64}$ && -f "${uninstaller}" &&
+       ! -L "${uninstaller}" && "$(sha256_file "${uninstaller}")" == "${old_uninstaller_sha}" ]]; then
+    owns_uninstaller=true
+  fi
+  receipt_manages "${receipt}" && owns_receipt=true
+fi
+
+if [[ -e "${destination}/hv" || -L "${destination}/hv" ]] && ! ${owns_hv}; then
+  echo "hive installer: refusing to overwrite foreign ${destination}/hv" >&2
+  exit 73
+fi
+for owned_pair in \
+  "${installed_installer}:${owns_installer}" \
+  "${uninstaller}:${owns_uninstaller}" \
+  "${receipt}:${owns_receipt}"; do
+  owned_path="${owned_pair%:*}"
+  owned_value="${owned_pair##*:}"
+  if [[ -e "${owned_path}" || -L "${owned_path}" ]] && ! ${owned_value}; then
+    echo "hive installer: refusing to overwrite unowned ${owned_path}" >&2
+    exit 73
+  fi
+done
+
+selected_command="hive"
+install_alias=true
+if [[ -e "${destination}/hive" || -L "${destination}/hive" ]]; then
+  if ! ${owns_alias}; then
+    selected_command="hv"
+    install_alias=false
+  fi
+else
+  existing_hive="$(command -v hive 2>/dev/null || true)"
+  if [[ -n "${existing_hive}" ]]; then
+    selected_command="hv"
+    install_alias=false
+  fi
+fi
+
+staged_hv="${destination}/.hv.new-$$"
+staged_installer="${data_home}/.install.sh.new-$$"
+staged_uninstaller="${data_home}/.uninstall-shell.sh.new-$$"
+staged_receipt="${data_home}/.install-receipt.yml.new-$$"
+staged_alias="${destination}/.hive.new-$$"
+cp "${candidate}" "${staged_hv}"
+cp "${temporary}/install.sh" "${staged_installer}"
+cp "${temporary}/uninstall-shell.sh" "${staged_uninstaller}"
+chmod 0755 "${staged_hv}" "${staged_installer}" "${staged_uninstaller}"
+executable_sha="$(sha256_file "${staged_hv}")"
+
+cat > "${staged_receipt}" <<EOF
+schema_version: 1
+channel: shell
+package: hive
+command_name: ${selected_command}
+executable: ${destination}/hv
+executable_sha256: ${executable_sha}
+release_source: ${release_root}
+installer: ${installed_installer}
+installer_sha256: ${installer_sha}
+uninstaller: ${uninstaller}
+uninstaller_sha256: ${uninstaller_sha}
+managed_files:
+  - ${destination}/hv
+  - ${installed_installer}
+  - ${uninstaller}
+  - ${receipt}
+EOF
+if ${install_alias}; then
+  printf '  - %s\n' "${destination}/hive" >> "${staged_receipt}"
+  ln -s hv "${staged_alias}"
+fi
+chmod 0600 "${staged_receipt}"
+
+[[ "$("${staged_hv}" --version)" == "${version}" ]] || {
+  echo "hive installer: staged executable version check failed" >&2
+  exit 65
+}
+
+targets=( "${installed_installer}" "${uninstaller}" "${receipt}" )
+staged=( "${staged_installer}" "${staged_uninstaller}" "${staged_receipt}" )
+actions=( replace replace replace )
+if ${install_alias}; then
+  targets+=( "${destination}/hive" )
+  staged+=( "${staged_alias}" )
+  actions+=( replace )
+elif ${owns_alias}; then
+  targets+=( "${destination}/hive" )
+  staged+=( "" )
+  actions+=( remove )
+fi
+targets+=( "${destination}/hv" )
+staged+=( "${staged_hv}" )
+actions+=( replace )
+
+existed=()
+for index in "${!targets[@]}"; do
+  target_path="${targets[$index]}"
+  backup_path="${target_path}.hive-backup-$$"
+  rm -f "${backup_path}"
+  if [[ -e "${target_path}" || -L "${target_path}" ]]; then
+    cp -a "${target_path}" "${backup_path}"
+    existed[$index]=true
+  else
+    existed[$index]=false
+  fi
+done
+
+commit_failed=false
+set +e
+for index in "${!targets[@]}"; do
+  if [[ "${actions[$index]}" == "replace" ]]; then
+    mv -f "${staged[$index]}" "${targets[$index]}" || { commit_failed=true; break; }
+  else
+    rm -f "${targets[$index]}" || { commit_failed=true; break; }
+  fi
+done
+set -e
+
+if ${commit_failed}; then
+  set +e
+  for index in "${!targets[@]}"; do
+    rm -f "${targets[$index]}"
+    if ${existed[$index]}; then
+      mv -f "${targets[$index]}.hive-backup-$$" "${targets[$index]}"
+    fi
+  done
+  set -e
+  echo "hive installer: install transaction failed; previous installation restored" >&2
+  exit 74
+fi
+
+for target_path in "${targets[@]}"; do
+  rm -f "${target_path}.hive-backup-$$"
+done
+
+echo "hive ${version} installed as ${selected_command} in ${destination}"
+case ":${PATH}:" in
+  *":${destination}:"*) ;;
+  *) echo "Add ${destination} to PATH before invoking ${selected_command}." ;;
+esac
+
+for tool in git bash claude gh jq; do
+  command -v "${tool}" >/dev/null 2>&1 || echo "Optional dependency missing: ${tool} (install it separately)." >&2
+done
diff --git a/scripts/package-release b/scripts/package-release
new file mode 100755
index 00000000..02e13c9b
--- /dev/null
+++ b/scripts/package-release
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version=""
+build_dir="${root}/build"
+dist_dir="${root}/dist"
+
+while (($#)); do
+  case "$1" in
+    --version) version="${2:-}"; shift 2 ;;
+    --build-dir) build_dir="${2:-}"; shift 2 ;;
+    --dist-dir) dist_dir="${2:-}"; shift 2 ;;
+    *) echo "package-release: unknown argument: $1" >&2; exit 64 ;;
+  esac
+done
+
+if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "package-release: --version must be strict semver" >&2
+  exit 64
+fi
+
+repository="${HIVE_RELEASE_REPOSITORY:-}"
+if [[ ! "${repository}" =~ ^[^/]+/[^/]+$ ]]; then
+  echo "package-release: HIVE_RELEASE_REPOSITORY must be org/repository" >&2
+  exit 64
+fi
+
+source "${root}/packaging/tebako/versions.env"
+mkdir -p "${dist_dir}"
+temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-package.XXXXXX")"
+trap 'rm -rf "${temporary}"' EXIT
+records="${temporary}/targets.tsv"
+: > "${records}"
+
+for target in darwin-arm64 linux-x86_64; do
+  binary="${build_dir}/hive-${target}"
+  [[ -f "${binary}" ]] || continue
+  case "${target}" in
+    darwin-arm64) minimum_os="macOS 13" ;;
+    linux-x86_64) minimum_os="Ubuntu 22.04 / Arch" ;;
+    *) echo "package-release: unexpected target ${target}" >&2; exit 64 ;;
+  esac
+
+  stage="${temporary}/${target}/hive-${version}"
+  mkdir -p "${stage}/bin" "${stage}/share/hive"
+  cp "${binary}" "${stage}/bin/hv"
+  chmod 0755 "${stage}/bin/hv"
+  ln -s hv "${stage}/bin/hive"
+  cp "${root}/LICENSE" "${stage}/LICENSE"
+  cp "${root}/packaging/receipt-template.yml" "${stage}/share/hive/receipt-template.yml"
+  cp -R "${root}/assets/shared/." "${stage}/share/hive/"
+  cp -R "${root}/assets/project" "${stage}/share/hive/project"
+
+  asset="hive-${version}-${target}.tar.gz"
+  tar -C "${temporary}/${target}" -czf "${dist_dir}/${asset}" "hive-${version}"
+  sha="$(sha256sum "${dist_dir}/${asset}" | awk '{print $1}')"
+  size="$(wc -c < "${dist_dir}/${asset}" | tr -d ' ')"
+  url="https://github.com/${repository}/releases/download/v${version}/${asset}"
+  printf '%s\t%s\t%s\t%s\t%s\t%s\n' "${target}" "${asset}" "${url}" "${sha}" "${size}" "${minimum_os}" >> "${records}"
+done
+
+[[ -s "${records}" ]] || { echo "package-release: no target binaries found" >&2; exit 66; }
+cp "${root}/scripts/install.sh" "${root}/scripts/uninstall-shell.sh" "${dist_dir}/"
+chmod 0755 "${dist_dir}/install.sh" "${dist_dir}/uninstall-shell.sh"
+
+ruby -rjson -e '
+  require "digest"
+  version, repository, ruby_version, records, dist, output = ARGV
+  targets = File.readlines(records, chomp: true).map do |line|
+    target, asset, url, sha, size, minimum_os = line.split("\t", 6)
+    { "target" => target, "asset" => asset, "url" => url, "sha256" => sha,
+      "size" => Integer(size), "minimum_os" => minimum_os }
+  end
+  manifest = {
+    "schema_version" => 1, "version" => version, "tag" => "v#{version}",
+    "repository" => repository, "ruby_version" => ruby_version,
+    "installer_compatibility" => 1, "targets" => targets.sort_by { |row| row["target"] },
+    "shell_assets" => %w[install.sh uninstall-shell.sh].map do |asset|
+      path = File.join(dist, asset)
+      {
+        "asset" => asset,
+        "url" => "https://github.com/#{repository}/releases/download/v#{version}/#{asset}",
+        "sha256" => Digest::SHA256.file(path).hexdigest,
+        "size" => File.size(path)
+      }
+    end
+  }
+  File.write(output, JSON.pretty_generate(manifest) + "\n")
+' "${version}" "${repository}" "${RUBY_VERSION}" "${records}" "${dist_dir}" "${dist_dir}/release-manifest.json"
+
+(
+  cd "${dist_dir}"
+  sha256sum hive-"${version}"-*.tar.gz install.sh uninstall-shell.sh release-manifest.json > SHA256SUMS
+)
diff --git a/scripts/publish-packages b/scripts/publish-packages
new file mode 100755
index 00000000..f36dd7d1
--- /dev/null
+++ b/scripts/publish-packages
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+manifest=""
+while (($#)); do
+  case "$1" in
+    --manifest) manifest="${2:-}"; shift 2 ;;
+    *) echo "publish-packages: unknown argument $1" >&2; exit 64 ;;
+  esac
+done
+[[ -f "${manifest}" ]] || { echo "publish-packages: manifest is required" >&2; exit 64; }
+: "${HOMEBREW_TAP_DIR:?set HOMEBREW_TAP_DIR to a checked-out tap repository}"
+: "${AUR_REPO_DIR:?set AUR_REPO_DIR to a checked-out hive-bin AUR repository}"
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+temporary="$(mktemp -d "${TMPDIR:-/tmp}/hive-packages.XXXXXX")"
+trap 'rm -rf "${temporary}"' EXIT
+"${root}/scripts/verify-release" --manifest "${manifest}" --remote
+"${root}/scripts/render-packages" --manifest "${manifest}" --output-dir "${temporary}"
+
+mkdir -p "${HOMEBREW_TAP_DIR}/Formula"
+cp "${temporary}/homebrew/Formula/hive.rb" "${HOMEBREW_TAP_DIR}/Formula/hive.rb"
+cp "${temporary}/aur/PKGBUILD" "${temporary}/aur/.SRCINFO" \
+  "${temporary}/aur/hive-bin.install" "${AUR_REPO_DIR}/"
+echo "Package metadata rendered after release asset verification; commit and push each downstream repository."
diff --git a/scripts/render-packages b/scripts/render-packages
new file mode 100755
index 00000000..950c20e1
--- /dev/null
+++ b/scripts/render-packages
@@ -0,0 +1,181 @@
+#!/usr/bin/env ruby
+
+require "fileutils"
+require "json"
+require "optparse"
+
+options = { output_dir: File.expand_path("../packaging", __dir__) }
+OptionParser.new do |parser|
+  parser.on("--manifest PATH") { |value| options[:manifest] = value }
+  parser.on("--output-dir PATH") { |value| options[:output_dir] = value }
+end.parse!
+abort "render-packages: --manifest is required" unless options[:manifest]
+
+manifest = JSON.parse(File.read(options[:manifest]))
+version = manifest.fetch("version")
+repository = manifest.fetch("repository")
+darwin = manifest.fetch("targets").find { |row| row["target"] == "darwin-arm64" } or abort "missing darwin-arm64"
+linux = manifest.fetch("targets").find { |row| row["target"] == "linux-x86_64" } or abort "missing linux-x86_64"
+
+formula = <<~RUBY
+  class Hive < Formula
+    desc "Agent-driven software delivery pipeline"
+    homepage "https://github.com/#{repository}"
+    version "#{version}"
+    url "#{darwin.fetch('url')}"
+    sha256 "#{darwin.fetch('sha256')}"
+
+    def install
+      libexec.install Dir["*"]
+      path_hive = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
+                     .map { |directory| Pathname(directory)/"hive" }
+                     .find(&:executable?)
+      prefix_hive = HOMEBREW_PREFIX/"bin/hive"
+      prefix_hv = HOMEBREW_PREFIX/"bin/hv"
+      candidates = [ path_hive, (prefix_hive if prefix_hive.exist? || prefix_hive.symlink?) ].compact
+      owned_prefix_hive = prefix_hive.symlink? && prefix_hv.exist? &&
+                          prefix_hive.realpath == prefix_hv.realpath
+      foreign_hive = candidates.any? do |candidate|
+        !owned_prefix_hive || !File.identical?(candidate, prefix_hive)
+      rescue Errno::ENOENT
+        true
+      end
+      selected_command = foreign_hive ? "hv" : "hive"
+
+      receipt = share/"hive/install-receipt.yml"
+      receipt.dirname.mkpath
+      receipt.write <<~YAML
+        schema_version: 1
+        channel: homebrew
+        package: #{repository}/hive
+        command_name: \#{selected_command}
+        executable: \#{opt_bin}/hv
+        release_source: #{darwin.fetch('url')}
+        managed_files:
+          - \#{opt_bin}/hv
+      YAML
+      (bin/"hv").write <<~SH
+        #!/bin/sh
+        HIVE_INSTALL_RECEIPT="\#{receipt}" exec "\#{libexec}/bin/hv" "$@"
+      SH
+      chmod 0755, bin/"hv"
+      bin.install_symlink "hv" => "hive" if selected_command == "hive"
+    end
+
+    def post_install
+      %w[git bash claude gh jq].each do |tool|
+        opoo "Optional Hive dependency missing: \#{tool}. Install it separately." unless which(tool)
+      end
+    end
+
+    service do
+      run [ opt_bin/"hv", "daemon", "start" ]
+      keep_alive false
+      log_path var/"log/hive.log"
+      error_log_path var/"log/hive.log"
+    end
+
+    test do
+      assert_equal version.to_s, shell_output("\#{bin}/hv --version").strip
+    end
+  end
+RUBY
+
+pkgbuild = <<~BASH
+  # Maintainer: Hive release automation
+  pkgname=hive-bin
+  pkgver=#{version}
+  pkgrel=1
+  pkgdesc='Agent-driven software delivery pipeline (prebuilt)'
+  arch=('x86_64')
+  url='https://github.com/#{repository}'
+  license=('MIT')
+  depends=('glibc')
+  provides=('hive')
+  install=hive-bin.install
+  source_x86_64=('hive-'${pkgver}'-linux-x86_64.tar.gz::#{linux.fetch('url')}')
+  sha256sums_x86_64=('#{linux.fetch('sha256')}')
+
+  package() {
+    install -Dm755 "$srcdir/hive-$pkgver/bin/hv" "$pkgdir/usr/lib/hive/hv"
+    install -Dm644 "$srcdir/hive-$pkgver/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
+    install -Dm644 /dev/stdin "$pkgdir/usr/share/hive/install-receipt.yml" <<EOF
+  schema_version: 1
+  channel: aur
+  package: hive-bin
+  command_name: hv
+  executable: /usr/bin/hv
+  release_source: #{linux.fetch('url')}
+  managed_files:
+    - /usr/bin/hv
+  EOF
+    install -Dm755 /dev/stdin "$pkgdir/usr/bin/hv" <<'EOF'
+  #!/bin/sh
+  HIVE_INSTALL_RECEIPT=/usr/share/hive/install-receipt.yml exec /usr/lib/hive/hv "$@"
+  EOF
+  }
+BASH
+
+srcinfo = <<~INFO
+  pkgbase = hive-bin
+    pkgdesc = Agent-driven software delivery pipeline (prebuilt)
+    pkgver = #{version}
+    pkgrel = 1
+    url = https://github.com/#{repository}
+    arch = x86_64
+    license = MIT
+    depends = glibc
+    provides = hive
+    install = hive-bin.install
+    source_x86_64 = hive-#{version}-linux-x86_64.tar.gz::#{linux.fetch('url')}
+    sha256sums_x86_64 = #{linux.fetch('sha256')}
+
+  pkgname = hive-bin
+INFO
+
+  install_hook = <<~BASH
+  post_install() {
+    if [ -e /usr/bin/hive ] || [ -L /usr/bin/hive ]; then
+      sed -i 's/^command_name: hive$/command_name: hv/' /usr/share/hive/install-receipt.yml
+      echo "Hive installed as hv; preserving existing hive command."
+    elif command -v hive >/dev/null 2>&1; then
+      echo "Hive installed as hv; preserving existing hive command."
+    else
+      ln -s /usr/bin/hv /usr/bin/hive
+      sed -i 's/^command_name: hv$/command_name: hive/' /usr/share/hive/install-receipt.yml
+    fi
+    for tool in git bash claude gh jq; do
+      command -v "$tool" >/dev/null 2>&1 ||
+        echo "Optional Hive dependency missing: $tool. Install it separately."
+    done
+    echo "Run hive init (or hv init) to register the user daemon; package installation does not start it."
+  }
+
+  pre_upgrade() {
+    if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then
+      rm -f /usr/bin/hive
+    fi
+  }
+
+  post_upgrade() {
+    post_install
+  }
+
+  pre_remove() {
+    if [ -L /usr/bin/hive ] && [ "$(readlink /usr/bin/hive)" = "/usr/bin/hv" ]; then
+      rm -f /usr/bin/hive
+    fi
+  }
+BASH
+
+files = {
+  "homebrew/Formula/hive.rb" => formula,
+  "aur/PKGBUILD" => pkgbuild,
+  "aur/.SRCINFO" => srcinfo,
+  "aur/hive-bin.install" => install_hook
+}
+files.each do |relative, content|
+  path = File.join(options[:output_dir], relative)
+  FileUtils.mkdir_p(File.dirname(path))
+  File.write(path, content)
+end
diff --git a/scripts/uninstall-shell.sh b/scripts/uninstall-shell.sh
new file mode 100755
index 00000000..e7d129fd
--- /dev/null
+++ b/scripts/uninstall-shell.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+assume_yes=false
+while (($#)); do
+  case "$1" in
+    --yes) assume_yes=true; shift ;;
+    *) echo "hive shell uninstaller: unknown argument $1" >&2; exit 64 ;;
+  esac
+done
+
+data_home="${XDG_DATA_HOME:-${HOME}/.local/share}/hive"
+receipt="${data_home}/install-receipt.yml"
+[[ -f "${receipt}" ]] || { echo "hive shell uninstaller: receipt missing" >&2; exit 78; }
+schema_version="$(sed -n 's/^schema_version: //p' "${receipt}")"
+channel="$(sed -n 's/^channel: //p' "${receipt}")"
+[[ "${schema_version}" == "1" && "${channel}" == "shell" ]] || {
+  echo "hive shell uninstaller: receipt is not a supported shell receipt" >&2
+  exit 78
+}
+executable="$(sed -n 's/^executable: //p' "${receipt}")"
+expected_executable_sha="$(sed -n 's/^executable_sha256: //p' "${receipt}")"
+expected_uninstaller_sha="$(sed -n 's/^uninstaller_sha256: //p' "${receipt}")"
+receipt_uninstaller="$(sed -n 's/^uninstaller: //p' "${receipt}")"
+
+sha256_file() {
+  if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum "$1" | awk '{print $1}'
+  elif command -v shasum >/dev/null 2>&1; then
+    shasum -a 256 "$1" | awk '{print $1}'
+  else
+    echo "hive shell uninstaller: sha256sum or shasum is required" >&2
+    exit 69
+  fi
+}
+
+managed() {
+  grep -Fqx "  - $1" "${receipt}"
+}
+
+if [[ "${receipt_uninstaller}" != "$0" || -L "$0" ||
+      ! "${expected_uninstaller_sha}" =~ ^[a-f0-9]{64}$ ||
+      "$(sha256_file "$0")" != "${expected_uninstaller_sha}" ]] || ! managed "$0"; then
+  echo "hive shell uninstaller: uninstaller ownership verification failed" >&2
+  exit 78
+fi
+if [[ ! "${expected_executable_sha}" =~ ^[a-f0-9]{64}$ || ! -f "${executable}" ||
+      -L "${executable}" || "$(sha256_file "${executable}")" != "${expected_executable_sha}" ]] ||
+   ! managed "${executable}"; then
+  echo "hive shell uninstaller: executable ownership verification failed" >&2
+  exit 78
+fi
+
+if ! ${assume_yes}; then
+  printf 'Remove the shell-managed Hive executable? [y/N] '
+  read -r answer
+  [[ "${answer}" =~ ^[Yy]$ ]] || { echo "hive shell uninstaller: cancelled"; exit 0; }
+fi
+
+if [[ -n "${executable}" && -f "${executable}" && ! -L "${executable}" ]]; then
+  alias_path="$(dirname "${executable}")/hive"
+  if managed "${alias_path}" && [[ -L "${alias_path}" && "$(readlink "${alias_path}")" == "hv" ]]; then
+    rm -f "${alias_path}"
+  fi
+  rm -f "${executable}"
+fi
+rm -f "${receipt}" "${data_home}/install.sh"
+echo "Hive core removed; project work, XDG config/state, outputs, and agent skills were preserved."
+rm -f -- "$0"
diff --git a/scripts/verify-release b/scripts/verify-release
new file mode 100755
index 00000000..98a232d4
--- /dev/null
+++ b/scripts/verify-release
@@ -0,0 +1,85 @@
+#!/usr/bin/env ruby
+
+require "digest"
+require "json"
+require "open3"
+require "optparse"
+require "tmpdir"
+
+options = {}
+OptionParser.new do |parser|
+  parser.on("--manifest PATH") { |value| options[:manifest] = value }
+  parser.on("--dist-dir PATH") { |value| options[:dist_dir] = value }
+  parser.on("--remote") { options[:remote] = true }
+end.parse!
+abort "verify-release: --manifest is required" unless options[:manifest]
+
+manifest_path = File.expand_path(options[:manifest])
+dist = File.expand_path(options[:dist_dir] || File.dirname(manifest_path))
+manifest = JSON.parse(File.read(manifest_path))
+version = manifest.fetch("version")
+abort "verify-release: invalid semver" unless version.match?(/\A\d+\.\d+\.\d+\z/)
+abort "verify-release: tag/version mismatch" unless manifest["tag"] == "v#{version}"
+targets = manifest.fetch("targets")
+shell_assets = manifest.fetch("shell_assets")
+expected_targets = %w[darwin-arm64 linux-x86_64]
+abort "verify-release: tier-1 target set mismatch" unless targets.map { |row| row["target"] }.sort == expected_targets
+expected_shell_assets = %w[install.sh uninstall-shell.sh]
+unless shell_assets.map { |row| row["asset"] }.sort == expected_shell_assets
+  abort "verify-release: shell asset set mismatch"
+end
+
+records = targets + shell_assets
+records.each do |row|
+  archive = File.join(dist, row.fetch("asset"))
+  abort "verify-release: missing #{archive}" unless File.file?(archive)
+  abort "verify-release: size mismatch for #{archive}" unless File.size(archive) == row.fetch("size")
+  abort "verify-release: digest mismatch for #{archive}" unless Digest::SHA256.file(archive).hexdigest == row.fetch("sha256")
+end
+
+targets.each do |row|
+  archive = File.join(dist, row.fetch("asset"))
+  listing, error, status = Open3.capture3("tar", "-tzf", archive)
+  abort "verify-release: unreadable archive #{archive}: #{error}" unless status.success?
+  %W[hive-#{version}/bin/hv hive-#{version}/bin/hive hive-#{version}/LICENSE].each do |entry|
+    abort "verify-release: #{archive} missing #{entry}" unless listing.lines.map(&:chomp).include?(entry)
+  end
+end
+
+if options[:remote]
+  Dir.mktmpdir("hive-remote-verify") do |directory|
+    records.each do |row|
+      downloaded = File.join(directory, row.fetch("asset"))
+      _out, error, status = Open3.capture3(
+        "curl", "--fail", "--silent", "--show-error", "--location",
+        "--connect-timeout", "10", "--max-time", "120",
+        "--retry", "3", "--retry-delay", "1", "--retry-all-errors",
+        "--output", downloaded, row.fetch("url")
+      )
+      abort "verify-release: remote asset unavailable: #{error}" unless status.success?
+      unless File.size(downloaded) == row.fetch("size")
+        abort "verify-release: remote size mismatch for #{row.fetch('asset')}"
+      end
+      unless Digest::SHA256.file(downloaded).hexdigest == row.fetch("sha256")
+        abort "verify-release: remote digest mismatch for #{row.fetch('asset')}"
+      end
+    end
+  end
+end
+
+Dir.mktmpdir("hive-render-verify") do |directory|
+  _out, error, status = Open3.capture3(
+    File.expand_path("render-packages", __dir__),
+    "--manifest", manifest_path,
+    "--output-dir", directory
+  )
+  abort "verify-release: package rendering failed: #{error}" unless status.success?
+  formula = File.read(File.join(directory, "homebrew/Formula/hive.rb"))
+  pkgbuild = File.read(File.join(directory, "aur/PKGBUILD"))
+  darwin = targets.find { |row| row["target"] == "darwin-arm64" }
+  linux = targets.find { |row| row["target"] == "linux-x86_64" }
+  abort "verify-release: Homebrew digest drift" unless formula.include?(darwin.fetch("sha256"))
+  abort "verify-release: AUR digest drift" unless pkgbuild.include?(linux.fetch("sha256"))
+end
+
+puts "verified Hive v#{version}: #{expected_targets.join(', ')}"
diff --git a/test/acceptance/arch_aur_test.rb b/test/acceptance/arch_aur_test.rb
new file mode 100644
index 00000000..be03826f
--- /dev/null
+++ b/test/acceptance/arch_aur_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+
+class ArchAurAcceptanceTest < Minitest::Test
+  def test_aur_package_is_prebuilt_and_does_not_start_daemon
+    root = File.expand_path("../..", __dir__)
+    pkgbuild = File.read(File.join(root, "packaging/aur/PKGBUILD"))
+    hook = File.read(File.join(root, "packaging/aur/hive-bin.install"))
+    assert_includes pkgbuild, "pkgname=hive-bin"
+    refute_match(/ruby/, pkgbuild)
+    refute_match(/systemctl.*enable|systemctl.*start/, hook)
+  end
+end
diff --git a/test/acceptance/macos_homebrew_test.rb b/test/acceptance/macos_homebrew_test.rb
new file mode 100644
index 00000000..9dca5b68
--- /dev/null
+++ b/test/acceptance/macos_homebrew_test.rb
@@ -0,0 +1,10 @@
+require "test_helper"
+
+class MacosHomebrewAcceptanceTest < Minitest::Test
+  def test_formula_is_manifest_generated_and_does_not_depend_on_ruby
+    formula = File.read(File.expand_path("../../packaging/homebrew/Formula/hive.rb", __dir__))
+    assert_includes formula, "Generated by scripts/render-packages"
+    refute_match(/depends_on\s+[\"']ruby/, formula)
+    refute_match(/brew services start/, formula)
+  end
+end
diff --git a/test/acceptance/prompt_installer_test.rb b/test/acceptance/prompt_installer_test.rb
new file mode 100644
index 00000000..7922f5a5
--- /dev/null
+++ b/test/acceptance/prompt_installer_test.rb
@@ -0,0 +1,9 @@
+require "test_helper"
+
+class PromptInstallerAcceptanceTest < Minitest::Test
+  def test_one_prompt_names_all_agents_and_three_consent_boundaries
+    prompt = File.read(File.expand_path("../../docs/install-with-agent.md", __dir__))
+    %w[Claude Codex Pi Homebrew hive-bin].each { |value| assert_includes prompt, value }
+    assert_operator prompt.scan(/ask .*approval|Ask /i).size, :>=, 3
+  end
+end
diff --git a/test/acceptance/release_qualification_test.rb b/test/acceptance/release_qualification_test.rb
new file mode 100644
index 00000000..ef8984a7
--- /dev/null
+++ b/test/acceptance/release_qualification_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class ReleaseQualificationTest < Minitest::Test
+  ROOT = File.expand_path("../..", __dir__)
+
+  def test_release_workflow_is_two_phase_and_distribution_suite_is_blocking
+    workflow = File.read(File.join(ROOT, ".github/workflows/release.yml"))
+    build_workflow = File.read(File.join(ROOT, ".github/workflows/build-release.yml"))
+    install_smoke = File.read(File.join(ROOT, ".github/workflows/install-smoke.yml"))
+    assert_match(/needs:.*build/m, workflow)
+    assert_match(/gh release create/, workflow)
+    assert_match(/needs:.*release/m, workflow)
+    assert_includes workflow, "rake distribution"
+    assert_includes workflow, "tier1-shell"
+    assert_includes workflow, "tier1-homebrew"
+    assert_includes workflow, "tier1-aur"
+    assert_includes workflow, "publish-skills"
+    assert_includes workflow, "claude plugin marketplace add"
+    assert_includes workflow, "claude plugin install"
+    assert_includes workflow, "codex plugin add"
+    assert_includes workflow, "pi install"
+    assert_includes workflow, "subject-path: dist/hive-*.tar.gz"
+    publisher = File.read(File.join(ROOT, "scripts/publish-packages"))
+    assert_includes publisher, "scripts/verify-release"
+    assert_includes publisher, "--remote"
+    assert_includes workflow, "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"
+    assert_includes build_workflow, "attestations: write"
+    refute_includes workflow, "ssh-keyscan aur.archlinux.org >>"
+    refute_match(/if:.*HOMEBREW_TAP_REPOSITORY.*AUR_REPOSITORY/, workflow)
+    refute_includes build_workflow, "subject-path: build/hive-"
+    assert_includes install_smoke, "homebrew:"
+    refute_includes install_smoke, "base-devel git ruby"
+    refute_includes install_smoke, "pacman -Rns"
+  end
+
+  def test_public_docs_name_collision_consent_and_preserved_paths
+    readme = File.read(File.join(ROOT, "README.md"))
+    uninstall = File.read(File.join(ROOT, "docs/uninstall.md"))
+    assert_includes readme, "Homebrew"
+    assert_includes readme, "`hv`"
+    assert_includes uninstall, "preserved"
+    assert_includes uninstall, "agent skills"
+    assert_includes uninstall, "completed"
+  end
+end
diff --git a/test/acceptance/ubuntu_shell_test.rb b/test/acceptance/ubuntu_shell_test.rb
new file mode 100644
index 00000000..485ae1e1
--- /dev/null
+++ b/test/acceptance/ubuntu_shell_test.rb
@@ -0,0 +1,11 @@
+require "test_helper"
+
+class UbuntuShellAcceptanceTest < Minitest::Test
+  def test_installer_requires_manifest_digest_and_version_check
+    installer = File.read(File.expand_path("../../scripts/install.sh", __dir__))
+    assert_includes installer, "release-manifest.json"
+    assert_match(/sha256sum|shasum/, installer)
+    assert_includes installer, "--version"
+    refute_match(/sudo/, installer)
+  end
+end
diff --git a/test/acceptance/uninstall_preserves_work_test.rb b/test/acceptance/uninstall_preserves_work_test.rb
new file mode 100644
index 00000000..612af687
--- /dev/null
+++ b/test/acceptance/uninstall_preserves_work_test.rb
@@ -0,0 +1,38 @@
+require "test_helper"
+require "digest"
+require "hive/commands/uninstall"
+
+class UninstallPreservesWorkTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_purge_boundary_preserves_modified_unknown_state_and_skills
+    with_tmp_dir do |root|
+      FileUtils.mkdir_p(File.join(root, ".hive"))
+      generated = File.join(root, ".hive/generated")
+      modified = File.join(root, ".hive/modified")
+      unknown = File.join(root, ".hive/unknown")
+      state = File.join(root, ".hive-state/stages/8-done/task/output")
+      skill = File.join(root, ".agents/skills/hive/SKILL.md")
+      [ generated, modified, unknown, state, skill ].each { |path| FileUtils.mkdir_p(File.dirname(path)) }
+      File.write(generated, "owned")
+      File.write(modified, "changed")
+      File.write(unknown, "unknown")
+      File.write(state, "completed")
+      File.write(skill, "skill")
+      File.write(File.join(root, ".hive/ownership.yml"), {
+        "version" => 1,
+        "files" => {
+          ".hive/generated" => Digest::SHA256.hexdigest("owned"),
+          ".hive/modified" => Digest::SHA256.hexdigest("original")
+        }
+      }.to_yaml)
+
+      Hive::Commands::Uninstall.cleanup_project(root)
+      refute File.exist?(generated)
+      assert File.exist?(modified)
+      assert File.exist?(unknown)
+      assert File.exist?(state)
+      assert File.exist?(skill)
+    end
+  end
+end
diff --git a/test/install/agent_prompt_contract_test.rb b/test/install/agent_prompt_contract_test.rb
new file mode 100644
index 00000000..ac550dc8
--- /dev/null
+++ b/test/install/agent_prompt_contract_test.rb
@@ -0,0 +1,37 @@
+require "test_helper"
+require "json"
+
+class AgentPromptContractTest < Minitest::Test
+  ROOT = File.expand_path("../..", __dir__)
+
+  def test_prompt_covers_channels_consent_and_collision_without_private_writes
+    prompt = File.read(File.join(ROOT, "docs/install-with-agent.md"))
+    assert_includes prompt, "uname -s"
+    assert_includes prompt, "Homebrew"
+    assert_includes prompt, "paru"
+    assert_includes prompt, "checksum-verifying Hive shell"
+    assert_includes prompt, "ivankuznetsov/hive"
+    assert_includes prompt, "ivankuznetsov/hive-skills"
+    assert_includes prompt, "0.1.0"
+    assert_includes prompt, "ask for approval"
+    assert_includes prompt, "use `hv`"
+    assert_match(/confirm the\s+> absolute Git project root/, prompt)
+    refute_match(%r{(?:~|\$HOME)/\.(?:claude|codex|pi)}, prompt)
+  end
+
+  def test_skill_copy_and_marketplace_versions_match
+    canonical = File.read(File.join(ROOT, "hive-skills/skills/hive/SKILL.md"))
+    packaged = File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/skills/hive/SKILL.md"))
+    assert_equal canonical, packaged
+
+    codex = JSON.parse(File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/.codex-plugin/plugin.json")))
+    claude = JSON.parse(File.read(File.join(ROOT, "hive-skills/plugins/hive-skills/.claude-plugin/plugin.json")))
+    pi = JSON.parse(File.read(File.join(ROOT, "hive-skills/package.json")))
+    adapters = JSON.parse(File.read(File.join(ROOT, "hive-skills/marketplaces/adapters.json")))
+    assert_equal [ "0.1.0" ], [ codex["version"], claude["version"], pi["version"], adapters["version"] ].uniq
+    assert_equal Hive::VERSION, adapters["version"]
+    adapters.fetch("adapters").each_value do |commands|
+      commands.each { |command| refute_includes command, "<org>" }
+    end
+  end
+end
diff --git a/test/install/package_render_test.rb b/test/install/package_render_test.rb
new file mode 100644
index 00000000..b1accdb8
--- /dev/null
+++ b/test/install/package_render_test.rb
@@ -0,0 +1,66 @@
+require "test_helper"
+require "json"
+require "open3"
+
+class PackageRenderTest < Minitest::Test
+  include HiveTestHelper
+
+  ROOT = File.expand_path("../..", __dir__)
+
+  def test_renders_homebrew_and_aur_from_one_manifest
+    with_tmp_dir do |dir|
+      manifest = File.join(dir, "manifest.json")
+      output = File.join(dir, "packages")
+      File.write(manifest, JSON.generate(
+        "schema_version" => 1,
+        "version" => "0.1.0",
+        "tag" => "v0.1.0",
+        "repository" => "example/hive",
+        "ruby_version" => "3.4.1",
+        "installer_compatibility" => 1,
+        "targets" => [
+          target("darwin-arm64", "mac.tar.gz", "a" * 64),
+          target("linux-x86_64", "linux.tar.gz", "b" * 64)
+        ]
+      ))
+      _out, err, status = Open3.capture3(
+        File.join(ROOT, "scripts/render-packages"),
+        "--manifest", manifest,
+        "--output-dir", output
+      )
+      assert status.success?, err
+      formula = File.read(File.join(output, "homebrew/Formula/hive.rb"))
+      pkgbuild = File.read(File.join(output, "aur/PKGBUILD"))
+      install_hook = File.read(File.join(output, "aur/hive-bin.install"))
+      assert_includes formula, "sha256 \"#{'a' * 64}\""
+      assert_includes pkgbuild, "sha256sums_x86_64=('#{'b' * 64}')"
+      assert_includes pkgbuild, "pkgname=hive-bin"
+      refute_match(/\bruby\b.*depends/, pkgbuild)
+      assert_includes formula, "prefix_hive.realpath == prefix_hv.realpath"
+      assert_includes formula, "File.identical?(candidate, prefix_hive)"
+      assert_includes formula, "command_name: \#{selected_command}"
+      assert_includes formula, "package: example/hive/hive"
+      assert_includes formula, "Optional Hive dependency missing"
+      assert_includes pkgbuild, "managed_files:"
+      assert_includes install_hook, "pre_upgrade()"
+      assert_includes install_hook, "s/^command_name: hive$/command_name: hv/"
+      _out, syntax_error, syntax_status = Open3.capture3("bash", "-n", File.join(output, "aur/PKGBUILD"))
+      assert syntax_status.success?, syntax_error
+      _out, syntax_error, syntax_status = Open3.capture3("ruby", "-c", File.join(output, "homebrew/Formula/hive.rb"))
+      assert syntax_status.success?, syntax_error
+    end
+  end
+
+  private
+
+  def target(name, asset, sha)
+    {
+      "target" => name,
+      "asset" => asset,
+      "url" => "https://example.test/#{asset}",
+      "sha256" => sha,
+      "size" => 10,
+      "minimum_os" => "test"
+    }
+  end
+end
diff --git a/test/install/shell_installer.bats b/test/install/shell_installer.bats
new file mode 100755
index 00000000..35b47490
--- /dev/null
+++ b/test/install/shell_installer.bats
@@ -0,0 +1,33 @@
+#!/usr/bin/env bats
+
+setup() {
+  export TEST_ROOT="${BATS_TEST_TMPDIR}/case"
+  export HOME="${TEST_ROOT}/home"
+  export XDG_DATA_HOME="${TEST_ROOT}/data"
+  export HIVE_BIN_DIR="${TEST_ROOT}/bin"
+  export HIVE_INSTALL_BASE_URL="file://${TEST_ROOT}/releases"
+  mkdir -p "${HOME}" "${TEST_ROOT}/build"
+  printf '#!/bin/sh\nprintf "0.1.0\\n"\n' > "${TEST_ROOT}/build/hive-linux-x86_64"
+  chmod +x "${TEST_ROOT}/build/hive-linux-x86_64"
+  HIVE_RELEASE_REPOSITORY=example/hive \
+    "${BATS_TEST_DIRNAME}/../../scripts/package-release" \
+      --version 0.1.0 --build-dir "${TEST_ROOT}/build" --dist-dir "${TEST_ROOT}/releases/v0.1.0"
+  cp "${BATS_TEST_DIRNAME}/../../scripts/install.sh" "${TEST_ROOT}/releases/v0.1.0/install.sh"
+  cp "${BATS_TEST_DIRNAME}/../../scripts/uninstall-shell.sh" "${TEST_ROOT}/releases/v0.1.0/uninstall-shell.sh"
+}
+
+@test "installs and verifies the linux artifact" {
+  run "${BATS_TEST_DIRNAME}/../../scripts/install.sh" --version 0.1.0 --yes
+  [ "$status" -eq 0 ]
+  [ "$("${HIVE_BIN_DIR}/hv" --version)" = "0.1.0" ]
+  [ -L "${HIVE_BIN_DIR}/hive" ]
+}
+
+@test "digest mismatch preserves an existing executable" {
+  mkdir -p "${HIVE_BIN_DIR}"
+  printf 'old\n' > "${HIVE_BIN_DIR}/hv"
+  printf 'tamper' >> "${TEST_ROOT}/releases/v0.1.0/hive-0.1.0-linux-x86_64.tar.gz"
+  run "${BATS_TEST_DIRNAME}/../../scripts/install.sh" --version 0.1.0 --yes
+  [ "$status" -ne 0 ]
+  [ "$(cat "${HIVE_BIN_DIR}/hv")" = "old" ]
+}
diff --git a/test/install/shell_installer_test.rb b/test/install/shell_installer_test.rb
new file mode 100644
index 00000000..e2fda89f
--- /dev/null
+++ b/test/install/shell_installer_test.rb
@@ -0,0 +1,110 @@
+require "test_helper"
+require "digest"
+require "open3"
+
+class ShellInstallerTest < Minitest::Test
+  include HiveTestHelper
+
+  ROOT = File.expand_path("../..", __dir__)
+
+  def test_public_defaults_use_the_canonical_latest_release_and_bounded_downloads
+    installer = File.read(File.join(ROOT, "scripts/install.sh"))
+
+    assert_includes installer, "ivankuznetsov/hive"
+    assert_includes installer, "/releases/latest/download"
+    assert_includes installer, "--connect-timeout 10"
+    assert_includes installer, "--max-time 120"
+    assert_includes installer, "--retry 3"
+    refute_match(/\bsigned (?:release )?manifest\b/i, installer)
+  end
+
+  def test_verified_install_and_uninstall_preserve_state
+    with_tmp_dir do |dir|
+      home = File.join(dir, "home")
+      build = File.join(dir, "build")
+      release = File.join(dir, "releases", "v0.1.0")
+      bin = File.join(dir, "bin")
+      data = File.join(dir, "data")
+      FileUtils.mkdir_p([ home, build, release ])
+      binary = File.join(build, "hive-linux-x86_64")
+      File.write(binary, "#!/bin/sh\nprintf '0.1.0\\n'\n")
+      File.chmod(0o755, binary)
+      package_release(build, release)
+      FileUtils.cp(File.join(ROOT, "scripts/install.sh"), File.join(release, "install.sh"))
+      FileUtils.cp(File.join(ROOT, "scripts/uninstall-shell.sh"), File.join(release, "uninstall-shell.sh"))
+      env = {
+        "HOME" => home,
+        "XDG_DATA_HOME" => data,
+        "HIVE_BIN_DIR" => bin,
+        "HIVE_INSTALL_BASE_URL" => "file://#{File.join(dir, 'releases')}",
+        "PATH" => ENV.fetch("PATH")
+      }
+
+      _out, err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes")
+      assert status.success?, err
+      assert_equal "0.1.0\n", IO.popen([ File.join(bin, "hv"), "--version" ], &:read)
+      receipt = File.read(File.join(data, "hive/install-receipt.yml"))
+      assert_includes receipt, "installer_sha256:"
+      assert_includes receipt, "uninstaller_sha256:"
+      assert_includes receipt, "executable_sha256:"
+
+      FileUtils.rm_f(File.join(bin, "hive"))
+      File.write(File.join(bin, "hive"), "#!/bin/sh\necho foreign\n")
+      File.chmod(0o755, File.join(bin, "hive"))
+      foreign_digest = Digest::SHA256.file(File.join(bin, "hive")).hexdigest
+      _out, err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes")
+      assert status.success?, err
+      assert_equal foreign_digest, Digest::SHA256.file(File.join(bin, "hive")).hexdigest
+      assert_includes File.read(File.join(data, "hive/install-receipt.yml")), "command_name: hv"
+
+      FileUtils.mkdir_p(File.join(data, "state"))
+      File.write(File.join(data, "state", "keep"), "work")
+
+      _out, err, status = Open3.capture3(env, File.join(data, "hive/uninstall-shell.sh"), "--yes")
+      assert status.success?, err
+      refute File.exist?(File.join(bin, "hv"))
+      assert File.exist?(File.join(data, "state", "keep"))
+    end
+  end
+
+  def test_tampered_helper_preserves_the_previous_executable
+    with_tmp_dir do |dir|
+      home = File.join(dir, "home")
+      build = File.join(dir, "build")
+      release = File.join(dir, "releases", "v0.1.0")
+      bin = File.join(dir, "bin")
+      FileUtils.mkdir_p([ home, build, release, bin ])
+      binary = File.join(build, "hive-linux-x86_64")
+      File.write(binary, "#!/bin/sh\nprintf '0.1.0\\n'\n")
+      File.chmod(0o755, binary)
+      package_release(build, release)
+      File.write(File.join(bin, "hv"), "previous\n")
+      File.write(File.join(release, "uninstall-shell.sh"), "tampered\n")
+      env = {
+        "HOME" => home,
+        "XDG_DATA_HOME" => File.join(dir, "data"),
+        "HIVE_BIN_DIR" => bin,
+        "HIVE_INSTALL_BASE_URL" => "file://#{File.join(dir, 'releases')}",
+        "PATH" => ENV.fetch("PATH")
+      }
+
+      _out, _err, status = Open3.capture3(env, File.join(ROOT, "scripts/install.sh"), "--version", "0.1.0", "--yes")
+
+      refute status.success?
+      assert_equal "previous\n", File.read(File.join(bin, "hv"))
+    end
+  end
+
+  private
+
+  def package_release(build, release)
+    _out, err, status = Open3.capture3(
+      { "HIVE_RELEASE_REPOSITORY" => "example/hive" },
+      File.join(ROOT, "scripts/package-release"),
+      "--version", "0.1.0",
+      "--build-dir", build,
+      "--dist-dir", release
+    )
+    assert status.success?, err
+  end
+end
diff --git a/test/integration/fixtures/tui-fake-child b/test/integration/fixtures/tui-fake-child
index 3a9fdbd6..11f7a2a3 100755
--- a/test/integration/fixtures/tui-fake-child
+++ b/test/integration/fixtures/tui-fake-child
@@ -7,6 +7,11 @@
 #   HIVE_TUI_FAKE_STDERR=text    print to stderr
 #   HIVE_TUI_FAKE_TRAP_INT=1     trap SIGINT, print marker, exit 130
 #   HIVE_TUI_FAKE_BLOCK=1        sleep until killed (used for SIGINT/SIGTERM tests)
+#   HIVE_TUI_FAKE_PID_FILE=path  append this fixture's PID for targeted cleanup
+
+if [[ -n "$HIVE_TUI_FAKE_PID_FILE" ]]; then
+  printf '%s\n' "$$" >> "$HIVE_TUI_FAKE_PID_FILE"
+fi
 
 if [[ "$HIVE_TUI_FAKE_TRAP_INT" == "1" ]]; then
   trap 'echo "fake-child:caught-SIGINT"; exit 130' INT
diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb
index 0b423861..baa0d36e 100644
--- a/test/integration/init_test.rb
+++ b/test/integration/init_test.rb
@@ -36,6 +36,12 @@ class InitTest < Minitest::Test
 
         projects = Hive::Config.registered_projects
         assert(projects.any? { |p| p["path"] == File.expand_path(dir) })
+        assert_equal false, Hive::Config.load(dir).dig("daemon", "enabled")
+
+        provider_config = YAML.safe_load_file(File.join(ENV.fetch("XDG_CONFIG_HOME"), "hive", "config.yml"))
+        assert_equal "openai", provider_config["provider"]
+        assert_equal "gpt-5", provider_config["model"]
+        assert_equal "env:OPENAI_API_KEY", provider_config["credential_source"]
       end
     end
   end
@@ -113,14 +119,17 @@ class InitTest < Minitest::Test
     end
   end
 
-  def test_double_init_raises_already_initialized_with_exit_2
+  def test_double_init_is_idempotent_and_repairs_missing_scaffold
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
         capture_io { Hive::Commands::Init.new(dir).call }
-        _, err, status = with_captured_exit { Hive::Commands::Init.new(dir).call }
-        assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status,
-                     "second init must raise Hive::AlreadyInitialized (exit 2), not bare exit"
-        assert_includes err, "already initialized"
+        generated = File.join(dir, ".hive", "settings.yml")
+        File.delete(generated)
+
+        _out, err = capture_io { Hive::Commands::Init.new(dir).call }
+
+        refute_includes err, "already initialized"
+        assert File.file?(generated)
       end
     end
   end
@@ -210,11 +219,11 @@ class InitTest < Minitest::Test
     # triage bias, 9 limit prompts, daemon-enable, confirm. Choose codex
     # for both, safetyist triage, only first + third reviewer, override
     # `plan` budget/timeout, accept the rest (daemon defaults to enabled on
-    # blank, confirm defaults to yes on blank).
+    # blank, provider/model/credential use defaults, confirmation defaults yes).
     inputs = [
       "codex", "2", "1,3", "safetyist",
       "", "30,900", "", "", "", "", "", "", "",
-      "", ""
+      "", "", "", "", ""
     ].join("\n") + "\n"
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
@@ -244,7 +253,7 @@ class InitTest < Minitest::Test
 
   def test_init_with_daemon_disabled_writes_disabled_config
     # Same shape as above but explicitly answer `n` to the daemon prompt.
-    inputs = (([ "" ] * 13) + [ "n", "" ]).join("\n") + "\n"
+    inputs = (([ "" ] * 13) + [ "n", "", "", "", "" ]).join("\n") + "\n"
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
         prompts = make_tty_prompts(inputs)
@@ -259,8 +268,9 @@ class InitTest < Minitest::Test
 
   def test_init_aborts_with_zero_disk_state_when_user_says_n
     # Blank for everything until confirmation; answer `n` at the end.
-    # 14 blanks: planning, dev, reviewers, triage bias, 9 limits, daemon-enable.
-    inputs = (([ "" ] * 14) + [ "n" ]).join("\n") + "\n"
+    # 17 blanks: planning, dev, reviewers, triage bias, 9 limits,
+    # daemon-enable, provider, model, and credential environment.
+    inputs = (([ "" ] * 17) + [ "n" ]).join("\n") + "\n"
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
         prompts = make_tty_prompts(inputs)
@@ -301,11 +311,7 @@ class InitTest < Minitest::Test
         input.define_singleton_method(:tty?) { true }
         prompts = Hive::Commands::Init::Prompts.new(input: input, output: StringIO.new)
 
-        _, err, status = with_captured_exit do
-          Hive::Commands::Init.new(dir, prompts: prompts).call
-        end
-        assert_equal Hive::ExitCodes::ALREADY_INITIALIZED, status
-        assert_includes err, "already initialized"
+        capture_io { Hive::Commands::Init.new(dir, prompts: prompts).call }
         assert_equal "crash-on-this-input", input.gets&.chomp,
                      "no input should have been consumed by the second init"
       end
diff --git a/test/integration/packaged_entrypoint_test.rb b/test/integration/packaged_entrypoint_test.rb
new file mode 100644
index 00000000..ecaaaa3d
--- /dev/null
+++ b/test/integration/packaged_entrypoint_test.rb
@@ -0,0 +1,16 @@
+require "test_helper"
+require "open3"
+
+class PackagedEntrypointTest < Minitest::Test
+  def test_version_works_outside_checkout
+    root = File.expand_path("../..", __dir__)
+    out, err, status = Open3.capture3(
+      { "HIVE_RESOURCE_ROOT" => root },
+      File.join(root, "bin", "hive"),
+      "--version",
+      chdir: "/tmp"
+    )
+    assert status.success?, err
+    assert_equal "#{Hive::VERSION}\n", out
+  end
+end
diff --git a/test/integration/tui_subprocess_test.rb b/test/integration/tui_subprocess_test.rb
index 34ad39c3..2ee60de6 100644
--- a/test/integration/tui_subprocess_test.rb
+++ b/test/integration/tui_subprocess_test.rb
@@ -24,12 +24,12 @@ class TuiSubprocessTest < Minitest::Test
     # registry populated. Re-zeroing it keeps tests order-independent.
     Hive::Tui::SubprocessRegistry.clear
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) }
   end
 
   def teardown
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) }
     Hive::Tui::SubprocessRegistry.clear
   end
 
@@ -239,7 +239,7 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test
   def setup
     Hive::Tui::SubprocessRegistry.clear
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) }
     @messages = []
     @messages_mutex = Mutex.new
     @dispatch = ->(msg) { @messages_mutex.synchronize { @messages << msg } }
@@ -247,7 +247,7 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test
 
   def teardown
     %w[HIVE_TUI_FAKE_EXIT HIVE_TUI_FAKE_STDOUT HIVE_TUI_FAKE_STDERR
-       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK].each { |k| ENV.delete(k) }
+       HIVE_TUI_FAKE_TRAP_INT HIVE_TUI_FAKE_BLOCK HIVE_TUI_FAKE_PID_FILE].each { |k| ENV.delete(k) }
     Hive::Tui::SubprocessRegistry.clear
   end
 
@@ -315,18 +315,24 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test
     # Two slow children, dispatched back-to-back. If `dispatch_background`
     # is truly non-blocking, both finish in ~max(t1, t2), not t1 + t2.
     ENV["HIVE_TUI_FAKE_BLOCK"] = "1"
-    pids_killed = []
-    pids_mutex = Mutex.new
+    pid_capture = Tempfile.new("hive-tui-background-pids")
+    pid_capture.close
+    ENV["HIVE_TUI_FAKE_PID_FILE"] = pid_capture.path
 
     Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "develop" ], dispatch: @dispatch)
     Hive::Tui::Subprocess.dispatch_background([ FAKE_CHILD, "review" ], dispatch: @dispatch)
 
-    # Kill any blocking child after a brief moment so the reapers can fire.
+    # Target only the children spawned by this test. This keeps the fixture
+    # portable to minimal hosts that do not provide procps/pgrep.
     killer = Thread.new do
-      sleep 0.3
-      `pgrep -f tui-fake-child`.split.each do |pid|
+      deadline = Time.now + 3.0
+      pids = []
+      until pids.length == 2 || Time.now > deadline
+        pids = File.readlines(pid_capture.path, chomp: true).reject(&:empty?)
+        sleep 0.02 if pids.length < 2
+      end
+      pids.each do |pid|
         Process.kill("TERM", pid.to_i)
-        pids_mutex.synchronize { pids_killed << pid.to_i }
       rescue Errno::ESRCH
         nil
       end
@@ -338,6 +344,8 @@ class TuiSubprocessDispatchBackgroundTest < Minitest::Test
     verbs = @messages.map(&:verb).sort
     assert_equal %w[develop review], verbs,
       "each dispatch must surface its own verb on completion (no cross-talk)"
+  ensure
+    pid_capture&.unlink
   end
 
   # ---- Verb caching from argv[1] ----
diff --git a/test/release/manifest_contract_test.rb b/test/release/manifest_contract_test.rb
new file mode 100644
index 00000000..45ad1adb
--- /dev/null
+++ b/test/release/manifest_contract_test.rb
@@ -0,0 +1,95 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "open3"
+require "digest"
+
+class ReleaseManifestContractTest < Minitest::Test
+  include HiveTestHelper
+
+  ROOT = File.expand_path("../..", __dir__)
+
+  def test_package_release_emits_valid_digest_and_metadata
+    with_tmp_dir do |dir|
+      build = File.join(dir, "build")
+      dist = File.join(dir, "dist")
+      FileUtils.mkdir_p(build)
+      File.write(File.join(build, "hive-linux-x86_64"), "#!/bin/sh\necho 0.1.0\n")
+      File.chmod(0o755, File.join(build, "hive-linux-x86_64"))
+      File.write(File.join(build, "hive-linux-x86_64.spdx.json"), "{}\n")
+
+      _out, err, status = Open3.capture3(
+        { "HIVE_RELEASE_REPOSITORY" => "example/hive" },
+        File.join(ROOT, "scripts", "package-release"),
+        "--version", "0.1.0",
+        "--build-dir", build,
+        "--dist-dir", dist
+      )
+      assert status.success?, err
+      manifest = JSON.parse(File.read(File.join(dist, "release-manifest.json")))
+      schema = JSONSchemer.schema(JSON.parse(File.read(File.join(ROOT, "packaging/release-manifest.schema.json"))))
+      assert_empty schema.validate(manifest).to_a
+      target = manifest.fetch("targets").first
+      archive = File.join(dist, target.fetch("asset"))
+      assert_equal Digest::SHA256.file(archive).hexdigest, target.fetch("sha256")
+      assert_equal File.size(archive), target.fetch("size")
+      assert_equal %w[install.sh uninstall-shell.sh],
+                   manifest.fetch("shell_assets").map { |row| row.fetch("asset") }.sort
+      manifest.fetch("shell_assets").each do |row|
+        path = File.join(dist, row.fetch("asset"))
+        assert_equal Digest::SHA256.file(path).hexdigest, row.fetch("sha256")
+        assert_equal File.size(path), row.fetch("size")
+      end
+    end
+  end
+
+  def test_non_semver_is_rejected
+    _out, _err, status = Open3.capture3(File.join(ROOT, "scripts", "build-release"), "--version", "main")
+    refute status.success?
+  end
+
+  def test_remote_verification_downloads_and_hashes_public_asset_bytes
+    with_tmp_dir do |dir|
+      build = File.join(dir, "build")
+      dist = File.join(dir, "dist")
+      remote = File.join(dir, "remote")
+      FileUtils.mkdir_p(build)
+      %w[darwin-arm64 linux-x86_64].each do |target|
+        path = File.join(build, "hive-#{target}")
+        File.write(path, "#!/bin/sh\necho 0.1.0\n")
+        File.chmod(0o755, path)
+      end
+      _out, err, status = Open3.capture3(
+        { "HIVE_RELEASE_REPOSITORY" => "example/hive" },
+        File.join(ROOT, "scripts", "package-release"),
+        "--version", "0.1.0",
+        "--build-dir", build,
+        "--dist-dir", dist
+      )
+      assert status.success?, err
+      FileUtils.cp_r(dist, remote)
+
+      manifest_path = File.join(dist, "release-manifest.json")
+      manifest = JSON.parse(File.read(manifest_path))
+      (manifest.fetch("targets") + manifest.fetch("shell_assets")).each do |row|
+        row["url"] = "file://#{File.join(remote, row.fetch('asset'))}"
+      end
+      File.write(manifest_path, JSON.pretty_generate(manifest))
+
+      _out, err, status = Open3.capture3(
+        File.join(ROOT, "scripts", "verify-release"),
+        "--manifest", manifest_path,
+        "--remote"
+      )
+      assert status.success?, err
+
+      File.open(File.join(remote, manifest.fetch("targets").first.fetch("asset")), "ab") { |file| file << "tampered" }
+      _out, _err, status = Open3.capture3(
+        File.join(ROOT, "scripts", "verify-release"),
+        "--manifest", manifest_path,
+        "--remote"
+      )
+      refute status.success?
+    end
+  end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d251d52b..65aec42b 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -70,13 +70,22 @@ module HiveTestHelper
 
   def with_tmp_global_config
     dir = Dir.mktmpdir("hive-global")
-    old = ENV["HIVE_HOME"]
+    env_keys = %w[
+      HIVE_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME
+      XDG_CACHE_HOME XDG_RUNTIME_DIR
+    ]
+    old = env_keys.to_h { |key| [ key, ENV[key] ] }
     ENV["HIVE_HOME"] = dir
+    ENV["XDG_CONFIG_HOME"] = File.join(dir, "xdg-config")
+    ENV["XDG_DATA_HOME"] = File.join(dir, "xdg-data")
+    ENV["XDG_STATE_HOME"] = File.join(dir, "xdg-state")
+    ENV["XDG_CACHE_HOME"] = File.join(dir, "xdg-cache")
+    ENV["XDG_RUNTIME_DIR"] = File.join(dir, "xdg-runtime")
     File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml)
     begin
       yield(dir)
     ensure
-      ENV["HIVE_HOME"] = old
+      old.each { |key, value| value ? ENV[key] = value : ENV.delete(key) }
       # Same race-tolerant cleanup as `with_tmp_dir`: tests inside this
       # tmpdir invoke `hive`/git subprocesses that can leave the tree
       # mid-rename.
diff --git a/test/unit/assets_test.rb b/test/unit/assets_test.rb
new file mode 100644
index 00000000..857e715f
--- /dev/null
+++ b/test/unit/assets_test.rb
@@ -0,0 +1,63 @@
+require "test_helper"
+require "hive/assets"
+
+class HiveAssetsTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_seed_and_scaffold_are_idempotent_and_preserve_edits
+    with_tmp_dir do |dir|
+      data = File.join(dir, "data")
+      project = File.join(dir, "project")
+      source = File.join(dir, "source")
+      FileUtils.mkdir_p(source)
+      File.write(File.join(source, "hello.txt"), "original\n")
+      FileUtils.mkdir_p(project)
+
+      Hive::Assets.seed_shared!(source: source, destination: data, version: "0.1.0")
+      result = Hive::Assets.scaffold_project!(project, source: source)
+      generated = File.join(project, ".hive", "hello.txt")
+      assert_equal [ ".hive/hello.txt" ], result[:created]
+      assert_equal "original\n", File.read(generated)
+
+      File.write(generated, "user edit\n")
+      second = Hive::Assets.scaffold_project!(project, source: source)
+      assert_equal [ ".hive/hello.txt" ], second[:conflicts]
+      assert_equal "user edit\n", File.read(generated)
+    end
+  end
+
+  def test_seed_repairs_the_current_pointer_for_an_existing_complete_release
+    with_tmp_dir do |dir|
+      source = File.join(dir, "source")
+      release = File.join(dir, "releases", "0.1.0")
+      FileUtils.mkdir_p([ source, release ])
+      File.write(File.join(source, "shared.txt"), "shared\n")
+      File.write(File.join(release, ".complete"), "0.1.0\n")
+      File.symlink("0.0.9", File.join(dir, "releases", "current"))
+
+      Hive::Assets.seed_shared!(source: source, destination: File.join(dir, "releases"), version: "0.1.0")
+
+      assert_equal "0.1.0", File.readlink(File.join(dir, "releases", "current"))
+    end
+  end
+
+  def test_scaffold_rejects_a_symlinked_hive_directory
+    with_tmp_dir do |dir|
+      project = File.join(dir, "project")
+      outside = File.join(dir, "outside")
+      source = File.join(dir, "source")
+      FileUtils.mkdir_p([ project, outside, source ])
+      File.write(File.join(source, "settings.yml"), "version: 1\n")
+      File.symlink(outside, File.join(project, ".hive"))
+
+      assert_raises(Hive::ConfigError) do
+        Hive::Assets.scaffold_project!(project, source: source)
+      end
+      refute File.exist?(File.join(outside, "settings.yml"))
+    end
+  end
+
+  def test_rejects_broad_project_root
+    assert_raises(Hive::ConfigError) { Hive::Assets.scaffold_project!("/") }
+  end
+end
diff --git a/test/unit/command_name_test.rb b/test/unit/command_name_test.rb
new file mode 100644
index 00000000..4f310248
--- /dev/null
+++ b/test/unit/command_name_test.rb
@@ -0,0 +1,30 @@
+require "test_helper"
+require "hive/command_name"
+
+class HiveCommandNameTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_selects_hive_when_unclaimed
+    with_tmp_dir do |dir|
+      assert_equal "hive", Hive::CommandName.select(path: dir)
+    end
+  end
+
+  def test_selects_hv_when_foreign_hive_is_on_path
+    with_tmp_dir do |dir|
+      File.write(File.join(dir, "hive"), "#!/bin/sh\n")
+      File.chmod(0o755, File.join(dir, "hive"))
+      assert_equal "hv", Hive::CommandName.select(path: dir, installed_executable: "/elsewhere/hv")
+    end
+  end
+
+  def test_stable_executable_prefers_the_path_resolved_command
+    with_tmp_dir do |dir|
+      stable = File.join(dir, "hv")
+      File.write(stable, "#!/bin/sh\n")
+      File.chmod(0o755, stable)
+
+      assert_equal stable, Hive::CommandName.stable_executable(path: dir, current: "/cellar/hive/0.1.0/bin/hv")
+    end
+  end
+end
diff --git a/test/unit/commands/init/prompts_test.rb b/test/unit/commands/init/prompts_test.rb
index 734b955d..bdfdbadc 100644
--- a/test/unit/commands/init/prompts_test.rb
+++ b/test/unit/commands/init/prompts_test.rb
@@ -34,7 +34,7 @@ class InitPromptsTest < Minitest::Test
 
   # Build the canonical "all defaults" answer key set so tests can assert
   # against the same shape Init#call will pass to the ERB template.
-  def all_defaults
+  def all_defaults(daemon_enabled: false)
     {
       "planning_agent" => "claude",
       "development_agent" => "codex",
@@ -46,7 +46,10 @@ class InitPromptsTest < Minitest::Test
       "timeouts" => Hive::Commands::Init::Prompts::LIMIT_KEYS.each_with_object({}) do |k, h|
         h[k] = Hive::Config::DEFAULTS["timeout_sec"][k]
       end,
-      "daemon_enabled" => true
+      "daemon_enabled" => daemon_enabled,
+      "provider" => "openai",
+      "model" => "gpt-5",
+      "credential_env" => "OPENAI_API_KEY"
     }
   end
 
@@ -59,11 +62,16 @@ class InitPromptsTest < Minitest::Test
   #         open_pr, finalize, review_ci, review_triage, review_fix,
   #         review_browser)
   #  14. daemon enable Y/n            (added under ADR-024)
-  #  15. confirmation Y/n
+  #  15. provider
+  #  16. model
+  #  17. credential environment variable
+  #  18. confirmation Y/n
   # Each line is one answer; blank line = accept default.
   def interactive_input(planning: "", development: "", reviewers: "",
-                        triage_bias: "", limits: ([ "" ] * 9), daemon: "", confirm: "")
-    [ planning, development, reviewers, triage_bias, *limits, daemon, confirm ].map { |a| "#{a}\n" }.join
+                        triage_bias: "", limits: ([ "" ] * 9), daemon: "",
+                        provider: "", model: "", credential_env: "", confirm: "")
+    [ planning, development, reviewers, triage_bias, *limits, daemon,
+      provider, model, credential_env, confirm ].map { |a| "#{a}\n" }.join
   end
 
   # --- non-TTY: short-circuit to defaults ----------------------------------
@@ -111,7 +119,7 @@ class InitPromptsTest < Minitest::Test
   def test_interactive_all_defaults
     prompts, _output = make_prompts(interactive_input)
     answers = prompts.collect
-    assert_equal all_defaults, answers
+    assert_equal all_defaults(daemon_enabled: true), answers
   end
 
   def test_interactive_all_defaults_summary_says_so
@@ -138,10 +146,10 @@ class InitPromptsTest < Minitest::Test
 
   def test_interactive_planning_agent_unknown_reprompts_then_accepts
     # First answer is invalid → re-prompt; second answer is valid.
-    # 16 reads total: planning (invalid + retry) + dev + reviewers
-    # + triage bias + 9 limits + daemon + confirm. Each blank line
+    # 19 reads total: planning (invalid + retry) + dev + reviewers
+    # + triage bias + 9 limits + daemon + provider/model/credential + confirm. Each blank line
     # accepts the default.
-    raw = ([ "nonexistent", "claude" ] + ([ "" ] * 14)).join("\n") + "\n"
+    raw = ([ "nonexistent", "claude" ] + ([ "" ] * 17)).join("\n") + "\n"
     prompts, output, _summary = make_prompts(raw)
     answers = prompts.collect
     assert_equal "claude", answers["planning_agent"]
@@ -149,7 +157,7 @@ class InitPromptsTest < Minitest::Test
   end
 
   def test_interactive_planning_agent_index_out_of_range_reprompts
-    raw = ([ "7", "claude" ] + ([ "" ] * 14)).join("\n") + "\n"
+    raw = ([ "7", "claude" ] + ([ "" ] * 17)).join("\n") + "\n"
     prompts, output, _summary = make_prompts(raw)
     answers = prompts.collect
     assert_equal "claude", answers["planning_agent"]
@@ -189,7 +197,7 @@ class InitPromptsTest < Minitest::Test
     # Build the input manually since interactive_input doesn't allow
     # multi-line reviewer answers cleanly. Trailing values are triage
     # bias, nine limits, daemon, and confirm.
-    input = ([ "", "", "7", "1,2" ] + ([ "" ] * 12)).join("\n") + "\n"
+    input = ([ "", "", "7", "1,2" ] + ([ "" ] * 15)).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal %w[claude-ce-code-review codex-ce-code-review], answers["enabled_reviewers"]
@@ -197,7 +205,7 @@ class InitPromptsTest < Minitest::Test
   end
 
   def test_interactive_reviewers_unknown_name_reprompts
-    input = ([ "", "", "nope", "1" ] + ([ "" ] * 12)).join("\n") + "\n"
+    input = ([ "", "", "nope", "1" ] + ([ "" ] * 15)).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"]
@@ -231,7 +239,7 @@ class InitPromptsTest < Minitest::Test
   end
 
   def test_interactive_triage_bias_unknown_reprompts
-    input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 11)).join("\n") + "\n"
+    input = ([ "", "", "", "bad", "2" ] + ([ "" ] * 14)).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal "safetyist", answers["triage_bias"]
@@ -277,8 +285,8 @@ class InitPromptsTest < Minitest::Test
 
   def test_interactive_limits_zero_budget_reprompts
     # First answer 0,300 fails validation → re-prompt; second answer 10,600 accepted.
-    # Trailing pair is daemon + confirm (both blank → daemon enabled, confirm yes).
-    input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 10)).join("\n") + "\n"
+    # Trailing values cover the remaining limits, daemon, provider settings, and confirmation.
+    input = ([ "", "", "", "", "0,300", "10,600" ] + ([ "" ] * 13)).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal 10, answers["budgets"]["brainstorm"]
@@ -288,7 +296,7 @@ class InitPromptsTest < Minitest::Test
 
   def test_interactive_limits_malformed_format_reprompts
     # "30" without comma fails the <budget>,<timeout> shape → re-prompt
-    input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 10)).join("\n") + "\n"
+    input = ([ "", "", "", "", "30", "30,900" ] + ([ "" ] * 13)).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal 30, answers["budgets"]["brainstorm"]
@@ -347,7 +355,7 @@ class InitPromptsTest < Minitest::Test
     # daemon slot. interactive_input(daemon: "maybe") provides "maybe" + "" (confirm).
     # We need: maybe (rejected) → y (accepted as daemon=enabled) → blank (confirm).
     # So push two extra reads in.
-    input = interactive_input(daemon: "maybe", confirm: "") + "y\n\n"
+    input = ([ "" ] * 13 + [ "maybe", "y", "", "", "", "" ]).join("\n") + "\n"
     prompts, output = make_prompts(input)
     answers = prompts.collect
     assert_equal true, answers["daemon_enabled"]
@@ -361,17 +369,16 @@ class InitPromptsTest < Minitest::Test
     assert_match(/daemon\s+= disabled/, summary)
   end
 
-  def test_non_tty_default_includes_daemon_enabled_true
+  def test_non_tty_default_keeps_daemon_disabled_without_consent
     prompts, _output, _summary = make_prompts("", tty: false)
     answers = prompts.collect
-    assert_equal true, answers["daemon_enabled"],
-                 "non-TTY callers should land on the same recommended default as TTY (Y)"
+    assert_equal false, answers["daemon_enabled"]
   end
 
   def test_non_tty_summary_includes_daemon_state
     prompts, _output, summary_io = make_prompts("", tty: false)
     prompts.collect
-    assert_match(/daemon=enabled/, summary_io.string)
+    assert_match(/daemon=disabled/, summary_io.string)
   end
 
   def test_non_tty_default_includes_triage_bias_courageous
@@ -465,7 +472,7 @@ class InitPromptsTest < Minitest::Test
     # Truncate the input transcript right before confirmation. read_line
     # must distinguish nil-from-gets (EOF) from an empty line and bubble
     # Aborted up the stack rather than silently confirming.
-    inputs = ([ "" ] * 14).join("\n") + "\n"  # 14 blank reads, then EOF
+    inputs = ([ "" ] * 17).join("\n") + "\n"  # 17 blank reads, then EOF
     prompts, _output, _summary = make_prompts(inputs)
     assert_raises(Hive::Commands::Init::Prompts::Aborted) { prompts.collect }
   end
@@ -493,8 +500,8 @@ class InitPromptsTest < Minitest::Test
   # that would produce an invalid YAML key (parses to nil) which
   # validate_reviewers! rejects on the next `hive run`. Re-prompt instead.
   def test_reviewers_comma_only_reprompts
-    # Trailing values are triage bias, nine limits, daemon, and confirm.
-    input = ([ "", "", ",", "1" ] + ([ "" ] * 12)).join("\n") + "\n"
+    # Trailing values are triage bias, nine limits, daemon, provider settings, and confirmation.
+    input = ([ "", "", ",", "1" ] + ([ "" ] * 15)).join("\n") + "\n"
     prompts, output, _summary = make_prompts(input)
     answers = prompts.collect
     assert_equal %w[claude-ce-code-review], answers["enabled_reviewers"]
@@ -502,7 +509,7 @@ class InitPromptsTest < Minitest::Test
   end
 
   def test_reviewers_whitespace_only_reprompts
-    input = ([ "", "", "  ,  ,  ", "2" ] + ([ "" ] * 12)).join("\n") + "\n"
+    input = ([ "", "", "  ,  ,  ", "2" ] + ([ "" ] * 15)).join("\n") + "\n"
     prompts, output, _summary = make_prompts(input)
     answers = prompts.collect
     assert_equal %w[codex-ce-code-review], answers["enabled_reviewers"]
diff --git a/test/unit/dependency_check_test.rb b/test/unit/dependency_check_test.rb
new file mode 100644
index 00000000..0ec0753b
--- /dev/null
+++ b/test/unit/dependency_check_test.rb
@@ -0,0 +1,11 @@
+require "test_helper"
+require "hive/dependency_check"
+
+class HiveDependencyCheckTest < Minitest::Test
+  def test_reports_missing_tools_without_installing
+    rows = Hive::DependencyCheck.call(path: "")
+    assert_equal %w[git bash claude gh jq], rows.map { |row| row[:name] }
+    assert rows.all? { |row| row[:status] == :missing }
+    assert rows.all? { |row| row[:hint].include?("Install") }
+  end
+end
diff --git a/test/unit/first_run_config_test.rb b/test/unit/first_run_config_test.rb
new file mode 100644
index 00000000..83d932f2
--- /dev/null
+++ b/test/unit/first_run_config_test.rb
@@ -0,0 +1,53 @@
+require "test_helper"
+require "hive/config"
+
+class FirstRunConfigTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_writes_choices_and_credential_reference_but_not_secret
+    with_tmp_dir do |dir|
+      path = File.join(dir, "config.yml")
+      Hive::Config.write_provider_config!(
+        path: path,
+        provider: "openai",
+        model: "gpt-5",
+        credential_env: "OPENAI_API_KEY"
+      )
+      data = YAML.safe_load_file(path)
+      assert_equal "openai", data["provider"]
+      assert_equal "env:OPENAI_API_KEY", data["credential_source"]
+      assert_equal 0o600, File.stat(path).mode & 0o777
+    end
+  end
+
+  def test_rejects_raw_secret_values
+    with_tmp_dir do |dir|
+      assert_raises(Hive::ConfigError) do
+        Hive::Config.write_provider_config!(
+          path: File.join(dir, "config.yml"),
+          provider: "openai",
+          model: "gpt-5",
+          credential_env: "sk-secret-value"
+        )
+      end
+    end
+  end
+
+  def test_preserves_unrelated_existing_xdg_configuration
+    with_tmp_dir do |dir|
+      path = File.join(dir, "config.yml")
+      File.write(path, { "theme" => "dark", "provider" => "legacy" }.to_yaml)
+
+      Hive::Config.write_provider_config!(
+        path: path,
+        provider: "openai",
+        model: "gpt-5",
+        credential_env: "OPENAI_API_KEY"
+      )
+
+      data = YAML.safe_load_file(path)
+      assert_equal "dark", data["theme"]
+      assert_equal "openai", data["provider"]
+    end
+  end
+end
diff --git a/test/unit/install_channel_test.rb b/test/unit/install_channel_test.rb
new file mode 100644
index 00000000..d3cf0c71
--- /dev/null
+++ b/test/unit/install_channel_test.rb
@@ -0,0 +1,105 @@
+require "test_helper"
+require "hive/install/channel"
+
+class InstallChannelTest < Minitest::Test
+  class FakeRunner
+    attr_reader :commands
+
+    def initialize(outputs: {})
+      @commands = []
+      @outputs = outputs
+    end
+
+    def call(*command)
+      @commands << command
+      [ @outputs.fetch(command, ""), "", Struct.new(:success?).new(true) ]
+    end
+  end
+
+  def test_homebrew_update_delegates_without_writing_executable
+    runner = FakeRunner.new
+    receipt = {
+      "schema_version" => 1,
+      "channel" => "homebrew",
+      "package" => "hive/tap/hive",
+      "command_name" => "hive",
+      "executable" => "/opt/homebrew/opt/hive/bin/hv",
+      "release_source" => "https://example.test/release",
+      "managed_files" => [ "/opt/homebrew/opt/hive/bin/hv" ]
+    }
+    Hive::Install::Channel.new(receipt, runner: runner).update
+    assert_equal [ "brew", "upgrade", "hive/tap/hive" ], runner.commands.last
+  end
+
+  def test_homebrew_ownership_requires_the_receipt_executable_to_match_the_formula_prefix
+    Dir.mktmpdir("hive-brew-prefix") do |prefix|
+      executable = File.join(prefix, "bin", "hv")
+      FileUtils.mkdir_p(File.dirname(executable))
+      File.write(executable, "#!/bin/sh\n")
+      runner = FakeRunner.new(outputs: {
+        [ "brew", "--prefix", "hive" ] => "#{prefix}\n"
+      })
+      receipt = {
+        "schema_version" => 1,
+        "channel" => "homebrew",
+        "package" => "hive",
+        "command_name" => "hv",
+        "executable" => executable,
+        "release_source" => "https://example.test/release",
+        "managed_files" => [ executable ]
+      }
+
+      assert Hive::Install::Channel.new(receipt, runner: runner).verify_ownership!
+    end
+  end
+
+  def test_aur_selects_available_helper
+    runner = FakeRunner.new
+    receipt = {
+      "schema_version" => 1,
+      "channel" => "aur",
+      "package" => "hive-bin",
+      "command_name" => "hive",
+      "executable" => "/usr/bin/hv",
+      "release_source" => "https://example.test/release",
+      "managed_files" => [ "/usr/bin/hv" ]
+    }
+    channel = Hive::Install::Channel.new(receipt, runner: runner, resolver: ->(name) { name == "paru" ? "/bin/paru" : nil })
+    channel.update
+    assert_equal [ "/bin/paru", "-S", "--needed", "hive-bin" ], runner.commands.last
+  end
+
+  def test_aur_uninstall_uses_the_available_helper
+    runner = FakeRunner.new
+    receipt = {
+      "schema_version" => 1,
+      "channel" => "aur",
+      "package" => "hive-bin",
+      "command_name" => "hv",
+      "executable" => "/usr/bin/hv",
+      "release_source" => "https://example.test/release",
+      "managed_files" => [ "/usr/bin/hv" ]
+    }
+    channel = Hive::Install::Channel.new(receipt, runner: runner, resolver: ->(name) { name == "yay" ? "/bin/yay" : nil })
+
+    channel.uninstall
+
+    assert_equal [ "/bin/yay", "-Rns", "--noconfirm", "hive-bin" ], runner.commands.last
+  end
+
+  def test_aur_ownership_must_match_the_receipt_package
+    command = [ "pacman", "-Qqo", "/usr/bin/hv" ]
+    runner = FakeRunner.new(outputs: { command => "other-package\n" })
+    receipt = {
+      "schema_version" => 1,
+      "channel" => "aur",
+      "package" => "hive-bin",
+      "command_name" => "hv",
+      "executable" => "/usr/bin/hv",
+      "release_source" => "https://example.test/release",
+      "managed_files" => [ "/usr/bin/hv" ]
+    }
+
+    assert_raises(Hive::ConfigError) { Hive::Install::Channel.new(receipt, runner: runner).verify_ownership! }
+  end
+end
diff --git a/test/unit/install_receipt_test.rb b/test/unit/install_receipt_test.rb
new file mode 100644
index 00000000..9a84ba0e
--- /dev/null
+++ b/test/unit/install_receipt_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+require "hive/install/receipt"
+
+class InstallReceiptTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_round_trip_and_validation
+    with_tmp_dir do |dir|
+      path = File.join(dir, "receipt.yml")
+      receipt = Hive::Install::Receipt.write!(
+        path: path,
+        data: {
+          "channel" => "shell",
+          "package" => "hive",
+          "command_name" => "hv",
+          "executable" => File.join(dir, "hv"),
+          "executable_sha256" => "a" * 64,
+          "release_source" => "https://example.test/v0.1.0",
+          "installer" => File.join(dir, "install.sh"),
+          "installer_sha256" => "b" * 64,
+          "uninstaller" => File.join(dir, "uninstall-shell.sh"),
+          "uninstaller_sha256" => "c" * 64,
+          "managed_files" => [
+            File.join(dir, "hv"),
+            File.join(dir, "install.sh"),
+            File.join(dir, "uninstall-shell.sh")
+          ]
+        }
+      )
+      assert_equal "shell", receipt["channel"]
+      assert_equal 1, Hive::Install::Receipt.load(path)["schema_version"]
+    end
+  end
+
+  def test_rejects_unknown_channel
+    error = assert_raises(Hive::ConfigError) do
+      Hive::Install::Receipt.validate!("channel" => "guess")
+    end
+    assert_includes error.message, "channel"
+  end
+
+  def test_rejects_missing_schema_and_shell_helper_fields
+    base = {
+      "channel" => "shell",
+      "package" => "hive",
+      "command_name" => "hv",
+      "executable" => "/tmp/hv",
+      "release_source" => "https://example.test/v0.1.0"
+    }
+    assert_raises(Hive::ConfigError) { Hive::Install::Receipt.validate!(base) }
+    assert_raises(Hive::ConfigError) do
+      Hive::Install::Receipt.validate!(base.merge("schema_version" => 1))
+    end
+  end
+end
diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb
new file mode 100644
index 00000000..c7fef95c
--- /dev/null
+++ b/test/unit/paths_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+require "hive/paths"
+
+class HivePathsTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_standard_xdg_defaults
+    with_env(
+      "HOME" => "/tmp/hive-home",
+      "XDG_CONFIG_HOME" => nil,
+      "XDG_DATA_HOME" => nil,
+      "XDG_STATE_HOME" => nil,
+      "XDG_CACHE_HOME" => nil,
+      "XDG_RUNTIME_DIR" => nil
+    ) do
+      assert_equal "/tmp/hive-home/.config/hive", Hive::Paths.config_dir
+      assert_equal "/tmp/hive-home/.local/share/hive", Hive::Paths.data_dir
+      assert_equal "/tmp/hive-home/.local/state/hive", Hive::Paths.state_dir
+      assert_equal "/tmp/hive-home/.cache/hive", Hive::Paths.cache_dir
+      assert_equal "/tmp/hive-home/.local/state/hive/run", Hive::Paths.runtime_dir
+    end
+  end
+
+  def test_xdg_overrides
+    with_env(
+      "XDG_CONFIG_HOME" => "/c",
+      "XDG_DATA_HOME" => "/d",
+      "XDG_STATE_HOME" => "/s",
+      "XDG_CACHE_HOME" => "/k",
+      "XDG_RUNTIME_DIR" => "/r"
+    ) do
+      assert_equal "/c/hive", Hive::Paths.config_dir
+      assert_equal "/d/hive", Hive::Paths.data_dir
+      assert_equal "/s/hive", Hive::Paths.state_dir
+      assert_equal "/k/hive", Hive::Paths.cache_dir
+      assert_equal "/r/hive", Hive::Paths.runtime_dir
+    end
+  end
+
+  def test_service_state_override_is_consumed
+    with_env("HIVE_STATE_DIR" => "/service/state", "XDG_STATE_HOME" => "/ignored") do
+      assert_equal "/service/state", Hive::Paths.state_dir
+    end
+  end
+
+  private
+
+  def with_env(values)
+    old = values.to_h { |key, _| [ key, ENV[key] ] }
+    values.each { |key, value| value ? ENV[key] = value : ENV.delete(key) }
+    yield
+  ensure
+    old.each { |key, value| value ? ENV[key] = value : ENV.delete(key) }
+  end
+end
diff --git a/test/unit/service_test.rb b/test/unit/service_test.rb
new file mode 100644
index 00000000..2a39742f
--- /dev/null
+++ b/test/unit/service_test.rb
@@ -0,0 +1,99 @@
+require "test_helper"
+require "hive/service"
+
+class HiveServiceTest < Minitest::Test
+  include HiveTestHelper
+
+  class FakeRunner
+    attr_reader :commands
+
+    def initialize(success: true)
+      @success = success
+      @commands = []
+    end
+
+    def call(*command)
+      @commands << command
+      [ "", "", Struct.new(:success?).new(@success) ]
+    end
+  end
+
+  class SequenceRunner
+    def initialize(*results)
+      @results = results
+    end
+
+    def call(*)
+      success = @results.shift
+      [ "", "reload failed", Struct.new(:success?).new(success) ]
+    end
+  end
+
+  def test_systemd_registers_stopped_then_starts_on_request
+    with_tmp_dir do |dir|
+      runner = FakeRunner.new
+      service = Hive::Services::SystemdUser.new(
+        executable: "/opt/hive/bin/hv",
+        config_home: dir,
+        runner: runner
+      )
+      service.register
+      assert File.file?(File.join(dir, "systemd/user/hive.service"))
+      assert_equal [ [ "systemctl", "--user", "daemon-reload" ] ], runner.commands
+
+      service.start
+      assert_equal [ "systemctl", "--user", "enable", "--now", "hive.service" ], runner.commands.last
+    end
+  end
+
+  def test_launchd_registration_is_idempotent_and_updates_executable
+    with_tmp_dir do |dir|
+      runner = FakeRunner.new
+      service = Hive::Services::Launchd.new(
+        executable: "/one/hive",
+        home: dir,
+        runner: runner
+      )
+      service.register
+      path = File.join(dir, "Library/LaunchAgents/org.hive.daemon.plist")
+      assert_includes File.read(path), "/one/hive"
+
+      Hive::Services::Launchd.new(executable: "/two/hv", home: dir, runner: runner).register
+      assert_includes File.read(path), "/two/hv"
+      refute_includes File.read(path), "/one/hive"
+    end
+  end
+
+  def test_systemd_registration_failure_removes_new_definition
+    with_tmp_dir do |dir|
+      service = Hive::Services::SystemdUser.new(
+        executable: "/opt/hive/bin/hv",
+        config_home: dir,
+        runner: SequenceRunner.new(false, true)
+      )
+
+      assert_raises(Hive::Error) { service.register }
+      refute File.exist?(File.join(dir, "systemd/user/hive.service"))
+    end
+  end
+
+  def test_unregister_without_a_definition_is_a_manager_independent_no_op
+    with_tmp_dir do |dir|
+      systemd_runner = FakeRunner.new(success: false)
+      launchd_runner = FakeRunner.new(success: false)
+
+      assert_equal :unregistered, Hive::Services::SystemdUser.new(
+        executable: "/opt/hive/bin/hv",
+        config_home: dir,
+        runner: systemd_runner
+      ).unregister
+      assert_equal :unregistered, Hive::Services::Launchd.new(
+        executable: "/opt/hive/bin/hv",
+        home: dir,
+        runner: launchd_runner
+      ).unregister
+      assert_empty systemd_runner.commands
+      assert_empty launchd_runner.commands
+    end
+  end
+end
diff --git a/test/unit/uninstall_test.rb b/test/unit/uninstall_test.rb
new file mode 100644
index 00000000..a7db29bc
--- /dev/null
+++ b/test/unit/uninstall_test.rb
@@ -0,0 +1,100 @@
+require "test_helper"
+require "digest"
+require "stringio"
+require "hive/commands/uninstall"
+
+class UninstallTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_cleanup_removes_only_unmodified_owned_files
+    with_tmp_dir do |dir|
+      hive_dir = File.join(dir, ".hive")
+      FileUtils.mkdir_p(hive_dir)
+      generated = File.join(hive_dir, "generated.yml")
+      modified = File.join(hive_dir, "modified.yml")
+      unknown = File.join(hive_dir, "unknown.yml")
+      File.write(generated, "generated\n")
+      File.write(modified, "changed\n")
+      File.write(unknown, "unknown\n")
+      manifest = {
+        "version" => 1,
+        "files" => {
+          ".hive/generated.yml" => Digest::SHA256.file(generated).hexdigest,
+          ".hive/modified.yml" => Digest::SHA256.hexdigest("original\n"),
+          "../escape" => Digest::SHA256.hexdigest("x")
+        }
+      }
+      File.write(File.join(hive_dir, "ownership.yml"), manifest.to_yaml)
+
+      result = Hive::Commands::Uninstall.cleanup_project(dir)
+      refute File.exist?(generated)
+      assert File.exist?(modified)
+      assert File.exist?(unknown)
+      assert_includes result[:preserved], ".hive/modified.yml"
+      assert_includes result[:preserved], "../escape"
+    end
+  end
+
+  def test_preview_and_service_unregister_precede_package_and_project_mutation
+    events = []
+    output = StringIO.new
+    output.define_singleton_method(:puts) do |line|
+      events << :preview
+      super(line)
+    end
+    channel = Object.new
+    channel.define_singleton_method(:verify_ownership!) { events << :verify }
+    channel.define_singleton_method(:uninstall) { events << :package }
+    service = Object.new
+    service.define_singleton_method(:unregister) { events << :service }
+    receipt = { "channel" => "shell", "package" => "hive" }
+
+    Hive::Commands::Uninstall.new(
+      output: output,
+      receipt_loader: ->(_path) { receipt },
+      channel_factory: ->(_loaded) { channel },
+      service: service
+    ).call
+
+    assert_operator events.index(:preview), :<, events.index(:service)
+    assert_operator events.index(:service), :<, events.index(:package)
+  end
+
+  def test_unregister_failure_stops_package_and_project_removal
+    events = []
+    channel = Object.new
+    channel.define_singleton_method(:verify_ownership!) { events << :verify }
+    channel.define_singleton_method(:uninstall) { events << :package }
+    service = Object.new
+    service.define_singleton_method(:unregister) do
+      events << :service
+      raise Hive::Error, "reload failed"
+    end
+    receipt = { "channel" => "shell", "package" => "hive" }
+
+    with_tmp_dir do |dir|
+      hive_dir = File.join(dir, ".hive")
+      FileUtils.mkdir_p(hive_dir)
+      generated = File.join(hive_dir, "generated.yml")
+      File.write(generated, "generated\n")
+      File.write(File.join(hive_dir, "ownership.yml"), {
+        "version" => 1,
+        "files" => { ".hive/generated.yml" => Digest::SHA256.file(generated).hexdigest }
+      }.to_yaml)
+
+      assert_raises(Hive::Error) do
+        Hive::Commands::Uninstall.new(
+          project_root: dir,
+          cleanup: true,
+          output: StringIO.new,
+          receipt_loader: ->(_path) { receipt },
+          channel_factory: ->(_loaded) { channel },
+          service: service
+        ).call
+      end
+
+      assert_equal %i[verify service], events
+      assert File.exist?(generated)
+    end
+  end
+end
diff --git a/wiki/active-areas.md b/wiki/active-areas.md
index 46a64712..0dd7a312 100644
--- a/wiki/active-areas.md
+++ b/wiki/active-areas.md
@@ -29,7 +29,7 @@ Working tree clean as of 2026-04-25. Three commits on `main`:
 | CI | `.github/workflows/ci.yml`, `.github/dependabot.yml`, `config/brakeman.ignore` | Wired |
 | Repo hygiene | `CHANGELOG.md`, `SECURITY.md`, `.github/ISSUE_TEMPLATE/`, `.github/PULL_REQUEST_TEMPLATE.md` | Authored |
 | Docs | `README.md`, `wiki/` knowledge base | Authored |
-| Daemon (ADR-024) | `lib/hive/daemon/*`, `lib/hive/commands/daemon.rb`, `wiki/commands/daemon.md`, `wiki/modules/daemon.md` | Auto-advancing dispatcher: polls `hive status --json`, fires workflow verbs on tasks ready to advance, auto-archives 7-finalize after PR merge via `gh pr view`. Per-project enrolled at `hive init` (default Y). |
+| Daemon (ADR-024) | `lib/hive/daemon/*`, `lib/hive/commands/daemon.rb`, `wiki/commands/daemon.md`, `wiki/modules/daemon.md` | Auto-advancing dispatcher: polls `hive status --json`, fires workflow verbs on tasks ready to advance, auto-archives 7-finalize after PR merge via `gh pr view`. Interactive init asks separately; non-TTY init leaves enrollment disabled. |
 | Telegram bot (ADR-026) | `lib/hive/bot/*`, `lib/hive/commands/bot.rb`, `wiki/commands/bot.md`, `wiki/modules/bot.md` | Mobile human-input surface: long-polls Telegram, notifies on waiting/recovery gates, writes brainstorm answers under lock, and dispatches existing `hive` commands from inline buttons. |
 
 ## Phase 1 deferred work
diff --git a/wiki/architecture.md b/wiki/architecture.md
index bcd5de15..91b0a65c 100644
--- a/wiki/architecture.md
+++ b/wiki/architecture.md
@@ -9,6 +9,82 @@ tags: [architecture, overview]
 
 **TLDR**: Hive is a Ruby 3.4 / Thor control plane over an eight-stage filesystem state machine. The CLI dispatches into per-stage runners; stage agents run through configured AgentProfile CLIs inside per-task and per-project locks. Optional long-running surfaces sit beside the CLI: `hive daemon` advances safe tasks automatically, `hive tui` renders a terminal dashboard, and `hive bot` turns human-input gates into Telegram interactions. There is still no database; durable state is the filesystem plus global YAML config.
 
+Packaged builds resolve code and bundled assets from `HIVE_RESOURCE_ROOT` (or
+the executable's repository root in source checkouts), so command behavior no
+longer depends on the current working directory. Runtime-owned global paths use
+the XDG resolver in `Hive::Paths`: config, data, state, cache, and runtime files
+have distinct roots, while `HIVE_HOME` remains a legacy test/operator override.
+`HIVE_STATE_DIR` is the service-safe state override. Existing per-project
+`.hive-state` behavior remains project-local.
+
+`hive init` additionally seeds immutable shared and project assets under
+`$XDG_DATA_HOME/hive/releases/<version>`, repairs the managed `current`
+pointer, and scaffolds `.hive/` only from that immutable release. Generated
+file digests live in `.hive/ownership.yml`; edits, symlinks, and unrelated
+content are preserved, and a symlinked destination is rejected before writes.
+Provider configuration stores only an environment-variable reference, merges
+with existing global YAML, and uses mode `0600`.
+
+Per-user daemon registration is isolated behind `Hive::Service`. Linux renders
+`~/.config/systemd/user/hive.service`; macOS renders
+`~/Library/LaunchAgents/org.hive.daemon.plist`. Definitions execute the stable
+channel command rather than a versioned package path. Registration is
+idempotent, rolls its definition back when manager reload fails, and is stopped
+by default; the separate init daemon-consent answer controls whether the native
+manager starts it.
+
+Install ownership is explicit in a versioned, channel-specific XDG data
+receipt. Before update or uninstall, Hive cross-checks the receipt's stable
+executable against the Homebrew prefix, pacman owner, or shell-managed file
+digests. Native mutations delegate to Homebrew or `paru`/`yay`; the shell
+channel verifies SHA-256 metadata but does not claim signature authenticity.
+Uninstall prints its plan and successfully unregisters the service before
+package or project mutation. Project cleanup only removes digest-matching
+regular files named by `.hive/ownership.yml`; config, state, completed output,
+symlinks, unknown files, and agent skills remain outside the deletion boundary.
+
+Release builds pass through `scripts/build-release`, whose output contract is
+one executable named `build/hive-<target>`. The primary adapter is pinned
+Tebako with embedded Ruby 3.4.1; `scripts/package-release` creates the stable
+archive layout, canonical JSON manifest, authenticated shell-helper records,
+and SHA-256 list while ignoring adjacent SPDX artifacts. CI adds SPDX SBOMs and
+GitHub build provenance for the actual release archives before publication, so
+the packager can be replaced without changing channel inputs.
+
+The shell channel downloads the canonical manifest, target archive, installer,
+and uninstaller with bounded timeouts/retries. It verifies size and SHA-256
+before extraction, smoke-checks the embedded version, stages every managed
+file beside its destination, and commits them as one rollback-capable
+transaction. It links `hive` only when no foreign command would be shadowed.
+The receipt and verified helpers live under the XDG data directory; uninstall
+removes only receipt-owned command files.
+
+Homebrew and AUR metadata are generated from that same manifest by
+`scripts/render-packages`. Both install the prebuilt `hv` executable behind a
+small receipt-exporting wrapper and carry no Ruby dependency. A conditional
+`hive` link is created only when the host has no foreign command, and the
+receipt records the command actually selected. Publication downloads every
+public asset and verifies its size and digest before writing into separately
+checked-out tap/AUR repositories.
+
+Agent integration is a separate `hive-skills` release tree. One canonical
+`skills/hive/SKILL.md` is copied byte-for-byte into the installable plugin and
+checked for drift. Claude uses its `.claude-plugin` marketplace, Codex uses its
+`.agents/plugins` marketplace plus `.codex-plugin` manifest, and Pi consumes
+the root `package.json#pi.skills` package. Tagged qualification installs the
+current Claude, Codex, and Pi clients and performs their native marketplace or
+package install flows. A compatible tag is published to the separately
+versioned `ivankuznetsov/hive-skills` repository before the core release. The
+core installer and `hive init` never write agent directories.
+
+Release qualification is deliberately gated. Tagged builds run Ruby,
+distribution, lint, all three current-client skill validations, build both
+tier-1 artifacts, attest the archives, and execute shell/macOS Homebrew/Arch
+AUR lifecycle gates. The GitHub Release is created only after all gates and
+skills publication succeed. Tap and AUR publication is a required dependent
+job that re-downloads and verifies the public bytes before rendering or pushing
+either downstream repository; missing channel configuration fails the tag.
+
 ## Layer cake
 
 ```
diff --git a/wiki/cli.md b/wiki/cli.md
index 179a4998..a36a7a30 100644
--- a/wiki/cli.md
+++ b/wiki/cli.md
@@ -21,7 +21,7 @@ tags: [cli, api]
 | `hive new PROJECT TEXT...` | Create a task in `1-inbox/` of a registered project | `Hive::Commands::New` | [[commands/new]] |
 | `hive status [--diagnose SLUG [--write [--force]] [--project NAME] [--stage STAGE]]` | Action-grouped task list across registered projects. With `--diagnose <slug>`, prints the bounded diagnostic for one task (schema `hive-status-diagnose`). Add `--write` to spawn the configured execute `AgentProfile` and atomically write `<task>/diagnostics/red-status.md` (no lock, no marker mutation; `--force` bypasses the `marker_signature` idempotency short-circuit; green rows are rejected). | `Hive::Commands::Status` (delegates write path to `Hive::DiagnosisAgent`) | [[commands/status]] |
 | `hive tui` | Live, keystroke-driven Charm bubbletea + lipgloss dashboard over `hive status` (human-only; rejects `--json`) | `Hive::Tui` | [[commands/tui]] |
-| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Subcommands: `start` / `stop` / `status` / `reload` / `tail` for the daemon process; `enable PROJECT \| --all` and `disable` for per-project enrollment (atomic write to `<project>/.hive-state/config.yml`). Polls `hive status --json` and fires workflow verbs on tasks ready to advance; auto-archives 7-finalize after PR merge. New projects opt in at `hive init` (default Y); existing ones via `hive daemon enable`. See [[operating]] for the install + systemd/launchd setup guide. | `Hive::Commands::Daemon` | [[commands/daemon]] |
+| `hive daemon SUBCOMMAND` | Auto-advance pipeline dispatcher. Subcommands: `start` / `stop` / `status` / `reload` / `tail` for the daemon process; `enable PROJECT \| --all` and `disable` for per-project enrollment (atomic write to `<project>/.hive-state/config.yml`). Polls `hive status --json` and fires workflow verbs on tasks ready to advance; auto-archives 7-finalize after PR merge. Interactive init asks separately (default Y); non-TTY init leaves it disabled. Existing projects use `hive daemon enable`. See [[operating]] for the install + systemd/launchd setup guide. | `Hive::Commands::Daemon` | [[commands/daemon]] |
 | `hive bot SUBCOMMAND` | Telegram bot lifecycle. Subcommands: `start` / `stop` / `status` / `reload` / `tail`. Long-polls Telegram, authenticates by `bot.chat_id_allowlist`, notifies on waiting/recovery gates, and dispatches existing `hive` commands from inline buttons. | `Hive::Commands::Bot` | [[commands/bot]] |
 | `hive brainstorm TARGET [--from STAGE]` | Start or re-run brainstorm by slug/path | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] |
 | `hive plan TARGET [--from STAGE]` | Promote completed brainstorm to plan, or re-run plan | `Hive::Commands::StageAction` → approve/run | [[commands/stage_action]] |
@@ -39,7 +39,7 @@ tags: [cli, api]
 | `hive markers clear FOLDER --name <NAME> [--project NAME] [--json]` | Remove a recovery marker (`REVIEW_STALE`, `REVIEW_CI_STALE`, `REVIEW_ERROR`, `EXECUTE_STALE`, `ERROR`) from a task's state file (atomic write + hive_commit). Terminal-success markers (`REVIEW_COMPLETE` / `EXECUTE_COMPLETE` / `COMPLETE`) are deliberately rejected — use `hive approve` instead. | `Hive::Commands::Markers` | [[commands/markers]] |
 | `hive metrics SUBCOMMAND [--days N] [--project NAME] [--json]` | Compute project-wide metrics. Currently one subcommand: `rollback-rate` walks `git log --all` and reports the fraction of fix-commits (those carrying `Hive-Fix-Pass` trailer) that were later reverted, broken down by `Hive-Triage-Bias` and `Hive-Fix-Phase`. | `Hive::Commands::Metrics` → `Hive::Metrics` | — |
 | `hive doctor [--json]` | Walk `brainstorm` + `plan` stage configs and every `review.reviewers[]` entry, asking each agent profile to verify its configured skill resolves to an installed slash-command / SKILL.md on disk. When `brainstorm.runtime == "tmux_interactive"` also adds a `kind: "dependency"` row checking tmux availability + minimum version. Also runs non-fatally at the tail of `hive init`. Exits 0 / 65 / 78. | `Hive::Commands::Doctor` | [[commands/doctor]] |
-| `hive forget NAME [--json]` | Drop one named entry from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] |
+| `hive forget NAME [--json]` | Drop one named entry from the XDG global registry (`~/.config/hive/config.yml` by default). Inverse of `hive init`. The project's `.hive-state` directory on disk is not touched. Unknown name → exit 64. | `Hive::Commands::Forget` | [[commands/forget]] |
 | `hive prune [--dry-run] [--json]` | Drop every registry entry whose `path` is no longer a directory on disk OR whose row shape is invalid (hand-edit accident). `--dry-run` returns the would-be-removed list without writing. | `Hive::Commands::Prune` | [[commands/prune]] |
 | `hive version` / `hive --version` | Print `Hive::VERSION` and exit 0. Used by e2e environment snapshots and binary smoke tests. | `Hive::CLI#version` | — |
 
@@ -59,7 +59,7 @@ tags: [cli, api]
 |------|----------|---------|-----------|
 | 0 | `SUCCESS` | command completed | — |
 | 1 | `GENERIC` | unclassified `Hive::Error` | base `Hive::Error` |
-| 2 | `ALREADY_INITIALIZED` | idempotent reject of `hive init` on existing project | `Hive::AlreadyInitialized` |
+| 2 | `ALREADY_INITIALIZED` | reserved legacy code; current init repairs existing projects | `Hive::AlreadyInitialized` |
 | 3 | `TASK_IN_ERROR` | a stage agent recorded `:error` (runner itself succeeded) | `Hive::TaskInErrorState` |
 | 4 | `WRONG_STAGE` | `hive run` invoked on an inert stage (e.g. `1-inbox`) | `Hive::WrongStage` |
 | 64 | `USAGE` | EX_USAGE — bad slug, malformed task path | `Hive::InvalidTaskPath` |
@@ -93,7 +93,7 @@ The CLI itself has no auth. Preconditions checked at runtime by individual stage
 | `Hive::StageError` | `Commands::Run#pick_runner` for unknown stage names |
 | `Hive::TaskInErrorState` | `Commands::Run#report` when the stage marker is `:error` |
 | `Hive::WrongStage` | `Stages::Inbox#run!` (running an agent on an inert stage) |
-| `Hive::AlreadyInitialized` | `Commands::Init#call` when `hive/state` branch already exists |
+| `Hive::AlreadyInitialized` | legacy typed error retained for exit-code compatibility; current init uses its repair path |
 
 A few stage runners still call `warn`/`exit N` directly for non-bug user errors that don't yet have a typed class — most notably `Init#validate_git_repo!` / `validate_clean_tree!` (exit 1), `Execute#run!` for `plan.md missing` (exit 1), and the `OpenPr` / `Finalize` network/auth abort paths. Migrating these to typed exceptions is tracked as Phase 2 follow-up work.
 
diff --git a/wiki/commands/bot.md b/wiki/commands/bot.md
index 0b360ad2..f0afcc76 100644
--- a/wiki/commands/bot.md
+++ b/wiki/commands/bot.md
@@ -3,7 +3,7 @@ title: hive bot
 type: command
 source: lib/hive/commands/bot.rb, lib/hive/bot/*
 created: 2026-05-14
-updated: 2026-05-14
+updated: 2026-07-24
 tags: [command, bot, telegram, mobile, json]
 ---
 
@@ -25,11 +25,11 @@ hive bot tail
 
 | Subcommand | Behavior |
 |-----------|----------|
-| `start` | Loads `bot:` from `~/Dev/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes `~/Dev/hive/.bot.pid`, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. |
+| `start` | Loads `bot:` from `$XDG_CONFIG_HOME/hive/config.yml`, requires `HIVE_TELEGRAM_BOT_TOKEN`, writes its XDG runtime PID file, then starts the long-poll/status/reaper supervisor. With `--detach`, daemonizes. With `--dry-run`, outbound Telegram parsing still works but child `hive ...` dispatches are reported rather than spawned. A second live bot exits `75 (TEMPFAIL)`. |
 | `stop` | Sends `SIGTERM` to the PID file's process and waits up to `bot.shutdown_grace_sec`, then escalates to `SIGKILL`. Idempotent when no bot is running. With `--json`, emits `hive-bot-stop.v1`. |
 | `status` | Reports running/not-running and exits `0` when running, `1` when not. With `--json`, emits `hive-bot-status.v1` with `running`, `pid`, `uptime_sec`, `pid_file`, and `log_file`. |
 | `reload` | Sends `SIGHUP`; the supervisor reloads config at the next loop boundary while preserving in-flight children and conversations. With `--json`, emits `hive-bot-reload.v1`. |
-| `tail` | Streams `~/Dev/hive/logs/bot.log`; exits 1 if the log does not exist. |
+| `tail` | Streams `$XDG_STATE_HOME/hive/logs/bot.log`; exits 1 if the log does not exist. |
 
 ## Commands in Telegram
 
@@ -67,9 +67,9 @@ bot:
   poll_interval_sec: 30
   long_poll_timeout_sec: 25
   notification_dedupe_window_sec: 300
-  pid_file: ~/Dev/hive/.bot.pid
-  log_file: ~/Dev/hive/logs/bot.log
-  last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id
+  pid_file: ~/.local/state/hive/run/bot.pid
+  log_file: ~/.local/state/hive/logs/bot.log
+  last_seen_state_file: ~/.local/state/hive/bot.last_seen_update_id
 ```
 
 `HIVE_TELEGRAM_BOT_TOKEN` is the only supported token source. Missing
@@ -79,7 +79,7 @@ silently.
 
 ## Structured log
 
-`~/Dev/hive/logs/bot.log` is one JSON document per line with schema
+`$XDG_STATE_HOME/hive/logs/bot.log` is one JSON document per line with schema
 `hive-bot-log.v1`. The event enum is closed in
 `Hive::Bot::Logger::EVENTS`; unknown events raise at the call site.
 Events include `bot_started`, `poll_failure`, `update_received`,
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index 308f04ce..d68fc5fa 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -3,7 +3,7 @@ title: hive daemon
 type: command
 source: lib/hive/commands/daemon.rb, lib/hive/daemon/*
 created: 2026-05-06
-updated: 2026-05-15
+updated: 2026-07-24
 tags: [command, daemon, automation, json]
 ---
 
@@ -30,11 +30,11 @@ hive daemon disable PROJECT | --all  [--json]
 
 | Subcommand | Behavior |
 |-----------|----------|
-| `start`    | Acquires the PID file (`~/Dev/hive/.daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. |
+| `start`    | Acquires the XDG runtime PID file (`$XDG_RUNTIME_DIR/hive/daemon.pid`, falling back to `$XDG_STATE_HOME/hive/run/daemon.pid`); without `--detach` runs in the foreground. With `--detach` calls `Process.daemon(true, true)` and the parent returns immediately. With `--dry-run` logs every dispatch decision but does NOT spawn child `hive ...` processes. Refuses with exit `75 (TEMPFAIL)` if a live daemon already holds the PID file. |
 | `stop`     | Sends `SIGTERM` to the running daemon's PID. Waits up to `daemon.shutdown_grace_sec` (default 600s) for the daemon to exit, then escalates to `SIGKILL`. Idempotent: `stop` with no PID file exits 0 with `daemon not running` on stderr; a stale PID file (process gone) is removed and the call exits 0. With `--json`, emits a `hive-daemon-stop` envelope (fields: `running`, `was_running`, `stale_pid?`, `reason?` — `pid_reused` / `unverified` for safety bailouts). |
 | `status`   | Reports running / not running. Exit code 0 if running, 1 if not. With `--json`, emits a `hive-daemon-status` envelope with `running`, `pid`, `uptime_sec`, `pid_file`, `log_file`. |
 | `reload`   | Sends `SIGHUP` to the running daemon's PID, which triggers config reload at the next tick boundary. In-flight children continue uninterrupted. Exit 1 if no daemon running. With `--json`, emits a `hive-daemon-reload` envelope (`ok`, `reason`, `pid`, `message`). |
-| `tail`     | `tail -F` semantics on `~/Dev/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
+| `tail`     | `tail -F` semantics on `$XDG_STATE_HOME/hive/logs/daemon.log` (self-implemented; doesn't shell out to the `tail` binary). Exit 1 if the log file doesn't exist. |
 | `enable`   | Sets `daemon.enabled: true` in `<project>/.hive-state/config.yml`. Surgical line-level YAML editor (upsert) preserves comments, key order, and file-mode bits across enable/disable flips; rejects inline-flow `daemon: { ... }`, CRLF endings, and 4-space-indented children before any write. Atomic write goes via tempfile + `flock(LOCK_EX)` + `fsync` + rename; tempfile is ensure-cleaned on rename failure (ENOSPC / EACCES / EXDEV). Pre-flight (`preflight_targets`) validates every target before any write so `--all` cannot half-flip the registry on a bad middle project. Pass a registered project name OR `--all` (mutually exclusive — passing both raises USAGE 64). Exit 64 on missing/unknown target / not-initialised project / no registered projects. With `--json`, emits a `hive-daemon-enroll` envelope on success and an `EnrollErrorKind` JSON error envelope on failure (`missing_project` / `unknown_project` / `project_and_all` / `not_initialised` / `no_projects` / `config` / `internal`); YAML parse failures surface as `Hive::ConfigError` (exit 78). |
 | `disable`  | Same shape as `enable`, sets `daemon.enabled: false`. The next dispatcher tick honours the change automatically (per-tick enable-cache invalidation); `hive daemon reload` is optional for instant pickup. |
 
@@ -70,18 +70,18 @@ Each project's `.hive-state/config.yml` carries:
 
 ```yaml
 daemon:
-  enabled: true   # asked at `hive init`, default Y
+  enabled: true   # explicit TTY consent; non-TTY init writes false
 ```
 
-Newly-init'd projects render `daemon.enabled: true` from the init
-prompt's default. **Legacy projects** whose YAML predates the `daemon:`
+Interactive init defaults its separate service consent prompt to yes; non-TTY
+init renders `daemon.enabled: false`. **Legacy projects** whose YAML predates the `daemon:`
 key fall back to `Config::DEFAULTS["daemon"]["enabled"] = false` — same
 "don't silently flip legacy projects" pattern ADR-023 used for
 stage agents. Operators of legacy projects opt in by adding `daemon: { enabled: true }` and running `hive daemon reload`.
 
 ## Concurrency caps
 
-All under `daemon:` in `~/Dev/hive/config.yml`:
+All under `daemon:` in `$XDG_CONFIG_HOME/hive/config.yml`:
 
 | Key | Default | Purpose |
 |-----|---------|---------|
@@ -93,7 +93,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`:
 | `max_runs_per_day_per_project` | 50 | Circuit breaker for runaway loops. |
 | `transient_retry_backoff_sec` | 60 | Base of `60 → 120 → 300 s` backoff schedule. |
 | `shutdown_grace_sec` | 600 | TERM→KILL window for in-flight children on `daemon stop`. |
-| `log_file` | `~/Dev/hive/logs/daemon.log` | Structured-log destination. |
+| `log_file` | `$XDG_STATE_HOME/hive/logs/daemon.log` | Structured-log destination. |
 | `log_max_bytes` | 10485760 | 10 MB rotation threshold. |
 | `log_max_files` | 5 | 5 × 10 MB = 50 MB log budget. |
 
@@ -111,7 +111,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`:
 
 ## Structured log
 
-`~/Dev/hive/logs/daemon.log` is one JSON document per line:
+`$XDG_STATE_HOME/hive/logs/daemon.log` is one JSON document per line:
 
 ```json
 {"ts":"2026-05-06T12:00:00Z","schema":"hive-daemon-log","schema_version":1,"event":"dispatched","pid":12345,"project":"writero","slug":"fix-x","stage":"6-review","command":"hive run fix-x --json","dry_run":false}
diff --git a/wiki/commands/forget.md b/wiki/commands/forget.md
index 460118f0..400ba056 100644
--- a/wiki/commands/forget.md
+++ b/wiki/commands/forget.md
@@ -7,7 +7,7 @@ updated: 2026-05-06
 tags: [command, registry, cleanup, json]
 ---
 
-**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches NAME from the global registry (`~/Dev/hive/config.yml`). Inverse of `hive init`. The project's `.hive-state` directory on disk is **not** touched — registry and on-disk state are intentionally independent. Unknown name is a USAGE error (exit 64); empty NAME positional is a distinct USAGE error with `error_kind: "missing_name"`. Bulk version: [[commands/prune]].
+**TLDR**: `hive forget NAME [--json]` removes the entry whose `name` matches NAME from the XDG global registry (`~/.config/hive/config.yml` by default). Inverse of `hive init`. The project's `.hive-state` directory on disk is **not** touched — registry and on-disk state are intentionally independent. Unknown name is a USAGE error (exit 64); empty NAME positional is a distinct USAGE error with `error_kind: "missing_name"`. Bulk version: [[commands/prune]].
 
 ## Usage
 
diff --git a/wiki/commands/init.md b/wiki/commands/init.md
index ee73c372..b390d8da 100644
--- a/wiki/commands/init.md
+++ b/wiki/commands/init.md
@@ -3,11 +3,15 @@ title: hive init
 type: command
 source: lib/hive/commands/init.rb
 created: 2026-04-25
-updated: 2026-05-04
+updated: 2026-07-24
 tags: [command, bootstrap, git, prompts]
 ---
 
-**TLDR**: `hive init [PATH]` bootstraps a project for hive: creates the orphan `hive/state` branch, attaches it as a worktree at `<project>/.hive-state/`, scaffolds stage folders, asks the operator (on TTY) which agents to use for planning / development / review and what budget+timeout sanity caps to set, scaffolds `config.yml` from those answers, ignores `.hive-state/` in master, and registers the project globally.
+**TLDR**: `hive init [PATH]` bootstraps or repairs a project: it checks
+dependencies, creates/attaches the `hive/state` worktree, collects project,
+provider/model, credential-reference, and separate daemon-start choices,
+scaffolds from the immutable XDG release, preserves existing global config,
+registers the user service, and registers the project globally.
 
 ## Usage
 
@@ -22,12 +26,18 @@ hive init [PROJECT_PATH] [--force]
 1. Path must be a git repository — `git -C <path> rev-parse --git-common-dir` must succeed.
 2. Path must be the **main checkout**, not a worktree. The check compares git's common-dir against `<path>/.git`. Running inside an existing worktree exits 1 with `"target appears to be inside a worktree"`.
 3. Working tree must be clean unless `--force` is passed; otherwise exit 1 with `"uncommitted changes"`.
-4. `hive/state` branch must not already exist; if it does, prints `"already initialized"` and exits 2 (idempotent).
+4. A pre-existing `hive/state` branch is accepted. Init enters its repair path
+   without prompting, reattaches/reseeds missing managed state, and preserves
+   existing configuration and user-edited scaffold files.
 
 ## Steps performed
 
-1. **Validate** — `validate_git_repo!` then `validate_clean_tree!` (skipped under `--force`).
-2. **Already-initialized guard** — if `hive/state` exists, raise `Hive::AlreadyInitialized` (exit 2). Runs **before** the prompt so a re-run never asks the user anything.
+1. **Diagnose dependencies, then validate** — missing `git`, `bash`, `claude`,
+   `gh`, or `jq` gets an actionable channel-independent warning before any
+   Git-dependent operation. Init then runs `validate_git_repo!` and the
+   clean-tree check (skipped under `--force`).
+2. **Repair guard** — if `hive/state` exists, run the non-interactive repair
+   path before any prompt.
 3. **Collect prompt answers** — `Hive::Commands::Init::Prompts.new(input: $stdin, output: $stderr, summary_io: $stdout).collect`. Prompt UI (intro / menus / re-prompts / confirmation) goes to **stderr**; the non-TTY one-line summary goes to **stdout** so scripted callers can `summary=$(hive init)` cleanly. On TTY this opens the interactive flow described below; on non-TTY (CI, pipes, test harness) it short-circuits to recommended defaults. Two abort paths exit **64** (`Hive::ExitCodes::USAGE`, distinct from generic crashes at 1) with **zero disk side effects** — no orphan branch, no worktree, no master gitignore commit:
    - Operator answers `n` at the final confirmation prompt.
    - Input stream closes mid-flow (Ctrl-D / EOF / disconnected pipe). `Prompts#read_line` distinguishes `nil` (closed stream) from `""` (blank line for default) and raises `Aborted`. Treating EOF as a blank confirmation would silently write disk state with whatever was already collected.
@@ -37,19 +47,31 @@ hive init [PROJECT_PATH] [--force]
    - `git rm -rf .` plus glob cleanup of any leftover dotfiles (preserving `.git`).
    - Create `stages/{1-inbox,2-brainstorm,3-plan,4-execute,7-finalize,8-done}/` with `.gitkeep` markers and `logs/.gitkeep`.
    - Initial commit `hive: bootstrap` on `hive/state`.
-5. **Render `<path>/.hive-state/config.yml`** from `templates/project_config.yml.erb`, threading the answers hash from step 3 through `ProjectConfigBinding`. Skipped if the file already exists.
-6. **Ignore `.hive-state/` on master** via `GitOps#add_hive_state_to_master_gitignore!`: appends `/.hive-state/` to `.gitignore` (idempotent), then commits `chore: ignore .hive-state worktree` on master.
-7. **Register globally** via `Hive::Config.register_project(name: basename(path), path: path)`, writing into `~/Dev/hive/config.yml`.
-8. Print summary: project name, default branch, hive-state path, worktree root, and a `next:` line with the `hive new ...` invocation.
+5. **Render project and provider config** — write the project YAML from prompt
+   answers; merge provider/model/credential-environment fields into
+   `$XDG_CONFIG_HOME/hive/config.yml` without replacing unrelated keys.
+6. **Seed and scaffold** — materialize
+   `$XDG_DATA_HOME/hive/releases/<version>`, repair `current`, and scaffold
+   `.hive/` from that release with digest and symlink confinement.
+7. **Register the service** — write the user-service definition stopped by
+   default; start it only after explicit TTY consent. Non-TTY init never starts
+   it.
+8. **Ignore and register** — idempotently ignore `.hive-state/` on master and
+   register the project in the XDG global config.
+9. Print the summary and run the non-fatal doctor preflight.
 
 ## Prompt flow (ADR-023)
 
-On TTY input streams the prompt walks the operator through four sections in order:
+On TTY input streams the prompt walks the operator through these choices in order:
 
 1. **Planning agent** (`brainstorm.agent` + `plan.agent`): one combined choice; the answer maps to both keys. Recommended default `claude`.
 2. **Development agent** (`execute.agent`): the implementer in `4-execute`. Recommended default `codex` (its edit-mode is more efficient for implementation work). Codex's status-detection mode is `:output_file_exists`, but the execute spawn pins `status_mode: :state_file_marker` because the stage's lifecycle contract is the marker the agent writes — the pin keeps that contract independent of the chosen profile.
 3. **Review agents** (`review.reviewers[]`): multi-select over the three default reviewers (claude-ce-code-review, codex-ce-code-review, pr-review-toolkit). Disabled entries are omitted from the rendered array.
-4. **Per-stage limits**: budget+timeout for each of 8 effective keys (`brainstorm`, `plan`, `execute_implementation`, `pr`, `review_ci`, `review_triage`, `review_fix`, `review_browser`). Defaults are generous sanity caps — most tasks finish well within them.
+4. **Triage bias and per-stage limits**: budget+timeout for the current
+   pipeline keys.
+5. **Daemon start consent**: separate from all install/init consent.
+6. **Provider, model, and credential environment variable**: stores only the
+   environment-variable name, never a credential value.
 
 Each agent and reviewer prompt accepts **either a name or a 1-based index** (e.g., `codex` or `2`; `claude-ce-code-review,pr-review-toolkit` or `1,3`). Name strings are the recommended path for scripted automation since they're stable across template-default reordering.
 
@@ -66,7 +88,7 @@ Reordering either is a **breaking change for scripted automation** that uses ind
 When `$stdin.tty?` is false the prompt module skips every question and emits exactly one line to `$stdout`:
 
 ```
-hive: using defaults — planning=claude, dev=codex, reviewers=all3, limits=defaults
+hive: using defaults — planning=claude, dev=codex, reviewers=all3, triage=courageous, limits=defaults, daemon=disabled, provider=openai, model=gpt-5
 ```
 
 Piped input is **not** consumed — `printf 'codex\n...' | hive init` ignores the piped data and uses defaults. Document this contract for any automation that wants to set non-default values: use `--force` plus an explicitly-edited YAML rather than expecting heredoc piping to populate answers.
@@ -89,12 +111,16 @@ This branch is what the orphan worktree is initially based on, and what feature
 | `not a git repository` | path isn't a git repo | `git init` first |
 | `target appears to be inside a worktree` | running from a feature worktree | run on main checkout |
 | `uncommitted changes` | dirty working tree | commit/stash, or pass `--force` |
-| `already initialized` (exit 2) | `hive/state` already exists | nothing to do |
+| repair fails | existing `hive/state` cannot be safely reattached or scaffolded | resolve the reported Git/path/service error and rerun init |
 
 ## Tests
 
-- `test/integration/init_test.rb` covers all five preconditions, the `--force` path, the idempotent double-init, the rendered template's stage-agent blocks, the bumped-generous limits, the dropped `execute_review` key, and the U5 piped-input + abort + already-initialized-guard scenarios.
-- `test/unit/commands/init/prompts_test.rb` covers the prompt module in isolation: 29 cases over happy paths, edge re-prompts, the non-TTY summary contract, and the testability invariant.
+- `test/integration/init_test.rb` covers preconditions, `--force`, idempotent
+  repair, first-run provider persistence, daemon consent, rendered defaults,
+  piped input, and zero-footprint abort.
+- `test/unit/commands/init/prompts_test.rb` covers prompt happy paths,
+  validation/re-prompts, provider/model choices, separate service consent, and
+  the non-TTY contract.
 
 ## Backlinks
 
diff --git a/wiki/commands/prune.md b/wiki/commands/prune.md
index 531131be..4fecb806 100644
--- a/wiki/commands/prune.md
+++ b/wiki/commands/prune.md
@@ -7,7 +7,7 @@ updated: 2026-05-06
 tags: [command, registry, cleanup, json]
 ---
 
-**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in `~/Dev/hive/config.yml` whose `path` no longer points at a directory on disk **or** whose row shape is invalid (non-Hash, missing `path`, non-String name/path — all hand-edit accidents). The project's `.hive-state` directory on disk (when present) is **not** touched. Bulk version of [[commands/forget]] for the common-case `mktemp -d`-style stale-entry pile-up.
+**TLDR**: `hive prune [--dry-run] [--json]` drops every registry entry in the XDG global config (`~/.config/hive/config.yml` by default) whose `path` no longer points at a directory on disk **or** whose row shape is invalid (non-Hash, missing `path`, non-String name/path — all hand-edit accidents). The project's `.hive-state` directory on disk (when present) is **not** touched. Bulk version of [[commands/forget]] for the common-case `mktemp -d`-style stale-entry pile-up.
 
 ## Usage
 
diff --git a/wiki/log.md b/wiki/log.md
index 641bec70..55592913 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -1607,3 +1607,47 @@ dispatch while preserving first-sight brainstorm baseline behavior.
 - `wiki/commands/tui.md` — documented red-status detail mode, keybindings, snapshot refresh behavior, and preserved direct recovery exceptions.
 - `wiki/decisions.md` — ADR-027 records the diagnose-then-act policy and its relationship to ADR-025.
 - `wiki/index.md` — bumped refresh date.
+## [2026-07-24] Distribution U1 — packaged path and command contract
+
+Added a relocatable entrypoint, centralized Hive version, XDG config/data/state/cache/runtime resolution, bundled-resource lookup, and collision-aware `hive`/`hv` command selection. Packaged-entrypoint coverage now runs from outside the checkout. See [[architecture]] and [[cli]].
+## [2026-07-24] Distribution U2 — safe first-run assets
+
+Extended `hive init` with versioned shared assets, digest-owned `.hive/` scaffolding, optional provider/model/credential-reference configuration, and non-mutating dependency diagnostics. Existing and modified project content is preserved, raw credential values are rejected, and no agent-owned skill directory is written. See [[architecture]] and [[commands/init]].
+## [2026-07-24] Distribution U3 — native user services
+
+Added common register/start/stop/status/unregister behavior with launchd and systemd-user adapters. Definitions use the stable installed executable and XDG-aware state paths; re-registration updates one service definition. `hive init` registers after setup and starts only from the existing daemon-consent choice. See [[architecture]] and [[commands/daemon]].
+## [2026-07-24] Distribution U4 — channel receipts and safe removal
+
+Added versioned install receipts, ownership checks, native update/removal delegation, service unregister, and digest-gated project cleanup. Ambiguous/malformed ownership fails with repair instructions; default and purge paths preserve XDG state/config, completed work, unknown files, symlinks, and skills. See [[architecture]] and [[cli]].
+## [2026-07-24] Distribution U5 — self-contained release contract
+
+Added the pinned Tebako builder boundary, tier-1 build workflow, stable archive layout, canonical release-manifest schema/generator, checksums, SPDX generation, and build provenance. Strict semver and target validation stop publication inputs early. See [[architecture]] and [[dependencies]].
+## [2026-07-24] Distribution U6 — verified shell channel
+
+Added a non-root tier-1 installer with manifest selection, mandatory SHA-256 and embedded-version checks, atomic command replacement, collision-safe `hv`, PATH/dependency diagnostics, a shell receipt, updates, and a narrowly scoped self-uninstaller. Failure before replacement leaves the previous executable intact. See [[architecture]] and [[cli]].
+## [2026-07-24] Distribution U7 — native package generation
+
+Added manifest-driven Homebrew and `hive-bin` AUR generation, collision-aware `hive` linking with guaranteed `hv`, native receipts/wrappers, package lifecycle hooks, and a two-repository publisher that verifies release assets before copying metadata. Package installation never starts the daemon and declares no Ruby dependency. See [[architecture]] and [[dependencies]].
+## [2026-07-24] Distribution U8 — agent prompt and skills package
+
+Added one consent-gated host-neutral install prompt and an exportable `hive-skills` repository tree with a canonical skill plus Claude, Codex, and Pi adapters. Current-client validation established native commands for all three: Claude marketplace/plugin install, Codex marketplace/plugin add, and Pi Git package install. Claude's native marketplace validator passes; canonical/package skill drift is contract-tested. See [[architecture]] and [[commands/doctor]].
+## [2026-07-24] Distribution U9 — release qualification and gates
+
+Added the two-phase tagged release workflow, cross-platform install-smoke jobs, canonical release verifier, distribution Rake suite, AE1–AE4 safety contracts, and install/release/uninstall documentation. Release reruns reject existing tags/releases; downstream publishing runs only after public asset verification. See [[architecture]], [[testing]], and [[cli]].
+
+## [2026-07-24] Testing — portable background subprocess cleanup
+
+The concurrent TUI subprocess integration fixture now reports its own child PIDs through a test-only file instead of discovering them with `pgrep`. This preserves the process/reaper coverage on minimal tier-1 release hosts without adding procps as a test dependency. See [[testing]].
+
+## [2026-07-24] Distribution review — ownership and publication hardening
+
+Closed the first distribution review pass across installer transactions,
+versioned receipts, XDG runtime paths, immutable scaffold assets, service
+rollback, and idempotent first-run configuration. Release qualification now
+authenticates shell helpers, attests the released archives, runs current
+Claude/Codex/Pi native-install checks plus shell/Homebrew/AUR lifecycle gates,
+publishes the compatible skills tag, and verifies downloaded public bytes
+before both downstream channels. The distribution suite and portable
+background-process PID-file contract are documented in [[testing]]; runtime
+and channel invariants are reflected in [[architecture]], [[commands/init]],
+[[commands/daemon]], and [[operating]].
diff --git a/wiki/modules/bot.md b/wiki/modules/bot.md
index c7df393a..59319ada 100644
--- a/wiki/modules/bot.md
+++ b/wiki/modules/bot.md
@@ -38,7 +38,7 @@ and subprocess I/O.
 hive bot start
   └─ Hive::Commands::Bot
        ├─ validates global bot config + HIVE_TELEGRAM_BOT_TOKEN
-       ├─ writes ~/Dev/hive/.bot.pid
+       ├─ writes the XDG runtime bot PID file
        └─ Hive::Bot::Supervisor.run_forever
             ├─ poll loop: Telegram.getUpdates → Router → handler descriptors
             ├─ status loop: hive status --json → NotificationDispatcher
diff --git a/wiki/modules/config.md b/wiki/modules/config.md
index b7c53d6f..c3556ea2 100644
--- a/wiki/modules/config.md
+++ b/wiki/modules/config.md
@@ -7,7 +7,11 @@ updated: 2026-05-14
 tags: [config, yaml, validation]
 ---
 
-**TLDR**: Two YAML configs — global at `~/Dev/hive/config.yml` (registered projects) and per-project at `<project>/.hive-state/config.yml` (default branch, worktree root, budgets, timeouts, **stage agents**, review-stage roles). `Config.load(project_root)` **recursively** deep-merges per-project values onto `Config::DEFAULTS`, then runs `validate!`. Arrays (notably `review.reviewers`) are replaced wholesale, never per-element merged.
+**TLDR**: Two YAML configs — global at `$XDG_CONFIG_HOME/hive/config.yml`
+(registered projects plus provider/daemon/bot settings) and per-project at
+`<project>/.hive-state/config.yml`. `Config.load(project_root)` recursively
+deep-merges project values onto `Config::DEFAULTS`; arrays are replaced
+wholesale.
 
 ## Defaults (`Config::DEFAULTS`)
 
@@ -68,7 +72,7 @@ tags: [config, yaml, validation]
 
 | Function | Returns / does |
 |----------|----------------|
-| `hive_home` | `ENV["HIVE_HOME"] || ~/Dev/hive` |
+| `hive_home` | legacy `ENV["HIVE_HOME"]`, otherwise `Hive::Paths.config_dir` |
 | `global_config_path` | `<hive_home>/config.yml` |
 | `hive_state_dir(project_root, name = ".hive-state")` | `<project_root>/<name>` |
 | `load(project_root)` | Reads `<project_root>/.hive-state/config.yml`, recursively deep-merges onto DEFAULTS, validates, returns Hash with `"project_root"` injected. Returns DEFAULTS-only hash if config absent. |
diff --git a/wiki/modules/daemon.md b/wiki/modules/daemon.md
index 81498482..fa8f1d65 100644
--- a/wiki/modules/daemon.md
+++ b/wiki/modules/daemon.md
@@ -31,9 +31,9 @@ decisions are unit-testable without forking.
 ```
 hive daemon start
   └─ Hive::Commands::Daemon
-       ├─ writes ~/Dev/hive/.daemon.pid
+       ├─ writes the XDG runtime daemon PID file
        └─ Hive::Daemon::Dispatcher.run_forever
-            ├─ Hive::Daemon::Logger      (~/Dev/hive/logs/daemon.log, JSON-line)
+            ├─ Hive::Daemon::Logger      ($XDG_STATE_HOME/hive/logs/daemon.log, JSON-line)
             ├─ Hive::Daemon::ConcurrencyController
             ├─ Hive::Daemon::ChildSupervisor   (Process.spawn pgroup: true)
             ├─ Hive::Daemon::StatusConsumer    (Open3.capture3 hive status --json)
diff --git a/wiki/operating.md b/wiki/operating.md
index bf12c796..1feb509b 100644
--- a/wiki/operating.md
+++ b/wiki/operating.md
@@ -3,7 +3,7 @@ title: Operating Hive
 type: operating
 source: lib/hive/commands/daemon.rb, lib/hive/commands/bot.rb, examples/systemd/, examples/launchd/
 created: 2026-05-07
-updated: 2026-05-14
+updated: 2026-07-24
 tags: [operating, daemon, bot, systemd, launchd, install]
 ---
 
@@ -27,7 +27,8 @@ Once per workstation:
   Verify: `ls /proc/$$ >/dev/null && echo OK` or `command -v ps`.
 - **Telegram bot token** if using `hive bot`. Create the bot with
   BotFather, export `HIVE_TELEGRAM_BOT_TOKEN`, and add your numeric
-  `chat_id` to `bot.chat_id_allowlist` in `~/Dev/hive/config.yml`.
+  `chat_id` to `bot.chat_id_allowlist` in
+  `$XDG_CONFIG_HOME/hive/config.yml`.
 
 ## Enrolling existing projects
 
@@ -54,8 +55,9 @@ The command edits the project's `.hive-state/config.yml` atomically
 tunables (`poll_interval_sec`, `max_concurrent_runs`, …) survive — the
 toggle only flips `enabled`.
 
-For new projects, `hive init` asks at the TTY prompt and defaults to Y
-— no separate enable step.
+For new projects, interactive `hive init` asks separately and defaults to Y.
+Non-TTY init writes `daemon.enabled: false`; enable it explicitly after
+reviewing the generated configuration.
 
 ## First run: the mandatory `--dry-run` shakedown
 
@@ -70,21 +72,21 @@ hive daemon status                  # verify running
 hive daemon tail                    # ctrl-C to leave; daemon keeps running
 ```
 
-Inspect `~/Dev/hive/logs/daemon.log` (one JSON document per line):
+Inspect `$XDG_STATE_HOME/hive/logs/daemon.log` (one JSON document per line):
 
 ```bash
 # What WOULD have been dispatched, by project
 jq -r 'select(.event=="dispatched") | "\(.project)/\(.slug): \(.command)"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
+   "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u
 
 # Things skipped — the `reason` field tells you why
 jq -r 'select(.event=="skipped") | "\(.project)/\(.slug): action=\(.action) reason=\(.reason // "—")"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
+   "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u
 
 # Anything blocked by caps (means at least one cap is too tight, or
 # expected throttling under load)
 jq -r 'select(.event=="blocked") | "\(.project)/\(.slug): \(.reason)"' \
-   ~/Dev/hive/logs/daemon.log | sort -u
+   "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/daemon.log" | sort -u
 ```
 
 Once the would-be dispatches look right, swap to live mode:
@@ -151,7 +153,7 @@ launchctl load   ~/Library/LaunchAgents/hive-daemon.plist
 
 launchd captures stdout/stderr to the paths declared in the plist
 (`~/Library/Logs/hive-daemon.{out,err}.log` by default). The daemon's
-own structured log (`~/Dev/hive/logs/daemon.log`) is independent and
+own structured log (`$XDG_STATE_HOME/hive/logs/daemon.log`) is independent and
 preferred for parsing — the launchd capture is mostly empty.
 
 `KeepAlive` with `SuccessfulExit: false` means launchd respawns on
@@ -170,7 +172,8 @@ normally. If you customise `ProgramArguments`, keep the precheck.
 
 ## Bot setup
 
-The bot is global and uses the registry in `~/Dev/hive/config.yml`.
+The bot is global and uses the registry in
+`$XDG_CONFIG_HOME/hive/config.yml`.
 Minimum config:
 
 ```yaml
@@ -188,10 +191,10 @@ hive bot start --dry-run
 
 `--dry-run` is useful for first shakedown: inbound command parsing and
 status notifications run, but state-changing child commands are not
-spawned. The bot log is `~/Dev/hive/logs/bot.log`:
+spawned. The bot log is `$XDG_STATE_HOME/hive/logs/bot.log`:
 
 ```bash
-jq -r '.event' ~/Dev/hive/logs/bot.log | sort | uniq -c
+jq -r '.event' "${XDG_STATE_HOME:-$HOME/.local/state}/hive/logs/bot.log" | sort | uniq -c
 ```
 
 Unauthorized chat IDs are logged once and receive no reply. Missing
@@ -229,14 +232,14 @@ drain path when you are not using systemd/launchd.
 |-----------------------------------------------|-------------------------------------------------------|
 | Status + uptime                               | `hive daemon status` (`--json` for envelope)          |
 | Follow the structured log                     | `hive daemon tail`                                    |
-| Reload caps without restart                   | edit `~/Dev/hive/config.yml` → `hive daemon reload`   |
+| Reload caps without restart                   | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive daemon reload`   |
 | Disable a project mid-flight                  | `hive daemon disable PROJECT` → `hive daemon reload`* |
 | Enable a project mid-flight                   | `hive daemon enable PROJECT` → `hive daemon reload`*  |
 | Drain + stop                                  | `hive daemon stop` (graceful TERM, ≤ `shutdown_grace_sec`) |
 | Force-stop after a hang                       | `hive daemon stop` then check PID; the stop CLI escalates to KILL |
 | Bot status / uptime                           | `hive bot status` (`--json` for envelope)          |
 | Follow bot log                                | `hive bot tail`                                    |
-| Reload bot allowlist / polling config         | edit `~/Dev/hive/config.yml` → `hive bot reload`   |
+| Reload bot allowlist / polling config         | edit `$XDG_CONFIG_HOME/hive/config.yml` → `hive bot reload`   |
 | Drain + stop bot                              | `hive bot stop`                                    |
 
 \* `hive daemon reload` clears the per-tick enable cache, but the
@@ -271,7 +274,7 @@ Defaults in `Config::DEFAULTS["daemon"]`:
 | `log_max_bytes`                 | 10 MB   | Rotation threshold.                                         |
 | `log_max_files`                 | 5       | 5 × 10 MB = 50 MB log budget.                               |
 
-To override, edit `~/Dev/hive/config.yml`:
+To override, edit `$XDG_CONFIG_HOME/hive/config.yml`:
 
 ```yaml
 registered_projects:
@@ -308,7 +311,8 @@ images). Mount `/proc`, install `procps`, or run the daemon in an
 environment where one of the two is reachable.
 
 **`hive daemon status` says "not running" but I just started it.**
-Check `~/Dev/hive/.daemon.pid`. If it's missing, the daemon failed
+Check `$XDG_RUNTIME_DIR/hive/daemon.pid` (or the
+`$XDG_STATE_HOME/hive/run/daemon.pid` fallback). If it's missing, the daemon failed
 during startup — check `journalctl --user -u hive-daemon` (Linux) or
 the launchd `~/Library/Logs/hive-daemon.err.log` (macOS) for the
 crash reason.
diff --git a/wiki/state-model.md b/wiki/state-model.md
index 29a9e1a2..4818b439 100644
--- a/wiki/state-model.md
+++ b/wiki/state-model.md
@@ -7,7 +7,10 @@ updated: 2026-05-14
 tags: [state, filesystem, model, architecture, review]
 ---
 
-**TLDR**: Hive has no database. Persistent state lives entirely in two filesystem trees per project — `<project>/.hive-state/` (an orphan-branch worktree holding task folders, configs, locks, logs) and `~/Dev/<project>.worktrees/<slug>/` (feature worktrees holding actual code) — plus one global `~/Dev/hive/config.yml`. The "data model" is the directory layout, marker grammar, and YAML schemas described below.
+**TLDR**: Hive has no database. Persistent state lives in two filesystem
+trees per project—`<project>/.hive-state/` and the configured feature-worktree
+root—plus XDG-separated global config/data/state. The data model is the
+directory layout, marker grammar, and YAML schemas described below.
 
 ## Stage directory layout
 
@@ -142,7 +145,7 @@ No `pass:` frontmatter or sidecar — recovery is "delete the highest-NN files t
 
 ## Configs
 
-### Global: `~/Dev/hive/config.yml`
+### Global: `$XDG_CONFIG_HOME/hive/config.yml`
 
 ```yaml
 registered_projects:
@@ -159,18 +162,18 @@ bot:
   codex_budget_usd: 1
   codex_timeout_sec: 120
   shutdown_grace_sec: 60
-  pid_file: ~/Dev/hive/.bot.pid
-  log_file: ~/Dev/hive/logs/bot.log
+  pid_file: ~/.local/state/hive/run/bot.pid
+  log_file: ~/.local/state/hive/logs/bot.log
   log_max_bytes: 10485760
   log_max_files: 5
-  last_seen_state_file: ~/Dev/hive/.bot.last_seen_update_id
+  last_seen_state_file: ~/.local/state/hive/bot.last_seen_update_id
 ```
 
-Managed by `Hive::Config.register_project` (`lib/hive/config.rb:79`); deregistered by `unregister_project` (one row, by name) and `prune_missing_projects!` (every row whose `path` is missing OR whose shape is invalid). `HIVE_HOME` env var overrides the default `~/Dev/hive`.
+Managed by `Hive::Config.register_project` (`lib/hive/config.rb`); deregistered by `unregister_project` (one row, by name) and `prune_missing_projects!` (every row whose `path` is missing OR whose shape is invalid). The default is `~/.config/hive`; `HIVE_HOME` remains a legacy override.
 
-Loader tolerance (`Config.registered_projects` / `load_global_config`): a non-Hash row, a row missing `name`, or a row whose `path` isn't a String is *skipped silently* instead of raising — a single hand-edit accident can no longer brick `status`/`forget`/`prune`/TUI. `Psych::Exception` (any malformed YAML — syntax, disallowed-class, alias-not-enabled) plus `Errno::EACCES`/`EISDIR` are rewrapped as `ConfigError` (exit 78); `chmod 000 ~/Dev/hive/config.yml` no longer leaks as exit-70 InternalError. `prune` is the cleanup verb for invalid rows surfaced this way (predicate `Config.droppable_registry_entry?` covers both missing-path and invalid-shape; `valid_registry_entry?` is the shared shape gate). All writes go through `write_global_config!` so the read/write classification is symmetric and a future flock upgrade (Issue #31) lands in one place. See [[commands/forget]] · [[commands/prune]] · [[modules/config]]. All writers (`register_project`, `unregister_project`, `prune_missing_projects!`) go through `Hive::Config.write_global_config!`, which rewraps `Errno::EACCES`/`EROFS`/`ENOSPC` as `Hive::ConfigError` (exit 78). The reader (`load_global_config`) likewise rewraps `Psych::SyntaxError` AND `Errno::EACCES`/`EISDIR` to `ConfigError` so a `chmod 000` on `~/Dev/hive/config.yml` surfaces as exit 78, not exit 70. Name matching in `unregister_project` is `to_s`-symmetric so a hand-edited Integer `name:` in YAML still resolves. `forget`/`prune` `--json` envelopes use `Hive::Schemas::EnvelopeEmitter` (`lib/hive.rb`) and `File.expand_path` raw `path` / `hive_state_path` to honor the schemas' "Absolute path" contract regardless of how the registry row was hand-edited.
+Loader tolerance (`Config.registered_projects` / `load_global_config`): a non-Hash row, a row missing `name`, or a row whose `path` isn't a String is *skipped silently* instead of raising — a single hand-edit accident can no longer brick `status`/`forget`/`prune`/TUI. `Psych::Exception` (any malformed YAML — syntax, disallowed-class, alias-not-enabled) plus `Errno::EACCES`/`EISDIR` are rewrapped as `ConfigError` (exit 78); an unreadable XDG config no longer leaks as exit-70 InternalError. `prune` is the cleanup verb for invalid rows surfaced this way (predicate `Config.droppable_registry_entry?` covers both missing-path and invalid-shape; `valid_registry_entry?` is the shared shape gate). All writes go through `write_global_config!` so the read/write classification is symmetric and a future flock upgrade (Issue #31) lands in one place. See [[commands/forget]] · [[commands/prune]] · [[modules/config]]. All writers (`register_project`, `unregister_project`, `prune_missing_projects!`) go through `Hive::Config.write_global_config!`, which rewraps `Errno::EACCES`/`EROFS`/`ENOSPC` as `Hive::ConfigError` (exit 78). Name matching in `unregister_project` is `to_s`-symmetric so a hand-edited Integer `name:` in YAML still resolves. `forget`/`prune` `--json` envelopes use `Hive::Schemas::EnvelopeEmitter` (`lib/hive.rb`) and `File.expand_path` raw `path` / `hive_state_path` to honor the schemas' "Absolute path" contract regardless of how the registry row was hand-edited.
 
-`bot:` is a global operator-surface block, not a per-project enrollment knob. `Config.load_global_bot(require_runtime: true)` merges it over `Config.global_bot_defaults`, validates integer chat IDs, poll bounds, and path strings, then requires both a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN` before `hive bot start` can run. Runtime files are global: `~/Dev/hive/.bot.pid` for the single-instance lock, `~/Dev/hive/logs/bot.log` for structured JSON lines, and `~/Dev/hive/.bot.last_seen_update_id` for Telegram reconnect summaries.
+`bot:` is a global operator-surface block, not a per-project enrollment knob. `Config.load_global_bot(require_runtime: true)` merges it over `Config.global_bot_defaults`, validates integer chat IDs, poll bounds, and path strings, then requires both a non-empty allowlist and `HIVE_TELEGRAM_BOT_TOKEN` before `hive bot start` can run. Runtime files are global but XDG-separated: the PID is under the runtime root, while logs and reconnect state are under the state root.
 
 ### Per-project: `<project>/.hive-state/config.yml`
 
diff --git a/wiki/testing.md b/wiki/testing.md
index 28096a6d..e408210e 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -3,7 +3,7 @@ title: Testing
 type: reference
 source: test/, Rakefile, .rubocop.yml
 created: 2026-04-25
-updated: 2026-04-29
+updated: 2026-07-24
 tags: [test, minitest, fixtures]
 ---
 
@@ -30,7 +30,10 @@ task default: :test
 
 - `with_tmp_dir` — `Dir.mktmpdir("hive-test", &block)`.
 - `with_tmp_git_repo` — `git init -b master`, configures user/email and disables GPG signing, makes one initial commit, yields the path.
-- `with_tmp_global_config` — overrides `ENV["HIVE_HOME"]` to a tmp dir and writes an empty `registered_projects: []` YAML so tests don't touch `~/Dev/hive/config.yml`.
+- `with_tmp_global_config` — points the legacy `HIVE_HOME` override and every
+  XDG root at one temporary tree, then writes an empty
+  `registered_projects: []` YAML. Tests therefore cannot touch the operator's
+  config, data, state, cache, or runtime files.
 - `run!(*cmd)` — shells out and raises on non-zero exit (used in setup helpers; not for testing the CLI itself).
 
 ## Fixtures
@@ -80,6 +83,23 @@ bin/hive-e2e run
 
 The six starter scenarios copy `test/e2e/sample-project/` into a per-run sandbox, set `HIVE_HOME` to a run-local directory, and call the real `bin/hive` as a subprocess. TUI scenarios use private tmux sockets (`hive-e2e-<run-id>`) so they never touch the operator's daily tmux server.
 
+Background-process fixtures report child PIDs through test-only PID files.
+Tests must not discover those children with `pgrep`: procps is not present on
+every tier-1 release host, and process-name matching can select unrelated
+processes. The PID-file handshake keeps reaper/cleanup assertions portable.
+
+## Distribution suite
+
+```bash
+bundle exec rake distribution
+```
+
+The distribution suite is a release gate. It covers the packaged entrypoint,
+release-manifest/archive contracts, shell installation and preservation,
+Homebrew/AUR rendering, client-native skills adapters, and the unchanged
+agent-install prompt. Tagged CI also executes the generated shell, Homebrew,
+and AUR lifecycle gates before creating the GitHub Release.
+
 ## Lint
 
 `bundle exec rubocop` is the lint command. Config in `.rubocop.yml`:
