diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml
new file mode 100644
index 00000000..f7dce317
--- /dev/null
+++ b/.github/workflows/package-smoke.yml
@@ -0,0 +1,31 @@
+name: Package smoke
+
+on:
+  pull_request:
+    paths:
+      - '.tebako.yml'
+      - '.ruby-version'
+      - 'Gemfile*'
+      - 'exe/hive'
+      - 'bin/hive'
+      - 'lib/**'
+      - 'script/package'
+      - 'test/smoke/packaged_binary.sh'
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  linux-x86_64:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - name: Install Tebako build prerequisites
+        run: sudo apt-get update && sudo apt-get install --yes cmake build-essential
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: '3.4.2'
+          bundler-cache: true
+      - run: script/package linux x86_64
+      - run: test/smoke/packaged_binary.sh dist/hive_0.1.0_linux_x86_64 0.1.0
diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml
new file mode 100644
index 00000000..a7cb714b
--- /dev/null
+++ b/.github/workflows/release-smoke.yml
@@ -0,0 +1,20 @@
+name: Release smoke
+
+on:
+  workflow_call:
+    inputs:
+      artifact-name:
+        required: true
+        type: string
+
+permissions:
+  contents: read
+
+jobs:
+  verify:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/download-artifact@v7
+        with:
+          name: ${{ inputs.artifact-name }}
+      - run: test/release/checksums.sh .
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..c0347ef8
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,127 @@
+name: Release
+
+on:
+  push:
+    tags: ['v*']
+
+permissions:
+  contents: read
+
+jobs:
+  validate-tag:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - name: Validate immutable release tag
+        run: |
+          [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
+          test "${GITHUB_REF_NAME#v}" = "$(ruby -ne 'puts $1 if /VERSION = "([^"]+)"/' lib/hive.rb)"
+
+  test-and-security:
+    needs: validate-tag
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with: { ruby-version: '3.4.2', bundler-cache: true }
+      - run: bundle exec rake test
+      - run: bundle exec rubocop --parallel
+      - run: bundle exec brakeman --force --no-pager --quiet --ignore-config config/brakeman.ignore
+      - run: bundle exec bundler-audit check --update
+
+  build-linux:
+    needs: validate-tag
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with: { ruby-version: '3.4.2', bundler-cache: true }
+      - run: sudo apt-get update && sudo apt-get install --yes cmake build-essential
+      - run: script/package linux x86_64
+      - uses: actions/upload-artifact@v6
+        with: { name: hive-linux, path: dist/hive_*_linux_x86_64 }
+
+  build-macos:
+    needs: validate-tag
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with: { ruby-version: '3.4.2', bundler-cache: true }
+      - run: script/package darwin arm64
+      - uses: actions/upload-artifact@v6
+        with: { name: hive-macos, path: dist/hive_*_darwin_arm64 }
+
+  tier1-acceptance:
+    needs: [build-linux, build-macos]
+    uses: ./.github/workflows/tier1-acceptance.yml
+    with:
+      version: ${{ github.ref_name }}
+      linux-artifact: hive-linux
+      macos-artifact: hive-macos
+
+  publish:
+    needs: [test-and-security, build-linux, build-macos, tier1-acceptance]
+    runs-on: ubuntu-22.04
+    permissions:
+      contents: write
+      id-token: write
+      attestations: write
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v7
+        with: { path: dist, merge-multiple: true }
+      - run: cp install.sh dist/install.sh
+      - run: script/release-manifest --dist dist --version "$GITHUB_REF_NAME"
+      - run: test/release/checksums.sh dist
+      - uses: actions/attest-build-provenance@v2
+        with: { subject-path: 'dist/hive_*' }
+      - env: { GH_TOKEN: '${{ github.token }}' }
+        run: gh release create "$GITHUB_REF_NAME" dist/hive_* dist/install.sh dist/SHA256SUMS dist/release-manifest.json --generate-notes
+
+  publish-downstream:
+    needs: publish
+    runs-on: ubuntu-22.04
+    permissions:
+      contents: read
+    env:
+      GH_TOKEN: ${{ secrets.HIVE_DOWNSTREAM_TOKEN }}
+      AUR_SSH_KEY: ${{ secrets.HIVE_AUR_SSH_KEY }}
+      HOMEBREW_REPOSITORY: ${{ vars.HIVE_HOMEBREW_REPOSITORY }}
+      SKILLS_REPOSITORY: ${{ vars.HIVE_SKILLS_REPOSITORY }}
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with: { ruby-version: '3.4.2' }
+      - name: Download existing immutable release metadata
+        run: gh release download "$GITHUB_REF_NAME" --pattern release-manifest.json --dir dist
+      - name: Configure scoped AUR credential
+        run: |
+          install -d -m 700 ~/.ssh
+          printf '%s\n' "$AUR_SSH_KEY" > ~/.ssh/aur
+          chmod 600 ~/.ssh/aur
+          ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
+          printf 'Host aur.archlinux.org\n  IdentityFile ~/.ssh/aur\n  IdentitiesOnly yes\n' >> ~/.ssh/config
+      - name: Clone downstream metadata repositories
+        run: |
+          gh repo clone "${HOMEBREW_REPOSITORY:-ivankuznetsov/homebrew-hive}" downstream/homebrew
+          gh repo clone "${SKILLS_REPOSITORY:-ivankuznetsov/hive-skills}" downstream/skills
+          git clone ssh://aur@aur.archlinux.org/hive-bin.git downstream/aur
+      - name: Generate deterministic metadata from the release manifest
+        run: |
+          script/update-homebrew-formula dist/release-manifest.json downstream/homebrew/Formula/hive.rb
+          cp packaging/aur/install-channel.yml downstream/aur/install-channel.yml
+          script/update-aur-pkgbuild dist/release-manifest.json downstream/aur/PKGBUILD downstream/aur/.SRCINFO
+          rsync -a --delete --exclude .git packaging/hive-skills/ downstream/skills/
+          script/update-skills-metadata "$GITHUB_REF_NAME" downstream/skills
+      - name: Publish idempotent downstream commits
+        run: |
+          git config --global user.name hive-release-bot
+          git config --global user.email hive-release-bot@users.noreply.github.com
+          for repository in homebrew aur skills; do
+            if ! git -C "downstream/$repository" diff --quiet; then
+              git -C "downstream/$repository" add -A
+              git -C "downstream/$repository" commit -m "chore(release): publish ${GITHUB_REF_NAME}"
+              git -C "downstream/$repository" push
+            fi
+          done
diff --git a/.github/workflows/tier1-acceptance.yml b/.github/workflows/tier1-acceptance.yml
new file mode 100644
index 00000000..4fb1afad
--- /dev/null
+++ b/.github/workflows/tier1-acceptance.yml
@@ -0,0 +1,61 @@
+name: Tier-1 acceptance
+
+on:
+  workflow_call:
+    inputs:
+      version:
+        required: true
+        type: string
+      linux-artifact:
+        required: true
+        type: string
+      macos-artifact:
+        required: true
+        type: string
+
+permissions:
+  contents: read
+
+jobs:
+  ubuntu-bash:
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v7
+        with: { name: '${{ inputs.linux-artifact }}', path: dist }
+      - run: chmod +x dist/hive_*_linux_x86_64
+      - run: test/acceptance/ubuntu_bash.sh dist/hive_*_linux_x86_64 "${{ inputs.version }}"
+      - run: test/acceptance/uninstall_preserves_work.sh
+
+  macos-homebrew:
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v7
+        with: { name: '${{ inputs.macos-artifact }}', path: dist }
+      - run: chmod +x dist/hive_*_darwin_arm64
+      - run: test/acceptance/macos_homebrew.sh dist/hive_*_darwin_arm64 "${{ inputs.version }}"
+
+  arch-aur:
+    runs-on: ubuntu-22.04
+    container: archlinux:base-devel
+    steps:
+      - run: pacman -Syu --noconfirm git ruby
+      - uses: actions/checkout@v6
+      - uses: actions/download-artifact@v7
+        with: { name: '${{ inputs.linux-artifact }}', path: dist }
+      - run: chmod +x dist/hive_*_linux_x86_64
+      - run: test/acceptance/arch_aur.sh dist/hive_*_linux_x86_64 "${{ inputs.version }}"
+
+  prompt-hosts:
+    runs-on: ubuntu-22.04
+    strategy:
+      fail-fast: false
+      matrix:
+        host: [claude, codex, pi]
+    steps:
+      - uses: actions/checkout@v6
+      - uses: ruby/setup-ruby@v1
+        with: { ruby-version: '3.4.2', bundler-cache: true }
+      - env: { HIVE_PROMPT_HOST: '${{ matrix.host }}' }
+        run: bundle exec ruby -Itest test/prompt/evaluate_install_prompt.rb
diff --git a/.gitignore b/.gitignore
index dc646373..32acd1ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
 !/test/e2e/sample-project/vendor/cache/*.gem
 .byebug_history
 /coverage/
+/build/
 /.qmd/
 # Runtime registry of installed projects — machine-specific, not source.
 # See config.example.yml for the schema.
diff --git a/.ruby-version b/.ruby-version
index 2aa51319..4d9d11cf 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.4.7
+3.4.2
diff --git a/.tebako.yml b/.tebako.yml
new file mode 100644
index 00000000..3cf93c6d
--- /dev/null
+++ b/.tebako.yml
@@ -0,0 +1,7 @@
+# Tebako is deliberately configured here rather than in CI so local and
+# release builds use the same forward-portable Linux contract. The prefix is
+# supplied through TEBAKO_PREFIX by script/package; never place its build cache
+# inside the application root or it would be embedded in releases.
+options:
+  patchelf: true
+# Ruby: 3.4.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b1f430d..80283b1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 
 ## [Unreleased]
 
+### Added — self-contained distribution groundwork
+
+- Tebako-based macOS arm64 and Linux x86_64 release artifact contract, canonical release manifest, SHA-256 verification, and tag-gated GitHub Release workflow.
+- XDG-owned installation/config/assets paths, channel receipts, and channel-aware lifecycle commands. Packaged releases no longer require a target-host Ruby runtime.
+
 ### Added — opt-in interactive tmux brainstorm runtime
 
 - `brainstorm.runtime: tmux_interactive` runs stage `2-brainstorm` in a fresh per-task tmux session (`hive-2-brainstorm-<slug>`) with interactive `claude`, while preserving the existing `brainstorm.md` WAITING/COMPLETE marker contract.
diff --git a/Gemfile b/Gemfile
index 7a669b6e..ba1df0e6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,6 +20,10 @@ gem "telegram-bot-ruby", "~> 2.7"
 gem "bubbletea", "= 0.1.4"
 gem "lipgloss", "~> 0.2.2"
 
+# Build-time only. Tebako bundles a CRuby runtime and the application into the
+# release artifact; end users never install this gem or Ruby.
+gem "tebako", "~> 0.14.0", require: false
+
 group :development, :test do
   gem "minitest", "~> 6.0"
   gem "rake", "~> 13.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index fd2ee152..0a85ea96 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -121,6 +121,10 @@ GEM
     ruby-progressbar (1.13.0)
     securerandom (0.4.1)
     simpleidn (0.2.3)
+    tebako (0.14.0)
+      bundler
+      thor (~> 1.2)
+      yaml (~> 0.2.1)
     telegram-bot-ruby (2.7.0)
       dry-struct (~> 1.6)
       faraday (~> 2.0)
@@ -133,6 +137,7 @@ GEM
       unicode-emoji (~> 4.1)
     unicode-emoji (4.2.0)
     uri (1.1.1)
+    yaml (0.2.1)
     zeitwerk (2.7.5)
 
 PLATFORMS
@@ -149,6 +154,7 @@ DEPENDENCIES
   rake (~> 13.0)
   rubocop (~> 1.86)
   rubocop-rails-omakase (~> 1.1)
+  tebako (~> 0.14.0)
   telegram-bot-ruby (~> 2.7)
   thor (~> 1.3)
 
diff --git a/assets/agents/README.md b/assets/agents/README.md
new file mode 100644
index 00000000..41bfa5f5
--- /dev/null
+++ b/assets/agents/README.md
@@ -0,0 +1,3 @@
+# Embedded agent assets
+
+Release-owned agent defaults are materialized under the XDG data directory.
diff --git a/assets/project/.hive/README.md b/assets/project/.hive/README.md
new file mode 100644
index 00000000..a5db59e6
--- /dev/null
+++ b/assets/project/.hive/README.md
@@ -0,0 +1,4 @@
+# Hive project files
+
+This directory contains the project-local, user-editable Hive scaffold. Hive
+only updates files it originally generated and whose recorded hash is unchanged.
diff --git a/assets/project/.hive/manifest.yml b/assets/project/.hive/manifest.yml
new file mode 100644
index 00000000..34a120f6
--- /dev/null
+++ b/assets/project/.hive/manifest.yml
@@ -0,0 +1,2 @@
+schema_version: 1
+description: Hive-owned starter files; install-manifest.yml records their hashes.
diff --git a/assets/project/.hive/workflow.yml b/assets/project/.hive/workflow.yml
new file mode 100644
index 00000000..3f413dc7
--- /dev/null
+++ b/assets/project/.hive/workflow.yml
@@ -0,0 +1,2 @@
+schema_version: 1
+notes: Add project-specific Hive workflow guidance here.
diff --git a/assets/prompts/README.md b/assets/prompts/README.md
new file mode 100644
index 00000000..47ec626f
--- /dev/null
+++ b/assets/prompts/README.md
@@ -0,0 +1,3 @@
+# Embedded prompt assets
+
+Release-owned prompt defaults are materialized under the XDG data directory.
diff --git a/assets/services/dev.hive.daemon.plist.erb b/assets/services/dev.hive.daemon.plist.erb
new file mode 100644
index 00000000..c3376159
--- /dev/null
+++ b/assets/services/dev.hive.daemon.plist.erb
@@ -0,0 +1 @@
+<!-- Rendered by Hive::Service::Launchd; retained as an embedded service asset. -->
diff --git a/assets/services/hive.service.erb b/assets/services/hive.service.erb
new file mode 100644
index 00000000..938d23d7
--- /dev/null
+++ b/assets/services/hive.service.erb
@@ -0,0 +1 @@
+# Rendered by Hive::Service::SystemdUser; retained as an embedded service asset.
diff --git a/assets/stages/README.md b/assets/stages/README.md
new file mode 100644
index 00000000..6f5f0c60
--- /dev/null
+++ b/assets/stages/README.md
@@ -0,0 +1,3 @@
+# Embedded stage assets
+
+Release-owned stage defaults are materialized under the XDG data directory.
diff --git a/bin/hive b/bin/hive
index bfd53884..51d7b9e2 100755
--- a/bin/hive
+++ b/bin/hive
@@ -4,11 +4,8 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
 
 require "hive"
 require "hive/cli"
-
-if ARGV == [ "--version" ] || ARGV == [ "-v" ]
-  puts Hive::VERSION
-  exit 0
-end
+require "hive/paths"
+require "hive/install/receipt"
 
 # Thor only honours `--help` *before* the subcommand name (`hive help approve`);
 # `hive approve --help` would be consumed as the TARGET positional. Intercept
@@ -27,9 +24,15 @@ def rewrite_help_flag!(argv)
   argv.unshift("help", cmd)
 end
 
-rewrite_help_flag!(ARGV)
-
 begin
+  Hive::Install::Receipt.ensure_native!(paths: Hive::Paths.new, program_name: $PROGRAM_NAME)
+
+  if ARGV == [ "--version" ] || ARGV == [ "-v" ]
+    puts Hive::VERSION
+    exit 0
+  end
+
+  rewrite_help_flag!(ARGV)
   Hive::CLI.start(ARGV)
 rescue Hive::Error => e
   warn "hive: #{e.message}"
diff --git a/docs/install-prompt.md b/docs/install-prompt.md
new file mode 100644
index 00000000..4b9d53bd
--- /dev/null
+++ b/docs/install-prompt.md
@@ -0,0 +1,42 @@
+# Host-neutral Hive installation prompt
+
+Paste this exact prompt into Claude Code, Codex, or Pi:
+
+```text
+Install the stable Hive release for this machine, but do not change the host
+until you show the selected channel and I approve it.
+
+First inspect OS, architecture, `command -v hive`, whether `hive --version`
+looks like Hive (a bare MAJOR.MINOR.PATCH), package-manager availability, and
+the current directory. Report missing git, bash, claude, gh, or jq as actionable
+prerequisites; never install them or a package manager yourself.
+
+Choose exactly one channel:
+- Clean macOS arm64 with Homebrew and no hive collision: `brew install
+  ivankuznetsov/hive/hive`.
+- Clean Arch x86_64 with yay or paru and no owner of `/usr/bin/hive`: install
+  `hive-bin` through that helper.
+- Ubuntu, any collision, or another supported fallback: run the official
+  checksum-verifying installer from the GitHub Release. If hive is owned by
+  another program, it must install only `hv` and leave hive untouched.
+
+After approval, execute the chosen command. Save the collision-safe executable
+name as `HIVE_COMMAND` (`hive` or `hv`) and reuse it for every later Hive
+invocation: `$HIVE_COMMAND --version`, `$HIVE_COMMAND init`, update, daemon, and
+uninstall. Do not claim success until the version command prints a stable
+version. Then ask whether to run `$HIVE_COMMAND init` in the current Git project;
+do not start a daemon unless explicitly approved.
+
+Offer skills separately and only when the host supports its native mechanism:
+- Claude Code: `claude plugin marketplace add ivankuznetsov/hive-skills`, then
+  `claude plugin install hive@hive-skills`. Remove it with
+  `claude plugin uninstall hive@hive-skills`.
+- Codex: run `codex plugin marketplace add ivankuznetsov/hive-skills --ref
+  v0.1.0`, then `codex plugin add hive@hive-skills` and restart the session.
+  Remove it with `codex plugin remove hive@hive-skills`.
+- Pi: install with `pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0`
+  and remove with
+  `pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`.
+If the marketplace or policy prevents that action, finish the core install and
+give one native follow-up command; never copy skill files manually.
+```
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 00000000..6dfb66f2
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,20 @@
+# Install Hive
+
+Hive releases are self-contained binaries: you do not need system Ruby.
+
+On macOS arm64, Ubuntu 22.04+ x86_64, or Arch Linux x86_64, install the latest
+stable release into `~/.local/bin`:
+
+```bash
+curl -fsSL https://github.com/ivankuznetsov/hive/releases/latest/download/install.sh | bash
+```
+
+Set `HIVE_VERSION=v0.1.0` to pin an immutable stable release. The installer
+downloads the release manifest and binary, verifies SHA-256 before moving it
+into place, and writes an XDG state receipt. It never installs `git`, agent
+CLIs, `gh`, or `jq`; it reports any missing tools with a next step instead.
+
+If another program owns `hive`, the installer leaves it untouched and installs
+Hive as `hv`. It does not edit shell profiles; add `~/.local/bin` to your PATH
+yourself if needed. Verify with `hive --version` (or `hv --version`), then run
+`hive init` when you are ready to attach a project.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 00000000..080d4f83
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,15 @@
+# Distribution troubleshooting
+
+If `hive` is already owned by another program, use the `hv` command selected by
+the installer. Do not unlink the other package. If an update or uninstall says
+that `install.yml` is malformed, repair or remove only that receipt and rerun;
+Hive intentionally refuses to guess a package manager.
+
+The installer reports missing `git`, `bash`, Claude Code, Codex/Pi, `gh`, or
+`jq` but does not install them. Install required tools through your normal OS or
+agent workflow, then repeat the affected command.
+
+For a user service that is registered but not running, inspect `hive daemon
+status`, then use `hive init --start-daemon` only when you want it enabled.
+Project `.hive` edits, XDG config/state, and legacy `~/.hive-state` are never
+removed by default or `--purge`.
diff --git a/exe/hive b/exe/hive
new file mode 100755
index 00000000..592be90f
--- /dev/null
+++ b/exe/hive
@@ -0,0 +1,5 @@
+#!/usr/bin/env ruby
+
+# Stable Tebako entry point. Keeping this wrapper separate from bin/hive makes
+# the packaged launch contract explicit while preserving the development CLI.
+load File.expand_path("../bin/hive", __dir__)
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..fc9d2c6e
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,192 @@
+#!/usr/bin/env bash
+# Hive's user-local, checksum-verifying installer. It intentionally diagnoses
+# missing prerequisites rather than invoking a system package manager.
+set -euo pipefail
+
+repository=${HIVE_REPOSITORY:-ivankuznetsov/hive}
+api_base="https://api.github.com/repos/$repository"
+release_override=${HIVE_RELEASE_BASE_URL:-}
+bin_dir=${HIVE_BIN_DIR:-"$HOME/.local/bin"}
+state_home=${XDG_STATE_HOME:-"$HOME/.local/state"}
+receipt="$state_home/hive/install.yml"
+
+die() { printf 'hive installer: %s\n' "$*" >&2; exit 1; }
+warn() { printf 'hive installer: warning: %s\n' "$*" >&2; }
+
+command -v curl >/dev/null 2>&1 || die "curl is required; install curl and rerun"
+if command -v sha256sum >/dev/null 2>&1; then
+  checksum() { printf '%s  %s\n' "$1" "$2" | sha256sum --check --status -; }
+elif command -v shasum >/dev/null 2>&1; then
+  checksum() { printf '%s  %s\n' "$1" "$2" | shasum -a 256 --check --status -; }
+else
+  die "a SHA-256 tool (sha256sum or shasum) is required; install one and rerun"
+fi
+
+if [[ ${1:-} == --uninstall ]]; then
+  [[ -f $receipt ]] || die "no installation receipt at $receipt; remove the executable manually"
+  executable=$(awk -F': ' '$1 == "executable_path" { print $2; exit }' "$receipt")
+  command_name=$(awk -F': ' '$1 == "command_name" { print $2; exit }' "$receipt")
+  [[ -n $executable && -n $command_name ]] || die "receipt is malformed; repair it manually"
+  helper=$(mktemp "${TMPDIR:-/tmp}/hive-uninstall.XXXXXX")
+  printf '#!/usr/bin/env bash\nsleep 1\nrm -f -- %q\n' "$executable" > "$helper"
+  if [[ $command_name == hive && -L "$(dirname "$executable")/hv" ]] && [[ $(readlink "$(dirname "$executable")/hv") == hive ]]; then
+    printf 'rm -f -- %q\n' "$(dirname "$executable")/hv" >> "$helper"
+  fi
+  printf 'rm -f -- %q\nrm -f -- %q\n' "$receipt" "$helper" >> "$helper"
+  chmod 700 "$helper"
+  nohup bash "$helper" >/dev/null 2>&1 &
+  printf 'hive installer: scheduled post-exit removal for %s\n' "$executable"
+  exit 0
+fi
+
+version=${HIVE_VERSION:-}
+if [[ -z $version ]]; then
+  latest=$(curl -fsSL "$api_base/releases/latest") || die "could not determine the latest stable release"
+  version=$(printf '%s\n' "$latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
+fi
+[[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "HIVE_VERSION must be a stable vMAJOR.MINOR.PATCH tag"
+
+case $(uname -s) in
+  Darwin)
+    [[ $(uname -m) == arm64 || $(uname -m) == aarch64 ]] || die "unsupported macOS architecture $(uname -m); Hive v1 requires arm64"
+    target="hive_${version#v}_darwin_arm64"
+    ;;
+  Linux)
+    [[ $(uname -m) == x86_64 || $(uname -m) == amd64 ]] || die "unsupported Linux architecture $(uname -m); Hive v1 requires x86_64"
+    target="hive_${version#v}_linux_x86_64"
+    ;;
+  *) die "unsupported operating system $(uname -s); Hive v1 supports macOS arm64 and Linux x86_64" ;;
+esac
+
+release_base=${release_override:-"https://github.com/$repository/releases/download/$version"}
+receipt_release_base="https://github.com/ivankuznetsov/hive/releases/download/$version"
+mkdir -p "$bin_dir" "$(dirname "$receipt")" || die "cannot create the user-local install directories"
+
+is_hive() {
+  local output
+  output=$("$1" --version 2>/dev/null || true)
+  [[ $output =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
+}
+
+has_foreign_collision() {
+  local command_name=$1 destination=$2 path_command
+  path_command=$(command -v "$command_name" 2>/dev/null || true)
+  { [[ -n $path_command ]] && ! is_hive "$path_command"; } ||
+    { [[ -e $destination || -L $destination ]] && ! is_hive "$destination"; }
+}
+
+destination="$bin_dir/hive"
+command_name=hive
+if has_foreign_collision hive "$destination"; then
+  destination="$bin_dir/hv"
+  command_name=hv
+  if has_foreign_collision hv "$destination"; then
+    die "hive and hv collide with other programs on PATH; choose HIVE_BIN_DIR and retry"
+  fi
+fi
+
+if [[ -x $destination ]] && [[ $("$destination" --version 2>/dev/null || true) == "${version#v}" ]] && [[ -f $receipt ]]; then
+  printf 'hive installer: %s is already installed at %s\n' "$version" "$destination"
+  exit 0
+fi
+if [[ -e $receipt || -L $receipt ]]; then
+  [[ -f $receipt && ! -L $receipt ]] || die "receipt path $receipt is not a regular file; repair it manually before retrying"
+fi
+
+install_lock="$receipt.lock"
+temporary=
+transaction_active=0
+destination_replaced=0
+alias_replaced=0
+receipt_replaced=0
+destination_backup=
+alias_backup=
+receipt_backup=
+destination_stage=
+receipt_tmp=
+cleanup() {
+  if [[ $transaction_active == 1 ]]; then
+    [[ $destination_replaced == 1 ]] && rm -f -- "$destination"
+    [[ -n $destination_backup && -e $destination_backup ]] && mv -f -- "$destination_backup" "$destination"
+    if [[ $command_name == hive ]]; then
+      [[ $alias_replaced == 1 ]] && rm -f -- "$bin_dir/hv"
+      [[ -n $alias_backup && ( -e $alias_backup || -L $alias_backup ) ]] && mv -f -- "$alias_backup" "$bin_dir/hv"
+    fi
+    [[ $receipt_replaced == 1 ]] && rm -f -- "$receipt"
+    [[ -n $receipt_backup && -e $receipt_backup ]] && mv -f -- "$receipt_backup" "$receipt"
+  fi
+  [[ -n $destination_stage ]] && rm -f -- "$destination_stage"
+  [[ -n $receipt_tmp ]] && rm -f -- "$receipt_tmp"
+  [[ -n $temporary ]] && rm -rf -- "$temporary"
+  rm -rf -- "$install_lock"
+}
+mkdir "$install_lock" 2>/dev/null || die "another Hive install is active (lock: $install_lock); wait for it to finish and retry"
+trap cleanup EXIT
+temporary=$(mktemp -d "${TMPDIR:-/tmp}/hive-install.XXXXXX")
+manifest="$temporary/release-manifest.json"
+curl -fsSL "$release_base/release-manifest.json" -o "$manifest" || die "could not download release manifest"
+expected=$(awk -v target="$target" '
+  index($0, target) { wanted=1 }
+  wanted && /"sha256"/ { line=$0; sub(/^.*"sha256"[[:space:]]*:[[:space:]]*"/, "", line); sub(/".*$/, "", line); print line; exit }
+' "$manifest")
+[[ $expected =~ ^[a-f0-9]{64}$ ]] || die "release manifest has no valid checksum for $target"
+binary="$temporary/$target"
+curl -fsSL "$release_base/$target" -o "$binary" || die "could not download $target"
+checksum "$expected" "$binary" || die "checksum mismatch for $target; no files were installed"
+
+for tool in git bash claude gh jq; do
+  command -v "$tool" >/dev/null 2>&1 || warn "$tool is not installed; install it yourself before using the command that needs it"
+done
+umask 077
+destination_stage="$bin_dir/.${command_name}.new.$$"
+mv -f -- "$binary" "$destination_stage"
+chmod 755 "$destination_stage"
+receipt_tmp="$receipt.tmp.$$"
+cat > "$receipt_tmp" <<YAML
+schema_version: 1
+channel: bash
+package_id: hive
+release_base_url: $receipt_release_base
+installed_version: ${version#v}
+executable_path: $destination
+command_name: $command_name
+YAML
+chmod 600 "$receipt_tmp"
+
+transaction_active=1
+if [[ -e $destination || -L $destination ]]; then
+  destination_backup="$bin_dir/.${command_name}.old.$$"
+  mv -f -- "$destination" "$destination_backup"
+fi
+if [[ -e $receipt || -L $receipt ]]; then
+  receipt_backup="$receipt.old.$$"
+  mv -f -- "$receipt" "$receipt_backup"
+fi
+mv -f -- "$destination_stage" "$destination"
+destination_replaced=1
+destination_stage=
+if [[ $command_name == hive ]]; then
+  path_hv=$(command -v hv 2>/dev/null || true)
+  if [[ -n $path_hv ]] && ! is_hive "$path_hv"; then
+    warn "preserving unrelated hv command at $path_hv; no hv alias was created"
+  elif [[ -e $bin_dir/hv || -L $bin_dir/hv ]]; then
+    if is_hive "$bin_dir/hv"; then
+      alias_backup="$bin_dir/.hv.old.$$"
+      mv -f -- "$bin_dir/hv" "$alias_backup"
+      ln -s hive "$bin_dir/hv"
+      alias_replaced=1
+    else
+      warn "preserving unrelated hv command at $bin_dir/hv; no hv alias was created"
+    fi
+  else
+    ln -s hive "$bin_dir/hv"
+    alias_replaced=1
+  fi
+fi
+mv -f -- "$receipt_tmp" "$receipt"
+receipt_replaced=1
+receipt_tmp=
+rm -f -- "$destination_backup" "$alias_backup" "$receipt_backup"
+transaction_active=0
+printf 'hive installer: installed %s as %s at %s\n' "$version" "$command_name" "$destination"
+printf 'hive installer: add %s to PATH if it is not already present (no shell profile was changed)\n' "$bin_dir"
diff --git a/lib/hive/assets.rb b/lib/hive/assets.rb
new file mode 100644
index 00000000..4cffd4f9
--- /dev/null
+++ b/lib/hive/assets.rb
@@ -0,0 +1,116 @@
+require "digest"
+require "fileutils"
+require "securerandom"
+require "yaml"
+
+module Hive
+  # Materializes immutable embedded assets to writable XDG storage and only
+  # refreshes project files Hive still owns. Nothing in here writes into the
+  # packaged image, which is read-only under Tebako.
+  class Assets
+    Report = Data.define(:generated, :preserved)
+
+    class << self
+      def hydrate!(paths:, project_root:, version: Hive::VERSION)
+        materialize_release!(paths, version)
+        scaffold_project!(project_root, version)
+      end
+
+      def materialize_release!(paths, version)
+        destination = File.join(paths.releases, version)
+        unless File.directory?(destination)
+          begin
+            FileUtils.mkdir_p(paths.releases, mode: 0o700)
+            temporary = "#{destination}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
+            FileUtils.cp_r("#{root}/.", temporary)
+            File.rename(temporary, destination)
+          ensure
+            FileUtils.rm_rf(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
+          end
+        end
+
+        FileUtils.mkdir_p(File.dirname(paths.current_assets), mode: 0o700)
+        current_tmp = "#{paths.current_assets}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
+        File.symlink(destination, current_tmp)
+        File.rename(current_tmp, paths.current_assets)
+        destination
+      end
+
+      def scaffold_project!(project_root, version)
+        destination = File.join(project_root, ".hive")
+        FileUtils.mkdir_p(destination, mode: 0o700)
+        previous = load_manifest(File.join(destination, "install-manifest.yml"))
+        owned = previous.fetch("files", {})
+        generated = []
+        preserved = []
+
+        project_sources.each do |relative, source|
+          target = File.join(destination, relative)
+          source_hash = digest(source)
+          if !File.exist?(target)
+            write_atomically(target, File.binread(source))
+            generated << relative
+            owned[relative] = source_hash
+          else
+            target_hash = digest(target)
+            if target_hash == source_hash
+              # A previous process may have completed the atomic file rename
+              # but been interrupted before the final manifest write. Exact
+              # embedded bytes are sufficient proof to recover ownership.
+              generated << relative
+              owned[relative] = source_hash
+            elsif owned[relative] == target_hash
+              write_atomically(target, File.binread(source))
+              generated << relative
+              owned[relative] = source_hash
+            else
+              preserved << relative
+            end
+          end
+        end
+
+        manifest = { "schema_version" => 1, "version" => version, "files" => owned }
+        write_atomically(File.join(destination, "install-manifest.yml"), YAML.dump(manifest))
+        Report.new(generated, preserved)
+      end
+
+      private
+
+      def root
+        File.expand_path("../../assets", __dir__)
+      end
+
+      def project_sources
+        Dir.glob(File.join(root, "project", ".hive", "**", "*"), File::FNM_DOTMATCH).filter_map do |source|
+          next unless File.file?(source)
+
+          [ source.delete_prefix(File.join(root, "project", ".hive", "")), source ]
+        end
+      end
+
+      def load_manifest(path)
+        return { "files" => {} } unless File.file?(path)
+
+        parsed = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
+        return { "files" => {} } unless parsed.is_a?(Hash) && parsed["files"].is_a?(Hash)
+
+        parsed
+      rescue Psych::Exception
+        { "files" => {} }
+      end
+
+      def digest(path)
+        Digest::SHA256.file(path).hexdigest
+      end
+
+      def write_atomically(path, content)
+        FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
+        temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
+        File.binwrite(temporary, content)
+        File.rename(temporary, path)
+      ensure
+        File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
+      end
+    end
+  end
+end
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index 9bc60cb8..34672575 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -63,9 +63,32 @@ 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, default: "anthropic", desc: "provider reference stored in XDG user config"
+    option :model, type: :string, default: "claude-sonnet", desc: "model reference stored in XDG user config"
+    option :credential_env, type: :string, default: "ANTHROPIC_API_KEY", desc: "environment variable holding provider credentials"
+    option :start_daemon, type: :boolean, default: false, desc: "enable and start the registered per-user daemon service"
     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], start_service: options[:start_daemon]
+      ).call
+    end
+
+    desc "update", "Update Hive through the channel recorded at install time"
+    def update
+      require "hive/paths"
+      require "hive/commands/update"
+      Hive::Commands::Update.new(paths: Hive::Paths.new).call
+    end
+
+    desc "uninstall [PROJECT_PATH]", "Remove Hive through its install channel while preserving user work"
+    option :purge, type: :boolean, default: false,
+                   desc: "remove only unchanged Hive-generated files without prompting"
+    def uninstall(project_path = Dir.pwd)
+      require "hive/paths"
+      require "hive/commands/uninstall"
+      Hive::Commands::Uninstall.new(paths: Hive::Paths.new, project_root: project_path, purge: options[:purge]).call
     end
 
     desc "forget NAME", "Remove a project from the global registry (inverse of `hive init`)"
diff --git a/lib/hive/command_name.rb b/lib/hive/command_name.rb
new file mode 100644
index 00000000..c45b413a
--- /dev/null
+++ b/lib/hive/command_name.rb
@@ -0,0 +1,27 @@
+module Hive
+  # The executable can be exposed as hive or hv. The latter is required when a
+  # different program already owns hive; keeping argv-zero here makes all
+  # messages and service definitions use the command the user actually chose.
+  class CommandName
+    class Invalid < Hive::InvalidTaskPath; end
+
+    ALIASES = %w[hive hv].freeze
+
+    attr_reader :value
+
+    def self.from(argv_zero = $PROGRAM_NAME)
+      value = File.basename(argv_zero.to_s)
+      raise Invalid, "unsupported Hive command name #{value.inspect}; expected hive or hv" unless ALIASES.include?(value)
+
+      new(value)
+    end
+
+    def initialize(value)
+      @value = value
+    end
+
+    def usage(*args)
+      ([value] + args).join(" ")
+    end
+  end
+end
diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb
index f88bca6f..a638f47e 100644
--- a/lib/hive/commands/daemon.rb
+++ b/lib/hive/commands/daemon.rb
@@ -10,6 +10,10 @@ require "hive/daemon/child_supervisor"
 require "hive/daemon/status_consumer"
 require "hive/daemon/pr_merge_watcher"
 require "hive/daemon/logger"
+require "hive/paths"
+require "hive/platform"
+require "hive/install/receipt"
+require "hive/service/manager"
 
 module Hive
   module Commands
@@ -44,7 +48,8 @@ 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: Hive::Config.hive_home,
+                     paths: Hive::Paths.new, service_manager: nil)
         @subcommand = subcommand
         @target = target
         @detach = detach
@@ -52,6 +57,8 @@ module Hive
         @all = all
         @json = json
         @hive_home = hive_home
+        @paths = paths
+        @service_manager = service_manager
       end
 
       def call
@@ -61,6 +68,8 @@ module Hive
                 "(expected: #{VALID_SUBCOMMANDS.join(', ')})"
         end
 
+        return call_service_manager if service_managed_subcommand?
+
         case @subcommand
         when "start"            then start_daemon
         when "stop"             then stop_daemon
@@ -72,15 +81,49 @@ module Hive
       end
 
       def pid_file
-        @pid_file ||= File.join(@hive_home, ".daemon.pid")
+        @pid_file ||= File.join(@paths.state, "daemon.pid")
       end
 
       def log_file
-        @log_file ||= File.join(@hive_home, "logs", "daemon.log")
+        @log_file ||= File.join(@paths.cache, "daemon.log")
       end
 
       private
 
+      def service_managed_subcommand?
+        return false if ENV["HIVE_SERVICE_CHILD"] == "1"
+        return true if @service_manager
+        return false unless %w[start stop status].include?(@subcommand)
+
+        File.file?(@paths.receipt)
+      end
+
+      def call_service_manager
+        if @json
+          raise Hive::InvalidTaskPath,
+                "hive daemon #{@subcommand}: --json is not supported for installed service commands; omit --json"
+        end
+
+        manager = @service_manager || begin
+          receipt = Hive::Install::Receipt.load(@paths)
+          Hive::Service::Manager.new(
+            paths: @paths, platform: Hive::Platform.current.first,
+            executable_path: receipt.executable_path
+          )
+        end
+        state = case @subcommand
+        when "start" then manager.start!
+        when "stop" then manager.stop!
+        when "status" then manager.status
+        end
+        if @subcommand == "status" && state != :running
+          raise Hive::Error, "hive daemon service is #{state.to_s.tr('_', ' ')}"
+        end
+
+        puts "hive: daemon service #{state.to_s.tr('_', ' ')}" unless @json
+        state
+      end
+
       def start_daemon
         warn_unsupported_json_flag if @json
         FileUtils.mkdir_p(@hive_home)
diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb
index b86e8d61..96cc75ca 100644
--- a/lib/hive/commands/init.rb
+++ b/lib/hive/commands/init.rb
@@ -2,16 +2,36 @@ require "open3"
 require "fileutils"
 require "stringio"
 require "hive/config"
+require "hive/paths"
+require "hive/assets"
+require "hive/user_config"
+require "hive/install/receipt"
+require "hive/platform"
+require "hive/service/manager"
 require "hive/git_ops"
 require "hive/commands/init/prompts"
 require "hive/commands/doctor"
+require "hive/dependencies"
 
 module Hive
   module Commands
     class Init
-      def initialize(project_path, force: false, prompts: nil)
+      def initialize(project_path, force: false, prompts: nil, paths: Hive::Paths.new,
+                     provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY",
+                     start_service: false, service_manager: nil, dependencies: nil, dependency_output: nil,
+                     service_input: $stdin, service_output: $stderr)
         @project_path = File.expand_path(project_path)
         @force = force
+        @paths = paths
+        @provider = provider
+        @model = model
+        @credential_env = credential_env
+        @start_service = start_service
+        @service_manager = service_manager
+        @dependencies = dependencies || Hive::Dependencies.new
+        @dependency_output = dependency_output
+        @service_input = service_input
+        @service_output = service_output
         # 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,32 +42,66 @@ module Hive
       end
 
       def call
+        emit_dependency_diagnostics!
+        Hive::Install::Receipt.load(@paths) if File.exist?(@paths.receipt)
         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}"
+        unless ops.hive_state_branch_exists?
+          validate_clean_tree! unless @force
+
+          # Prompt placement is load-bearing (per ADR-023): it runs before
+          # any project write. A re-run skips it and simply refreshes only
+          # manifest-owned distribution files, making init genuinely safe to
+          # repeat after a user has edited their project.
+          answers = collect_prompt_answers
+          ops.hive_state_init
+          write_per_project_config(ops, answers: answers)
+          ops.add_hive_state_to_master_gitignore!
         end
 
-        # Prompt placement is load-bearing (per ADR-023): runs AFTER the
-        # already-initialized guard above, BEFORE any disk writes below.
-        # An aborted prompt (`n` at confirmation) leaves zero footprint —
-        # no orphan branch, no worktree, no master .gitignore update —
-        # so a re-run of `hive init` proceeds normally.
-        answers = collect_prompt_answers
-
-        ops.hive_state_init
-        write_per_project_config(ops, answers: answers)
-        ops.add_hive_state_to_master_gitignore!
-
         entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path)
+        distribution = setup_distribution!
+        setup_service_registration!
 
         print_summary(entry: entry, ops: ops)
+        warn "hive: preserved modified project files: #{distribution.preserved.join(', ')}" unless distribution.preserved.empty?
         run_init_preflight!
       end
 
+      def setup_distribution!
+        Hive::UserConfig.ensure!(
+          paths: @paths, provider: @provider, model: @model, credential_env: @credential_env
+        )
+        Hive::Assets.hydrate!(paths: @paths, project_root: @project_path)
+      end
+
+      def emit_dependency_diagnostics!
+        @dependencies.diagnostics_for(:init).each do |diagnostic|
+          (@dependency_output || $stderr).puts "hive: dependency: #{diagnostic.hint}"
+        end
+      end
+
+      def setup_service_registration!
+        return unless File.file?(@paths.receipt)
+
+        receipt = Hive::Install::Receipt.load(@paths)
+        manager = @service_manager || Hive::Service::Manager.new(
+          paths: @paths, platform: Hive::Platform.current.first,
+          executable_path: receipt.executable_path
+        )
+        manager.register!
+        manager.start! if service_start_consented?
+      end
+
+      def service_start_consented?
+        return true if @start_service
+        return false unless @service_input.respond_to?(:tty?) && @service_input.tty?
+
+        @service_output.print "Start the registered Hive daemon service now? [y/N] "
+        @service_output.flush
+        @service_input.gets.to_s.strip.casecmp("y").zero?
+      end
+
       # Non-fatal skill preflight: after init succeeds, run the doctor
       # against the freshly-written config and emit stderr warnings for
       # any `:missing` rows. Init's exit code is unaffected — install
diff --git a/lib/hive/commands/uninstall.rb b/lib/hive/commands/uninstall.rb
new file mode 100644
index 00000000..0c11e79b
--- /dev/null
+++ b/lib/hive/commands/uninstall.rb
@@ -0,0 +1,152 @@
+require "digest"
+require "fileutils"
+require "pathname"
+require "yaml"
+require "hive/install/channel"
+require "hive/install/receipt"
+require "hive/platform"
+require "hive/service/manager"
+
+module Hive
+  module Commands
+    class Uninstall
+      def initialize(paths:, project_root: Dir.pwd, purge: false, input: $stdin, output: $stdout,
+                     runner: nil, service_manager: nil)
+        @paths = paths
+        @project_root = File.expand_path(project_root)
+        @purge = purge
+        @input = input
+        @output = output
+        @runner = runner
+        @service_manager = service_manager
+      end
+
+      def call
+        receipt = Hive::Install::Receipt.load(@paths)
+        manager = @service_manager || Hive::Service::Manager.new(
+          paths: @paths, platform: Hive::Platform.current.first, executable_path: receipt.executable_path
+        )
+        cleanup_plan = cleanup_confirmed? ? project_cleanup_plan : nil
+
+        begin
+          Hive::Install::Channel.for(receipt, runner: @runner).remove!
+        rescue Hive::Error => e
+          raise Hive::Error,
+                "#{e.message}. No service or project files were removed; " \
+                "the installation remains registered. Fix the package-manager error and retry uninstall."
+        end
+
+        manager.unregister!
+        summary = cleanup_plan ? apply_project_cleanup(cleanup_plan) : { "removed" => [], "retained" => [] }
+        summary["shared_removed"] = cleanup_shared_assets
+        File.delete(@paths.receipt) if File.exist?(@paths.receipt)
+        @output.puts YAML.dump(summary) if @output
+        summary
+      end
+
+      private
+
+      def cleanup_confirmed?
+        return true if @purge
+        return false unless @input.respond_to?(:tty?) && @input.tty?
+
+        @output.print "Remove unchanged Hive-generated files from #{@project_root}/.hive? [y/N] "
+        @output.flush
+        @input.gets.to_s.strip.casecmp("y").zero?
+      end
+
+      def project_cleanup_plan
+        directory = File.join(@project_root, ".hive")
+        manifest_path = File.join(directory, "install-manifest.yml")
+        return { directory: directory, manifest: manifest_path, files: {}, retained: [] } unless File.file?(manifest_path)
+
+        stat = File.lstat(directory)
+        raise Hive::ConfigError, "project .hive directory must not be a symlink" if stat.symlink?
+
+        manifest = YAML.safe_load(File.read(manifest_path), permitted_classes: [], aliases: false)
+        files = manifest.is_a?(Hash) && manifest["files"].is_a?(Hash) ? manifest["files"] : {}
+        candidates = {}
+        retained = []
+        files.each do |relative, hash|
+          path = safe_manifest_path!(directory, relative)
+          if regular_file_without_symlinks?(directory, relative, path) && Digest::SHA256.file(path).hexdigest == hash
+            candidates[relative] = hash
+          else
+            retained << relative if File.exist?(path)
+          end
+        end
+        unknown = Dir.children(directory) - files.keys.map { |path| path.split("/").first } - [ "install-manifest.yml" ]
+        retained.concat(unknown)
+        { directory: directory, manifest: manifest_path, files: candidates, retained: retained }
+      rescue Psych::Exception => e
+        raise Hive::ConfigError, "project install manifest is malformed: #{e.message}"
+      end
+
+      def apply_project_cleanup(plan)
+        removed = []
+        retained = plan.fetch(:retained).dup
+        plan.fetch(:files).each do |relative, digest|
+          path = safe_manifest_path!(plan.fetch(:directory), relative)
+          if regular_file_without_symlinks?(plan.fetch(:directory), relative, path) &&
+             Digest::SHA256.file(path).hexdigest == digest
+            File.delete(path)
+            removed << relative
+          else
+            retained << relative if File.exist?(path)
+          end
+        end
+        File.delete(plan.fetch(:manifest)) if retained.empty? && File.exist?(plan.fetch(:manifest))
+        { "removed" => removed.sort, "retained" => retained.uniq.sort }
+      end
+
+      def safe_manifest_path!(directory, relative)
+        if File.symlink?(directory) || !File.directory?(directory)
+          raise Hive::ConfigError, "project .hive directory must be a real directory"
+        end
+
+        unless relative.is_a?(String) && !relative.empty?
+          raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}"
+        end
+
+        pathname = Pathname.new(relative)
+        normalized = pathname.cleanpath.to_s
+        if pathname.absolute? || normalized != relative || normalized == "." || normalized.start_with?("../")
+          raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}"
+        end
+
+        path = File.expand_path(relative, directory)
+        root_prefix = "#{File.expand_path(directory)}#{File::SEPARATOR}"
+        raise Hive::ConfigError, "unsafe project install manifest path #{relative.inspect}" unless path.start_with?(root_prefix)
+
+        path
+      end
+
+      def regular_file_without_symlinks?(directory, relative, path)
+        parent = directory
+        relative.split(File::SEPARATOR)[0...-1].each do |component|
+          parent = File.join(parent, component)
+          next unless File.exist?(parent) || File.symlink?(parent)
+
+          raise Hive::ConfigError, "unsafe symlinked parent in project install manifest path #{relative.inspect}" if File.lstat(parent).symlink?
+        end
+        File.exist?(path) && File.lstat(path).file?
+      end
+
+      def cleanup_shared_assets
+        removed = []
+        if File.symlink?(@paths.current_assets) || File.file?(@paths.current_assets)
+          FileUtils.rm_f(@paths.current_assets)
+          removed << @paths.current_assets
+        end
+        if File.symlink?(@paths.releases)
+          FileUtils.rm_f(@paths.releases)
+          removed << @paths.releases
+        elsif File.directory?(@paths.releases)
+          FileUtils.rm_rf(@paths.releases)
+          removed << @paths.releases
+        end
+        removed
+      end
+    end
+  end
+end
diff --git a/lib/hive/commands/update.rb b/lib/hive/commands/update.rb
new file mode 100644
index 00000000..38a76ffb
--- /dev/null
+++ b/lib/hive/commands/update.rb
@@ -0,0 +1,29 @@
+require "hive/install/channel"
+require "hive/install/receipt"
+require "hive/dependencies"
+
+module Hive
+  module Commands
+    class Update
+      def initialize(paths:, runner: nil, output: $stdout, dependencies: nil)
+        @paths = paths
+        @runner = runner
+        @output = output
+        @dependencies = dependencies || Hive::Dependencies.new
+      end
+
+      def call
+        receipt = Hive::Install::Receipt.load(@paths)
+        diagnostics = @dependencies.diagnostics_for(:"update_#{receipt.channel}")
+        diagnostics.each { |diagnostic| @output.puts "hive: dependency: #{diagnostic.hint}" }
+        unless diagnostics.empty?
+          raise Hive::Error, "missing dependencies for #{receipt.channel} update: #{diagnostics.map(&:tool).join(', ')}"
+        end
+        channel = Hive::Install::Channel.for(receipt, runner: @runner)
+        @output.puts "hive: delegating update to #{receipt.channel} for #{receipt.package_id}"
+        channel.update!
+        true
+      end
+    end
+  end
+end
diff --git a/lib/hive/dependencies.rb b/lib/hive/dependencies.rb
new file mode 100644
index 00000000..7da1b4dc
--- /dev/null
+++ b/lib/hive/dependencies.rb
@@ -0,0 +1,54 @@
+require "hive/platform"
+
+module Hive
+  # Read-only diagnostics only. This deliberately never invokes a package
+  # manager: users decide if and how they install a missing prerequisite.
+  class Dependencies
+    Diagnostic = Data.define(:tool, :hint, :required)
+
+    ACTION_TOOLS = {
+      init: %w[git bash claude gh jq],
+      update_bash: %w[bash curl],
+      update_homebrew: %w[brew],
+      bash_install: %w[bash curl],
+      agent_assist: %w[claude gh jq],
+      daemon_status: []
+    }.freeze
+
+    def initialize(which: nil, platform: nil)
+      @which = which || ->(tool) { ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, tool)) } }
+      @platform = platform || Platform.current.first
+    end
+
+    def diagnostics_for(action)
+      tools_for(action).filter_map do |tool|
+        next if @which.call(tool)
+
+        Diagnostic.new(tool, hint_for(tool), true)
+      end
+    end
+
+    def missing_for(action)
+      diagnostics_for(action)
+    end
+
+    private
+
+    def tools_for(action)
+      return [ ENV.fetch("HIVE_AUR_HELPER", "yay") ] if action.to_sym == :update_aur
+
+      ACTION_TOOLS.fetch(action.to_sym) { [] }
+    end
+
+    def hint_for(tool)
+      return "brew is required for this Homebrew lifecycle action; install Homebrew from https://brew.sh and rerun Hive." if tool == "brew"
+      if %w[yay paru].include?(tool)
+        return "#{tool} is required for this AUR lifecycle action; install or select an AUR helper with HIVE_AUR_HELPER and rerun Hive."
+      end
+      return "claude is required for Claude-powered stages; install Claude Code and ensure `claude` is on PATH." if tool == "claude"
+
+      manager = @platform == :macos ? "Homebrew (brew install #{tool})" : "your package manager (apt or pacman install #{tool})"
+      "#{tool} is required; install it with #{manager} and rerun Hive."
+    end
+  end
+end
diff --git a/lib/hive/install/aur.rb b/lib/hive/install/aur.rb
new file mode 100644
index 00000000..ad1e4a6f
--- /dev/null
+++ b/lib/hive/install/aur.rb
@@ -0,0 +1,21 @@
+require "open3"
+
+module Hive
+  module Install
+    class Aur < ChannelAdapter
+      def update!
+        run!(helper, "-Syu", @receipt.package_id)
+      end
+
+      def remove!
+        run!(helper, "-Rns", @receipt.package_id)
+      end
+
+      private
+
+      def helper
+        ENV.fetch("HIVE_AUR_HELPER", "yay")
+      end
+    end
+  end
+end
diff --git a/lib/hive/install/bash.rb b/lib/hive/install/bash.rb
new file mode 100644
index 00000000..ebda0518
--- /dev/null
+++ b/lib/hive/install/bash.rb
@@ -0,0 +1,30 @@
+require "open3"
+require "shellwords"
+
+module Hive
+  module Install
+    class Bash < ChannelAdapter
+      def update!
+        run!("bash", "-o", "pipefail", "-c", installer_pipeline)
+      end
+
+      def remove!
+        # The installer owns post-exit cleanup so a running Hive binary is
+        # never truncated or unlinked in place by itself.
+        run!("bash", "-o", "pipefail", "-c", "#{installer_pipeline} -s -- --uninstall")
+      end
+
+      private
+
+      def installer_url
+        "#{@receipt.release_base_url.sub(%r{/*$}, '')}/install.sh"
+      end
+
+      def installer_pipeline
+        bin_dir = Shellwords.escape(File.dirname(@receipt.executable_path))
+        version = Shellwords.escape("v#{@receipt.installed_version}")
+        "curl -fsSL #{Shellwords.escape(installer_url)} | HIVE_BIN_DIR=#{bin_dir} HIVE_VERSION=#{version} bash"
+      end
+    end
+  end
+end
diff --git a/lib/hive/install/channel.rb b/lib/hive/install/channel.rb
new file mode 100644
index 00000000..4db1883c
--- /dev/null
+++ b/lib/hive/install/channel.rb
@@ -0,0 +1,47 @@
+module Hive
+  module Install
+    class ChannelAdapter
+      def initialize(receipt, runner: nil)
+        @receipt = receipt
+        @runner = runner || method(:default_runner)
+      end
+
+      private
+
+      def run!(*command)
+        _out, err, success = @runner.call(*command)
+        return if success
+
+        raise Hive::Error, "#{command.first} failed while delegating Hive #{operation_name}; #{err}"
+      end
+
+      def default_runner(*command)
+        out, err, status = Open3.capture3(*command)
+        [out, err, status.success?]
+      end
+
+      def operation_name
+        "lifecycle operation"
+      end
+    end
+  end
+end
+
+require "hive/install/homebrew"
+require "hive/install/aur"
+require "hive/install/bash"
+
+module Hive
+  module Install
+    class Channel
+      def self.for(receipt, runner: nil)
+        case receipt.channel
+        when "homebrew" then Homebrew.new(receipt, runner: runner)
+        when "aur" then Aur.new(receipt, runner: runner)
+        when "bash" then Bash.new(receipt, runner: runner)
+        else raise Hive::Install::Receipt::Invalid, "unknown install channel #{receipt.channel.inspect}; repair the receipt manually"
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/install/homebrew.rb b/lib/hive/install/homebrew.rb
new file mode 100644
index 00000000..d42bc728
--- /dev/null
+++ b/lib/hive/install/homebrew.rb
@@ -0,0 +1,15 @@
+require "open3"
+
+module Hive
+  module Install
+    class Homebrew < ChannelAdapter
+      def update!
+        run!("brew", "upgrade", @receipt.package_id)
+      end
+
+      def remove!
+        run!("brew", "uninstall", @receipt.package_id)
+      end
+    end
+  end
+end
diff --git a/lib/hive/install/receipt.rb b/lib/hive/install/receipt.rb
new file mode 100644
index 00000000..42a1bc27
--- /dev/null
+++ b/lib/hive/install/receipt.rb
@@ -0,0 +1,187 @@
+require "fileutils"
+require "securerandom"
+require "yaml"
+require "hive/command_name"
+
+module Hive
+  module Install
+    # Durable declaration of who owns the executable. Update and uninstall use
+    # this receipt to delegate to the right channel instead of guessing or
+    # overwriting package-manager-owned files.
+    class Receipt
+      class Invalid < Hive::ConfigError; end
+
+      SCHEMA_VERSION = 1
+      CHANNELS = %w[bash homebrew aur].freeze
+      FIELDS = %w[channel package_id release_base_url installed_version executable_path command_name].freeze
+      PACKAGE_IDS = {
+        "bash" => "hive",
+        "homebrew" => "ivankuznetsov/hive/hive",
+        "aur" => "hive-bin"
+      }.freeze
+      STABLE_VERSION = /\A\d+\.\d+\.\d+\z/
+      RELEASE_ROOT = "https://github.com/ivankuznetsov/hive/releases/download".freeze
+
+      attr_reader(*FIELDS.map(&:to_sym))
+
+      def self.load(paths)
+        path = paths.receipt
+        raise Invalid, recovery_message("receipt is missing at #{path}") unless File.file?(path)
+
+        raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
+        raise Invalid, recovery_message("receipt at #{path} must be a mapping") unless raw.is_a?(Hash)
+
+        new(**raw.transform_keys(&:to_sym))
+      rescue Psych::Exception => e
+        raise Invalid, recovery_message("receipt at #{path} is malformed (#{e.message})")
+      end
+
+      def self.recovery_message(problem)
+        "#{problem}; repair or remove it manually before retrying update or uninstall"
+      end
+
+      def self.ensure_native!(paths:, program_name: $PROGRAM_NAME)
+        executable_path = resolve_executable(program_name)
+        return unless executable_path
+
+        real_executable = File.realpath(executable_path)
+        prefix = File.dirname(File.dirname(real_executable))
+        marker_path = File.join(prefix, "share", "hive", "install-channel.yml")
+        return unless File.file?(marker_path)
+
+        marker = YAML.safe_load(File.read(marker_path), permitted_classes: [], aliases: false)
+        unless marker.is_a?(Hash) && %w[homebrew aur].include?(marker["channel"]) &&
+               marker["package_id"] == PACKAGE_IDS.fetch(marker["channel"])
+          raise Invalid, recovery_message("native package marker at #{marker_path} is invalid")
+        end
+
+        existing = nil
+        if File.file?(paths.receipt)
+          existing = load(paths)
+          unless existing.channel == marker.fetch("channel") && existing.package_id == marker.fetch("package_id")
+            raise Invalid, recovery_message(
+              "native #{marker.fetch('channel')} package conflicts with the recorded #{existing.channel} installation"
+            )
+          end
+        end
+
+        recorded_executable =
+          if existing && File.executable?(existing.executable_path)
+            existing.executable_path
+          else
+            executable_path
+          end
+        receipt = new(
+          channel: marker.fetch("channel"),
+          package_id: marker.fetch("package_id"),
+          release_base_url: "#{RELEASE_ROOT}/v#{Hive::VERSION}",
+          installed_version: Hive::VERSION,
+          executable_path: recorded_executable,
+          command_name: File.basename(recorded_executable)
+        )
+        receipt.write!(paths) unless existing && existing.to_h == receipt.to_h
+        receipt
+      rescue Psych::Exception, Errno::ENOENT, Errno::EACCES => e
+        raise Invalid, recovery_message("native package marker could not be read (#{e.message})")
+      end
+
+      def self.resolve_executable(program_name)
+        candidate = program_name.to_s
+        if candidate.include?(File::SEPARATOR)
+          path = File.expand_path(candidate)
+          return path if File.file?(path) && File.executable?(path)
+
+          return nil
+        end
+
+        ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
+          path = File.expand_path(File.join(directory, candidate))
+          return path if File.file?(path) && File.executable?(path)
+        end
+        nil
+      end
+
+      def initialize(schema_version: SCHEMA_VERSION, **values)
+        raise Invalid, self.class.recovery_message("unsupported receipt schema #{schema_version.inspect}") unless schema_version == SCHEMA_VERSION
+
+        FIELDS.each do |field|
+          value = values[field.to_sym]
+          raise Invalid, self.class.recovery_message("receipt field #{field} is missing") unless value.is_a?(String) && !value.empty?
+
+          instance_variable_set("@#{field}", value)
+        end
+        unless CHANNELS.include?(channel)
+          raise Invalid, self.class.recovery_message("receipt channel #{channel.inspect} is unknown")
+        end
+        unless CommandName::ALIASES.include?(command_name)
+          raise Invalid, self.class.recovery_message("receipt command_name #{command_name.inspect} is invalid")
+        end
+
+        validate_identity!
+      end
+
+      def to_h
+        { "schema_version" => SCHEMA_VERSION }.merge(FIELDS.to_h { |field| [ field, public_send(field) ] })
+      end
+
+      def write!(paths)
+        path = paths.receipt
+        FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
+        temp = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
+        File.open(temp, "w", 0o600) do |file|
+          file.write(YAML.dump(to_h))
+          file.flush
+          file.fsync
+        end
+        File.rename(temp, path)
+        File.chmod(0o600, path)
+      ensure
+        File.delete(temp) if defined?(temp) && temp && File.exist?(temp)
+      end
+
+      private
+
+      def validate_identity!
+        expected_package = PACKAGE_IDS.fetch(channel)
+        unless package_id == expected_package
+          raise Invalid, self.class.recovery_message(
+            "receipt package_id #{package_id.inspect} does not match #{channel} package #{expected_package.inspect}"
+          )
+        end
+
+        unless STABLE_VERSION.match?(installed_version)
+          raise Invalid, self.class.recovery_message("receipt installed_version #{installed_version.inspect} is invalid")
+        end
+
+        expected_release = "#{RELEASE_ROOT}/v#{installed_version}"
+        unless release_base_url == expected_release
+          raise Invalid, self.class.recovery_message(
+            "receipt release_base_url #{release_base_url.inspect} does not match #{expected_release.inspect}"
+          )
+        end
+
+        normalized = File.expand_path(executable_path)
+        unless executable_path == normalized && File.basename(executable_path) == command_name
+          raise Invalid, self.class.recovery_message(
+            "receipt executable_path #{executable_path.inspect} is not an absolute path for #{command_name.inspect}"
+          )
+        end
+
+        case channel
+        when "homebrew"
+          unless executable_path.match?(%r{\A/.+/(?:homebrew[^/]*|\.linuxbrew)/bin/(?:hive|hv)\z})
+            raise Invalid, self.class.recovery_message(
+              "receipt executable_path #{executable_path.inspect} is not the Homebrew arm64 prefix command"
+            )
+          end
+        when "aur"
+          unless %W[/usr/bin/#{command_name}].include?(executable_path)
+            raise Invalid, self.class.recovery_message(
+              "receipt executable_path #{executable_path.inspect} is not the AUR package command"
+            )
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb
new file mode 100644
index 00000000..9c4cbcbe
--- /dev/null
+++ b/lib/hive/paths.rb
@@ -0,0 +1,44 @@
+module Hive
+  # Writable locations for distribution-owned content. Existing project-local
+  # .hive-state content remains a separate, read-only compatibility location;
+  # this resolver must never migrate or delete it implicitly.
+  class Paths
+    attr_reader :home, :config, :data, :state, :cache, :legacy_state
+
+    def initialize(env: ENV, home: Dir.home)
+      @home = File.expand_path(home)
+      @config = under(env["XDG_CONFIG_HOME"], ".config")
+      @data = under(env["XDG_DATA_HOME"], ".local/share")
+      @state = under(env["XDG_STATE_HOME"], ".local/state")
+      @cache = under(env["XDG_CACHE_HOME"], ".cache")
+      @legacy_state = File.join(@home, ".hive-state")
+    end
+
+    def config_file
+      File.join(config, "config.yml")
+    end
+
+    def receipt
+      File.join(state, "install.yml")
+    end
+
+    def releases
+      File.join(data, "releases")
+    end
+
+    def current_assets
+      File.join(data, "current")
+    end
+
+    def systemd_user_dir
+      File.join(File.dirname(config), "systemd", "user")
+    end
+
+    private
+
+    def under(override, fallback)
+      root = override.nil? || override.empty? ? File.join(@home, fallback) : File.expand_path(override)
+      File.join(root, "hive")
+    end
+  end
+end
diff --git a/lib/hive/platform.rb b/lib/hive/platform.rb
new file mode 100644
index 00000000..08fc2d27
--- /dev/null
+++ b/lib/hive/platform.rb
@@ -0,0 +1,28 @@
+require "rbconfig"
+
+module Hive
+  # Small, injectable platform classifier shared by the installer, service
+  # adapters, and dependency diagnostics. Keeping this separate prevents each
+  # lifecycle command from growing a subtly different host check.
+  module Platform
+    module_function
+
+    def current(host_os: RbConfig::CONFIG.fetch("host_os"), host_cpu: RbConfig::CONFIG.fetch("host_cpu"))
+      os = case host_os
+           when /darwin/i then :macos
+           when /linux/i then :linux
+           else :unknown
+           end
+      arch = case host_cpu
+             when "arm64", "aarch64" then :arm64
+             when "x86_64", "amd64" then :x86_64
+             else host_cpu.to_sym
+             end
+      [os, arch]
+    end
+
+    def supported?(os, arch)
+      (os == :macos && arch == :arm64) || (os == :linux && arch == :x86_64)
+    end
+  end
+end
diff --git a/lib/hive/release_manifest.rb b/lib/hive/release_manifest.rb
new file mode 100644
index 00000000..74174416
--- /dev/null
+++ b/lib/hive/release_manifest.rb
@@ -0,0 +1,53 @@
+require "digest"
+require "json"
+
+module Hive
+  class ReleaseManifest
+    TARGETS = [
+      { "os" => "darwin", "arch" => "arm64", "minimum_os" => "macOS 14" },
+      { "os" => "linux", "arch" => "x86_64", "minimum_os" => "Ubuntu 22.04", "minimum_glibc" => "2.35" }
+    ].freeze
+    SEMVER_TAG = /\Av\d+\.\d+\.\d+\z/
+
+    def self.generate!(dist:, version:, release_base_url: nil)
+      raise ArgumentError, "release version must be a stable vMAJOR.MINOR.PATCH semver tag" unless SEMVER_TAG.match?(version)
+      raise ArgumentError, "release version #{version} disagrees with Hive::VERSION #{Hive::VERSION}" unless version.delete_prefix("v") == Hive::VERSION
+
+      release_base_url ||= "https://github.com/ivankuznetsov/hive/releases/download/#{version}"
+      canonical_url = "https://github.com/ivankuznetsov/hive/releases/download/#{version}"
+      raise ArgumentError, "release base URL must be the canonical GitHub release URL #{canonical_url}" unless release_base_url == canonical_url
+
+      targets = TARGETS.map do |target|
+        name = "hive_#{Hive::VERSION}_#{target.fetch('os')}_#{target.fetch('arch')}"
+        artifact = File.join(dist, name)
+        raise ArgumentError, "missing release artifact #{artifact}" unless File.file?(artifact)
+
+        target.merge(
+          "name" => name,
+          "url" => "#{release_base_url}/#{name}",
+          "size" => File.size(artifact),
+          "sha256" => Digest::SHA256.file(artifact).hexdigest
+        )
+      end
+      installer = File.join(dist, "install.sh")
+      raise ArgumentError, "missing release installer #{installer}" unless File.file?(installer)
+
+      manifest = {
+        "schema_version" => 1,
+        "version" => version,
+        "release_base_url" => release_base_url,
+        "targets" => targets,
+        "installer" => {
+          "name" => "install.sh",
+          "url" => "#{release_base_url}/install.sh",
+          "size" => File.size(installer),
+          "sha256" => Digest::SHA256.file(installer).hexdigest
+        }
+      }
+      File.write(File.join(dist, "release-manifest.json"), JSON.pretty_generate(manifest) + "\n")
+      checksums = (targets.map { |target| [ target.fetch("sha256"), target.fetch("name") ] } + [ [ manifest.dig("installer", "sha256"), "install.sh" ] ]).map { |digest, name| "#{digest}  #{name}" }
+      File.write(File.join(dist, "SHA256SUMS"), checksums.join("\n") + "\n")
+      manifest
+    end
+  end
+end
diff --git a/lib/hive/service/launchd.rb b/lib/hive/service/launchd.rb
new file mode 100644
index 00000000..7ef40b2c
--- /dev/null
+++ b/lib/hive/service/launchd.rb
@@ -0,0 +1,49 @@
+require "cgi"
+require "fileutils"
+
+module Hive
+  module Service
+    module Launchd
+      LABEL = "dev.hive.daemon".freeze
+
+      module_function
+
+      def path(paths)
+        File.join(paths.home, "Library", "LaunchAgents", "#{LABEL}.plist")
+      end
+
+      def write!(paths:, executable_path:)
+        FileUtils.mkdir_p(File.dirname(path(paths)), mode: 0o700)
+        executable = CGI.escapeHTML(executable_path)
+        environment = {
+          "XDG_CONFIG_HOME" => File.dirname(paths.config),
+          "XDG_DATA_HOME" => File.dirname(paths.data),
+          "XDG_STATE_HOME" => File.dirname(paths.state),
+          "XDG_CACHE_HOME" => File.dirname(paths.cache),
+          "HIVE_STATE_DIR" => paths.state
+        }
+        environment_xml = environment.map do |key, value|
+          "      <key>#{key}</key><string>#{CGI.escapeHTML(value)}</string>"
+        end.join("\n")
+        content = <<~PLIST
+          <?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>#{LABEL}</string>
+            <key>ProgramArguments</key><array>
+              <string>#{executable}</string><string>daemon</string><string>start</string>
+            </array>
+            <key>EnvironmentVariables</key><dict>
+              <key>HIVE_SERVICE_CHILD</key><string>1</string>
+        #{environment_xml}
+            </dict>
+            <key>RunAtLoad</key><false/>
+            <key>KeepAlive</key><false/>
+          </dict></plist>
+        PLIST
+        File.write(path(paths), content.lstrip)
+        path(paths)
+      end
+    end
+  end
+end
diff --git a/lib/hive/service/manager.rb b/lib/hive/service/manager.rb
new file mode 100644
index 00000000..56c857a4
--- /dev/null
+++ b/lib/hive/service/manager.rb
@@ -0,0 +1,107 @@
+require "open3"
+require "hive/service/launchd"
+require "hive/service/systemd_user"
+
+module Hive
+  module Service
+    # Adapter for the two supported per-user service managers. Files are
+    # registered first; enabling or starting is an explicit separate action.
+    class Manager
+      def initialize(paths:, platform:, executable_path:, runner: nil, uid: Process.uid)
+        @paths = paths
+        @platform = platform.to_sym
+        @executable_path = executable_path
+        @uid = uid
+        @runner = runner || lambda do |*command|
+          out, err, status = Open3.capture3(*command)
+          [ out, err, status.success? ]
+        end
+      end
+
+      def register!
+        write_definition
+        :registered_stopped
+      end
+
+      def start!
+        register! unless definition_exists?
+        case @platform
+        when :macos
+          execute!("launchctl", "bootstrap", "gui/#{@uid}", definition_path)
+          execute!("launchctl", "kickstart", "-k", "gui/#{@uid}/#{Launchd::LABEL}")
+        when :linux
+          execute!("systemctl", "--user", "daemon-reload")
+          execute!("systemctl", "--user", "enable", "--now", SystemdUser::UNIT_NAME)
+        else
+          raise Hive::Error, "per-user daemon services are unsupported on #{@platform}"
+        end
+        :running
+      end
+
+      def stop!
+        return :unregistered unless definition_exists?
+
+        case @platform
+        when :macos
+          _out, _err, running = execute("launchctl", "print", "gui/#{@uid}/#{Launchd::LABEL}")
+          return :stopped unless running
+
+          execute!("launchctl", "bootout", "gui/#{@uid}/#{Launchd::LABEL}")
+        when :linux
+          execute!("systemctl", "--user", "disable", "--now", SystemdUser::UNIT_NAME)
+        end
+        :stopped
+      end
+
+      def status
+        return :unregistered unless definition_exists?
+
+        command = @platform == :macos ? [ "launchctl", "print", "gui/#{@uid}/#{Launchd::LABEL}" ] : [ "systemctl", "--user", "is-active", "--quiet", SystemdUser::UNIT_NAME ]
+        _out, _err, success = execute(*command)
+        success ? :running : :registered_stopped
+      end
+
+      def unregister!
+        stop!
+        File.delete(definition_path) if File.exist?(definition_path)
+        :unregistered
+      end
+
+      private
+
+      def definition_path
+        @platform == :macos ? Launchd.path(@paths) : SystemdUser.path(@paths)
+      end
+
+      def definition_exists?
+        File.file?(definition_path)
+      end
+
+      def write_definition
+        case @platform
+        when :macos then Launchd.write!(paths: @paths, executable_path: @executable_path)
+        when :linux then SystemdUser.write!(paths: @paths, executable_path: @executable_path)
+        else raise Hive::Error, "per-user daemon services are unsupported on #{@platform}"
+        end
+      end
+
+      def execute(*command)
+        result = @runner.call(*command)
+        return [ result[0], "", !!result[1] ] if result.size == 2
+
+        status = result[2]
+        success = status.respond_to?(:success?) ? status.success? : !!status
+        [ result[0], result[1].to_s, success ]
+      end
+
+      def execute!(*command)
+        out, err, success = execute(*command)
+        return out if success
+
+        detail = err.strip
+        detail = "no diagnostic output" if detail.empty?
+        raise Hive::Error, "#{command.join(' ')} failed: #{detail}"
+      end
+    end
+  end
+end
diff --git a/lib/hive/service/systemd_user.rb b/lib/hive/service/systemd_user.rb
new file mode 100644
index 00000000..d2f0b8b2
--- /dev/null
+++ b/lib/hive/service/systemd_user.rb
@@ -0,0 +1,57 @@
+require "fileutils"
+
+module Hive
+  module Service
+    module SystemdUser
+      UNIT_NAME = "hive.service".freeze
+
+      module_function
+
+      def path(paths)
+        File.join(paths.systemd_user_dir, UNIT_NAME)
+      end
+
+      def write!(paths:, executable_path:)
+        FileUtils.mkdir_p(paths.systemd_user_dir, mode: 0o700)
+        File.write(path(paths), <<~UNIT)
+          [Unit]
+          Description=Hive daemon
+
+          [Service]
+          Type=simple
+          Environment=#{quote_environment("HIVE_SERVICE_CHILD", "1")}
+          Environment=#{quote_environment("XDG_CONFIG_HOME", File.dirname(paths.config))}
+          Environment=#{quote_environment("XDG_DATA_HOME", File.dirname(paths.data))}
+          Environment=#{quote_environment("XDG_STATE_HOME", File.dirname(paths.state))}
+          Environment=#{quote_environment("XDG_CACHE_HOME", File.dirname(paths.cache))}
+          Environment=#{quote_environment("HIVE_STATE_DIR", paths.state)}
+          ExecStart=#{quote_argument(executable_path)} daemon start
+          Restart=on-failure
+          RestartSec=5
+
+          [Install]
+          WantedBy=default.target
+        UNIT
+        path(paths)
+      end
+
+      def quote_environment(key, value)
+        %Q("#{escape(value, dollar: false).prepend("#{key}=")}")
+      end
+
+      def quote_argument(value)
+        %Q("#{escape(value, dollar: true)}")
+      end
+
+      def escape(value, dollar:)
+        escaped = value.to_s
+                       .gsub("\\") { "\\\\" }
+                       .gsub('"') { '\\"' }
+                       .gsub("%", "%%")
+                       .gsub("\n", "\\n")
+                       .gsub("\r", "\\r")
+        dollar ? escaped.gsub("$", "$$") : escaped
+      end
+    end
+  end
+end
diff --git a/lib/hive/user_config.rb b/lib/hive/user_config.rb
new file mode 100644
index 00000000..4bf5009e
--- /dev/null
+++ b/lib/hive/user_config.rb
@@ -0,0 +1,60 @@
+require "fileutils"
+require "securerandom"
+require "yaml"
+
+module Hive
+  # Distribution-level provider preferences. This is intentionally separate
+  # from per-project pipeline configuration in .hive-state/config.yml.
+  module UserConfig
+    module_function
+
+    ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/
+
+    def ensure!(paths:, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY")
+      return load(paths) if File.file?(paths.config_file)
+
+      values = {
+        "schema_version" => 1,
+        "provider" => nonempty!(provider, "provider"),
+        "model" => nonempty!(model, "model"),
+        "credential_env" => credential_env_name!(credential_env)
+      }
+      write(paths.config_file, values)
+      values
+    end
+
+    def load(paths)
+      values = YAML.safe_load(File.read(paths.config_file), permitted_classes: [], aliases: false)
+      raise Hive::ConfigError, "user config at #{paths.config_file} must be a mapping" unless values.is_a?(Hash)
+
+      %w[provider model credential_env].each { |field| nonempty!(values[field], field) }
+      credential_env_name!(values.fetch("credential_env"))
+      values
+    rescue Psych::Exception => e
+      raise Hive::ConfigError, "user config at #{paths.config_file} is malformed: #{e.message}"
+    end
+
+    def nonempty!(value, field)
+      raise Hive::ConfigError, "user config #{field} must be a non-empty string" unless value.is_a?(String) && !value.empty?
+
+      value
+    end
+
+    def credential_env_name!(value)
+      nonempty!(value, "credential_env")
+      raise Hive::ConfigError, "credential_env must be an environment-variable name, not a secret value" unless ENV_NAME.match?(value)
+
+      value
+    end
+
+    def write(path, values)
+      FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
+      temporary = "#{path}.tmp-#{Process.pid}-#{SecureRandom.hex(6)}"
+      File.open(temporary, "w", 0o600) { |file| file.write(YAML.dump(values)) }
+      File.rename(temporary, path)
+      File.chmod(0o600, path)
+    ensure
+      File.delete(temporary) if defined?(temporary) && temporary && File.exist?(temporary)
+    end
+  end
+end
diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO
new file mode 100644
index 00000000..d6afbfa2
--- /dev/null
+++ b/packaging/aur/.SRCINFO
@@ -0,0 +1,14 @@
+pkgbase = hive-bin
+	pkgdesc = Folder-as-agent software delivery pipeline (prebuilt binary)
+	pkgver = 0.1.0
+	pkgrel = 1
+	url = https://github.com/ivankuznetsov/hive
+	arch = x86_64
+	license = MIT
+	depends = glibc
+	source = install-channel.yml
+	sha256sums = cb34e670f715c31be6c221475ae8a0e023c7f5d8fa97256382335a9b0371c76b
+	source_x86_64 = hive_0.1.0_linux_x86_64::https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive_0.1.0_linux_x86_64
+	sha256sums_x86_64 = 0000000000000000000000000000000000000000000000000000000000000000
+
+pkgname = hive-bin
diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD
new file mode 100644
index 00000000..40f1621b
--- /dev/null
+++ b/packaging/aur/PKGBUILD
@@ -0,0 +1,19 @@
+pkgname=hive-bin
+pkgver=0.1.0
+pkgrel=1
+pkgdesc='Folder-as-agent software delivery pipeline (prebuilt binary)'
+arch=('x86_64')
+url='https://github.com/ivankuznetsov/hive'
+license=('MIT')
+depends=('glibc')
+source=('install-channel.yml')
+source_x86_64=("hive_${pkgver}_linux_x86_64::https://github.com/ivankuznetsov/hive/releases/download/v${pkgver}/hive_${pkgver}_linux_x86_64")
+# Replaced from the canonical release manifest before AUR publication.
+sha256sums=('cb34e670f715c31be6c221475ae8a0e023c7f5d8fa97256382335a9b0371c76b')
+sha256sums_x86_64=('0000000000000000000000000000000000000000000000000000000000000000')
+
+package() {
+  install -Dm755 "$srcdir/hive_${pkgver}_linux_x86_64" "$pkgdir/usr/bin/hive"
+  ln -s hive "$pkgdir/usr/bin/hv"
+  install -Dm644 "$srcdir/install-channel.yml" "$pkgdir/usr/share/hive/install-channel.yml"
+}
diff --git a/packaging/aur/README.md b/packaging/aur/README.md
new file mode 100644
index 00000000..775cf798
--- /dev/null
+++ b/packaging/aur/README.md
@@ -0,0 +1,6 @@
+# AUR source
+
+This directory is the deterministic source for the `hive-bin` AUR repository.
+It installs only `/usr/bin/hive` and `/usr/bin/hv`; package hooks never write a
+user home directory or start `systemd --user`. Release automation replaces the
+version, URL, checksum, and generated `.SRCINFO` from the canonical manifest.
diff --git a/packaging/aur/hive-bin.install b/packaging/aur/hive-bin.install
new file mode 100644
index 00000000..c6c9396a
--- /dev/null
+++ b/packaging/aur/hive-bin.install
@@ -0,0 +1,7 @@
+post_install() {
+  echo 'Run hive init in a project to register the optional per-user systemd service.'
+}
+
+post_upgrade() {
+  echo 'Hive preserves XDG configuration and project work; run hive init to refresh service registration if needed.'
+}
diff --git a/packaging/aur/install-channel.yml b/packaging/aur/install-channel.yml
new file mode 100644
index 00000000..c28e3779
--- /dev/null
+++ b/packaging/aur/install-channel.yml
@@ -0,0 +1,2 @@
+channel: aur
+package_id: hive-bin
diff --git a/packaging/hive-skills/.agents/plugins/marketplace.json b/packaging/hive-skills/.agents/plugins/marketplace.json
new file mode 100644
index 00000000..30b93fcf
--- /dev/null
+++ b/packaging/hive-skills/.agents/plugins/marketplace.json
@@ -0,0 +1,12 @@
+{
+  "name": "hive-skills",
+  "interface": { "displayName": "Hive skills" },
+  "plugins": [
+    {
+      "name": "hive",
+      "source": { "source": "local", "path": "./plugins/hive" },
+      "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
+      "category": "Productivity"
+    }
+  ]
+}
diff --git a/packaging/hive-skills/.claude-plugin/marketplace.json b/packaging/hive-skills/.claude-plugin/marketplace.json
new file mode 100644
index 00000000..64af1528
--- /dev/null
+++ b/packaging/hive-skills/.claude-plugin/marketplace.json
@@ -0,0 +1,7 @@
+{
+  "name": "hive-skills",
+  "owner": { "name": "Hive maintainers" },
+  "plugins": [
+    { "name": "hive", "source": "./plugins/hive", "description": "Operate an installed Hive workflow safely." }
+  ]
+}
diff --git a/packaging/hive-skills/.codex-plugin/plugin.json b/packaging/hive-skills/.codex-plugin/plugin.json
new file mode 100644
index 00000000..1cb08463
--- /dev/null
+++ b/packaging/hive-skills/.codex-plugin/plugin.json
@@ -0,0 +1,25 @@
+{
+  "name": "hive",
+  "version": "0.1.0",
+  "description": "Operate an installed Hive distribution safely.",
+  "author": {
+    "name": "Hive maintainers",
+    "url": "https://github.com/ivankuznetsov/hive"
+  },
+  "homepage": "https://github.com/ivankuznetsov/hive",
+  "repository": "https://github.com/ivankuznetsov/hive-skills",
+  "license": "MIT",
+  "keywords": ["hive", "workflow", "agent"],
+  "skills": "./skills/",
+  "interface": {
+    "displayName": "Hive",
+    "shortDescription": "Operate an installed Hive workflow.",
+    "longDescription": "A host-native skill for safely operating an installed Hive distribution.",
+    "developerName": "Hive maintainers",
+    "category": "Productivity",
+    "capabilities": ["Interactive"],
+    "websiteURL": "https://github.com/ivankuznetsov/hive",
+    "defaultPrompt": ["Check the installed Hive version and show the next safe action."],
+    "brandColor": "#D97706"
+  }
+}
diff --git a/packaging/hive-skills/README.md b/packaging/hive-skills/README.md
new file mode 100644
index 00000000..5da06a75
--- /dev/null
+++ b/packaging/hive-skills/README.md
@@ -0,0 +1,16 @@
+# Hive skills
+
+This separately versioned package contains agent-host adapters only; it does
+not bundle Hive. Install the matching `v0.1.0` tag through the host surface:
+
+- Claude Code: `claude plugin marketplace add ivankuznetsov/hive-skills`, then
+  `claude plugin install hive@hive-skills`; remove with
+  `claude plugin uninstall hive@hive-skills`.
+- Codex: `codex plugin marketplace add ivankuznetsov/hive-skills --ref v0.1.0`,
+  then `codex plugin add hive@hive-skills`; remove with
+  `codex plugin remove hive@hive-skills`.
+- Pi: `pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0`; remove with
+  `pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`.
+
+Remove it through the same host package manager. The core Hive installer never
+writes agent configuration or skill directories.
diff --git a/packaging/hive-skills/package.json b/packaging/hive-skills/package.json
new file mode 100644
index 00000000..b454286f
--- /dev/null
+++ b/packaging/hive-skills/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "@hive/skills",
+  "version": "0.1.0",
+  "description": "Native agent skills for an installed Hive distribution",
+  "license": "MIT",
+  "repository": "github:ivankuznetsov/hive-skills",
+  "pi": { "skills": ["skills/hive"] }
+}
diff --git a/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json
new file mode 100644
index 00000000..805b92ca
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/.claude-plugin/plugin.json
@@ -0,0 +1,5 @@
+{
+  "name": "hive",
+  "version": "0.1.0",
+  "description": "Operate an installed Hive workflow safely."
+}
diff --git a/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json
new file mode 100644
index 00000000..219d8eb9
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/.codex-plugin/plugin.json
@@ -0,0 +1,6 @@
+{
+  "name": "hive",
+  "version": "0.1.0",
+  "description": "Operate an installed Hive distribution safely.",
+  "skills": "./skills/"
+}
diff --git a/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md
new file mode 100644
index 00000000..2aebb67f
--- /dev/null
+++ b/packaging/hive-skills/plugins/hive/skills/hive/SKILL.md
@@ -0,0 +1,14 @@
+---
+name: hive
+description: Operate an installed Hive distribution safely.
+---
+
+Detect the installed command once with `command -v hive` / `command -v hv`, save
+the result as `HIVE_COMMAND`, and run `$HIVE_COMMAND --version` first. Reuse that
+selected command for init and every later lifecycle action. Ask before a command
+that changes project state.
+
+The core installer never manages this plugin. Claude Code removes it with
+`claude plugin uninstall hive@hive-skills`; Codex uses
+`codex plugin remove hive@hive-skills`; Pi uses
+`pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0`.
diff --git a/packaging/hive-skills/skills/hive/SKILL.md b/packaging/hive-skills/skills/hive/SKILL.md
new file mode 100644
index 00000000..cd87d01f
--- /dev/null
+++ b/packaging/hive-skills/skills/hive/SKILL.md
@@ -0,0 +1,17 @@
+---
+name: hive
+description: Operate an installed Hive distribution safely.
+---
+
+# Hive
+
+Detect `hive` or `hv` once, save the executable as `HIVE_COMMAND`, and reuse
+`$HIVE_COMMAND` to inspect and operate the local Hive workflow. Before changing
+project state, run `$HIVE_COMMAND --version` and show the proposed command. Run
+`$HIVE_COMMAND init` only after the user agrees.
+
+Do not copy this skill into an agent-owned directory. Install it with the
+native Claude Code, Codex, or Pi package/marketplace mechanism instead.
+Remove it with `claude plugin uninstall hive@hive-skills`,
+`codex plugin remove hive@hive-skills`, or
+`pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0` for the active host.
diff --git a/packaging/homebrew/.github/workflows/audit.yml b/packaging/homebrew/.github/workflows/audit.yml
new file mode 100644
index 00000000..1c0d1949
--- /dev/null
+++ b/packaging/homebrew/.github/workflows/audit.yml
@@ -0,0 +1,10 @@
+name: Formula audit
+on: [pull_request, push]
+jobs:
+  audit:
+    runs-on: macos-14
+    steps:
+      - uses: actions/checkout@v6
+      - run: brew audit --strict Formula/hive.rb
+      - run: brew install --build-from-source Formula/hive.rb
+      - run: hive --version
diff --git a/packaging/homebrew/Formula/hive.rb b/packaging/homebrew/Formula/hive.rb
new file mode 100644
index 00000000..3fee0e9f
--- /dev/null
+++ b/packaging/homebrew/Formula/hive.rb
@@ -0,0 +1,27 @@
+class Hive < Formula
+  desc "Folder-as-agent software delivery pipeline"
+  homepage "https://github.com/ivankuznetsov/hive"
+  version "0.1.0"
+  license "MIT"
+
+  on_arm do
+    url "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0/hive_0.1.0_darwin_arm64"
+    # Updated atomically by script/update-homebrew-formula after a release
+    # manifest has been generated. This bootstrap value is intentionally not
+    # publishable until it is replaced with the release digest.
+    sha256 "0000000000000000000000000000000000000000000000000000000000000000"
+  end
+
+  def install
+    bin.install "hive_0.1.0_darwin_arm64" => "hive"
+    bin.install_symlink "hive" => "hv"
+    (share/"hive/install-channel.yml").write <<~YAML
+      channel: homebrew
+      package_id: ivankuznetsov/hive/hive
+    YAML
+  end
+
+  test do
+    assert_match version.to_s, shell_output("#{bin}/hive --version")
+  end
+end
diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md
new file mode 100644
index 00000000..6163c5b1
--- /dev/null
+++ b/packaging/homebrew/README.md
@@ -0,0 +1,6 @@
+# Homebrew tap source
+
+This directory is the source copied to `ivankuznetsov/homebrew-hive` by release
+automation. The formula consumes only the canonical GitHub Release binary and
+its SHA-256 from `release-manifest.json`; it does not install Ruby or start a
+daemon in `post_install`.
diff --git a/packaging/release-manifest.schema.json b/packaging/release-manifest.schema.json
new file mode 100644
index 00000000..a310b767
--- /dev/null
+++ b/packaging/release-manifest.schema.json
@@ -0,0 +1,29 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Hive release manifest",
+  "type": "object",
+  "required": ["schema_version", "version", "release_base_url", "targets", "installer"],
+  "properties": {
+    "schema_version": { "const": 1 },
+    "version": { "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" },
+    "release_base_url": { "type": "string", "format": "uri" },
+    "targets": {
+      "type": "array",
+      "minItems": 2,
+      "items": { "$ref": "#/$defs/artifact" }
+    },
+    "installer": { "$ref": "#/$defs/artifact" }
+  },
+  "$defs": {
+    "artifact": {
+      "type": "object",
+      "required": ["name", "url", "size", "sha256"],
+      "properties": {
+        "name": { "type": "string" },
+        "url": { "type": "string", "format": "uri" },
+        "size": { "type": "integer", "minimum": 1 },
+        "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
+      }
+    }
+  }
+}
diff --git a/script/package b/script/package
new file mode 100755
index 00000000..9897643f
--- /dev/null
+++ b/script/package
@@ -0,0 +1,83 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+require "rbconfig"
+require "fileutils"
+
+ROOT = File.expand_path("..", __dir__)
+VERSION_FILE = File.join(ROOT, "lib/hive.rb")
+TEBAKO_RUBY_VERSION = "3.4.2"
+TARGETS = [
+  { "os" => "darwin", "arch" => "arm64" },
+  { "os" => "linux", "arch" => "x86_64" }
+].freeze
+PAYLOAD_ENTRIES = %w[Gemfile Gemfile.lock exe bin lib assets templates schemas].freeze
+
+def hive_version
+  File.read(VERSION_FILE).match(/VERSION = "([^"]+)"/)[1]
+end
+
+def contract
+  version = hive_version
+  {
+    "version" => version,
+    "ruby" => TEBAKO_RUBY_VERSION,
+    "packager" => "tebako",
+    "payload_entries" => PAYLOAD_ENTRIES,
+    "targets" => TARGETS.map { |target| target.merge("name" => "hive_#{version}_#{target.fetch('os')}_#{target.fetch('arch')}") }
+  }
+end
+
+def host_target
+  os = case RbConfig::CONFIG.fetch("host_os")
+       when /darwin/ then "darwin"
+       when /linux/ then "linux"
+       else raise "unsupported packaging host: #{RbConfig::CONFIG.fetch('host_os')}"
+       end
+  arch = RbConfig::CONFIG.fetch("host_cpu")
+  arch = "arm64" if arch == "aarch64"
+  { "os" => os, "arch" => arch }
+end
+
+if ARGV == ["--print-contract"]
+  puts JSON.generate(contract)
+  exit 0
+end
+
+requested = ARGV.empty? ? host_target : { "os" => ARGV.fetch(0), "arch" => ARGV.fetch(1) }
+unless TARGETS.include?(requested)
+  warn "usage: script/package [darwin arm64|linux x86_64]"
+  exit 64
+end
+
+unless requested == host_target
+  warn "cross-packaging is unsupported; build #{requested['os']}/#{requested['arch']} on a matching native runner"
+  exit 64
+end
+
+artifact = File.join(ROOT, "dist", "hive_#{hive_version}_#{requested.fetch('os')}_#{requested.fetch('arch')}")
+FileUtils.mkdir_p(File.dirname(artifact))
+payload = File.join(ROOT, "build", "tebako-payload")
+FileUtils.rm_rf(payload)
+FileUtils.mkdir_p(payload)
+PAYLOAD_ENTRIES.each do |entry|
+  source = File.join(ROOT, entry)
+  raise "missing Tebako payload entry #{source}" unless File.exist?(source)
+
+  FileUtils.cp_r(source, File.join(payload, entry), preserve: true)
+end
+
+command = [
+  "bundle", "exec", "tebako", "press",
+  "--root=#{payload}",
+  "--entry-point=#{File.join(payload, 'exe/hive')}",
+  "--output=#{artifact}",
+  "--Ruby=#{TEBAKO_RUBY_VERSION}",
+  "--mode=bundle"
+]
+command << "--patchelf" if requested.fetch("os") == "linux"
+
+cache_home = ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache"))
+tebako_prefix = ENV.fetch("HIVE_TEBAKO_PREFIX", File.join(cache_home, "hive", "tebako"))
+exec({ "TEBAKO_PREFIX" => tebako_prefix }, *command)
diff --git a/script/release-manifest b/script/release-manifest
new file mode 100755
index 00000000..340e03c9
--- /dev/null
+++ b/script/release-manifest
@@ -0,0 +1,21 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "optparse"
+
+ROOT = File.expand_path("..", __dir__)
+$LOAD_PATH.unshift(File.join(ROOT, "lib"))
+require "hive"
+require "hive/release_manifest"
+
+options = { dist: File.join(ROOT, "dist") }
+OptionParser.new do |parser|
+  parser.on("--dist PATH") { |value| options[:dist] = value }
+  parser.on("--version TAG") { |value| options[:version] = value }
+  parser.on("--base-url URL") { |value| options[:base_url] = value }
+end.parse!
+
+abort "--version is required" unless options[:version]
+Hive::ReleaseManifest.generate!(
+  dist: File.expand_path(options[:dist]), version: options[:version], release_base_url: options[:base_url]
+)
diff --git a/script/update-aur-pkgbuild b/script/update-aur-pkgbuild
new file mode 100755
index 00000000..346d8308
--- /dev/null
+++ b/script/update-aur-pkgbuild
@@ -0,0 +1,36 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+require "open3"
+
+manifest_path = ARGV.fetch(0) { abort "usage: script/update-aur-pkgbuild RELEASE_MANIFEST.json" }
+manifest = JSON.parse(File.read(manifest_path))
+target = manifest.fetch("targets").find { |entry| entry["os"] == "linux" && entry["arch"] == "x86_64" }
+abort "manifest has no linux/x86_64 target" unless target
+version = manifest.fetch("version").delete_prefix("v")
+pkgbuild = File.expand_path(ARGV.fetch(1, "../packaging/aur/PKGBUILD"), __dir__)
+srcinfo = File.expand_path(ARGV.fetch(2, File.join(File.dirname(pkgbuild), ".SRCINFO")), __dir__)
+text = File.read(pkgbuild)
+text = text.sub(/^pkgver=.*/, "pkgver=#{version}")
+text = text.sub(%r{https://github\.com/ivankuznetsov/hive/releases/download/v[^/]+/hive_[^"]+}, target.fetch("url"))
+text = text.sub(/sha256sums_x86_64=\('\w+'\)/, "sha256sums_x86_64=('#{target.fetch('sha256')}')")
+text = text.gsub(/hive_(?:\$\{pkgver\}|\d+\.\d+\.\d+)_linux_x86_64/, target.fetch("name"))
+File.write(pkgbuild, text)
+
+package_dir = File.dirname(pkgbuild)
+command = if ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, "makepkg")) }
+            ["makepkg", "--printsrcinfo"]
+          else
+            [
+              "docker", "run", "--rm", "-v", "#{package_dir}:/input:ro", "archlinux:base-devel",
+              "bash", "-lc",
+              "cp -a /input /tmp/pkg && useradd -m builder && chown -R builder:builder /tmp/pkg && " \
+              "runuser -u builder -- bash -lc 'cd /tmp/pkg && makepkg --printsrcinfo'"
+            ]
+          end
+output, error, status = Open3.capture3(*command, chdir: package_dir)
+unless status.success?
+  abort "makepkg --printsrcinfo failed after updating PKGBUILD: #{error}"
+end
+File.write(srcinfo, output)
diff --git a/script/update-homebrew-formula b/script/update-homebrew-formula
new file mode 100755
index 00000000..6568262d
--- /dev/null
+++ b/script/update-homebrew-formula
@@ -0,0 +1,17 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+
+manifest_path = ARGV.fetch(0) { abort "usage: script/update-homebrew-formula RELEASE_MANIFEST.json" }
+manifest = JSON.parse(File.read(manifest_path))
+target = manifest.fetch("targets").find { |entry| entry["os"] == "darwin" && entry["arch"] == "arm64" }
+abort "manifest has no darwin/arm64 target" unless target
+
+formula = File.expand_path(ARGV.fetch(1, "../packaging/homebrew/Formula/hive.rb"), __dir__)
+text = File.read(formula)
+text = text.sub(/version "[^"]+"/, "version \"#{manifest.fetch('version').delete_prefix('v')}\"")
+text = text.sub(%r{url "[^"]+/hive_[^"]+"}, "url \"#{target.fetch('url')}\"")
+text = text.sub(/sha256 "[a-f0-9]{64}"/, "sha256 \"#{target.fetch('sha256')}\"")
+text = text.gsub(/hive_\d+\.\d+\.\d+_darwin_arm64/, target.fetch("name"))
+File.write(formula, text)
diff --git a/script/update-skills-metadata b/script/update-skills-metadata
new file mode 100755
index 00000000..a30a91e4
--- /dev/null
+++ b/script/update-skills-metadata
@@ -0,0 +1,25 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+
+tag = ARGV.fetch(0) { abort "usage: script/update-skills-metadata vMAJOR.MINOR.PATCH [SKILLS_ROOT]" }
+abort "version must be vMAJOR.MINOR.PATCH" unless tag.match?(/\Av\d+\.\d+\.\d+\z/)
+
+version = tag.delete_prefix("v")
+root = File.expand_path(ARGV.fetch(1, "../packaging/hive-skills"), __dir__)
+json_files = [
+  File.join(root, ".codex-plugin", "plugin.json"),
+  File.join(root, "plugins", "hive", ".claude-plugin", "plugin.json"),
+  File.join(root, "package.json")
+]
+json_files.each do |path|
+  document = JSON.parse(File.read(path))
+  document["version"] = version
+  File.write(path, JSON.pretty_generate(document) + "\n")
+end
+
+Dir.glob(File.join(root, "**", "*.md"), File::FNM_DOTMATCH).each do |path|
+  text = File.read(path).gsub(/v\d+\.\d+\.\d+/, tag)
+  File.write(path, text)
+end
diff --git a/test/acceptance/arch_aur.sh b/test/acceptance/arch_aur.sh
new file mode 100755
index 00000000..e8b74109
--- /dev/null
+++ b/test/acceptance/arch_aur.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+candidate=${1:?usage: arch_aur.sh CANDIDATE_BINARY vVERSION}
+tag=${2:?usage: arch_aur.sh CANDIDATE_BINARY vVERSION}
+version=${tag#v}
+tmp=$(mktemp -d)
+trap 'pacman -R --noconfirm hive-bin >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
+pkg="$tmp/pkg"
+mkdir -p "$pkg" "$tmp/home" "$tmp/fakebin" "$tmp/project"
+cp "$candidate" "$pkg/hive_${version}_linux_x86_64"
+cp packaging/aur/install-channel.yml "$pkg/install-channel.yml"
+digest=$(sha256sum "$candidate" | awk '{print $1}')
+marker_digest=$(sha256sum packaging/aur/install-channel.yml | awk '{print $1}')
+cat > "$pkg/PKGBUILD" <<PKG
+pkgname=hive-bin
+pkgver=$version
+pkgrel=1
+pkgdesc='Hive release acceptance fixture'
+arch=('x86_64')
+license=('MIT')
+depends=('glibc')
+source=('hive_${version}_linux_x86_64' 'install-channel.yml')
+sha256sums=('$digest' '$marker_digest')
+package() {
+  install -Dm755 "\$srcdir/hive_${version}_linux_x86_64" "\$pkgdir/usr/bin/hive"
+  ln -s hive "\$pkgdir/usr/bin/hv"
+  install -Dm644 "\$srcdir/install-channel.yml" "\$pkgdir/usr/share/hive/install-channel.yml"
+}
+PKG
+useradd -m hive-builder
+chown -R hive-builder:hive-builder "$pkg"
+runuser -u hive-builder -- bash -lc "cd '$pkg' && makepkg --noconfirm"
+pacman -U --noconfirm "$pkg"/hive-bin-*.pkg.tar.zst
+pacman -Qi hive-bin | grep -F 'Depends On      : glibc'
+
+export HOME="$tmp/home"
+export HIVE_HOME="$tmp/hive-home"
+export XDG_CONFIG_HOME="$tmp/config"
+export XDG_DATA_HOME="$tmp/data"
+export XDG_STATE_HOME="$tmp/state"
+export XDG_CACHE_HOME="$tmp/cache"
+test "$(/usr/bin/hive --version)" = "$version"
+grep -F 'channel: aur' "$XDG_STATE_HOME/hive/install.yml"
+
+cat > "$tmp/fakebin/systemctl" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG"
+exit 0
+SH
+cat > "$tmp/fakebin/yay" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+exit 0
+SH
+chmod +x "$tmp/fakebin/systemctl" "$tmp/fakebin/yay"
+export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/systemctl.log"
+export HIVE_ACCEPTANCE_PACKAGE_LOG="$tmp/yay.log"
+export PATH="$tmp/fakebin:$PATH"
+
+git -C "$tmp/project" init -b main --quiet
+git -C "$tmp/project" config user.email acceptance@example.com
+git -C "$tmp/project" config user.name Acceptance
+printf 'seed\n' > "$tmp/project/README.md"
+git -C "$tmp/project" add README.md
+git -C "$tmp/project" commit -m seed --quiet
+/usr/bin/hive init "$tmp/project"
+unit="$XDG_CONFIG_HOME/systemd/user/hive.service"
+test -f "$unit"
+test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG"
+/usr/bin/hive daemon start
+grep -F 'enable --now hive.service' "$HIVE_ACCEPTANCE_SERVICE_LOG"
+before_update=$(sha256sum /usr/bin/hive | awk '{print $1}')
+/usr/bin/hive update
+test "$(sha256sum /usr/bin/hive | awk '{print $1}')" = "$before_update"
+grep -F -- '-Syu hive-bin' "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+printf 'operator edit\n' > "$tmp/project/.hive/README.md"
+printf 'completed output\n' > "$tmp/project/.hive/operator-output.txt"
+/usr/bin/hive uninstall "$tmp/project" --purge
+grep -F -- '-Rns hive-bin' "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+test -f "$tmp/project/.hive/README.md"
+test -f "$tmp/project/.hive/operator-output.txt"
+test ! -e "$unit"
+printf 'arch aur acceptance artifact sha256=%s\n' "$digest"
diff --git a/test/acceptance/macos_homebrew.sh b/test/acceptance/macos_homebrew.sh
new file mode 100755
index 00000000..0be18e57
--- /dev/null
+++ b/test/acceptance/macos_homebrew.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+candidate=${1:?usage: macos_homebrew.sh CANDIDATE_BINARY vVERSION}
+tag=${2:?usage: macos_homebrew.sh CANDIDATE_BINARY vVERSION}
+version=${tag#v}
+command -v brew >/dev/null 2>&1
+tmp=$(mktemp -d)
+real_brew=$(command -v brew)
+trap '"$real_brew" uninstall --force hive-acceptance >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
+
+artifact="$tmp/hive_${version}_darwin_arm64"
+cp "$candidate" "$artifact"
+chmod +x "$artifact"
+digest=$(shasum -a 256 "$artifact" | awk '{print $1}')
+formula="$tmp/hive_acceptance.rb"
+cat > "$formula" <<RUBY
+class HiveAcceptance < Formula
+  desc "Hive release acceptance fixture"
+  homepage "https://github.com/ivankuznetsov/hive"
+  url "file://$artifact"
+  version "$version"
+  sha256 "$digest"
+  def install
+    bin.install "hive_${version}_darwin_arm64" => "hive"
+    bin.install_symlink "hive" => "hv"
+    (share/"hive/install-channel.yml").write <<~YAML
+      channel: homebrew
+      package_id: ivankuznetsov/hive/hive
+    YAML
+  end
+end
+RUBY
+"$real_brew" install --formula "$formula"
+! "$real_brew" deps --tree hive-acceptance | grep -qi ruby
+
+export HOME="$tmp/home"
+export HIVE_HOME="$tmp/hive-home"
+export XDG_CONFIG_HOME="$tmp/config"
+export XDG_DATA_HOME="$tmp/data"
+export XDG_STATE_HOME="$tmp/state"
+export XDG_CACHE_HOME="$tmp/cache"
+mkdir -p "$HOME" "$tmp/fakebin" "$tmp/project"
+command_path="$("$real_brew" --prefix)/bin/hive"
+test "$("$command_path" --version)" = "$version"
+grep -F 'channel: homebrew' "$XDG_STATE_HOME/hive/install.yml"
+
+cat > "$tmp/fakebin/launchctl" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG"
+exit 0
+SH
+cat > "$tmp/fakebin/brew" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+exit 0
+SH
+chmod +x "$tmp/fakebin/launchctl" "$tmp/fakebin/brew"
+export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/launchctl.log"
+export HIVE_ACCEPTANCE_PACKAGE_LOG="$tmp/brew.log"
+export PATH="$tmp/fakebin:$PATH"
+
+git -C "$tmp/project" init -b main --quiet
+git -C "$tmp/project" config user.email acceptance@example.com
+git -C "$tmp/project" config user.name Acceptance
+printf 'seed\n' > "$tmp/project/README.md"
+git -C "$tmp/project" add README.md
+git -C "$tmp/project" commit -m seed --quiet
+"$command_path" init "$tmp/project"
+plist="$HOME/Library/LaunchAgents/dev.hive.daemon.plist"
+grep -F '<key>RunAtLoad</key><false/>' "$plist"
+grep -F '<key>KeepAlive</key><false/>' "$plist"
+test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG"
+"$command_path" daemon start
+grep -F 'kickstart -k' "$HIVE_ACCEPTANCE_SERVICE_LOG"
+
+before_update=$(shasum -a 256 "$command_path" | awk '{print $1}')
+"$command_path" update
+test "$(shasum -a 256 "$command_path" | awk '{print $1}')" = "$before_update"
+grep -F 'upgrade ivankuznetsov/hive/hive' "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+printf 'operator edit\n' > "$tmp/project/.hive/README.md"
+printf 'completed output\n' > "$tmp/project/.hive/operator-output.txt"
+"$command_path" uninstall "$tmp/project" --purge
+grep -F 'uninstall ivankuznetsov/hive/hive' "$HIVE_ACCEPTANCE_PACKAGE_LOG"
+test -f "$tmp/project/.hive/README.md"
+test -f "$tmp/project/.hive/operator-output.txt"
+test ! -e "$plist"
+printf 'macos homebrew acceptance artifact sha256=%s\n' "$digest"
diff --git a/test/acceptance/prompt_transcripts.yml b/test/acceptance/prompt_transcripts.yml
new file mode 100644
index 00000000..4b910113
--- /dev/null
+++ b/test/acceptance/prompt_transcripts.yml
@@ -0,0 +1,17 @@
+transcripts:
+  - host: claude
+    os: macos-arm64
+    channel: homebrew
+    verifies: hive --version
+  - host: codex
+    os: arch-x86_64
+    channel: aur
+    verifies: hive --version
+  - host: pi
+    os: ubuntu-x86_64
+    channel: bash
+    verifies: hive --version
+  - host: any
+    os: collision
+    channel: bash-hv
+    verifies: hv --version
diff --git a/test/acceptance/release_gate_test.rb b/test/acceptance/release_gate_test.rb
new file mode 100644
index 00000000..27b5f803
--- /dev/null
+++ b/test/acceptance/release_gate_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class ReleaseGateTest < Minitest::Test
+  def test_release_publication_is_least_privilege_and_acceptance_blocked
+    workflow = File.read(".github/workflows/release.yml")
+
+    assert_match(/permissions:\n  contents: read/, workflow)
+    assert_match(/publish:\n.*?permissions:\n      contents: write\n      id-token: write\n      attestations: write/m, workflow)
+    assert_match(/needs: \[test-and-security, build-linux, build-macos, tier1-acceptance\]/, workflow)
+    assert_includes workflow, "bundle exec rake test"
+    assert_includes workflow, "bundle exec brakeman"
+    assert_includes workflow, "bundle exec bundler-audit"
+  end
+
+  def test_downstream_publication_updates_all_versioned_metadata_from_the_manifest
+    workflow = File.read(".github/workflows/release.yml")
+
+    assert_includes workflow, "script/update-homebrew-formula dist/release-manifest.json"
+    assert_includes workflow, "script/update-aur-pkgbuild dist/release-manifest.json"
+    assert_includes workflow, "script/update-skills-metadata"
+    assert_includes workflow, "git -C \"downstream/$repository\" push"
+    assert_includes File.read("script/update-aur-pkgbuild"), "makepkg"
+    assert_includes File.read("script/update-aur-pkgbuild"), ".SRCINFO"
+  end
+
+  def test_tier_one_jobs_are_non_skippable_and_run_full_lifecycle_contracts
+    workflow = File.read(".github/workflows/tier1-acceptance.yml")
+    refute_match(/^\s+if:/, workflow)
+    assert_includes workflow, "macos-homebrew"
+    assert_includes workflow, "ubuntu-bash"
+    assert_includes workflow, "arch-aur"
+    assert_includes workflow, "host: [claude, codex, pi]"
+
+    %w[ubuntu_bash.sh macos_homebrew.sh arch_aur.sh].each do |script|
+      content = File.read(File.join("test/acceptance", script))
+      %w[init daemon update uninstall sha256].each do |operation|
+        assert_includes content, operation, "#{script} must exercise #{operation}"
+      end
+    end
+  end
+end
diff --git a/test/acceptance/ubuntu_bash.sh b/test/acceptance/ubuntu_bash.sh
new file mode 100755
index 00000000..aac8bf26
--- /dev/null
+++ b/test/acceptance/ubuntu_bash.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+candidate=${1:?usage: ubuntu_bash.sh CANDIDATE_BINARY vVERSION}
+tag=${2:?usage: ubuntu_bash.sh CANDIDATE_BINARY vVERSION}
+version=${tag#v}
+root=$(cd "$(dirname "$0")/../.." && pwd)
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+release="$tmp/release"
+fakebin="$tmp/fakebin"
+home="$tmp/home"
+project="$tmp/project"
+mkdir -p "$release" "$fakebin" "$home" "$project"
+target="hive_${version}_linux_x86_64"
+cp "$candidate" "$release/$target"
+cp "$root/install.sh" "$release/install.sh"
+chmod +x "$release/$target"
+digest=$(sha256sum "$release/$target" | awk '{print $1}')
+printf '{"version":"%s","targets":[{"name":"%s","sha256":"%s"}]}\n' "$tag" "$target" "$digest" > "$release/release-manifest.json"
+
+cat > "$fakebin/curl" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+output=
+url=
+while (($#)); do
+  case "$1" in
+    -o) output=$2; shift 2 ;;
+    -*) shift ;;
+    *) url=$1; shift ;;
+  esac
+done
+source_file="$HIVE_ACCEPTANCE_RELEASE/${url##*/}"
+test -f "$source_file"
+if [[ -n $output ]]; then cp "$source_file" "$output"; else cat "$source_file"; fi
+SH
+cat > "$fakebin/systemctl" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$HIVE_ACCEPTANCE_SERVICE_LOG"
+exit 0
+SH
+chmod +x "$fakebin/curl" "$fakebin/systemctl"
+
+export HOME="$home"
+export HIVE_HOME="$tmp/hive-home"
+export XDG_CONFIG_HOME="$tmp/config"
+export XDG_DATA_HOME="$tmp/data"
+export XDG_STATE_HOME="$tmp/state"
+export XDG_CACHE_HOME="$tmp/cache"
+export HIVE_BIN_DIR="$tmp/bin"
+export HIVE_VERSION="$tag"
+export HIVE_RELEASE_BASE_URL="file://$release"
+export HIVE_ACCEPTANCE_RELEASE="$release"
+export HIVE_ACCEPTANCE_SERVICE_LOG="$tmp/systemctl.log"
+export PATH="$fakebin:$PATH"
+
+bash "$root/install.sh"
+command_path="$HIVE_BIN_DIR/hive"
+test "$("$command_path" --version)" = "$version"
+test "$(sha256sum "$command_path" | awk '{print $1}')" = "$digest"
+
+git -C "$project" init -b main --quiet
+git -C "$project" config user.email acceptance@example.com
+git -C "$project" config user.name Acceptance
+printf 'seed\n' > "$project/README.md"
+git -C "$project" add README.md
+git -C "$project" commit -m seed --quiet
+"$command_path" init "$project"
+unit="$XDG_CONFIG_HOME/systemd/user/hive.service"
+test -f "$unit"
+grep -F 'XDG_CONFIG_HOME' "$unit"
+test ! -s "$HIVE_ACCEPTANCE_SERVICE_LOG"
+
+"$command_path" daemon start
+grep -F 'enable --now hive.service' "$HIVE_ACCEPTANCE_SERVICE_LOG"
+before_update=$(sha256sum "$command_path" | awk '{print $1}')
+"$command_path" update
+test "$(sha256sum "$command_path" | awk '{print $1}')" = "$before_update"
+
+printf 'operator edit\n' > "$project/.hive/README.md"
+printf 'completed output\n' > "$project/.hive/operator-output.txt"
+printf 'durable state\n' > "$XDG_STATE_HOME/hive/operator-state"
+printf 'legacy state\n' > "$project/.hive-state/operator-state"
+"$command_path" uninstall "$project" --purge
+
+test -f "$project/.hive/README.md"
+test -f "$project/.hive/operator-output.txt"
+test -f "$project/.hive-state/operator-state"
+test -f "$XDG_STATE_HOME/hive/operator-state"
+test -f "$XDG_CONFIG_HOME/hive/config.yml"
+test ! -e "$XDG_DATA_HOME/hive/current"
+test ! -e "$XDG_DATA_HOME/hive/releases"
+test ! -e "$unit"
+for _attempt in 1 2 3 4 5; do
+  [[ ! -e $command_path ]] && break
+  sleep 1
+done
+test ! -e "$command_path"
+printf 'ubuntu bash acceptance artifact sha256=%s\n' "$digest"
diff --git a/test/acceptance/uninstall_preserves_work.sh b/test/acceptance/uninstall_preserves_work.sh
new file mode 100755
index 00000000..bf1a65af
--- /dev/null
+++ b/test/acceptance/uninstall_preserves_work.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+bundle exec ruby -I"$root/test" "$root/test/unit/commands/uninstall_test.rb"
diff --git a/test/acceptance_contract_test.rb b/test/acceptance_contract_test.rb
new file mode 100644
index 00000000..638dcaad
--- /dev/null
+++ b/test/acceptance_contract_test.rb
@@ -0,0 +1,13 @@
+require "test_helper"
+require "yaml"
+
+class AcceptanceContractTest < Minitest::Test
+  def test_curated_matrix_covers_every_tier_one_channel_and_collision_safe_command
+    matrix = YAML.safe_load(File.read("test/acceptance/prompt_transcripts.yml")).fetch("transcripts")
+    assert_equal %w[aur bash bash-hv homebrew], matrix.map { |row| row.fetch("channel") }.sort
+    assert File.executable?("test/acceptance/ubuntu_bash.sh")
+    assert File.executable?("test/acceptance/macos_homebrew.sh")
+    assert File.executable?("test/acceptance/arch_aur.sh")
+    assert File.executable?("test/acceptance/uninstall_preserves_work.sh")
+  end
+end
diff --git a/test/install/install_failure.sh b/test/install/install_failure.sh
new file mode 100755
index 00000000..4d620c3d
--- /dev/null
+++ b/test/install/install_failure.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+bin="$tmp/bin"
+mkdir -p "$bin"
+printf '#!/usr/bin/env bash\nprintf "Apache Hive 4.0\\n"\n' > "$bin/hive"
+chmod +x "$bin/hive"
+release="$tmp/release"
+mkdir "$release"
+artifact="$release/hive_0.1.0_linux_x86_64"
+printf '#!/usr/bin/env bash\nprintf "0.1.0\\n"\n' > "$artifact"
+chmod +x "$artifact"
+digest=$(sha256sum "$artifact" | awk '{print $1}')
+printf '{"version":"v0.1.0","targets":[{"name":"hive_0.1.0_linux_x86_64","sha256":"%s"}]}' "$digest" > "$release/release-manifest.json"
+
+HOME="$tmp/home" XDG_STATE_HOME="$tmp/state" HIVE_BIN_DIR="$bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"
+test "$("$bin/hive" --version)" = "Apache Hive 4.0"
+test "$("$bin/hv" --version)" = "0.1.0"
+grep -F 'command_name: hv' "$tmp/state/hive/install.yml"
+
+path_bin="$tmp/path-bin"
+isolated_bin="$tmp/isolated-bin"
+mkdir -p "$path_bin" "$isolated_bin"
+printf '#!/usr/bin/env bash\nprintf "Apache Hive elsewhere\\n"\n' > "$path_bin/hive"
+chmod +x "$path_bin/hive"
+PATH="$path_bin:$PATH" HOME="$tmp/home-path" XDG_STATE_HOME="$tmp/state-path" HIVE_BIN_DIR="$isolated_bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"
+test ! -e "$isolated_bin/hive"
+test "$($isolated_bin/hv --version)" = "0.1.0"
+test "$($path_bin/hive --version)" = "Apache Hive elsewhere"
+
+preserve_bin="$tmp/preserve-bin"
+mkdir -p "$preserve_bin"
+printf '#!/usr/bin/env bash\nprintf "foreign hv\\n"\n' > "$preserve_bin/hv"
+chmod +x "$preserve_bin/hv"
+HOME="$tmp/home-preserve" XDG_STATE_HOME="$tmp/state-preserve" HIVE_BIN_DIR="$preserve_bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"
+test "$($preserve_bin/hive --version)" = "0.1.0"
+test "$($preserve_bin/hv --version)" = "foreign hv"
+
+rollback_bin="$tmp/rollback-bin"
+rollback_state="$tmp/rollback-state"
+mkdir -p "$rollback_bin" "$rollback_state/hive/install.yml"
+if HOME="$tmp/home-rollback" XDG_STATE_HOME="$rollback_state" HIVE_BIN_DIR="$rollback_bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"; then
+  echo "installer unexpectedly succeeded with an unreplaceable receipt" >&2
+  exit 1
+fi
+test ! -e "$rollback_bin/hive"
+test ! -e "$rollback_bin/hv"
+test -d "$rollback_state/hive/install.yml"
+
+locked_bin="$tmp/locked-bin"
+locked_state="$tmp/locked-state"
+mkdir -p "$locked_bin" "$locked_state/hive/install.yml.lock"
+if HOME="$tmp/home-locked" XDG_STATE_HOME="$locked_state" HIVE_BIN_DIR="$locked_bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"; then
+  echo "installer unexpectedly bypassed an active install lock" >&2
+  exit 1
+fi
+test -d "$locked_state/hive/install.yml.lock"
+test ! -e "$locked_bin/hive"
+test ! -e "$locked_bin/hv"
diff --git a/test/install/install_success.sh b/test/install/install_success.sh
new file mode 100755
index 00000000..525e4120
--- /dev/null
+++ b/test/install/install_success.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+release="$tmp/release"
+bin="$tmp/bin"
+mkdir -p "$release" "$bin"
+artifact="$release/hive_0.1.0_linux_x86_64"
+printf '#!/usr/bin/env bash\nprintf "0.1.0\\n"\n' > "$artifact"
+chmod +x "$artifact"
+digest=$(sha256sum "$artifact" | awk '{print $1}')
+cat > "$release/release-manifest.json" <<JSON
+{"version":"v0.1.0","targets":[{"name":"hive_0.1.0_linux_x86_64","sha256":"$digest"}]}
+JSON
+
+HOME="$tmp/home" XDG_STATE_HOME="$tmp/state" HIVE_BIN_DIR="$bin" HIVE_VERSION=v0.1.0 \
+  HIVE_RELEASE_BASE_URL="file://$release" bash "$root/install.sh"
+test -x "$bin/hive"
+test -L "$bin/hv"
+test "$("$bin/hv" --version)" = "0.1.0"
+grep -F 'channel: bash' "$tmp/state/hive/install.yml"
diff --git a/test/integration/daemon_command_test.rb b/test/integration/daemon_command_test.rb
index ea385c4d..afd1fd85 100644
--- a/test/integration/daemon_command_test.rb
+++ b/test/integration/daemon_command_test.rb
@@ -14,11 +14,20 @@ class HiveDaemonCommandTest < Minitest::Test
 
   def with_isolated_hive_home(&block)
     Dir.mktmpdir("hive-daemon-test") do |home|
-      env = ENV.to_h.merge("HIVE_HOME" => home)
+      env = ENV.to_h.merge(
+        "HIVE_HOME" => home,
+        "XDG_STATE_HOME" => File.join(home, "state"),
+        "XDG_CACHE_HOME" => File.join(home, "cache")
+      )
+      FileUtils.mkdir_p(File.dirname(pid_file(home)))
       block.call(home, env)
     end
   end
 
+  def pid_file(home)
+    File.join(home, "state", "hive", "daemon.pid")
+  end
+
   # ── status: not running by default ────────────────────────────────────
 
   def test_status_returns_not_running_with_exit_1_when_no_daemon
@@ -37,7 +46,7 @@ class HiveDaemonCommandTest < Minitest::Test
       doc = JSON.parse(out)
       assert_equal "hive-daemon-status", doc["schema"]
       assert_equal false, doc["running"]
-      assert_equal File.join(home, ".daemon.pid"), doc["pid_file"]
+      assert_equal pid_file(home), doc["pid_file"]
       assert_equal 1, status.exitstatus
     end
   end
@@ -56,11 +65,11 @@ class HiveDaemonCommandTest < Minitest::Test
     with_isolated_hive_home do |home, env|
       # Bare-integer PID format (back-compat path for older daemon
       # versions) pointing at a process that doesn't exist.
-      File.write(File.join(home, ".daemon.pid"), "999999")
+      File.write(pid_file(home), "999999")
       _out, err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop")
       assert_equal 0, status.exitstatus
       assert_match(/stale/, err)
-      refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file must be removed"
+      refute File.exist?(pid_file(home)), "stale PID file must be removed"
     end
   end
 
@@ -71,7 +80,7 @@ class HiveDaemonCommandTest < Minitest::Test
   # file at all".
   def test_stop_with_malformed_pid_file_emits_envelope_and_cleans_up
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), "this is not a YAML PID payload\n")
+      File.write(pid_file(home), "this is not a YAML PID payload\n")
       out, _err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop", "--json")
       assert_equal 0, status.exitstatus
       doc = JSON.parse(out)
@@ -81,7 +90,7 @@ class HiveDaemonCommandTest < Minitest::Test
       assert_equal false, doc["was_running"]
       assert_equal "malformed_pid_file", doc["reason"],
                    "malformed PID file must surface the closed reason enum value"
-      refute File.exist?(File.join(home, ".daemon.pid")),
+      refute File.exist?(pid_file(home)),
              "malformed PID file must be cleaned up"
     end
   end
@@ -91,7 +100,7 @@ class HiveDaemonCommandTest < Minitest::Test
     # process_start_time + started_at). A YAML payload pointing at a
     # dead PID is the same scenario as the bare-int legacy path.
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: 999999
         process_start_time: "1234567890"
@@ -100,7 +109,7 @@ class HiveDaemonCommandTest < Minitest::Test
       _out, err, status = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "stop")
       assert_equal 0, status.exitstatus
       assert_match(/stale|not alive/, err)
-      refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file must be removed"
+      refute File.exist?(pid_file(home)), "stale PID file must be removed"
     end
   end
 
@@ -110,7 +119,7 @@ class HiveDaemonCommandTest < Minitest::Test
     # start_time → another process took over the PID. Don't TERM it.
     # Use the test runner's own PID with a fake/wrong start_time.
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: #{Process.pid}
         process_start_time: "definitely-not-the-real-start-time-#{rand(100_000)}"
@@ -122,7 +131,7 @@ class HiveDaemonCommandTest < Minitest::Test
       # Test process is still alive — we never sent it SIGTERM.
       assert Process.kill(0, Process.pid),
              "the test runner process must NOT have received SIGTERM"
-      refute File.exist?(File.join(home, ".daemon.pid")), "stale PID file removed"
+      refute File.exist?(pid_file(home)), "stale PID file removed"
     end
   end
 
@@ -134,7 +143,7 @@ class HiveDaemonCommandTest < Minitest::Test
     # the PID rather than risk hitting an unrelated process that was
     # handed the same PID after reuse. Test process must NOT be TERMed.
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: #{Process.pid}
         process_start_time:
@@ -146,7 +155,7 @@ class HiveDaemonCommandTest < Minitest::Test
       assert Process.kill(0, Process.pid), "test process must NOT have been signaled"
       # Unverified does NOT remove the PID file — operator must
       # confirm + clean up manually per the warn message.
-      assert File.exist?(File.join(home, ".daemon.pid")),
+      assert File.exist?(pid_file(home)),
              "unverified ownership leaves the PID file in place for operator inspection"
     end
   end
@@ -173,7 +182,7 @@ class HiveDaemonCommandTest < Minitest::Test
 
   def test_reload_json_envelope_when_pid_dead
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: 999999
         process_start_time: "1234567890"
@@ -191,7 +200,7 @@ class HiveDaemonCommandTest < Minitest::Test
 
   def test_reload_json_envelope_when_pid_reused
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: #{Process.pid}
         process_start_time: "definitely-not-the-real-start-time-#{rand(100_000)}"
@@ -208,7 +217,7 @@ class HiveDaemonCommandTest < Minitest::Test
 
   def test_reload_json_envelope_when_pid_unverified
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: #{Process.pid}
         process_start_time:
@@ -235,7 +244,7 @@ class HiveDaemonCommandTest < Minitest::Test
 
   def test_reload_refuses_when_pid_ownership_unverified
     with_isolated_hive_home do |home, env|
-      File.write(File.join(home, ".daemon.pid"), <<~YAML)
+      File.write(pid_file(home), <<~YAML)
         ---
         pid: #{Process.pid}
         process_start_time:
diff --git a/test/integration/init_distribution_test.rb b/test/integration/init_distribution_test.rb
new file mode 100644
index 00000000..384b815c
--- /dev/null
+++ b/test/integration/init_distribution_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+require "hive/commands/init"
+require "hive/paths"
+
+class InitDistributionTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_init_adds_xdg_assets_and_is_idempotent
+    with_tmp_global_config do |home|
+      with_tmp_git_repo do |project|
+        paths = Hive::Paths.new(env: {}, home: home)
+        init = Hive::Commands::Init.new(project, paths: paths, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY")
+        capture_io { init.call }
+
+        assert File.directory?(File.join(project, ".hive"))
+        assert_equal "anthropic", YAML.safe_load(File.read(paths.config_file)).fetch("provider")
+        scaffold = File.join(project, ".hive", "README.md")
+        File.write(scaffold, "custom work\n")
+
+        capture_io { init.call }
+        assert_equal "custom work\n", File.read(scaffold)
+      end
+    end
+  end
+
+  def test_init_emits_actionable_dependency_diagnostics_without_auto_installing
+    with_tmp_global_config do |home|
+      with_tmp_git_repo do |project|
+        diagnostics = %w[git bash claude gh jq].map do |tool|
+          Hive::Dependencies::Diagnostic.new(tool, "install #{tool} yourself", true)
+        end
+        dependencies = Object.new
+        dependencies.define_singleton_method(:diagnostics_for) { |action| action == :init ? diagnostics : [] }
+        dependency_output = StringIO.new
+        paths = Hive::Paths.new(env: {}, home: home)
+
+        capture_io do
+          Hive::Commands::Init.new(
+            project, paths: paths, dependencies: dependencies, dependency_output: dependency_output
+          ).call
+        end
+
+        %w[git bash claude gh jq].each do |tool|
+          assert_includes dependency_output.string, "install #{tool} yourself"
+        end
+      end
+    end
+  end
+
+  def test_registered_service_start_prompt_defaults_no_and_requires_explicit_yes
+    with_tmp_global_config do |home|
+      with_tmp_git_repo do |project|
+        paths = Hive::Paths.new(env: {}, home: home)
+        Hive::Install::Receipt.new(
+          channel: "bash", package_id: "hive",
+          release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}",
+          installed_version: Hive::VERSION, executable_path: File.join(home, "bin", "hive"), command_name: "hive"
+        ).write!(paths)
+        starts = 0
+        registrations = 0
+        manager = Object.new
+        manager.define_singleton_method(:register!) { registrations += 1 }
+        manager.define_singleton_method(:start!) { starts += 1 }
+        input = StringIO.new("\ny\n")
+        input.define_singleton_method(:tty?) { true }
+        output = StringIO.new
+        dependencies = Object.new
+        dependencies.define_singleton_method(:diagnostics_for) { |_action| [] }
+        init = Hive::Commands::Init.new(
+          project, paths: paths, service_manager: manager, dependencies: dependencies,
+          service_input: input, service_output: output
+        )
+
+        capture_io { init.call }
+        assert_equal 0, starts
+        assert_includes output.string, "[y/N]"
+
+        capture_io { init.call }
+        assert_equal 1, starts
+        assert_equal 2, registrations
+      end
+    end
+  end
+end
diff --git a/test/integration/init_test.rb b/test/integration/init_test.rb
index 0b423861..5f4eca5c 100644
--- a/test/integration/init_test.rb
+++ b/test/integration/init_test.rb
@@ -113,14 +113,14 @@ class InitTest < Minitest::Test
     end
   end
 
-  def test_double_init_raises_already_initialized_with_exit_2
+  def test_double_init_is_idempotent_and_keeps_the_existing_project_state
     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"
+        out, _err = capture_io { Hive::Commands::Init.new(dir).call }
+        assert_includes out, "hive: initialized"
+        assert File.directory?(File.join(dir, ".hive-state"))
+        assert File.directory?(File.join(dir, ".hive"))
       end
     end
   end
@@ -287,11 +287,10 @@ class InitTest < Minitest::Test
     end
   end
 
-  def test_init_already_initialized_short_circuits_before_any_prompt
-    # On a re-run of `hive init` the AlreadyInitialized guard must fire
-    # BEFORE the prompt module reads anything from stdin. We feed an
-    # input stream that would crash the prompt validator if consumed
-    # ('crash'), then assert it's still pristine after the second init.
+  def test_idempotent_init_short_circuits_before_any_prompt
+    # A re-run refreshes only manifest-owned assets and must not reopen the
+    # original onboarding prompts. We feed input that would crash the prompt
+    # validator if consumed, then assert it remains pristine.
     with_tmp_global_config do
       with_tmp_git_repo do |dir|
         capture_io { Hive::Commands::Init.new(dir).call }
@@ -301,11 +300,10 @@ 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
+        _out, _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"
+        assert_equal Hive::ExitCodes::SUCCESS, status
         assert_equal "crash-on-this-input", input.gets&.chomp,
                      "no input should have been consumed by the second init"
       end
diff --git a/test/packaging/aur_clean_chroot.sh b/test/packaging/aur_clean_chroot.sh
new file mode 100755
index 00000000..60c4ff7b
--- /dev/null
+++ b/test/packaging/aur_clean_chroot.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+command -v makechrootpkg >/dev/null 2>&1 || { echo 'makechrootpkg is required on an Arch build host' >&2; exit 77; }
+command -v namcap >/dev/null 2>&1 || { echo 'namcap is required on an Arch build host' >&2; exit 77; }
+cd "$root/packaging/aur"
+makechrootpkg -r "${HIVE_ARCH_CHROOT:-$HOME/chroot/root}" -- -f
+namcap ./*.pkg.tar.*
diff --git a/test/packaging/aur_pkgbuild_test.rb b/test/packaging/aur_pkgbuild_test.rb
new file mode 100644
index 00000000..3c6c3174
--- /dev/null
+++ b/test/packaging/aur_pkgbuild_test.rb
@@ -0,0 +1,51 @@
+require "test_helper"
+require "json"
+require "open3"
+
+class AurPkgbuildTest < Minitest::Test
+  def test_prebuilt_package_uses_canonical_linux_binary_and_installs_alias
+    pkgbuild = File.read("packaging/aur/PKGBUILD")
+    assert_includes pkgbuild, "pkgname=hive-bin"
+    assert_includes pkgbuild, "hive_\${pkgver}_linux_x86_64"
+    assert_match(/sha256sums_x86_64=\('[a-f0-9]{64}'\)/, pkgbuild)
+    assert_includes pkgbuild, 'ln -s hive "$pkgdir/usr/bin/hv"'
+    assert_includes pkgbuild, "/usr/share/hive/install-channel.yml"
+    assert File.file?("packaging/aur/install-channel.yml")
+    refute_match(/ruby/, pkgbuild)
+  end
+
+  def test_metadata_updater_regenerates_srcinfo_with_makepkg
+    Dir.mktmpdir("aur-update") do |root|
+      packaging = File.join(root, "packaging")
+      bin = File.join(root, "bin")
+      FileUtils.mkdir_p(packaging)
+      FileUtils.mkdir_p(bin)
+      FileUtils.cp("packaging/aur/PKGBUILD", File.join(packaging, "PKGBUILD"))
+      FileUtils.cp("packaging/aur/install-channel.yml", File.join(packaging, "install-channel.yml"))
+      manifest = File.join(root, "manifest.json")
+      File.write(manifest, JSON.generate(
+        "version" => "v1.2.3",
+        "targets" => [
+          {
+            "os" => "linux", "arch" => "x86_64", "name" => "hive_1.2.3_linux_x86_64",
+            "url" => "https://github.com/ivankuznetsov/hive/releases/download/v1.2.3/hive_1.2.3_linux_x86_64",
+            "sha256" => "a" * 64
+          }
+        ]
+      ))
+      makepkg = File.join(bin, "makepkg")
+      File.write(makepkg, "#!/usr/bin/env bash\nprintf 'pkgbase = hive-bin\\n\\tpkgver = 1.2.3\\n'\n")
+      File.chmod(0o755, makepkg)
+      srcinfo = File.join(packaging, ".SRCINFO")
+
+      _out, err, status = Open3.capture3(
+        { "PATH" => "#{bin}:#{ENV.fetch('PATH')}" },
+        "script/update-aur-pkgbuild", manifest, File.join(packaging, "PKGBUILD"), srcinfo
+      )
+
+      assert status.success?, err
+      assert_includes File.read(srcinfo), "pkgver = 1.2.3"
+      assert_includes File.read(File.join(packaging, "PKGBUILD")), "pkgver=1.2.3"
+    end
+  end
+end
diff --git a/test/packaging/hive_skills_test.rb b/test/packaging/hive_skills_test.rb
new file mode 100644
index 00000000..9e381e75
--- /dev/null
+++ b/test/packaging/hive_skills_test.rb
@@ -0,0 +1,40 @@
+require "test_helper"
+require "json"
+require "open3"
+
+class HiveSkillsTest < Minitest::Test
+  ROOT = "packaging/hive-skills".freeze
+
+  def test_host_manifests_are_parseable_and_point_to_the_canonical_skill
+    codex = JSON.parse(File.read(File.join(ROOT, ".codex-plugin/plugin.json")))
+    marketplace = JSON.parse(File.read(File.join(ROOT, ".agents/plugins/marketplace.json")))
+    pi = JSON.parse(File.read(File.join(ROOT, "package.json")))
+
+    assert_equal "hive", codex.fetch("name")
+    assert_equal "0.1.0", codex.fetch("version")
+    assert_equal "hive", marketplace.fetch("plugins").first.fetch("name")
+    assert_equal "./plugins/hive", marketplace.fetch("plugins").first.dig("source", "path")
+    assert_equal [ "skills/hive" ], pi.dig("pi", "skills")
+    assert File.file?(File.join(ROOT, "skills/hive/SKILL.md"))
+  end
+
+  def test_core_installer_does_not_mutate_agent_owned_directories
+    installer = File.read("install.sh")
+    %w[.claude .codex .pi .agents].each { |directory| refute_includes installer, directory }
+  end
+
+  def test_release_updater_keeps_host_metadata_on_one_version
+    Dir.mktmpdir("hive-skills-update") do |root|
+      copy = File.join(root, "skills")
+      FileUtils.cp_r(ROOT, copy)
+
+      _out, err, status = Open3.capture3("script/update-skills-metadata", "v1.2.3", copy)
+
+      assert status.success?, err
+      assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, "package.json"))).fetch("version")
+      assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, ".codex-plugin/plugin.json"))).fetch("version")
+      assert_equal "1.2.3", JSON.parse(File.read(File.join(copy, "plugins/hive/.claude-plugin/plugin.json"))).fetch("version")
+      assert_includes File.read(File.join(copy, "README.md")), "v1.2.3"
+    end
+  end
+end
diff --git a/test/packaging/homebrew_formula_test.rb b/test/packaging/homebrew_formula_test.rb
new file mode 100644
index 00000000..ac32553e
--- /dev/null
+++ b/test/packaging/homebrew_formula_test.rb
@@ -0,0 +1,14 @@
+require "test_helper"
+require "json"
+
+class HomebrewFormulaTest < Minitest::Test
+  def test_formula_is_pinned_to_the_canonical_macos_artifact_shape
+    formula = File.read("packaging/homebrew/Formula/hive.rb")
+    assert_includes formula, "hive_0.1.0_darwin_arm64"
+    assert_match(/sha256 "[a-f0-9]{64}"/, formula)
+    assert_includes formula, 'bin.install_symlink "hive" => "hv"'
+    assert_includes formula, "install-channel.yml"
+    assert_includes formula, "channel: homebrew"
+    refute_match(/depends_on .*ruby/i, formula)
+  end
+end
diff --git a/test/prompt/evaluate_install_prompt.rb b/test/prompt/evaluate_install_prompt.rb
new file mode 100644
index 00000000..5e830927
--- /dev/null
+++ b/test/prompt/evaluate_install_prompt.rb
@@ -0,0 +1,31 @@
+require "test_helper"
+require "yaml"
+
+class EvaluateInstallPromptTest < Minitest::Test
+  def test_the_published_prompt_covers_every_routing_and_safety_branch
+    prompt = File.read("docs/install-prompt.md")
+    cases = YAML.safe_load(File.read("test/prompt/install_cases.yml")).fetch("cases")
+
+    cases.each do |fixture|
+      fixture.fetch("required").each do |phrase|
+        assert_includes prompt, phrase, "#{fixture.fetch('name')} requires #{phrase.inspect}"
+      end
+    end
+  end
+
+  def test_requested_host_has_an_executable_install_and_remove_route
+    host = ENV["HIVE_PROMPT_HOST"]
+    skip "HIVE_PROMPT_HOST is only set by the tier-1 host matrix" unless host
+
+    prompt = File.read("docs/install-prompt.md")
+    routes = {
+      "claude" => [ "claude plugin install hive@hive-skills", "claude plugin uninstall hive@hive-skills" ],
+      "codex" => [ "codex plugin add hive@hive-skills", "codex plugin remove hive@hive-skills" ],
+      "pi" => [
+        "pi install git:github.com/ivankuznetsov/hive-skills@v0.1.0",
+        "pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0"
+      ]
+    }
+    routes.fetch(host).each { |command| assert_includes prompt, command }
+  end
+end
diff --git a/test/prompt/install_cases.yml b/test/prompt/install_cases.yml
new file mode 100644
index 00000000..b3392071
--- /dev/null
+++ b/test/prompt/install_cases.yml
@@ -0,0 +1,11 @@
+cases:
+  - name: claude-macos
+    required: ["macOS arm64", "brew install", "claude plugin install hive@hive-skills", "claude plugin uninstall hive@hive-skills"]
+  - name: codex-arch
+    required: ["Arch x86_64", "hive-bin", "codex plugin marketplace add", "codex plugin add hive@hive-skills", "codex plugin remove hive@hive-skills"]
+  - name: pi-ubuntu
+    required: ["Ubuntu", "checksum-verifying installer", "pi install git:", "pi remove git:github.com/ivankuznetsov/hive-skills@v0.1.0"]
+  - name: hive-collision
+    required: ["install only `hv`", "leave hive untouched", "HIVE_COMMAND", "$HIVE_COMMAND init"]
+  - name: denied-or-unavailable
+    required: ["do not change the host", "never copy skill files manually"]
diff --git a/test/release/checksums.sh b/test/release/checksums.sh
new file mode 100755
index 00000000..72652d28
--- /dev/null
+++ b/test/release/checksums.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+dist=${1:?usage: test/release/checksums.sh DIST}
+cd "$dist"
+if command -v sha256sum >/dev/null 2>&1; then
+  sha256sum --check SHA256SUMS
+else
+  shasum -a 256 --check SHA256SUMS
+fi
diff --git a/test/release/manifest_test.rb b/test/release/manifest_test.rb
new file mode 100644
index 00000000..d7413b72
--- /dev/null
+++ b/test/release/manifest_test.rb
@@ -0,0 +1,52 @@
+require "test_helper"
+require "json"
+require "open3"
+
+class ReleaseManifestTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_generates_complete_canonical_manifest_and_checksums
+    with_tmp_dir do |dist|
+      %W[hive_#{Hive::VERSION}_darwin_arm64 hive_#{Hive::VERSION}_linux_x86_64 install.sh].each do |name|
+        File.write(File.join(dist, name), name)
+      end
+
+      _out, err, status = Open3.capture3("script/release-manifest", "--dist", dist, "--version", "v#{Hive::VERSION}")
+
+      assert status.success?, err
+      manifest = JSON.parse(File.read(File.join(dist, "release-manifest.json")))
+      assert_equal "v#{Hive::VERSION}", manifest.fetch("version")
+      assert_equal 2, manifest.fetch("targets").size
+      assert_equal "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", manifest.fetch("release_base_url")
+      assert File.file?(File.join(dist, "SHA256SUMS"))
+    end
+  end
+
+  def test_rejects_non_semver_tags
+    with_tmp_dir do |dist|
+      _out, err, status = Open3.capture3("script/release-manifest", "--dist", dist, "--version", "nightly")
+
+      refute status.success?
+      assert_match(/semver/i, err)
+    end
+  end
+
+  def test_rejects_a_different_or_nested_release_tag_url
+    with_tmp_dir do |dist|
+      %W[hive_#{Hive::VERSION}_darwin_arm64 hive_#{Hive::VERSION}_linux_x86_64 install.sh].each do |name|
+        File.write(File.join(dist, name), name)
+      end
+
+      [
+        "https://github.com/ivankuznetsov/hive/releases/download/v9.9.9",
+        "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}/nested"
+      ].each do |url|
+        _out, err, status = Open3.capture3(
+          "script/release-manifest", "--dist", dist, "--version", "v#{Hive::VERSION}", "--base-url", url
+        )
+        refute status.success?
+        assert_match(/canonical/i, err)
+      end
+    end
+  end
+end
diff --git a/test/smoke/packaged_binary.sh b/test/smoke/packaged_binary.sh
new file mode 100755
index 00000000..c667d762
--- /dev/null
+++ b/test/smoke/packaged_binary.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+binary=${1:?usage: test/smoke/packaged_binary.sh /path/to/hive_binary}
+expected_version=${2:?usage: test/smoke/packaged_binary.sh /path/to/hive_binary VERSION}
+
+env -u RUBYOPT -u RUBYLIB PATH="$(dirname "$(command -v env)"):/usr/bin:/bin" \
+  "$binary" --version | grep -Fx "$expected_version"
+env -u RUBYOPT -u RUBYLIB PATH="$(dirname "$(command -v env)"):/usr/bin:/bin" \
+  "$binary" help >/dev/null
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d251d52b..009bba93 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -71,12 +71,20 @@ module HiveTestHelper
   def with_tmp_global_config
     dir = Dir.mktmpdir("hive-global")
     old = ENV["HIVE_HOME"]
+    old_xdg = %w[XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME].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")
     File.write(File.join(dir, "config.yml"), { "registered_projects" => [] }.to_yaml)
     begin
       yield(dir)
     ensure
       ENV["HIVE_HOME"] = old
+      old_xdg.each do |key, value|
+        value.nil? ? ENV.delete(key) : ENV[key] = value
+      end
       # 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..2b720d12
--- /dev/null
+++ b/test/unit/assets_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+require "hive/assets"
+require "hive/paths"
+
+class AssetsTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_hydrates_versioned_assets_and_preserves_modified_project_files
+    with_tmp_dir do |root|
+      paths = Hive::Paths.new(env: {}, home: File.join(root, "home"))
+      project = File.join(root, "project")
+      FileUtils.mkdir_p(project)
+
+      first = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0")
+
+      assert_equal File.join(paths.releases, "0.1.0"), File.realpath(paths.current_assets)
+      assert_includes first.generated, "README.md"
+      readme = File.join(project, ".hive", "README.md")
+      File.write(readme, "my project notes\n")
+
+      second = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0")
+
+      assert_includes second.preserved, "README.md"
+      assert_equal "my project notes\n", File.read(readme)
+      assert File.file?(File.join(project, ".hive", "install-manifest.yml"))
+    end
+  end
+
+  def test_recovers_ownership_when_scaffolding_was_interrupted_before_manifest_write
+    with_tmp_dir do |root|
+      paths = Hive::Paths.new(env: {}, home: File.join(root, "home"))
+      project = File.join(root, "project")
+      interrupted = File.join(project, ".hive", "README.md")
+      FileUtils.mkdir_p(File.dirname(interrupted))
+      FileUtils.cp("assets/project/.hive/README.md", interrupted)
+
+      report = Hive::Assets.hydrate!(paths: paths, project_root: project, version: "0.1.0")
+      manifest = YAML.safe_load(File.read(File.join(project, ".hive", "install-manifest.yml")))
+
+      assert_includes report.generated, "README.md"
+      refute_includes report.preserved, "README.md"
+      assert_equal Digest::SHA256.file(interrupted).hexdigest, manifest.fetch("files").fetch("README.md")
+    end
+  end
+end
diff --git a/test/unit/command_name_test.rb b/test/unit/command_name_test.rb
new file mode 100644
index 00000000..2c5561bb
--- /dev/null
+++ b/test/unit/command_name_test.rb
@@ -0,0 +1,14 @@
+require "test_helper"
+require "hive/command_name"
+
+class CommandNameTest < Minitest::Test
+  def test_hive_and_hv_are_equivalent_public_command_names
+    assert_equal "hive", Hive::CommandName.from("/usr/local/bin/hive").value
+    assert_equal "hv", Hive::CommandName.from("/home/me/.local/bin/hv").value
+    assert_equal "hv init", Hive::CommandName.from("hv").usage("init")
+  end
+
+  def test_rejects_unknown_argv_zero_aliases
+    assert_raises(Hive::CommandName::Invalid) { Hive::CommandName.from("other") }
+  end
+end
diff --git a/test/unit/commands/daemon_service_test.rb b/test/unit/commands/daemon_service_test.rb
new file mode 100644
index 00000000..d3850f06
--- /dev/null
+++ b/test/unit/commands/daemon_service_test.rb
@@ -0,0 +1,67 @@
+require "test_helper"
+require "hive/commands/daemon"
+require "hive/paths"
+
+class DaemonServiceTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_installed_daemon_start_delegates_to_the_service_manager
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      manager = Object.new
+      manager.define_singleton_method(:start!) { :running }
+
+      out, _err = capture_io do
+        result = Hive::Commands::Daemon.new("start", paths: paths, service_manager: manager).call
+        assert_equal :running, result
+      end
+
+      assert_includes out, "daemon service running"
+    end
+  end
+
+  def test_service_status_keeps_the_not_running_exit_contract
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      manager = Object.new
+      manager.define_singleton_method(:status) { :registered_stopped }
+
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Daemon.new("status", paths: paths, service_manager: manager).call
+      end
+      assert_match(/registered stopped/, error.message)
+    end
+  end
+
+  def test_service_managed_commands_reject_json_before_mutation
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      called = false
+      manager = Object.new
+      manager.define_singleton_method(:start!) { called = true }
+
+      error = assert_raises(Hive::InvalidTaskPath) do
+        Hive::Commands::Daemon.new("start", paths: paths, service_manager: manager, json: true).call
+      end
+
+      assert_match(/--json.*not supported/i, error.message)
+      refute called
+    end
+  end
+
+  def test_source_daemon_runtime_uses_xdg_state_and_cache_paths
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(
+        env: {
+          "XDG_STATE_HOME" => File.join(home, "state"),
+          "XDG_CACHE_HOME" => File.join(home, "cache")
+        },
+        home: home
+      )
+      daemon = Hive::Commands::Daemon.new("status", paths: paths, hive_home: File.join(home, "legacy"))
+
+      assert_equal File.join(paths.state, "daemon.pid"), daemon.pid_file
+      assert_equal File.join(paths.cache, "daemon.log"), daemon.log_file
+    end
+  end
+end
diff --git a/test/unit/commands/uninstall_test.rb b/test/unit/commands/uninstall_test.rb
new file mode 100644
index 00000000..61e22207
--- /dev/null
+++ b/test/unit/commands/uninstall_test.rb
@@ -0,0 +1,188 @@
+require "test_helper"
+require "hive/commands/uninstall"
+require "hive/paths"
+require "hive/install/receipt"
+require "digest"
+
+class UninstallTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_purge_removes_only_unchanged_manifest_owned_project_files
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      project = File.join(home, "project")
+      hive = File.join(project, ".hive")
+      FileUtils.mkdir_p(hive)
+      unchanged = File.join(hive, "generated.yml")
+      edited = File.join(hive, "edited.yml")
+      unknown = File.join(hive, "output.txt")
+      File.write(unchanged, "generated\n")
+      File.write(edited, "edited by user\n")
+      File.write(unknown, "completed output\n")
+      File.write(File.join(hive, "install-manifest.yml"), {
+        "schema_version" => 1,
+        "files" => {
+          "generated.yml" => Digest::SHA256.hexdigest("generated\n"),
+          "edited.yml" => Digest::SHA256.hexdigest("original\n")
+        }
+      }.to_yaml)
+      Hive::Install::Receipt.new(
+        channel: "bash", package_id: "hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: File.join(home, ".local", "bin", "hive"), command_name: "hive"
+      ).write!(paths)
+      calls = []
+      service = Object.new
+      service.define_singleton_method(:unregister!) { :unregistered }
+
+      result = Hive::Commands::Uninstall.new(
+        paths: paths, project_root: project, purge: true, service_manager: service,
+        runner: ->(*command) { calls << command; [ "", "", true ] }
+      ).call
+
+      refute File.exist?(unchanged)
+      assert File.exist?(edited)
+      assert File.exist?(unknown)
+      assert_equal [ "edited.yml", "output.txt" ], result.fetch("retained").sort
+      assert_equal [ "bash", "-o", "pipefail", "-c" ], calls.first.take(4)
+      assert_match(/install\.sh/, calls.first.last)
+    end
+  end
+
+  def test_rejects_manifest_paths_and_symlinked_parents_that_escape_dot_hive
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      project = File.join(home, "project")
+      hive = File.join(project, ".hive")
+      outside = File.join(project, "outside.txt")
+      FileUtils.mkdir_p(hive)
+      File.write(outside, "generated\n")
+      File.symlink(project, File.join(hive, "linked"))
+      File.write(File.join(hive, "install-manifest.yml"), {
+        "schema_version" => 1,
+        "files" => {
+          "../outside.txt" => Digest::SHA256.file(outside).hexdigest,
+          "linked/outside.txt" => Digest::SHA256.file(outside).hexdigest
+        }
+      }.to_yaml)
+      write_bash_receipt(paths, home)
+      service_called = false
+      service = Object.new
+      service.define_singleton_method(:unregister!) { service_called = true }
+      runner_called = false
+
+      error = assert_raises(Hive::ConfigError) do
+        Hive::Commands::Uninstall.new(
+          paths: paths, project_root: project, purge: true, service_manager: service,
+          runner: ->(*_command) { runner_called = true; [ "", "", true ] }
+        ).call
+      end
+
+      assert_match(/unsafe.*manifest path/i, error.message)
+      assert_equal "generated\n", File.read(outside)
+      refute service_called
+      refute runner_called
+    end
+  end
+
+  def test_rejects_a_hash_matching_file_beneath_a_symlinked_manifest_parent
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      project = File.join(home, "project")
+      hive = File.join(project, ".hive")
+      outside_dir = File.join(home, "outside")
+      outside = File.join(outside_dir, "generated.yml")
+      FileUtils.mkdir_p(hive)
+      FileUtils.mkdir_p(outside_dir)
+      File.write(outside, "generated\n")
+      File.symlink(outside_dir, File.join(hive, "linked"))
+      File.write(File.join(hive, "install-manifest.yml"), {
+        "schema_version" => 1,
+        "files" => { "linked/generated.yml" => Digest::SHA256.file(outside).hexdigest }
+      }.to_yaml)
+      write_bash_receipt(paths, home)
+      service = Object.new
+      service.define_singleton_method(:unregister!) { flunk "service mutation must not occur" }
+
+      error = assert_raises(Hive::ConfigError) do
+        Hive::Commands::Uninstall.new(
+          paths: paths, project_root: project, purge: true, service_manager: service,
+          runner: ->(*_command) { flunk "package mutation must not occur" }
+        ).call
+      end
+
+      assert_match(/symlinked parent/i, error.message)
+      assert_equal "generated\n", File.read(outside)
+    end
+  end
+
+  def test_package_manager_failure_leaves_service_project_and_receipt_recoverable
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      project = File.join(home, "project")
+      hive = File.join(project, ".hive")
+      FileUtils.mkdir_p(hive)
+      generated = File.join(hive, "generated.yml")
+      File.write(generated, "generated\n")
+      File.write(File.join(hive, "install-manifest.yml"), {
+        "schema_version" => 1,
+        "files" => { "generated.yml" => Digest::SHA256.file(generated).hexdigest }
+      }.to_yaml)
+      write_bash_receipt(paths, home)
+      service_called = false
+      service = Object.new
+      service.define_singleton_method(:unregister!) { service_called = true }
+
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Uninstall.new(
+          paths: paths, project_root: project, purge: true, service_manager: service,
+          runner: ->(*_command) { [ "", "network unavailable", false ] }
+        ).call
+      end
+
+      assert_match(/remains registered|no service or project files were removed/i, error.message)
+      refute service_called
+      assert File.file?(generated)
+      assert File.file?(paths.receipt)
+    end
+  end
+
+  def test_success_removes_generated_shared_assets_but_preserves_config_and_state
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      project = File.join(home, "project")
+      FileUtils.mkdir_p(project)
+      FileUtils.mkdir_p(paths.releases)
+      FileUtils.mkdir_p(File.dirname(paths.current_assets))
+      release = File.join(paths.releases, "0.1.0")
+      FileUtils.mkdir_p(release)
+      File.write(File.join(release, "asset"), "generated")
+      File.symlink(release, paths.current_assets)
+      FileUtils.mkdir_p(File.dirname(paths.config_file))
+      File.write(paths.config_file, "provider: user-owned\n")
+      write_bash_receipt(paths, home)
+      service = Object.new
+      service.define_singleton_method(:unregister!) { :unregistered }
+
+      result = Hive::Commands::Uninstall.new(
+        paths: paths, project_root: project, purge: true, service_manager: service,
+        runner: ->(*_command) { [ "", "", true ] }
+      ).call
+
+      refute File.exist?(paths.current_assets)
+      refute File.exist?(paths.releases)
+      assert File.file?(paths.config_file)
+      assert_includes result.fetch("shared_removed"), paths.releases
+    end
+  end
+
+  private
+
+  def write_bash_receipt(paths, home)
+    Hive::Install::Receipt.new(
+      channel: "bash", package_id: "hive",
+      release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+      installed_version: "0.1.0", executable_path: File.join(home, ".local", "bin", "hive"),
+      command_name: "hive"
+    ).write!(paths)
+  end
+end
diff --git a/test/unit/commands/update_test.rb b/test/unit/commands/update_test.rb
new file mode 100644
index 00000000..6c9b2d6c
--- /dev/null
+++ b/test/unit/commands/update_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+require "hive/commands/update"
+require "hive/paths"
+require "hive/install/receipt"
+
+class UpdateTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_delegates_a_homebrew_update_without_writing_the_executable
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      Hive::Install::Receipt.new(
+        channel: "homebrew", package_id: "ivankuznetsov/hive/hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive"
+      ).write!(paths)
+      calls = []
+      dependencies = Object.new
+      dependencies.define_singleton_method(:diagnostics_for) { |_action| [] }
+      updater = Hive::Commands::Update.new(
+        paths: paths, dependencies: dependencies,
+        runner: ->(*command) { calls << command; [ "", "", true ] }
+      )
+
+      updater.call
+
+      assert_equal [ [ "brew", "upgrade", "ivankuznetsov/hive/hive" ] ], calls
+      refute File.exist?("/opt/homebrew/bin/hive"), "the updater must not replace a package-manager binary directly"
+    end
+  end
+
+
+  def test_reports_channel_specific_missing_dependencies_before_mutation
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      Hive::Install::Receipt.new(
+        channel: "homebrew", package_id: "ivankuznetsov/hive/hive",
+        release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive"
+      ).write!(paths)
+      diagnostic = Hive::Dependencies::Diagnostic.new("brew", "install Homebrew first", true)
+      dependencies = Object.new
+      dependencies.define_singleton_method(:diagnostics_for) { |action| action == :update_homebrew ? [ diagnostic ] : [] }
+      output = StringIO.new
+      runner_called = false
+
+      error = assert_raises(Hive::Error) do
+        Hive::Commands::Update.new(
+          paths: paths, dependencies: dependencies, output: output,
+          runner: ->(*_command) { runner_called = true; [ "", "", true ] }
+        ).call
+      end
+
+      assert_match(/brew/, error.message)
+      assert_includes output.string, "install Homebrew first"
+      refute runner_called
+    end
+  end
+end
diff --git a/test/unit/dependencies_test.rb b/test/unit/dependencies_test.rb
new file mode 100644
index 00000000..92ef3e44
--- /dev/null
+++ b/test/unit/dependencies_test.rb
@@ -0,0 +1,22 @@
+require "test_helper"
+require "hive/dependencies"
+
+class DependenciesTest < Minitest::Test
+  def test_reports_action_specific_missing_tools_without_executing_package_managers
+    calls = []
+    dependencies = Hive::Dependencies.new(which: ->(tool) { calls << tool; nil }, platform: :linux)
+
+    diagnostics = dependencies.diagnostics_for(:init)
+
+    assert_equal %w[git bash claude gh jq], diagnostics.map(&:tool)
+    assert_match(/apt|pacman/i, diagnostics.first.hint)
+    assert_equal %w[git bash claude gh jq], calls
+  end
+
+  def test_optional_agent_tools_do_not_block_core_status
+    dependencies = Hive::Dependencies.new(which: ->(_tool) { nil }, platform: :linux)
+
+    assert_empty dependencies.missing_for(:daemon_status)
+    assert_equal %w[claude gh jq], dependencies.missing_for(:agent_assist).map(&:tool)
+  end
+end
diff --git a/test/unit/install/channel_test.rb b/test/unit/install/channel_test.rb
new file mode 100644
index 00000000..21288784
--- /dev/null
+++ b/test/unit/install/channel_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+require "hive/install/channel"
+require "hive/install/receipt"
+
+class InstallChannelTest < Minitest::Test
+  def receipt(channel)
+    package_id = { "bash" => "hive", "homebrew" => "ivankuznetsov/hive/hive", "aur" => "hive-bin" }.fetch(channel)
+    executable_path = channel == "homebrew" ? "/opt/homebrew/bin/hive" : "/usr/bin/hive"
+    Hive::Install::Receipt.new(
+      channel: channel, package_id: package_id, release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+      installed_version: "0.1.0", executable_path: executable_path, command_name: "hive"
+    )
+  end
+
+  def test_selects_only_the_recorded_channel_adapter
+    assert_instance_of Hive::Install::Homebrew, Hive::Install::Channel.for(receipt("homebrew"))
+    assert_instance_of Hive::Install::Aur, Hive::Install::Channel.for(receipt("aur"))
+    assert_instance_of Hive::Install::Bash, Hive::Install::Channel.for(receipt("bash"))
+  end
+
+  def test_bash_update_and_remove_use_pipefail_and_the_recorded_executable_directory
+    calls = []
+    adapter = Hive::Install::Bash.new(receipt("bash"), runner: ->(*command) { calls << command; [ "", "", true ] })
+
+    adapter.update!
+    adapter.remove!
+
+    assert_equal [ "bash", "-o", "pipefail", "-c" ], calls[0].take(4)
+    assert_includes calls[0].last, "HIVE_BIN_DIR=/usr/bin"
+    assert_includes calls[1].last, "--uninstall"
+    assert_equal [ "bash", "-o", "pipefail", "-c" ], calls[1].take(4)
+  end
+
+  def test_bash_download_failure_is_not_hidden_by_a_successful_empty_shell
+    adapter = Hive::Install::Bash.new(receipt("bash"), runner: ->(*_command) { [ "", "download failed", false ] })
+
+    error = assert_raises(Hive::Error) { adapter.update! }
+
+    assert_match(/bash failed/, error.message)
+  end
+end
diff --git a/test/unit/install/receipt_test.rb b/test/unit/install/receipt_test.rb
new file mode 100644
index 00000000..75f4b429
--- /dev/null
+++ b/test/unit/install/receipt_test.rb
@@ -0,0 +1,119 @@
+require "test_helper"
+require "hive/paths"
+require "hive/install/receipt"
+
+class ReceiptTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_round_trips_an_atomic_versioned_install_receipt
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      receipt = Hive::Install::Receipt.new(
+        channel: "bash", package_id: "hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: File.join(home, ".local/bin/hive"), command_name: "hive"
+      )
+
+      receipt.write!(paths)
+      loaded = Hive::Install::Receipt.load(paths)
+
+      assert_equal receipt.to_h, loaded.to_h
+      assert_equal 0o600, File.stat(paths.receipt).mode & 0o777
+    end
+  end
+
+  def test_refuses_malformed_receipts_instead_of_guessing_a_package_manager
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      FileUtils.mkdir_p(File.dirname(paths.receipt))
+      File.write(paths.receipt, "channel: who-knows\n")
+
+      error = assert_raises(Hive::Install::Receipt::Invalid) { Hive::Install::Receipt.load(paths) }
+      assert_match(/repair|remove/i, error.message)
+    end
+  end
+
+  def test_rejects_channel_specific_identity_mismatches
+    invalid = [
+      {
+        channel: "bash", package_id: "other", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/home/me/.local/bin/hive", command_name: "hive"
+      },
+      {
+        channel: "homebrew", package_id: "attacker/tap/hive", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/opt/homebrew/bin/hive", command_name: "hive"
+      },
+      {
+        channel: "aur", package_id: "hive-bin", release_base_url: "https://example.invalid/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/usr/bin/hive", command_name: "hive"
+      },
+      {
+        channel: "aur", package_id: "hive-bin", release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/tmp/../usr/bin/hive", command_name: "hive"
+      },
+      {
+        channel: "homebrew", package_id: "ivankuznetsov/hive/hive",
+        release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/tmp/hive", command_name: "hive"
+      },
+      {
+        channel: "aur", package_id: "hive-bin",
+        release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v0.1.0",
+        installed_version: "0.1.0", executable_path: "/opt/hive", command_name: "hive"
+      }
+    ]
+
+    invalid.each do |values|
+      error = assert_raises(Hive::Install::Receipt::Invalid) { Hive::Install::Receipt.new(**values) }
+      assert_match(/repair|remove/i, error.message)
+    end
+  end
+
+  def test_creates_and_refreshes_a_native_package_receipt_from_package_owned_metadata
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      prefix = File.join(home, "homebrew-prefix")
+      executable = File.join(prefix, "bin", "hive")
+      marker = File.join(prefix, "share", "hive", "install-channel.yml")
+      FileUtils.mkdir_p(File.dirname(executable))
+      FileUtils.mkdir_p(File.dirname(marker))
+      File.write(executable, "binary")
+      File.chmod(0o755, executable)
+      File.write(marker, { "channel" => "homebrew", "package_id" => "ivankuznetsov/hive/hive" }.to_yaml)
+
+      receipt = Hive::Install::Receipt.ensure_native!(paths: paths, program_name: executable)
+
+      assert_equal "homebrew", receipt.channel
+      assert_equal executable, receipt.executable_path
+      assert_equal "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}", receipt.release_base_url
+      assert_equal receipt.to_h, Hive::Install::Receipt.load(paths).to_h
+    end
+  end
+
+  def test_native_package_marker_refuses_to_replace_a_conflicting_receipt
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      prefix = File.join(home, "homebrew-prefix")
+      executable = File.join(prefix, "bin", "hive")
+      marker = File.join(prefix, "share", "hive", "install-channel.yml")
+      FileUtils.mkdir_p(File.dirname(executable))
+      FileUtils.mkdir_p(File.dirname(marker))
+      File.write(executable, "binary")
+      File.chmod(0o755, executable)
+      File.write(marker, { "channel" => "homebrew", "package_id" => "ivankuznetsov/hive/hive" }.to_yaml)
+      existing = Hive::Install::Receipt.new(
+        channel: "bash", package_id: "hive",
+        release_base_url: "https://github.com/ivankuznetsov/hive/releases/download/v#{Hive::VERSION}",
+        installed_version: Hive::VERSION, executable_path: File.join(home, ".local", "bin", "hive"),
+        command_name: "hive"
+      )
+      existing.write!(paths)
+
+      error = assert_raises(Hive::Install::Receipt::Invalid) do
+        Hive::Install::Receipt.ensure_native!(paths: paths, program_name: executable)
+      end
+
+      assert_match(/conflicts/i, error.message)
+      assert_equal existing.to_h, Hive::Install::Receipt.load(paths).to_h
+    end
+  end
+end
diff --git a/test/unit/package_contract_test.rb b/test/unit/package_contract_test.rb
new file mode 100644
index 00000000..9cb46e7b
--- /dev/null
+++ b/test/unit/package_contract_test.rb
@@ -0,0 +1,22 @@
+require "test_helper"
+require "json"
+require "open3"
+
+class PackageContractTest < Minitest::Test
+  def test_package_script_exposes_the_tier_one_artifact_contract
+    out, err, status = Open3.capture3("script/package", "--print-contract")
+
+    assert status.success?, err
+    contract = JSON.parse(out)
+    assert_equal Hive::VERSION, contract.fetch("version")
+    assert_equal [
+      { "os" => "darwin", "arch" => "arm64", "name" => "hive_#{Hive::VERSION}_darwin_arm64" },
+      { "os" => "linux", "arch" => "x86_64", "name" => "hive_#{Hive::VERSION}_linux_x86_64" }
+    ], contract.fetch("targets")
+    assert_equal "3.4.2", contract.fetch("ruby")
+    assert_equal "tebako", contract.fetch("packager")
+    assert_equal %w[Gemfile Gemfile.lock exe bin lib assets templates schemas], contract.fetch("payload_entries")
+    refute_includes contract.fetch("payload_entries"), ".git"
+    refute_includes contract.fetch("payload_entries"), "test"
+  end
+end
diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb
new file mode 100644
index 00000000..b0b395d9
--- /dev/null
+++ b/test/unit/paths_test.rb
@@ -0,0 +1,43 @@
+require "test_helper"
+require "hive/paths"
+
+class PathsTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_uses_required_xdg_defaults_without_mutating_legacy_state
+    with_tmp_dir do |home|
+      legacy = File.join(home, ".hive-state")
+      FileUtils.mkdir_p(legacy)
+      legacy_file = File.join(legacy, "completed.yml")
+      File.write(legacy_file, "finished: true\n")
+      before = File.stat(legacy_file)
+
+      paths = Hive::Paths.new(env: {}, home: home)
+
+      assert_equal File.join(home, ".config", "hive"), paths.config
+      assert_equal File.join(home, ".local", "share", "hive"), paths.data
+      assert_equal File.join(home, ".local", "state", "hive"), paths.state
+      assert_equal File.join(home, ".cache", "hive"), paths.cache
+      assert_equal legacy, paths.legacy_state
+      assert_equal before.mtime, File.stat(legacy_file).mtime
+      assert_equal "finished: true\n", File.read(legacy_file)
+    end
+  end
+
+  def test_honors_all_xdg_overrides_and_keeps_hive_content_beneath_them
+    with_tmp_dir do |root|
+      env = {
+        "XDG_CONFIG_HOME" => File.join(root, "config"),
+        "XDG_DATA_HOME" => File.join(root, "data"),
+        "XDG_STATE_HOME" => File.join(root, "state"),
+        "XDG_CACHE_HOME" => File.join(root, "cache")
+      }
+      paths = Hive::Paths.new(env: env, home: File.join(root, "home"))
+
+      assert_equal File.join(root, "config", "hive"), paths.config
+      assert_equal File.join(root, "data", "hive"), paths.data
+      assert_equal File.join(root, "state", "hive", "install.yml"), paths.receipt
+      assert_equal File.join(root, "cache", "hive"), paths.cache
+    end
+  end
+end
diff --git a/test/unit/platform_test.rb b/test/unit/platform_test.rb
new file mode 100644
index 00000000..78481ae5
--- /dev/null
+++ b/test/unit/platform_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+require "hive/platform"
+
+class PlatformTest < Minitest::Test
+  def test_normalizes_tier_one_hosts
+    assert_equal [:macos, :arm64], Hive::Platform.current(host_os: "darwin24.0", host_cpu: "aarch64")
+    assert_equal [:linux, :x86_64], Hive::Platform.current(host_os: "linux-gnu", host_cpu: "x86_64")
+    assert Hive::Platform.supported?(:macos, :arm64)
+    assert Hive::Platform.supported?(:linux, :x86_64)
+    refute Hive::Platform.supported?(:linux, :arm64)
+  end
+end
diff --git a/test/unit/service/launchd_test.rb b/test/unit/service/launchd_test.rb
new file mode 100644
index 00000000..005ad90b
--- /dev/null
+++ b/test/unit/service/launchd_test.rb
@@ -0,0 +1,26 @@
+require "test_helper"
+require "hive/service/launchd"
+require "hive/paths"
+
+class LaunchdTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_writes_a_per_user_plist_without_loading_it
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      path = Hive::Service::Launchd.write!(paths: paths, executable_path: "/opt/homebrew/bin/hive")
+
+      assert_equal File.join(home, "Library", "LaunchAgents", "dev.hive.daemon.plist"), path
+      content = File.read(path)
+      assert_includes content, "<string>/opt/homebrew/bin/hive</string>"
+      assert_includes content, "<key>HIVE_SERVICE_CHILD</key>"
+      assert_includes content, "<key>XDG_CONFIG_HOME</key>"
+      assert_includes content, CGI.escapeHTML(File.dirname(paths.config))
+      assert_includes content, "<key>XDG_DATA_HOME</key>"
+      assert_includes content, "<key>XDG_CACHE_HOME</key>"
+      assert_includes content, "<key>RunAtLoad</key><false/>"
+      assert_includes content, "<key>KeepAlive</key><false/>"
+      refute_includes content, "<key>RunAtLoad</key><true/>"
+    end
+  end
+end
diff --git a/test/unit/service/manager_test.rb b/test/unit/service/manager_test.rb
new file mode 100644
index 00000000..372e0039
--- /dev/null
+++ b/test/unit/service/manager_test.rb
@@ -0,0 +1,68 @@
+require "test_helper"
+require "hive/service/manager"
+require "hive/paths"
+
+class ServiceManagerTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_linux_register_then_start_and_stop_uses_systemd_user_without_duplicates
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      commands = []
+      manager = Hive::Service::Manager.new(
+        paths: paths, platform: :linux, executable_path: "/usr/bin/hive",
+        runner: ->(*command) { commands << command; [ "", true ] }
+      )
+
+      assert_equal :registered_stopped, manager.register!
+      assert_equal :running, manager.start!
+      assert_equal :stopped, manager.stop!
+
+      assert_includes commands, [ "systemctl", "--user", "daemon-reload" ]
+      assert_includes commands, [ "systemctl", "--user", "enable", "--now", "hive.service" ]
+      assert_includes commands, [ "systemctl", "--user", "disable", "--now", "hive.service" ]
+      assert_equal 1, Dir.glob(File.join(File.dirname(paths.config), "systemd", "user", "hive.service")).size
+    end
+  end
+
+  def test_status_reports_unregistered_without_calling_a_service_manager
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      manager = Hive::Service::Manager.new(paths: paths, platform: :linux, executable_path: "/usr/bin/hive")
+
+      assert_equal :unregistered, manager.status
+    end
+  end
+
+  def test_propagates_service_manager_failures_without_claiming_running
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      manager = Hive::Service::Manager.new(
+        paths: paths, platform: :linux, executable_path: "/usr/bin/hive",
+        runner: ->(*_command) { [ "", "user manager unavailable", false ] }
+      )
+
+      error = assert_raises(Hive::Error) { manager.start! }
+
+      assert_match(/systemctl.*failed/i, error.message)
+      assert_match(/user manager unavailable/, error.message)
+    end
+  end
+
+  def test_macos_explicit_start_bootstraps_then_kickstarts_a_non_autostart_agent
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      commands = []
+      manager = Hive::Service::Manager.new(
+        paths: paths, platform: :macos, executable_path: "/opt/homebrew/bin/hive", uid: 501,
+        runner: ->(*command) { commands << command; [ "", "", true ] }
+      )
+
+      assert_equal :running, manager.start!
+      assert_equal [
+        [ "launchctl", "bootstrap", "gui/501", Hive::Service::Launchd.path(paths) ],
+        [ "launchctl", "kickstart", "-k", "gui/501/dev.hive.daemon" ]
+      ], commands
+    end
+  end
+end
diff --git a/test/unit/service/systemd_user_test.rb b/test/unit/service/systemd_user_test.rb
new file mode 100644
index 00000000..5d6b358e
--- /dev/null
+++ b/test/unit/service/systemd_user_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+require "hive/service/systemd_user"
+require "hive/paths"
+
+class SystemdUserTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_writes_a_user_unit_with_xdg_paths_and_service_child_marker
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      path = Hive::Service::SystemdUser.write!(paths: paths, executable_path: "/usr/bin/hv")
+
+      content = File.read(path)
+      assert_equal File.join(File.dirname(paths.config), "systemd", "user", "hive.service"), path
+      assert_includes content, 'ExecStart="/usr/bin/hv" daemon start'
+      assert_includes content, 'Environment="HIVE_SERVICE_CHILD=1"'
+      assert_includes content, paths.state
+    end
+  end
+
+  def test_quotes_spaces_quotes_dollars_and_systemd_specifiers
+    with_tmp_dir do |home|
+      env = {
+        "XDG_CONFIG_HOME" => File.join(home, "config with space"),
+        "XDG_DATA_HOME" => File.join(home, "data%value"),
+        "XDG_STATE_HOME" => File.join(home, "state$dollar"),
+        "XDG_CACHE_HOME" => File.join(home, "cache\"quote")
+      }
+      paths = Hive::Paths.new(env: env, home: home)
+      Hive::Service::SystemdUser.write!(paths: paths, executable_path: File.join(home, "bin with space", "hi%ve$"))
+
+      content = File.read(Hive::Service::SystemdUser.path(paths))
+      assert_includes content, 'Environment="XDG_CONFIG_HOME='
+      assert_includes content, "data%%value"
+      assert_includes content, "state$dollar"
+      assert_includes content, 'cache\\"quote'
+      assert_includes content, 'ExecStart="'
+      assert_includes content, "hi%%ve$$"
+    end
+  end
+
+  def test_escapes_backslashes_without_interpreting_replacement_sequences
+    escaped = Hive::Service::SystemdUser.escape('C:\\Hive\\"100%$path', dollar: true)
+
+    assert_equal 'C:\\\\Hive\\\\\\"100%%$$path', escaped
+  end
+end
diff --git a/test/unit/user_config_test.rb b/test/unit/user_config_test.rb
new file mode 100644
index 00000000..d800d5d2
--- /dev/null
+++ b/test/unit/user_config_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+require "hive/user_config"
+require "hive/paths"
+
+class UserConfigTest < Minitest::Test
+  include HiveTestHelper
+
+  def test_writes_only_a_credential_reference_with_restrictive_permissions
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      config = Hive::UserConfig.ensure!(paths: paths, provider: "anthropic", model: "claude-sonnet", credential_env: "ANTHROPIC_API_KEY")
+
+      assert_equal "anthropic", config.fetch("provider")
+      assert_equal "ANTHROPIC_API_KEY", config.fetch("credential_env")
+      text = File.read(paths.config_file)
+      refute_includes text, "actual-secret"
+      assert_equal 0o600, File.stat(paths.config_file).mode & 0o777
+    end
+  end
+
+  def test_keeps_existing_user_configuration_unchanged
+    with_tmp_dir do |home|
+      paths = Hive::Paths.new(env: {}, home: home)
+      FileUtils.mkdir_p(File.dirname(paths.config_file))
+      File.write(paths.config_file, { "provider" => "custom", "model" => "custom-model", "credential_env" => "CUSTOM_TOKEN" }.to_yaml)
+
+      config = Hive::UserConfig.ensure!(paths: paths, provider: "anthropic", model: "ignored", credential_env: "ANTHROPIC_API_KEY")
+
+      assert_equal "custom", config.fetch("provider")
+      assert_equal "CUSTOM_TOKEN", config.fetch("credential_env")
+    end
+  end
+end
diff --git a/wiki/commands/daemon.md b/wiki/commands/daemon.md
index 308f04ce..d6d0e829 100644
--- a/wiki/commands/daemon.md
+++ b/wiki/commands/daemon.md
@@ -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 PID file (`$XDG_STATE_HOME/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. Installed service-managed start/stop/status reject `--json` explicitly rather than returning an empty response. |
 | `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_CACHE_HOME/hive/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. |
 
@@ -93,7 +93,6 @@ 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_max_bytes` | 10485760 | 10 MB rotation threshold. |
 | `log_max_files` | 5 | 5 × 10 MB = 50 MB log budget. |
 
@@ -111,7 +110,7 @@ All under `daemon:` in `~/Dev/hive/config.yml`:
 
 ## Structured log
 
-`~/Dev/hive/logs/daemon.log` is one JSON document per line:
+`$XDG_CACHE_HOME/hive/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/dependencies.md b/wiki/dependencies.md
index ddbf67af..6cccc99c 100644
--- a/wiki/dependencies.md
+++ b/wiki/dependencies.md
@@ -53,6 +53,16 @@ The codebase leans heavily on stdlib (no extra gems for these):
 
 The `/proc/<pid>/stat` reliance is Linux-specific. macOS would need a `ps -o lstart= -p <pid>` fallback (noted as a known limitation in the plan but not implemented in MVP).
 
+## Distribution diagnostics
+
+`Hive::Dependencies` is the production read-only prerequisite checker used by
+`hive init` and channel-aware `hive update`. Init reports missing `git`, `bash`,
+`claude`, `gh`, and `jq` with platform-specific recovery hints without invoking
+a package manager. Update checks only its recorded channel (`bash`/`curl`,
+`brew`, or `HIVE_AUR_HELPER`) and refuses before lifecycle mutation when that
+tool is unavailable. The checksum-verifying shell installer performs the same
+five-tool diagnostic after artifact verification.
+
 ## External CLI dependencies
 
 These are not gems but the CLI tools the runtime invokes:
diff --git a/wiki/distribution.md b/wiki/distribution.md
new file mode 100644
index 00000000..a47d4638
--- /dev/null
+++ b/wiki/distribution.md
@@ -0,0 +1,92 @@
+---
+title: Distribution and installation
+type: architecture
+source: lib/hive/{paths,assets,user_config,install,service,release_manifest}.rb, install.sh, packaging/
+created: 2026-07-15
+updated: 2026-07-15
+tags: [distribution, install, release, xdg]
+---
+
+**TLDR**: Hive v0.1.0 distribution is built around two Tebako artifacts named
+`hive_<version>_darwin_arm64` and `hive_<version>_linux_x86_64`. A GitHub
+Release manifest plus `SHA256SUMS` is the canonical source consumed by the bash
+installer and package metadata. User-owned runtime data follows XDG; project
+scaffolding lives in `.hive/` and is updated only when its install-manifest hash
+proves the file is still Hive-generated.
+
+## Runtime ownership
+
+| Content | Location | Ownership |
+| --- | --- | --- |
+| User config | `$XDG_CONFIG_HOME/hive/config.yml` | User; contains only provider/model/credential-env reference |
+| Embedded asset release | `$XDG_DATA_HOME/hive/releases/<version>` | Hive; immutable materialization |
+| Asset pointer | `$XDG_DATA_HOME/hive/current` | Hive symlink |
+| Install receipt | `$XDG_STATE_HOME/hive/install.yml` | Hive lifecycle metadata |
+| Legacy state | `~/.hive-state` | Compatibility input only; never migrated/deleted |
+| Project scaffold | `<project>/.hive/` | Mixed; ownership hashes in `install-manifest.yml` |
+
+`Hive::Paths` is the only resolver for distribution-owned XDG paths, including
+the daemon PID (`$XDG_STATE_HOME/hive/daemon.pid`) and log
+(`$XDG_CACHE_HOME/hive/daemon.log`). `Hive::Install::Receipt` records the
+channel, package identity, release URL, installed version, executable path, and
+selected public command (`hive` or collision-safe `hv`). Receipts validate
+exact channel package IDs, canonical tag URLs, and channel-specific executable
+locations before lifecycle mutation. Homebrew and AUR packages install an
+immutable `install-channel.yml`; the first binary invocation turns that marker
+into the user-owned XDG receipt and rejects a conflicting existing channel.
+[[commands/init]] retains its existing `.hive-state` pipeline worktree; the
+distribution scaffold is additive rather than a state migration.
+
+## Lifecycle safety
+
+`hive update` selects its adapter from the receipt: Homebrew delegates to
+`brew`, AUR to the configured helper, and bash reruns the official installer
+with `pipefail` and the receipt's executable directory. No adapter directly
+overwrites a package-manager executable. `hive uninstall` validates its project
+cleanup plan, delegates package removal before any local cleanup, unregisters
+the service only after that succeeds, removes XDG release assets, and removes
+only unchanged manifest-owned `.hive` files when confirmed or passed `--purge`.
+Config, state, legacy state, unknown files, edited generated files, and the
+receipt remain recoverable when package removal fails. Manifest paths must
+remain lexically and physically beneath `.hive/`; symlinked parents are never
+followed.
+
+On installed releases, `hive init` writes a launchd plist (macOS) or
+`systemd --user` unit (Linux). Registration is separate from startup;
+interactive init asks with a default-no prompt, non-interactive init defaults
+to stopped, and explicit `--start-daemon` grants automation consent. Launchd
+uses `RunAtLoad=false` / `KeepAlive=false` and is explicitly kickstarted only
+after consent. Both native definitions receive the complete configured XDG
+environment; systemd values and executable arguments are quoted. Service
+manager failures propagate instead of reporting a false state. The service
+child sets `HIVE_SERVICE_CHILD=1` so it runs the existing daemon loop rather
+than asking the service manager to start itself again.
+
+## Release contract
+
+`script/package` is the Tebako entry point. It first creates an explicit
+runtime-only payload from `Gemfile*`, `exe/`, `bin/`, `lib/`, `assets/`,
+`templates/`, and `schemas/`; repository metadata, tests, caches, and unrelated
+root files are outside the Tebako root. Linux invocations use `--patchelf`, and
+the package workflow runs on Ubuntu 22.04 after installing Tebako build
+prerequisites. `script/release-manifest` accepts only a stable
+`vMAJOR.MINOR.PATCH` tag matching `Hive::VERSION`, requires both target binaries
+and `install.sh`, requires the exact canonical URL for that tag, and writes a
+schema-validated JSON manifest plus checksums.
+
+`packaging/homebrew`, `packaging/aur`, and `packaging/hive-skills` are source
+bundles for their separately published repositories. Release automation
+replaces bootstrap checksum placeholders, regenerates AUR `.SRCINFO`, aligns
+skills versions, and pushes idempotent downstream commits from the canonical
+manifest only after unit, security, package, and non-skippable
+macOS/Ubuntu/Arch acceptance jobs pass. The core installer never writes an
+agent-owned skill directory; skills use host-native marketplace/package
+layouts.
+
+## Backlinks
+
+- [[architecture]]
+- [[commands/init]]
+- [[commands/daemon]]
+- [[dependencies]]
+- [[testing]]
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 7f96bc66..22d992a2 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -40,6 +40,21 @@ tags: [gap, todo]
 
 ## Open questions about the codebase
 
+0. **Native distribution acceptance is not yet evidenced in this checkout.** The
+   Tebako command reaches its configure step but this runner has no `cmake`; the
+   macOS arm64 and Arch AUR checks also require their native CI hosts. The
+   package, formula, AUR, and now non-skippable candidate-lifecycle acceptance
+   workflows carry those checks, but a real tagged release still needs their
+   artifacts recorded before v0.1.0 is declared available.
+
+0. **Separate channel repositories are not checked out here.**
+   `packaging/homebrew`, `packaging/aur`, and `packaging/hive-skills` are
+   deterministic publish sources, not proof that the external tap/AUR/skills
+   repositories have been updated. Release automation now replaces their
+   placeholder SHA-256 values, regenerates `.SRCINFO`, aligns skills versions,
+   and pushes each repository from the canonical release manifest; the first
+   real tag remains the external publication proof.
+
 1. **Has `hive run` been smoke-tested against a live `claude` v2.1.118?** The plan calls for this before declaring the MVP done. No evidence in tree (no `docs/solutions/` notes, no `docs/smoke-results.md`).
 2. **Has `hive init` been run against a real project yet?** Planned pilot, but the working tree shows no first commit on `~/Dev/hive` itself, so the pilot may not have started.
 3. **Is `hive/state` reachable after `git gc`?** The plan recommends `git config --add gc.reflogExpire never refs/heads/hive/state`. This is documented in [[decisions]] ADR-003 but not enforced in `Init#call`.
diff --git a/wiki/index.md b/wiki/index.md
index 2b728a8f..b7857df5 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -10,8 +10,8 @@ tags: [index, wiki]
 
 **TLDR**: Catalog of the LLM-maintained wiki for `hive`.
 
-Page count: 60
-Updated: 2026-05-16
+Page count: 61
+Updated: 2026-07-15
 
 Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eight-stage filesystem state machine (`1-inbox` → `2-brainstorm` → `3-plan` → `4-execute` → `5-open-pr` → `6-review` → `7-finalize` → `8-done`) where stage agents run via configurable AgentProfile CLIs (`claude` default, `codex`, `pi`) and `mv` between directories is the approval primitive.
 
@@ -38,6 +38,7 @@ Folder-as-agent pipeline: a Ruby 3.4 / Thor CLI control plane that drives an eig
 - [[commands/tui]] — `wiki/commands/tui.md`
 - [[decisions]] — `wiki/decisions.md`
 - [[dependencies]] — `wiki/dependencies.md`
+- [[distribution]] — `wiki/distribution.md`
 - [[e2e]] — `wiki/e2e.md`
 - [[gaps]] — `wiki/gaps.md`
 - [[index]] — `wiki/index.md`
diff --git a/wiki/log.md b/wiki/log.md
index 641bec70..3d1f98cc 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -2,6 +2,39 @@
 
 Append-only log of all wiki operations.
 
+## [2026-07-15T00:00:01Z] distribution — stage 6 lifecycle and release hardening
+
+**Action:** Hardened the v0.1.0 distribution review surface. Installer
+collisions now cover effective PATH and preserve unrelated aliases; executable
+and receipt publication is rollback-safe. Receipts enforce channel identity and
+native packages bootstrap them from immutable metadata. Uninstall validates
+physical `.hive/` containment, delegates package removal before cleanup, and
+removes generated XDG releases without touching config/state. Service startup
+uses default-no consent, truthful manager results, quoted XDG definitions, and
+XDG PID/log paths. Tebako receives a runtime-only payload, and release
+publication is blocked on security plus non-skippable tier-1 lifecycle jobs
+before deterministic Homebrew/AUR/skills metadata publication.
+
+**Refreshed pages:**
+- [[distribution]] — lifecycle, native receipts, service consent, payload, and release gates.
+- [[commands/daemon]] — XDG runtime paths and installed-service JSON behavior.
+- [[dependencies]] — production action-specific prerequisite diagnostics.
+- [[gaps]] — acceptance and external publication evidence status.
+
+## [2026-07-15T00:00:00Z] distribution — v0.1.0 installation contract
+
+**Action:** Added [[distribution]] to document the Tebako binary contract,
+XDG resolver, immutable asset materialization, secure credential references,
+channel receipt/update/uninstall safety, per-user service manager, release
+manifest, package-source layout, and separate skills package boundary. Logged
+the remaining evidence gaps: this runner lacks CMake for a Tebako smoke build,
+and the macOS/Arch/external channel repositories require native release CI.
+
+**Refreshed pages:**
+- [[distribution]] — new distribution/lifecycle/release contract.
+- [[index]] — catalog count and link.
+- [[gaps]] — native acceptance and external publishing evidence gaps.
+
 ## [2026-05-20T00:00:00Z] README rewritten with TUI-first framing
 
 **Action:** Restructured `README.md` so the user-facing entry point is the `hive tui` dashboard, the second-tier entry point is "Drive Hive From Your Coding Agent" (folding in the existing install-prompt block plus day-to-day operate-via-agent guidance), and direct CLI use is demoted to a Power-User / Scripting CLI summary that links out to `docs/cli.md` instead of duplicating the per-command table. The hero now explains what Hive does and the folder-as-agent + compound-engineering mental model before any install line. The Documentation section replaces bare bullet links with 1–3 sentence prose descriptions per linked doc.
